diff --git a/apps/web/src/app/[locale]/logout/page.tsx b/apps/web/src/app/[locale]/logout/page.tsx deleted file mode 100644 index e622c0651..000000000 --- a/apps/web/src/app/[locale]/logout/page.tsx +++ /dev/null @@ -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; -} diff --git a/apps/web/src/app/[locale]/logout/route.ts b/apps/web/src/app/[locale]/logout/route.ts new file mode 100644 index 000000000..aec0ccd4f --- /dev/null +++ b/apps/web/src/app/[locale]/logout/route.ts @@ -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; +} diff --git a/apps/web/src/app/guest.ts b/apps/web/src/app/guest.ts new file mode 100644 index 000000000..3797feb0a --- /dev/null +++ b/apps/web/src/app/guest.ts @@ -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; +} diff --git a/apps/web/src/components/user-dropdown.tsx b/apps/web/src/components/user-dropdown.tsx index bdf9ad947..d8866738c 100644 --- a/apps/web/src/components/user-dropdown.tsx +++ b/apps/web/src/components/user-dropdown.tsx @@ -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 = () => { + - signOut({ - redirect: false, - }) - } + asChild + className="flex text-destructive 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. */} + + + + - signOut()} - > - - + + {/* Don't use signOut() from next-auth. It doesn't work in vercel-production env. I don't know why. */} + + + + diff --git a/apps/web/src/components/user-provider.tsx b/apps/web/src/components/user-provider.tsx index dbd018a37..d6684ecf6 100644 --- a/apps/web/src/components/user-provider.tsx +++ b/apps/web/src/components/user-provider.tsx @@ -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; diff --git a/apps/web/src/middleware.ts b/apps/web/src/middleware.ts index de148d4e0..de0ffe402 100644 --- a/apps/web/src/middleware.ts +++ b/apps/web/src/middleware.ts @@ -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; }, diff --git a/apps/web/src/utils/auth.ts b/apps/web/src/utils/auth.ts index cb997e4e1..fb40c4569 100644 --- a/apps/web/src/utils/auth.ts +++ b/apps/web/src/utils/auth.ts @@ -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) { diff --git a/apps/web/src/utils/auth/legacy-token-provider.ts b/apps/web/src/utils/auth/legacy-token-provider.ts deleted file mode 100644 index bde7ff264..000000000 --- a/apps/web/src/utils/auth/legacy-token-provider.ts +++ /dev/null @@ -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; - }, -});