🐛 Fix login error (#932)

This commit is contained in:
Luke Vella 2023-11-08 16:14:09 +00:00 committed by GitHub
parent 3f6a5603b3
commit e3aacbe668
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
8 changed files with 141 additions and 176 deletions

View file

@ -1,10 +0,0 @@
"use client";
import { signOut } from "next-auth/react";
import React from "react";
export default function Page() {
React.useEffect(() => {
signOut({ callbackUrl: "/login" });
});
return null;
}

View file

@ -0,0 +1,10 @@
import { NextResponse } from "next/server";
import { resetUser } from "@/app/guest";
import { absoluteUrl } from "@/utils/absolute-url";
export async function GET() {
const res = NextResponse.redirect(absoluteUrl());
await resetUser(res);
return res;
}

107
apps/web/src/app/guest.ts Normal file
View file

@ -0,0 +1,107 @@
import { randomid } from "@rallly/backend/utils/nanoid";
import type { TimeFormat } from "@rallly/database";
import { unsealData } from "iron-session/edge";
import { NextRequest, NextResponse } from "next/server";
import { encode, JWT } from "next-auth/jwt";
import { absoluteUrl } from "@/utils/absolute-url";
function getCookieSettings() {
const secure = absoluteUrl().startsWith("https://");
const prefix = secure ? "__Secure-" : "";
const name = `${prefix}next-auth.session-token`;
return {
secure,
name,
};
}
async function setCookie(res: NextResponse, jwt: JWT) {
const { name, secure } = getCookieSettings();
const token = await encode({
token: jwt,
secret: process.env.SECRET_PASSWORD,
});
res.cookies.set({
name,
value: token,
httpOnly: true,
secure,
sameSite: "lax",
path: "/",
});
}
export async function resetUser(res: NextResponse) {
// resets to a new guest user
const jwt: JWT = {
sub: `user-${randomid()}`,
email: null,
};
await setCookie(res, jwt);
}
export async function initGuest(req: NextRequest, res: NextResponse) {
const { name } = getCookieSettings();
if (req.cookies.has(name)) {
// already has a session token
return;
}
// TODO (Luke Vella) [2023-11-07]: Remove this after 30 days (Date: 2023-12-07)
const legacyJwt = await getLegacyToken(req, res);
const jwt: JWT = legacyJwt || {
sub: `user-${randomid()}`,
email: null,
};
await setCookie(res, jwt);
return jwt;
}
async function getLegacyToken(req: NextRequest, res: NextResponse) {
/**
* We moved from a bespoke session implementation to next-auth.
* This middleware looks for the old session cookie and moves it to
* a temporary cookie accessible to the client which will exchange it
* for a new session token with the legacy-token provider.
*/
const legacyToken = req.cookies.get("rallly-session");
if (legacyToken) {
// delete old cookie
res.cookies.delete("rallly-session");
// make sure old cookie isn't expired
const payload = await unsealData<{
user: {
id: string;
isGuest: boolean;
preferences?: {
weekStart?: number;
timeZone?: string;
timeFormat?: TimeFormat;
};
};
}>(legacyToken.value, {
password: process.env.SECRET_PASSWORD,
});
// if it's not expired, write it to a new cookie that we
// can read from the client
if (Object.keys(payload).length > 0 && payload?.user?.isGuest) {
const jwt: JWT = {
sub: payload.user.id,
email: null,
...payload.user.preferences,
};
return jwt;
}
}
return null;
}

View file

@ -22,7 +22,6 @@ import {
UserPlusIcon,
} from "lucide-react";
import Link from "next/link";
import { signOut } from "next-auth/react";
import { LoginLink } from "@/components/login-link";
import { RegisterLink } from "@/components/register-link";
@ -147,25 +146,25 @@ export const UserDropdown = () => {
<Trans i18nKey="createAnAccount" defaults="Register" />
</RegisterLink>
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem
className="flex items-center gap-x-2"
onSelect={() =>
signOut({
redirect: false,
})
}
asChild
className="flex text-destructive items-center gap-x-2"
>
<RefreshCcwIcon className="h-4 w-4" />
<Trans i18nKey="forgetMe" />
{/* Don't use signOut() from next-auth. It doesn't work in vercel-production env. I don't know why. */}
<a href="/logout">
<RefreshCcwIcon className="h-4 w-4" />
<Trans i18nKey="forgetMe" />
</a>
</DropdownMenuItem>
</IfGuest>
<IfAuthenticated>
<DropdownMenuItem
className="flex items-center gap-x-2"
onSelect={() => signOut()}
>
<LogOutIcon className="h-4 w-4" />
<Trans i18nKey="logout" />
<DropdownMenuItem asChild className="flex items-center gap-x-2">
{/* Don't use signOut() from next-auth. It doesn't work in vercel-production env. I don't know why. */}
<a href="/logout">
<LogOutIcon className="h-4 w-4" />
<Trans i18nKey="logout" />
</a>
</DropdownMenuItem>
</IfAuthenticated>
</DropdownMenuContent>

View file

@ -1,7 +1,6 @@
"use client";
import Cookies from "js-cookie";
import { Session } from "next-auth";
import { signIn, useSession } from "next-auth/react";
import { useSession } from "next-auth/react";
import React from "react";
import { z } from "zod";
@ -59,27 +58,7 @@ export const IfGuest = (props: { children?: React.ReactNode }) => {
};
export const UserProvider = (props: { children?: React.ReactNode }) => {
const session = useSession({
required: true,
onUnauthenticated() {
// Begin: Legacy token migration
const legacyToken = Cookies.get("legacy-token");
// It's important to remove the token from the cookies,
// otherwise when the user signs out.
if (legacyToken) {
Cookies.remove("legacy-token");
signIn("legacy-token", {
token: legacyToken,
redirect: false,
});
} else {
// End: Legacy token migration
signIn("guest", {
redirect: false,
});
}
},
});
const session = useSession();
const user = session.data?.user;

View file

@ -1,14 +1,13 @@
import { randomid } from "@rallly/backend/utils/nanoid";
import languages from "@rallly/languages";
import languageParser from "accept-language-parser";
import { unsealData } from "iron-session/edge";
import { NextResponse } from "next/server";
import { encode } from "next-auth/jwt";
import withAuth from "next-auth/middleware";
import { initGuest } from "@/app/guest";
const supportedLocales = Object.keys(languages);
export default withAuth(
export const middleware = withAuth(
async function middleware(req) {
const { headers, nextUrl } = req;
const newUrl = nextUrl.clone();
@ -39,54 +38,7 @@ export default withAuth(
const res = NextResponse.rewrite(newUrl);
if (!req.nextauth.token) {
/**
* We moved from a bespoke session implementation to next-auth.
* This middleware looks for the old session cookie and moves it to
* a temporary cookie accessible to the client which will exchange it
* for a new session token with the legacy-token provider.
*/
const legacyToken = req.cookies.get("rallly-session");
if (legacyToken) {
// delete old cookie
res.cookies.delete("rallly-session");
// make sure old cookie isn't expired
const payload = await unsealData(legacyToken.value, {
password: process.env.SECRET_PASSWORD,
});
// if it's not expired, write it to a new cookie that we
// can read from the client
if (Object.keys(payload).length > 0) {
res.cookies.set({
name: "legacy-token",
value: legacyToken.value,
httpOnly: false,
});
} else {
// Create new guest user
const newUser = `user-${randomid()}`;
const token = await encode({
token: {
sub: newUser,
email: null,
},
secret: process.env.SECRET_PASSWORD,
});
const secure = process.env.NODE_ENV === "production";
const prefix = secure ? "__Secure-" : "";
const name = `${prefix}next-auth.session-token`;
res.cookies.set({
name,
value: token,
httpOnly: true,
secure,
sameSite: "lax",
path: "/",
});
}
}
}
await initGuest(req, res);
return res;
},

View file

@ -18,7 +18,6 @@ import CredentialsProvider from "next-auth/providers/credentials";
import EmailProvider from "next-auth/providers/email";
import { absoluteUrl } from "@/utils/absolute-url";
import { LegacyTokenProvider } from "@/utils/auth/legacy-token-provider";
import { mergeGuestsIntoUser } from "@/utils/auth/merge-user";
import { emailClient } from "@/utils/emails";
@ -30,7 +29,6 @@ const getAuthOptions = (...args: GetServerSessionParams) =>
strategy: "jwt",
},
providers: [
LegacyTokenProvider,
// When a user registers, we don't want to go through the email verification process
// so this providers allows us exchange the registration token for a session token
CredentialsProvider({
@ -139,7 +137,8 @@ const getAuthOptions = (...args: GetServerSessionParams) =>
return false;
}
}
} else {
} else if (user.email) {
// merge guest user into newly logged in user
const session = await getServerSession(...args);
if (session && session.user.email === null) {
await mergeGuestsIntoUser(user.id, [session.user.id]);
@ -198,8 +197,8 @@ type GetServerSessionParams =
| [NextApiRequest, NextApiResponse]
| [];
export function getServerSession(...args: GetServerSessionParams) {
return getServerSessionWithOptions(...args, getAuthOptions(...args));
export async function getServerSession(...args: GetServerSessionParams) {
return await getServerSessionWithOptions(...args, getAuthOptions(...args));
}
export async function AuthApiRoute(req: NextApiRequest, res: NextApiResponse) {

View file

@ -1,71 +0,0 @@
import { decryptToken } from "@rallly/backend/session";
import { prisma, TimeFormat } from "@rallly/database";
import CredentialsProvider from "next-auth/providers/credentials";
/**
* This provider allows us to login with a token from an older session created with
* iron-session.
*
* We should keep this provider available for at least 30 days in production to allow returning
* users to keep their existing sessions.
*
* @deprecated
*/
export const LegacyTokenProvider = CredentialsProvider({
id: "legacy-token",
name: "Legacy Token",
credentials: {
token: {
label: "Token",
type: "text",
},
},
async authorize(credentials) {
if (credentials?.token) {
const session = await decryptToken<{
user: {
id: string;
isGuest: boolean;
preferences?: {
weekStart?: number;
timeZone?: string;
timeFormat?: TimeFormat;
};
};
}>(credentials.token);
if (session?.user) {
if (session.user.isGuest) {
return {
id: session.user.id,
email: null,
weekStart: session.user.preferences?.weekStart,
timeZone: session.user.preferences?.timeZone,
timeFormat: session.user.preferences?.timeFormat,
};
} else {
const user = await prisma.user.findUnique({
where: {
id: session.user.id,
},
select: {
id: true,
email: true,
name: true,
},
});
if (user) {
return {
id: user.id,
name: user.name,
email: user.email,
};
}
}
}
}
return null;
},
});