mirror of
https://github.com/lukevella/rallly.git
synced 2025-04-28 17:56:37 +02:00
🐛 Fix infinite loop when trying to migrate legacy cookie (#1561)
This commit is contained in:
parent
cb27ae9ea7
commit
ff4a1d16cb
14 changed files with 260 additions and 139 deletions
|
@ -5,6 +5,9 @@ SECRET_PASSWORD=abcdef1234567890abcdef1234567890
|
||||||
# Example: https://example.com
|
# Example: https://example.com
|
||||||
NEXT_PUBLIC_BASE_URL=http://localhost:3000
|
NEXT_PUBLIC_BASE_URL=http://localhost:3000
|
||||||
|
|
||||||
|
# AUTH_URL should be the same as NEXT_PUBLIC_BASE_URL
|
||||||
|
AUTH_URL=http://localhost:3000
|
||||||
|
|
||||||
# A connection string to your Postgres database
|
# A connection string to your Postgres database
|
||||||
DATABASE_URL="postgres://postgres:postgres@localhost:5450/rallly"
|
DATABASE_URL="postgres://postgres:postgres@localhost:5450/rallly"
|
||||||
|
|
||||||
|
|
|
@ -1,5 +1,6 @@
|
||||||
PORT=3002
|
PORT=3002
|
||||||
NEXT_PUBLIC_BASE_URL=http://localhost:3002
|
NEXT_PUBLIC_BASE_URL=http://localhost:3002
|
||||||
|
AUTH_URL=$NEXT_PUBLIC_BASE_URL
|
||||||
SECRET_PASSWORD=abcdef1234567890abcdef1234567890
|
SECRET_PASSWORD=abcdef1234567890abcdef1234567890
|
||||||
DATABASE_URL=postgres://postgres:postgres@localhost:5450/rallly
|
DATABASE_URL=postgres://postgres:postgres@localhost:5450/rallly
|
||||||
SUPPORT_EMAIL=support@rallly.co
|
SUPPORT_EMAIL=support@rallly.co
|
||||||
|
|
1
apps/web/src/auth/edge/index.ts
Normal file
1
apps/web/src/auth/edge/index.ts
Normal file
|
@ -0,0 +1 @@
|
||||||
|
export * from "./with-auth";
|
63
apps/web/src/auth/edge/with-auth.ts
Normal file
63
apps/web/src/auth/edge/with-auth.ts
Normal file
|
@ -0,0 +1,63 @@
|
||||||
|
import type { NextResponse } from "next/server";
|
||||||
|
import type { NextAuthRequest, Session } from "next-auth";
|
||||||
|
import NextAuth from "next-auth";
|
||||||
|
|
||||||
|
import { nextAuthConfig } from "@/next-auth.config";
|
||||||
|
|
||||||
|
import {
|
||||||
|
getLegacySession,
|
||||||
|
migrateLegacyJWT,
|
||||||
|
} from "../legacy/next-auth-cookie-migration";
|
||||||
|
|
||||||
|
const { auth } = NextAuth(nextAuthConfig);
|
||||||
|
|
||||||
|
export const withAuth = (
|
||||||
|
middleware: (request: NextAuthRequest) => Promise<NextResponse>,
|
||||||
|
) => {
|
||||||
|
return async (request: NextAuthRequest) => {
|
||||||
|
let legacySession: Session | null = null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
legacySession = await getLegacySession();
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
}
|
||||||
|
|
||||||
|
let session = legacySession;
|
||||||
|
|
||||||
|
if (!session) {
|
||||||
|
try {
|
||||||
|
session = await auth();
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await nextAuthConfig.callbacks.authorized({
|
||||||
|
request,
|
||||||
|
auth: session,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res !== true) {
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
}
|
||||||
|
|
||||||
|
request.auth = session;
|
||||||
|
|
||||||
|
const middlewareRes = await middleware(request);
|
||||||
|
|
||||||
|
if (legacySession) {
|
||||||
|
try {
|
||||||
|
await migrateLegacyJWT(middlewareRes);
|
||||||
|
} catch (e) {
|
||||||
|
console.error(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return middlewareRes;
|
||||||
|
};
|
||||||
|
};
|
|
@ -1,7 +1,6 @@
|
||||||
import hkdf from "@panva/hkdf";
|
import hkdf from "@panva/hkdf";
|
||||||
import { jwtDecrypt } from "jose";
|
import { jwtDecrypt } from "jose";
|
||||||
|
import type { JWT } from "next-auth/jwt";
|
||||||
import type { JWT } from "./types";
|
|
||||||
|
|
||||||
/** Decodes a NextAuth.js issued JWT. */
|
/** Decodes a NextAuth.js issued JWT. */
|
||||||
export async function decodeLegacyJWT(token: string): Promise<JWT | null> {
|
export async function decodeLegacyJWT(token: string): Promise<JWT | null> {
|
||||||
|
|
|
@ -1,46 +0,0 @@
|
||||||
export interface DefaultJWT extends Record<string, unknown> {
|
|
||||||
name?: string | null;
|
|
||||||
email?: string | null;
|
|
||||||
picture?: string | null;
|
|
||||||
sub?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Returned by the `jwt` callback and `getToken`, when using JWT sessions
|
|
||||||
*
|
|
||||||
* [`jwt` callback](https://next-auth.js.org/configuration/callbacks#jwt-callback) | [`getToken`](https://next-auth.js.org/tutorials/securing-pages-and-api-routes#using-gettoken)
|
|
||||||
*/
|
|
||||||
export interface JWT extends Record<string, unknown>, DefaultJWT {}
|
|
||||||
|
|
||||||
export interface JWTEncodeParams {
|
|
||||||
/** The JWT payload. */
|
|
||||||
token?: JWT;
|
|
||||||
/**
|
|
||||||
* Used in combination with `secret` when deriving the encryption secret for the various NextAuth.js-issued JWTs.
|
|
||||||
* @note When no `salt` is passed, we assume this is a session token.
|
|
||||||
* This is for backwards-compatibility with currently active sessions, so they won't be invalidated when upgrading the package.
|
|
||||||
*/
|
|
||||||
salt?: string;
|
|
||||||
/** The key material used to encode the NextAuth.js issued JWTs. Defaults to `NEXTAUTH_SECRET`. */
|
|
||||||
secret: string | Buffer;
|
|
||||||
/**
|
|
||||||
* The maximum age of the NextAuth.js issued JWT in seconds.
|
|
||||||
* @default 30 * 24 * 60 * 60 // 30 days
|
|
||||||
*/
|
|
||||||
maxAge?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface JWTDecodeParams {
|
|
||||||
/** The NextAuth.js issued JWT to be decoded */
|
|
||||||
token?: string;
|
|
||||||
/**
|
|
||||||
* Used in combination with `secret` when deriving the encryption secret for the various NextAuth.js-issued JWTs.
|
|
||||||
* @note When no `salt` is passed, we assume this is a session token.
|
|
||||||
* This is for backwards-compatibility with currently active sessions, so they won't be invalidated when upgrading the package.
|
|
||||||
*/
|
|
||||||
salt?: string;
|
|
||||||
/** The key material used to decode the NextAuth.js issued JWTs. Defaults to `NEXTAUTH_SECRET`. */
|
|
||||||
secret: string | Buffer;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type Secret = string | Buffer;
|
|
|
@ -1,65 +1,70 @@
|
||||||
import type { NextRequest } from "next/server";
|
import { absoluteUrl } from "@rallly/utils/absolute-url";
|
||||||
import { NextResponse } from "next/server";
|
import { cookies } from "next/headers";
|
||||||
|
import type { NextResponse } from "next/server";
|
||||||
|
import type { Session } from "next-auth";
|
||||||
import { encode } from "next-auth/jwt";
|
import { encode } from "next-auth/jwt";
|
||||||
|
|
||||||
import { decodeLegacyJWT } from "./helpers/jwt";
|
import { decodeLegacyJWT } from "./helpers/jwt";
|
||||||
|
|
||||||
const isSecureCookie =
|
const isSecureCookie = absoluteUrl().startsWith("https://");
|
||||||
process.env.NEXT_PUBLIC_BASE_URL?.startsWith("https://") ?? false;
|
|
||||||
|
|
||||||
const prefix = isSecureCookie ? "__Secure-" : "";
|
const prefix = isSecureCookie ? "__Secure-" : "";
|
||||||
|
|
||||||
const oldCookieName = prefix + "next-auth.session-token";
|
const oldCookieName = prefix + "next-auth.session-token";
|
||||||
const newCookieName = prefix + "authjs.session-token";
|
const newCookieName = prefix + "authjs.session-token";
|
||||||
|
|
||||||
|
export async function getLegacySession(): Promise<Session | null> {
|
||||||
|
const cookieStore = cookies();
|
||||||
|
const legacySessionCookie = cookieStore.get(oldCookieName);
|
||||||
|
if (legacySessionCookie) {
|
||||||
|
const decodedCookie = await decodeLegacyJWT(legacySessionCookie.value);
|
||||||
|
|
||||||
|
if (decodedCookie?.sub) {
|
||||||
|
const { sub: id, ...rest } = decodedCookie;
|
||||||
|
return {
|
||||||
|
user: { id, ...rest },
|
||||||
|
expires: decodedCookie.exp
|
||||||
|
? new Date(decodedCookie.exp * 1000).toISOString()
|
||||||
|
: new Date(Date.now() + 30 * 60 * 60 * 1000).toISOString(),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getLegacyJWT() {
|
||||||
|
const cookieStore = cookies();
|
||||||
|
const legacySessionCookie = cookieStore.get(oldCookieName);
|
||||||
|
if (legacySessionCookie) {
|
||||||
|
const decodedCookie = await decodeLegacyJWT(legacySessionCookie.value);
|
||||||
|
if (decodedCookie) {
|
||||||
|
return decodedCookie;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Migrates the next-auth cookies to the new authjs cookie names
|
* Replace the old legacy cookie with the new one
|
||||||
* This is needed for next-auth v5 which renamed the cookie prefix from 'next-auth' to 'authjs'
|
|
||||||
*/
|
*/
|
||||||
export function withAuthMigration(
|
export async function migrateLegacyJWT(res: NextResponse) {
|
||||||
middleware: (req: NextRequest) => void | Response | Promise<void | Response>,
|
const legacyJWT = await getLegacyJWT();
|
||||||
) {
|
|
||||||
return async (req: NextRequest) => {
|
|
||||||
const oldCookie = req.cookies.get(oldCookieName);
|
|
||||||
|
|
||||||
// If the old cookie doesn't exist, return the middleware
|
if (legacyJWT) {
|
||||||
if (!oldCookie) {
|
const newJWT = await encode({
|
||||||
return middleware(req);
|
token: legacyJWT,
|
||||||
}
|
|
||||||
|
|
||||||
const response = NextResponse.redirect(req.url);
|
|
||||||
response.cookies.delete(oldCookieName);
|
|
||||||
|
|
||||||
// If the new cookie exists, delete the old cookie first and rerun middleware
|
|
||||||
if (req.cookies.get(newCookieName)) {
|
|
||||||
return response;
|
|
||||||
}
|
|
||||||
|
|
||||||
const decodedCookie = await decodeLegacyJWT(oldCookie.value);
|
|
||||||
|
|
||||||
// If old cookie is invalid, delete the old cookie first and rerun middleware
|
|
||||||
if (!decodedCookie) {
|
|
||||||
return response;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Set the new cookie
|
|
||||||
const encodedCookie = await encode({
|
|
||||||
token: decodedCookie,
|
|
||||||
secret: process.env.SECRET_PASSWORD,
|
secret: process.env.SECRET_PASSWORD,
|
||||||
salt: newCookieName,
|
salt: newCookieName,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Set the new cookie with the same value and attributes
|
res.cookies.set(newCookieName, newJWT, {
|
||||||
response.cookies.set(newCookieName, encodedCookie, {
|
|
||||||
path: "/",
|
|
||||||
secure: isSecureCookie,
|
|
||||||
sameSite: "lax",
|
|
||||||
httpOnly: true,
|
httpOnly: true,
|
||||||
|
secure: isSecureCookie,
|
||||||
|
expires: new Date(Date.now() + 1000 * 60 * 60 * 24 * 7),
|
||||||
|
sameSite: "lax",
|
||||||
|
path: "/",
|
||||||
});
|
});
|
||||||
|
res.cookies.delete(oldCookieName);
|
||||||
// Delete the old cookie
|
}
|
||||||
response.cookies.delete(oldCookieName);
|
|
||||||
|
|
||||||
return response;
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,15 +0,0 @@
|
||||||
import type { NextRequest } from "next/server";
|
|
||||||
import type { NextAuthRequest } from "next-auth";
|
|
||||||
import NextAuth from "next-auth";
|
|
||||||
|
|
||||||
import { nextAuthConfig } from "@/next-auth.config";
|
|
||||||
|
|
||||||
const { auth } = NextAuth(nextAuthConfig);
|
|
||||||
|
|
||||||
export function withAuth(
|
|
||||||
middleware: (
|
|
||||||
req: NextAuthRequest,
|
|
||||||
) => void | Response | Promise<void | Response>,
|
|
||||||
): (req: NextRequest) => void | Response | Promise<void | Response> {
|
|
||||||
return (req: NextRequest) => auth(middleware)(req, undefined as never);
|
|
||||||
}
|
|
|
@ -3,13 +3,11 @@ import { withPostHog } from "@rallly/posthog/next/middleware";
|
||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
|
|
||||||
import { getLocaleFromHeader } from "@/app/guest";
|
import { getLocaleFromHeader } from "@/app/guest";
|
||||||
import { withAuthMigration } from "@/auth/legacy/next-auth-cookie-migration";
|
import { withAuth } from "@/auth/edge";
|
||||||
import { withAuth } from "@/auth/middleware";
|
|
||||||
|
|
||||||
const supportedLocales = Object.keys(languages);
|
const supportedLocales = Object.keys(languages);
|
||||||
|
|
||||||
export const middleware = withAuthMigration(
|
export const middleware = withAuth(async (req) => {
|
||||||
withAuth(async (req) => {
|
|
||||||
const { nextUrl } = req;
|
const { nextUrl } = req;
|
||||||
const newUrl = nextUrl.clone();
|
const newUrl = nextUrl.clone();
|
||||||
|
|
||||||
|
@ -39,8 +37,7 @@ export const middleware = withAuthMigration(
|
||||||
}
|
}
|
||||||
|
|
||||||
return res;
|
return res;
|
||||||
}),
|
});
|
||||||
);
|
|
||||||
|
|
||||||
export const config = {
|
export const config = {
|
||||||
matcher: ["/((?!api|_next/static|_next/image|static|.*\\.).*)"],
|
matcher: ["/((?!api|_next/static|_next/image|static|.*\\.).*)"],
|
||||||
|
|
|
@ -7,6 +7,7 @@ import z from "zod";
|
||||||
import { CustomPrismaAdapter } from "./auth/adapters/prisma";
|
import { CustomPrismaAdapter } from "./auth/adapters/prisma";
|
||||||
import { isEmailBlocked } from "./auth/helpers/is-email-blocked";
|
import { isEmailBlocked } from "./auth/helpers/is-email-blocked";
|
||||||
import { mergeGuestsIntoUser } from "./auth/helpers/merge-user";
|
import { mergeGuestsIntoUser } from "./auth/helpers/merge-user";
|
||||||
|
import { getLegacySession } from "./auth/legacy/next-auth-cookie-migration";
|
||||||
import { EmailProvider } from "./auth/providers/email";
|
import { EmailProvider } from "./auth/providers/email";
|
||||||
import { GoogleProvider } from "./auth/providers/google";
|
import { GoogleProvider } from "./auth/providers/google";
|
||||||
import { GuestProvider } from "./auth/providers/guest";
|
import { GuestProvider } from "./auth/providers/guest";
|
||||||
|
@ -22,7 +23,12 @@ const sessionUpdateSchema = z.object({
|
||||||
weekStart: z.number().nullish(),
|
weekStart: z.number().nullish(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const { auth, handlers, signIn, signOut } = NextAuth({
|
const {
|
||||||
|
auth: originalAuth,
|
||||||
|
handlers,
|
||||||
|
signIn,
|
||||||
|
signOut,
|
||||||
|
} = NextAuth({
|
||||||
...nextAuthConfig,
|
...nextAuthConfig,
|
||||||
adapter: CustomPrismaAdapter({
|
adapter: CustomPrismaAdapter({
|
||||||
migrateData: async (userId) => {
|
migrateData: async (userId) => {
|
||||||
|
@ -169,3 +175,14 @@ export const { auth, handlers, signIn, signOut } = NextAuth({
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const auth = async () => {
|
||||||
|
const session = await getLegacySession();
|
||||||
|
if (session) {
|
||||||
|
return session;
|
||||||
|
}
|
||||||
|
|
||||||
|
return originalAuth();
|
||||||
|
};
|
||||||
|
|
||||||
|
export { auth, handlers, signIn, signOut };
|
||||||
|
|
|
@ -1,8 +1,10 @@
|
||||||
|
import { absoluteUrl } from "@rallly/utils/absolute-url";
|
||||||
|
|
||||||
export const sessionConfig = {
|
export const sessionConfig = {
|
||||||
password: process.env.SECRET_PASSWORD ?? "",
|
password: process.env.SECRET_PASSWORD ?? "",
|
||||||
cookieName: "rallly-session",
|
cookieName: "rallly-session",
|
||||||
cookieOptions: {
|
cookieOptions: {
|
||||||
secure: process.env.NEXT_PUBLIC_BASE_URL?.startsWith("https://") ?? false,
|
secure: absoluteUrl().startsWith("https://") ?? false,
|
||||||
},
|
},
|
||||||
ttl: 60 * 60 * 24 * 30, // 30 days
|
ttl: 60 * 60 * 24 * 30, // 30 days
|
||||||
};
|
};
|
||||||
|
|
36
apps/web/tests/helpers/next-auth-v4.ts
Normal file
36
apps/web/tests/helpers/next-auth-v4.ts
Normal file
|
@ -0,0 +1,36 @@
|
||||||
|
import hkdf from "@panva/hkdf";
|
||||||
|
import { EncryptJWT } from "jose";
|
||||||
|
import type { JWT } from "next-auth/jwt";
|
||||||
|
|
||||||
|
const now = () => (Date.now() / 1000) | 0;
|
||||||
|
export async function getDerivedEncryptionKey(
|
||||||
|
keyMaterial: string | Buffer,
|
||||||
|
salt: string,
|
||||||
|
) {
|
||||||
|
return await hkdf(
|
||||||
|
"sha256",
|
||||||
|
keyMaterial,
|
||||||
|
salt,
|
||||||
|
`NextAuth.js Generated Encryption Key${salt ? ` (${salt})` : ""}`,
|
||||||
|
32,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface JWTEncodeParams {
|
||||||
|
token?: JWT;
|
||||||
|
salt?: string;
|
||||||
|
secret: string | Buffer;
|
||||||
|
maxAge?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function encode(params: JWTEncodeParams) {
|
||||||
|
/** @note empty `salt` means a session token. See {@link JWTEncodeParams.salt}. */
|
||||||
|
const { token = {}, secret, maxAge = 30 * 24 * 60 * 60, salt = "" } = params;
|
||||||
|
const encryptionSecret = await getDerivedEncryptionKey(secret, salt);
|
||||||
|
return await new EncryptJWT(token)
|
||||||
|
.setProtectedHeader({ alg: "dir", enc: "A256GCM" })
|
||||||
|
.setIssuedAt()
|
||||||
|
.setExpirationTime(now() + maxAge)
|
||||||
|
.setJti("some-random-id")
|
||||||
|
.encrypt(encryptionSecret);
|
||||||
|
}
|
57
apps/web/tests/next-auth-migration.spec.ts
Normal file
57
apps/web/tests/next-auth-migration.spec.ts
Normal file
|
@ -0,0 +1,57 @@
|
||||||
|
import { expect, test } from "@playwright/test";
|
||||||
|
import { prisma } from "@rallly/database";
|
||||||
|
|
||||||
|
import { encode } from "./helpers/next-auth-v4";
|
||||||
|
|
||||||
|
const legacyGuestId = "user-1234";
|
||||||
|
|
||||||
|
test.describe.serial(() => {
|
||||||
|
test.beforeAll(async () => {
|
||||||
|
await prisma.poll.create({
|
||||||
|
data: {
|
||||||
|
id: "legacy-guest-poll",
|
||||||
|
title: "Test Poll",
|
||||||
|
adminUrlId: "admin-url-id",
|
||||||
|
participantUrlId: "participant-url-id",
|
||||||
|
guestId: legacyGuestId,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
test.afterAll(async () => {
|
||||||
|
await prisma.poll.delete({
|
||||||
|
where: {
|
||||||
|
id: "legacy-guest-poll",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
test("should see poll on login page", async ({ page }) => {
|
||||||
|
const context = page.context();
|
||||||
|
const legacyToken = await encode({
|
||||||
|
token: {
|
||||||
|
sub: legacyGuestId,
|
||||||
|
},
|
||||||
|
secret: process.env.SECRET_PASSWORD,
|
||||||
|
});
|
||||||
|
|
||||||
|
// set cookie to simulate legacy guest
|
||||||
|
await context.addCookies([
|
||||||
|
{
|
||||||
|
name: "next-auth.session-token",
|
||||||
|
value: legacyToken,
|
||||||
|
httpOnly: true,
|
||||||
|
expires: Date.now() / 1000 + 60 * 60 * 24 * 7,
|
||||||
|
secure: false,
|
||||||
|
sameSite: "Lax",
|
||||||
|
domain: "localhost",
|
||||||
|
path: "/",
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
// For some reason it doesn't work unless we need to redirect
|
||||||
|
await page.goto("/login");
|
||||||
|
|
||||||
|
// Check if the poll title exists in the page content
|
||||||
|
await expect(page.getByText("Test Poll")).toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
|
@ -2,6 +2,7 @@
|
||||||
set -e
|
set -e
|
||||||
|
|
||||||
export DIRECT_DATABASE_URL=$DATABASE_URL
|
export DIRECT_DATABASE_URL=$DATABASE_URL
|
||||||
|
export AUTH_URL=$NEXT_PUBLIC_BASE_URL
|
||||||
|
|
||||||
prisma migrate deploy --schema=./prisma/schema.prisma
|
prisma migrate deploy --schema=./prisma/schema.prisma
|
||||||
node apps/web/server.js
|
node apps/web/server.js
|
||||||
|
|
Loading…
Add table
Reference in a new issue