Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .changeset/refresh-cookie-shape.md
Original file line number Diff line number Diff line change
@@ -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.
22 changes: 20 additions & 2 deletions packages/core/src/verifyRefreshCookie.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
42 changes: 42 additions & 0 deletions packages/core/tests/verifyRefreshCookie.test.js
Original file line number Diff line number Diff line change
@@ -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();
});
});
15 changes: 15 additions & 0 deletions packages/nextjs/tests/session.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
Loading