From 71ea2d03dfba57dc6db53c200eb66ccacecc689b Mon Sep 17 00:00:00 2001 From: Brandon Corbett Date: Sun, 1 Nov 2026 15:00:02 -0500 Subject: [PATCH] fix: accept only refresh cookies in verifyRefreshCookie --- .changeset/refresh-cookie-shape.md | 6 +++ packages/core/src/verifyRefreshCookie.ts | 22 +++++++++- .../core/tests/verifyRefreshCookie.test.js | 42 +++++++++++++++++++ packages/nextjs/tests/session.test.js | 15 +++++++ 4 files changed, 83 insertions(+), 2 deletions(-) create mode 100644 .changeset/refresh-cookie-shape.md create mode 100644 packages/core/tests/verifyRefreshCookie.test.js diff --git a/.changeset/refresh-cookie-shape.md b/.changeset/refresh-cookie-shape.md new file mode 100644 index 0000000..1105cb5 --- /dev/null +++ b/.changeset/refresh-cookie-shape.md @@ -0,0 +1,6 @@ +--- +"@seamless-auth/core": patch +"@seamless-auth/nextjs": patch +--- + +`verifyRefreshCookie` accepts only a refresh cookie: a signed payload with a non-empty `sub` and `refreshToken` and no upstream `token`. Every adapter cookie is signed with the same secret, so before this a pre-auth, registration or access cookie placed in the refresh cookie slot verified too, and `hasSeamlessSession` in `@seamless-auth/nextjs` reported a session for it. Refresh cookies issued by earlier versions already have this shape and keep working. diff --git a/packages/core/src/verifyRefreshCookie.ts b/packages/core/src/verifyRefreshCookie.ts index 8895d80..014dd5f 100644 --- a/packages/core/src/verifyRefreshCookie.ts +++ b/packages/core/src/verifyRefreshCookie.ts @@ -9,11 +9,29 @@ export function verifyRefreshCookie( token: string, cookieSecret: string, ): RefreshCookiePayload | null { + let payload: JwtPayload | string; try { - return jwt.verify(token, cookieSecret, { + payload = jwt.verify(token, cookieSecret, { algorithms: ["HS256"], - }) as RefreshCookiePayload; + }); } catch { return null; } + + // Every adapter cookie is signed with the same secret, and /login hands out a + // pre-auth cookie for any existing account from its email address alone. So the + // signature does not make this a refresh cookie. Only a refresh cookie holds a + // refresh token, and none of them holds an upstream access or ephemeral token. + if ( + typeof payload !== "object" || + typeof payload.sub !== "string" || + !payload.sub || + typeof payload.refreshToken !== "string" || + !payload.refreshToken || + "token" in payload + ) { + return null; + } + + return payload as RefreshCookiePayload; } diff --git a/packages/core/tests/verifyRefreshCookie.test.js b/packages/core/tests/verifyRefreshCookie.test.js new file mode 100644 index 0000000..772ffd5 --- /dev/null +++ b/packages/core/tests/verifyRefreshCookie.test.js @@ -0,0 +1,42 @@ +import jwt from "jsonwebtoken"; + +const { verifyRefreshCookie } = await import("../dist/verifyRefreshCookie.js"); + +const COOKIE_SECRET = "cookie-secret-cookie-secret-cookie-secret"; + +const apiToken = (typ) => jwt.sign({ sub: "user-1", typ }, "upstream-signing-key"); +const cookie = (payload, secret = COOKIE_SECRET) => + jwt.sign(payload, secret, { algorithm: "HS256", expiresIn: "300s" }); + +// Every adapter cookie is signed with the same secret, so a pre-auth cookie from +// /login must not pass for a refresh cookie. +describe("verifyRefreshCookie only accepts a refresh cookie", () => { + it("accepts a cookie carrying a refresh token", () => { + expect( + verifyRefreshCookie(cookie({ sub: "user-1", refreshToken: "opaque" }), COOKIE_SECRET), + ).toMatchObject({ sub: "user-1", refreshToken: "opaque" }); + }); + + it.each([ + ["the pre-auth or registration cookie", { sub: "user-1", token: apiToken("ephemeral") }], + ["the access cookie", { sub: "user-1", token: apiToken("access"), roles: [] }], + [ + "a cookie carrying both tokens", + { sub: "user-1", token: apiToken("access"), refreshToken: "opaque" }, + ], + ["a cookie with an empty refresh token", { sub: "user-1", refreshToken: "" }], + ["a cookie whose refresh token is not a string", { sub: "user-1", refreshToken: 1 }], + ["a cookie without a subject", { refreshToken: "opaque" }], + ])("refuses %s", (_label, payload) => { + expect(verifyRefreshCookie(cookie(payload), COOKIE_SECRET)).toBeNull(); + }); + + it("refuses a cookie signed with another secret", () => { + expect( + verifyRefreshCookie( + cookie({ sub: "user-1", refreshToken: "opaque" }, "another-secret-another-secret-another"), + COOKIE_SECRET, + ), + ).toBeNull(); + }); +}); diff --git a/packages/nextjs/tests/session.test.js b/packages/nextjs/tests/session.test.js index 448664d..93cfb5b 100644 --- a/packages/nextjs/tests/session.test.js +++ b/packages/nextjs/tests/session.test.js @@ -232,6 +232,21 @@ describe("session helpers", () => { ], ["only a valid refresh cookie", { "seamless-refresh": refresh() }, true], ["an expired refresh cookie", { "seamless-refresh": refresh("-1s") }, false], + [ + "a pre-auth cookie copied into the refresh slot", + { + "seamless-refresh": signed({ + sub: "user-123", + token: jwt.sign({ sub: "user-123", typ: "ephemeral" }, "upstream-signing-key"), + }), + }, + false, + ], + [ + "an access cookie copied into the refresh slot", + { "seamless-refresh": signed(ACCESS) }, + false, + ], ["no cookies", {}, false], [ "a tampered refresh cookie",