From 2f5650093d4973c6f2c524890edae63c05fccd13 Mon Sep 17 00:00:00 2001 From: Aadesh Kheria Date: Sun, 19 Apr 2026 20:06:20 -0700 Subject: [PATCH 1/8] added new stack server token --- .../src/app/api/latest/integrations/idp.ts | 10 +- docker/server/.env | 1 + docker/server/.env.example | 2 + packages/stack-shared/src/utils/jwt.test.ts | 246 ++++++++++++++++++ packages/stack-shared/src/utils/jwt.tsx | 65 +++-- 5 files changed, 303 insertions(+), 21 deletions(-) create mode 100644 packages/stack-shared/src/utils/jwt.test.ts diff --git a/apps/backend/src/app/api/latest/integrations/idp.ts b/apps/backend/src/app/api/latest/integrations/idp.ts index 36a5944123..db28469d8b 100644 --- a/apps/backend/src/app/api/latest/integrations/idp.ts +++ b/apps/backend/src/app/api/latest/integrations/idp.ts @@ -1,10 +1,10 @@ -import { globalPrismaClient, retryTransaction } from '@/prisma-client'; import { Prisma } from '@/generated/prisma/client'; +import { globalPrismaClient, retryTransaction } from '@/prisma-client'; import { decodeBase64OrBase64Url, toHexString } from '@stackframe/stack-shared/dist/utils/bytes'; import { getEnvVariable } from '@stackframe/stack-shared/dist/utils/env'; import { StackAssertionError, captureError, throwErr } from '@stackframe/stack-shared/dist/utils/errors'; import { sha512 } from '@stackframe/stack-shared/dist/utils/hashes'; -import { getPrivateJwks, getPublicJwkSet } from '@stackframe/stack-shared/dist/utils/jwt'; +import { getOldStackServerSecret, getPrivateJwks, getPublicJwkSet } from '@stackframe/stack-shared/dist/utils/jwt'; import { deindent } from '@stackframe/stack-shared/dist/utils/strings'; import { generateUuid } from '@stackframe/stack-shared/dist/utils/uuids'; import Provider, { Adapter, AdapterConstructor, AdapterPayload } from 'oidc-provider'; @@ -176,8 +176,14 @@ export async function createOidcProvider(options: { id: string, baseUrl: string, clients: JSON.parse(getEnvVariable("STACK_INTEGRATION_CLIENTS_CONFIG", "[]")), ttl: {}, cookies: { + // oidc-provider passes these to Koa keygrip: index 0 signs new cookies, any entry verifies. + // During a STACK_SERVER_SECRET rotation, the old-secret-derived key is appended so cookies + // issued before the rotation remain readable until they expire naturally. keys: [ toHexString(await sha512(`oidc-idp-cookie-encryption-key:${getEnvVariable("STACK_SERVER_SECRET")}`)), + ...(getOldStackServerSecret() + ? [toHexString(await sha512(`oidc-idp-cookie-encryption-key:${getOldStackServerSecret()}`))] + : []), ], }, jwks: privateJwkSet, diff --git a/docker/server/.env b/docker/server/.env index 22e29e1348..4d1179e558 100644 --- a/docker/server/.env +++ b/docker/server/.env @@ -4,6 +4,7 @@ NEXT_PUBLIC_STACK_DASHBOARD_URL=# https://your-dashboard-domain.com, this will b STACK_DATABASE_CONNECTION_STRING=# postgres connection string STACK_SERVER_SECRET=# a 32 bytes base64url encoded random string, used for JWT encryption. can be generated with `pnpm generate-keys` +STACK_SERVER_SECRET_OLD=# optional: set to the previous STACK_SERVER_SECRET during a rotation. Accepted for verification only. Remove after the grace window. # seed script settings STACK_SEED_INTERNAL_PROJECT_SIGN_UP_ENABLED=# true to enable user sign up to the dashboard when seeding diff --git a/docker/server/.env.example b/docker/server/.env.example index cbba7e67ce..9c48ece45a 100644 --- a/docker/server/.env.example +++ b/docker/server/.env.example @@ -6,6 +6,8 @@ NEXT_PUBLIC_STACK_DASHBOARD_URL=http://localhost:8101 STACK_DATABASE_CONNECTION_STRING=postgres://postgres:password@host.docker.internal:8128/stackframe STACK_SERVER_SECRET=23-wuNpik0gIW4mruTz25rbIvhuuvZFrLOLtL7J4tyo +# Remove after the grace window +STACK_SERVER_SECRET_OLD= STACK_SEED_INTERNAL_PROJECT_ALLOW_LOCALHOST=true STACK_SEED_INTERNAL_PROJECT_SIGN_UP_ENABLED=true diff --git a/packages/stack-shared/src/utils/jwt.test.ts b/packages/stack-shared/src/utils/jwt.test.ts new file mode 100644 index 0000000000..e14f2c67b7 --- /dev/null +++ b/packages/stack-shared/src/utils/jwt.test.ts @@ -0,0 +1,246 @@ +import crypto from "crypto"; +import * as jose from "jose"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { toHexString } from "./bytes"; +import { sha512 } from "./hashes"; +import { getOldStackServerSecret, getPrivateJwks, getPublicJwkSet, signJWT, verifyJWT } from "./jwt"; + +const randomSecret = () => jose.base64url.encode(crypto.randomBytes(32)); + +// Mirrors the derivation used in apps/backend/src/app/api/latest/integrations/idp.ts. +// Keeping it identical here pins the algorithm contract across the two call sites. +async function deriveOidcCookieKey(secret: string): Promise { + return toHexString(await sha512(`oidc-idp-cookie-encryption-key:${secret}`)); +} + +// Mirrors the `cookies.keys` array built in idp.ts under the currently-set env vars. +async function buildOidcCookieKeys(): Promise { + const primary = process.env.STACK_SERVER_SECRET!; + const old = getOldStackServerSecret(); + return [ + await deriveOidcCookieKey(primary), + ...(old ? [await deriveOidcCookieKey(old)] : []), + ]; +} + +// signJWT only accepts string expirations; for the expiry test we need an explicit past +// timestamp, so we drop down to jose directly, reusing the same primary private JWK. +async function signJWTWithExplicitExp(options: { + audience: string, + issuer: string, + expUnixSeconds: number, +}) { + const jwks = await getPrivateJwks({ audience: options.audience }); + const privateKey = await jose.importJWK(jwks[0]); + return await new jose.SignJWT({}) + .setProtectedHeader({ alg: "ES256", kid: jwks[0].kid }) + .setIssuer(options.issuer) + .setIssuedAt(options.expUnixSeconds - 120) + .setAudience(options.audience) + .setExpirationTime(options.expUnixSeconds) + .sign(privateKey); +} + +describe("STACK_SERVER_SECRET rotation — Deploy 1 invariants", () => { + const savedPrimary = process.env.STACK_SERVER_SECRET; + const savedOld = process.env.STACK_SERVER_SECRET_OLD; + + beforeEach(() => { + delete process.env.STACK_SERVER_SECRET; + delete process.env.STACK_SERVER_SECRET_OLD; + }); + + afterEach(() => { + if (savedPrimary === undefined) delete process.env.STACK_SERVER_SECRET; + else process.env.STACK_SERVER_SECRET = savedPrimary; + if (savedOld === undefined) delete process.env.STACK_SERVER_SECRET_OLD; + else process.env.STACK_SERVER_SECRET_OLD = savedOld; + }); + + it("1. new login after Deploy 1: fresh JWT signs with new secret, verifies, and carries the new kid", async () => { + const newSecret = randomSecret(); + const oldSecret = randomSecret(); + process.env.STACK_SERVER_SECRET = newSecret; + process.env.STACK_SERVER_SECRET_OLD = oldSecret; + + const jwt = await signJWT({ issuer: "iss", audience: "aud", payload: { sub: "user-1" } }); + const payload = await verifyJWT({ allowedIssuers: ["iss"], jwt }); + expect(payload.sub).toBe("user-1"); + + const jwks = await getPrivateJwks({ audience: "aud" }); + expect(jose.decodeProtectedHeader(jwt).kid).toBe(jwks[0].kid); + }); + + it("2. old access token still works after Deploy 1: JWT signed with old secret verifies post-rotation", async () => { + const oldSecret = randomSecret(); + const newSecret = randomSecret(); + + process.env.STACK_SERVER_SECRET = oldSecret; + const oldJwt = await signJWT({ issuer: "iss", audience: "aud", payload: { sub: "user-2" } }); + + process.env.STACK_SERVER_SECRET = newSecret; + process.env.STACK_SERVER_SECRET_OLD = oldSecret; + + const payload = await verifyJWT({ allowedIssuers: ["iss"], jwt: oldJwt }); + expect(payload.sub).toBe("user-2"); + }); + + it("3. any JWT minted during Deploy 1 carries the new-secret kid (refresh-flow invariant; refresh itself is DB-backed)", async () => { + // The refresh exchange lives in apps/backend/src/lib/tokens.tsx and is not covered here. + // What this test pins is the JWT-layer invariant that the refresh exchange relies on: + // any access token minted while both secrets are configured carries the new-secret kid. + const oldSecret = randomSecret(); + const newSecret = randomSecret(); + + process.env.STACK_SERVER_SECRET = oldSecret; + const preRotationKids = new Set( + (await getPrivateJwks({ audience: "aud" })).map(j => j.kid), + ); + + process.env.STACK_SERVER_SECRET = newSecret; + process.env.STACK_SERVER_SECRET_OLD = oldSecret; + + const mintedJwt = await signJWT({ issuer: "iss", audience: "aud", payload: { sub: "user-3" } }); + const header = jose.decodeProtectedHeader(mintedJwt); + expect(preRotationKids.has(header.kid as string)).toBe(false); + await expect(verifyJWT({ allowedIssuers: ["iss"], jwt: mintedJwt })).resolves.toBeTruthy(); + }); + + it("4. verification accepts both old-signed and new-signed JWTs during overlap", async () => { + const oldSecret = randomSecret(); + const newSecret = randomSecret(); + + process.env.STACK_SERVER_SECRET = oldSecret; + const oldJwt = await signJWT({ issuer: "iss", audience: "aud", payload: { kind: "old" } }); + + process.env.STACK_SERVER_SECRET = newSecret; + process.env.STACK_SERVER_SECRET_OLD = oldSecret; + const newJwt = await signJWT({ issuer: "iss", audience: "aud", payload: { kind: "new" } }); + + expect((await verifyJWT({ allowedIssuers: ["iss"], jwt: oldJwt })).kind).toBe("old"); + expect((await verifyJWT({ allowedIssuers: ["iss"], jwt: newJwt })).kind).toBe("new"); + }); + + it("5. after Deploy 1, new JWTs are never signed with the old secret (no signing overlap)", async () => { + const oldSecret = randomSecret(); + const newSecret = randomSecret(); + + process.env.STACK_SERVER_SECRET = oldSecret; + const oldSecretKids = new Set( + (await getPrivateJwks({ audience: "aud" })).map(j => j.kid), + ); + + process.env.STACK_SERVER_SECRET = newSecret; + process.env.STACK_SERVER_SECRET_OLD = oldSecret; + + const jwt = await signJWT({ issuer: "iss", audience: "aud", payload: {} }); + expect(oldSecretKids.has(jose.decodeProtectedHeader(jwt).kid as string)).toBe(false); + }); + + it("6. in-progress OIDC flow: cookie key derived from the old secret stays in the verify set during overlap", async () => { + const oldSecret = randomSecret(); + const newSecret = randomSecret(); + process.env.STACK_SERVER_SECRET = newSecret; + process.env.STACK_SERVER_SECRET_OLD = oldSecret; + + const keys = await buildOidcCookieKeys(); + // Koa keygrip (used by oidc-provider for `cookies.keys`) verifies against any entry. + expect(keys).toContain(await deriveOidcCookieKey(oldSecret)); + }); + + it("7. new OIDC flow after Deploy 1 signs cookies with the new-secret-derived key", async () => { + const oldSecret = randomSecret(); + const newSecret = randomSecret(); + process.env.STACK_SERVER_SECRET = newSecret; + process.env.STACK_SERVER_SECRET_OLD = oldSecret; + + const keys = await buildOidcCookieKeys(); + // Koa keygrip signs using keys[0], so keys[0] must be the new-secret derivation. + expect(keys[0]).toBe(await deriveOidcCookieKey(newSecret)); + expect(keys[0]).not.toBe(await deriveOidcCookieKey(oldSecret)); + }); + + it("8. tampered, third-party-signed, or garbage JWTs are rejected during overlap", async () => { + const oldSecret = randomSecret(); + const newSecret = randomSecret(); + const unrelatedSecret = randomSecret(); + + // (a) signed by a totally unrelated secret — not in the verify set + process.env.STACK_SERVER_SECRET = unrelatedSecret; + const unrelatedJwt = await signJWT({ issuer: "iss", audience: "aud", payload: {} }); + + process.env.STACK_SERVER_SECRET = newSecret; + process.env.STACK_SERVER_SECRET_OLD = oldSecret; + await expect(verifyJWT({ allowedIssuers: ["iss"], jwt: unrelatedJwt })).rejects.toThrow(); + + // (b) tampered signature on an otherwise-valid JWT + const goodJwt = await signJWT({ issuer: "iss", audience: "aud", payload: {} }); + const [h, p] = goodJwt.split("."); + const tampered = `${h}.${p}.AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA`; + await expect(verifyJWT({ allowedIssuers: ["iss"], jwt: tampered })).rejects.toThrow(); + + // (c) complete garbage + await expect(verifyJWT({ allowedIssuers: ["iss"], jwt: "not.a.jwt" })).rejects.toThrow(); + }); + + it("9. expired old-signed JWT is rejected on exp even though its signature still verifies", async () => { + const oldSecret = randomSecret(); + const newSecret = randomSecret(); + + process.env.STACK_SERVER_SECRET = oldSecret; + const expiredJwt = await signJWTWithExplicitExp({ + audience: "aud", + issuer: "iss", + expUnixSeconds: Math.floor(Date.now() / 1000) - 60, + }); + + process.env.STACK_SERVER_SECRET = newSecret; + process.env.STACK_SERVER_SECRET_OLD = oldSecret; + + await expect(verifyJWT({ allowedIssuers: ["iss"], jwt: expiredJwt })).rejects.toThrow(/exp/i); + }); + + it("10. overlap JWKS equals the union of the new-secret-only and old-secret-only public sets, with no private scalars", async () => { + const oldSecret = randomSecret(); + const newSecret = randomSecret(); + + // New-secret-only public set (what the JWKS looks like before Deploy 1 and after Deploy 2). + process.env.STACK_SERVER_SECRET = newSecret; + const newOnly = await getPublicJwkSet(await getPrivateJwks({ audience: "aud" })); + expect(newOnly.keys).toHaveLength(2); + + // Old-secret-only public set (what the JWKS looked like before the rotation started). + process.env.STACK_SERVER_SECRET = oldSecret; + delete process.env.STACK_SERVER_SECRET_OLD; + const oldOnly = await getPublicJwkSet(await getPrivateJwks({ audience: "aud" })); + expect(oldOnly.keys).toHaveLength(2); + + // Overlap set during Deploy 1. + process.env.STACK_SERVER_SECRET = newSecret; + process.env.STACK_SERVER_SECRET_OLD = oldSecret; + const overlap = await getPublicJwkSet(await getPrivateJwks({ audience: "aud" })); + expect(overlap.keys).toHaveLength(4); + + // Identity: overlap kids == (new-only kids) ∪ (old-only kids). + const overlapKids = new Set(overlap.keys.map(k => k.kid)); + const expectedUnionKids = new Set([ + ...newOnly.keys.map(k => k.kid), + ...oldOnly.keys.map(k => k.kid), + ]); + expect(overlapKids).toEqual(expectedUnionKids); + + // Sanity: the two secrets produce disjoint kids (they're derived by hashing the secret). + const newKids = new Set(newOnly.keys.map(k => k.kid)); + const oldKids = new Set(oldOnly.keys.map(k => k.kid)); + for (const k of newKids) expect(oldKids.has(k)).toBe(false); + + // The public JWKs must not leak the private scalar `d`. + for (const k of overlap.keys) expect((k as unknown as { d?: unknown }).d).toBeUndefined(); + }); + + it("rejects STACK_SERVER_SECRET_OLD that is set but not valid base64url", async () => { + process.env.STACK_SERVER_SECRET = randomSecret(); + process.env.STACK_SERVER_SECRET_OLD = "not valid base64url!!!"; + await expect(getPrivateJwks({ audience: "aud" })).rejects.toThrow(/STACK_SERVER_SECRET_OLD/); + }); +}); diff --git a/packages/stack-shared/src/utils/jwt.tsx b/packages/stack-shared/src/utils/jwt.tsx index 922d881f04..bd4a9b3a97 100644 --- a/packages/stack-shared/src/utils/jwt.tsx +++ b/packages/stack-shared/src/utils/jwt.tsx @@ -20,6 +20,23 @@ function getStackServerSecret() { return STACK_SERVER_SECRET; } +/** + * Returns the previous `STACK_SERVER_SECRET` during a rotation, or `null` if none is set. + * + * When set, keys derived from this secret are accepted for verification (JWTs and OIDC cookies) + * but never used for signing new artifacts. Remove the env var once the grace window has + * elapsed — see the self-host rotation runbook. + */ +export function getOldStackServerSecret(): string | null { + const STACK_SERVER_SECRET_OLD = getEnvVariable("STACK_SERVER_SECRET_OLD", ""); + try { + jose.base64url.decode(STACK_SERVER_SECRET_OLD); + } catch (e) { + throw new StackAssertionError("STACK_SERVER_SECRET_OLD is set but not a valid base64url string. Remove it, or set it to the previous STACK_SERVER_SECRET value.", { cause: e }); + } + return STACK_SERVER_SECRET_OLD; +} + export async function getJwtInfo(options: { jwt: string, }) { @@ -103,26 +120,35 @@ async function getPrivateJwkFromDerivedSecret(derivedSecret: string, kid: string export async function getPrivateJwks(options: { audience: string, }): Promise { - const getHashOfJwkInfo = (type: string) => jose.base64url.encode( - crypto - .createHash('sha256') - .update(JSON.stringify([type, getStackServerSecret(), { - audience: options.audience, - }])) - .digest() - ); - const perAudienceSecret = getHashOfJwkInfo("stack-jwk-audience-secret"); - const perAudienceKid = getHashOfJwkInfo("stack-jwk-kid").slice(0, 12); - - const oldPerAudienceSecret = oldGetPerAudienceSecret({ audience: options.audience }); - const oldPerAudienceKid = oldGetKid({ secret: oldPerAudienceSecret }); + const derivePairForSecret = async (secret: string): Promise => { + const getHashOfJwkInfo = (type: string) => jose.base64url.encode( + crypto + .createHash('sha256') + .update(JSON.stringify([type, secret, { + audience: options.audience, + }])) + .digest() + ); + const perAudienceSecret = getHashOfJwkInfo("stack-jwk-audience-secret"); + const perAudienceKid = getHashOfJwkInfo("stack-jwk-kid").slice(0, 12); + + const oldPerAudienceSecret = oldGetPerAudienceSecret({ audience: options.audience, secret }); + const oldPerAudienceKid = oldGetKid({ secret: oldPerAudienceSecret }); + + return [ + // TODO next-release: make this not take precedence; then, in the release after that, remove it entirely + await getPrivateJwkFromDerivedSecret(oldPerAudienceSecret, oldPerAudienceKid), + + await getPrivateJwkFromDerivedSecret(perAudienceSecret, perAudienceKid), + ]; + }; - return [ - // TODO next-release: make this not take precedence; then, in the release after that, remove it entirely - await getPrivateJwkFromDerivedSecret(oldPerAudienceSecret, oldPerAudienceKid), + const primaryPair = await derivePairForSecret(getStackServerSecret()); + const oldSecret = getOldStackServerSecret(); + const oldPair = oldSecret ? await derivePairForSecret(oldSecret) : []; - await getPrivateJwkFromDerivedSecret(perAudienceSecret, perAudienceKid), - ]; + // Signing uses index 0 (primary secret, legacy derivation). Verify accepts all entries. + return [...primaryPair, ...oldPair]; } export type PublicJwk = { @@ -141,6 +167,7 @@ export async function getPublicJwkSet(privateJwks: PrivateJwk[]): Promise<{ keys function oldGetPerAudienceSecret(options: { audience: string, + secret: string, }) { if (options.audience === "kid") { throw new StackAssertionError("You cannot use the 'kid' audience for a per-audience secret, see comment below in jwt.tsx"); @@ -150,7 +177,7 @@ function oldGetPerAudienceSecret(options: { .createHash('sha256') // TODO we should prefix a string like "stack-audience-secret" before we hash so you can't use `getKid(...)` to get the secret for eg. the "kid" audience if the same secret value is used // Sadly doing this modification is a bit annoying as we need to leave the old keys to be valid for a little longer - .update(JSON.stringify([getStackServerSecret(), options.audience])) + .update(JSON.stringify([options.secret, options.audience])) .digest() ); }; From 1db4407de465a79c26d71970e34077874895522b Mon Sep 17 00:00:00 2001 From: aadesh18 <110230993+aadesh18@users.noreply.github.com> Date: Sun, 19 Apr 2026 20:35:15 -0700 Subject: [PATCH 2/8] Clarify comment for STACK_SERVER_SECRET_OLD Updated comment for STACK_SERVER_SECRET_OLD to clarify its purpose. --- docker/server/.env | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker/server/.env b/docker/server/.env index 4d1179e558..f32ec96fae 100644 --- a/docker/server/.env +++ b/docker/server/.env @@ -4,7 +4,7 @@ NEXT_PUBLIC_STACK_DASHBOARD_URL=# https://your-dashboard-domain.com, this will b STACK_DATABASE_CONNECTION_STRING=# postgres connection string STACK_SERVER_SECRET=# a 32 bytes base64url encoded random string, used for JWT encryption. can be generated with `pnpm generate-keys` -STACK_SERVER_SECRET_OLD=# optional: set to the previous STACK_SERVER_SECRET during a rotation. Accepted for verification only. Remove after the grace window. +STACK_SERVER_SECRET_OLD=# set to the previous STACK_SERVER_SECRET during a rotation. Accepted for verification only. Remove after the grace window. # seed script settings STACK_SEED_INTERNAL_PROJECT_SIGN_UP_ENABLED=# true to enable user sign up to the dashboard when seeding From 6eccbc9fcd65ff3bdb24d0a9af58feee68e122be Mon Sep 17 00:00:00 2001 From: Aadesh Kheria Date: Sun, 19 Apr 2026 20:47:34 -0700 Subject: [PATCH 3/8] Refactor createOidcProvider to use local variable for old stack server secret --- apps/backend/src/app/api/latest/integrations/idp.ts | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/apps/backend/src/app/api/latest/integrations/idp.ts b/apps/backend/src/app/api/latest/integrations/idp.ts index db28469d8b..502a48cdd1 100644 --- a/apps/backend/src/app/api/latest/integrations/idp.ts +++ b/apps/backend/src/app/api/latest/integrations/idp.ts @@ -170,6 +170,7 @@ export async function createOidcProvider(options: { id: string, baseUrl: string, keys: privateJwks, }; const publicJwkSet = await getPublicJwkSet(privateJwks); + const oldStackServerSecret = getOldStackServerSecret(); const oidc = new Provider(options.baseUrl, { adapter: createPrismaAdapter(options.id), @@ -181,8 +182,8 @@ export async function createOidcProvider(options: { id: string, baseUrl: string, // issued before the rotation remain readable until they expire naturally. keys: [ toHexString(await sha512(`oidc-idp-cookie-encryption-key:${getEnvVariable("STACK_SERVER_SECRET")}`)), - ...(getOldStackServerSecret() - ? [toHexString(await sha512(`oidc-idp-cookie-encryption-key:${getOldStackServerSecret()}`))] + ...(oldStackServerSecret + ? [toHexString(await sha512(`oidc-idp-cookie-encryption-key:${oldStackServerSecret}`))] : []), ], }, From 12feff5a5db535dd2a9dd79c09e76ec908a32134 Mon Sep 17 00:00:00 2001 From: Aadesh Kheria Date: Sun, 19 Apr 2026 20:51:32 -0700 Subject: [PATCH 4/8] Refactor getOldStackServerSecret to return only the previous STACK_SERVER_SECRET --- packages/stack-shared/src/utils/jwt.tsx | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/stack-shared/src/utils/jwt.tsx b/packages/stack-shared/src/utils/jwt.tsx index bd4a9b3a97..510e622576 100644 --- a/packages/stack-shared/src/utils/jwt.tsx +++ b/packages/stack-shared/src/utils/jwt.tsx @@ -21,14 +21,14 @@ function getStackServerSecret() { } /** - * Returns the previous `STACK_SERVER_SECRET` during a rotation, or `null` if none is set. + * Returns the previous `STACK_SERVER_SECRET` * * When set, keys derived from this secret are accepted for verification (JWTs and OIDC cookies) * but never used for signing new artifacts. Remove the env var once the grace window has * elapsed — see the self-host rotation runbook. */ -export function getOldStackServerSecret(): string | null { - const STACK_SERVER_SECRET_OLD = getEnvVariable("STACK_SERVER_SECRET_OLD", ""); +export function getOldStackServerSecret(): string { + const STACK_SERVER_SECRET_OLD = getEnvVariable("STACK_SERVER_SECRET_OLD"); try { jose.base64url.decode(STACK_SERVER_SECRET_OLD); } catch (e) { From 5e7eaf08929844195f08463953ac14bca3f83a57 Mon Sep 17 00:00:00 2001 From: Aadesh Kheria Date: Sun, 19 Apr 2026 21:19:43 -0700 Subject: [PATCH 5/8] Added tests --- .../endpoints/api/v1/secret-rotation.test.ts | 147 ++++++++++++++++++ packages/stack-shared/src/utils/jwt.test.ts | 106 +++++++------ 2 files changed, 199 insertions(+), 54 deletions(-) create mode 100644 apps/e2e/tests/backend/endpoints/api/v1/secret-rotation.test.ts diff --git a/apps/e2e/tests/backend/endpoints/api/v1/secret-rotation.test.ts b/apps/e2e/tests/backend/endpoints/api/v1/secret-rotation.test.ts new file mode 100644 index 0000000000..4bb17f6761 --- /dev/null +++ b/apps/e2e/tests/backend/endpoints/api/v1/secret-rotation.test.ts @@ -0,0 +1,147 @@ + import { isBase64Url } from "@stackframe/stack-shared/dist/utils/bytes"; +import * as jose from "jose"; +import { it } from "../../../../helpers"; +import { Auth, backendContext, niceBackendFetch } from "../../../backend-helpers"; + +/** + * End-to-end coverage for the dual-secret (`STACK_SERVER_SECRET` + + * `STACK_SERVER_SECRET_OLD`) configuration. Both env vars are required; when + * the two are equal the backend is in steady state, when they differ it is in + * a Deploy 1 rotation overlap. These tests assert behavior that must hold in + * both modes. + * + * What these tests close: + * - JWKS route returns both the primary-secret and _OLD-secret derivations + * (4 entries total). Kid uniqueness is 2 in steady state, 4 during a + * rotation — we only assert the lower bound here. + * - `getOldStackServerSecret` is correctly wired into `getPrivateJwks` at + * runtime (the unit tests pin the function; only a live JWKS response + * proves the call graph). + * - Fresh access tokens are cryptographically verifiable against the live + * JWKS. + * - Refresh still mints verifiable tokens (refresh tokens are random DB + * strings, so this also confirms they are unaffected by the secret). + * - Revocation is unaffected by the presence of a second secret. + */ + +const INTERNAL_JWKS_PATH = "/api/v1/projects/internal/.well-known/jwks.json"; + +it("JWKS publishes 4 ES256 P-256 entries (primary-secret pair + _OLD-secret pair), no private scalars", async ({ expect }) => { + const response = await niceBackendFetch(INTERNAL_JWKS_PATH); + expect(response.status).toBe(200); + expect(response.headers.get("content-type")).includes("application/json"); + expect(response.headers.get("cache-control")).toBe("public, max-age=3600"); + expect(response.body.keys).toHaveLength(4); + for (const key of response.body.keys) { + expect(key).toEqual({ + alg: "ES256", + crv: "P-256", + kid: expect.any(String), + kty: "EC", + x: expect.toSatisfy(isBase64Url), + y: expect.toSatisfy(isBase64Url), + }); + // Must not leak the private scalar. + expect((key as { d?: unknown }).d).toBeUndefined(); + } + // In steady state (primary === _OLD) uniqueness is 2; mid-rotation it is 4. + const kids = response.body.keys.map((k: { kid: string }) => k.kid); + expect(new Set(kids).size).toBeGreaterThanOrEqual(2); +}); + +it("a fresh sign-up's access token verifies against the live JWKS", async ({ expect }) => { + await Auth.Password.signUpWithEmail(); + const accessToken = backendContext.value.userAuth?.accessToken; + expect(accessToken).toBeDefined(); + + const jwks = await niceBackendFetch(INTERNAL_JWKS_PATH); + expect(jwks.status).toBe(200); + + // Token's kid must be one of the published keys — proves signing used the + // in-memory JWK array that getPrivateJwks produced. + const header = jose.decodeProtectedHeader(accessToken!); + const kids = jwks.body.keys.map((k: { kid: string }) => k.kid); + expect(kids).toContain(header.kid); + + // Full cryptographic verification against the public JWKS. + const jwkSet = jose.createLocalJWKSet(jwks.body); + await expect(jose.jwtVerify(accessToken!, jwkSet)).resolves.toBeDefined(); +}); + +it("refresh returns a verifiable access token", async ({ expect }) => { + await Auth.Password.signUpWithEmail(); + // Drop the access token so expectSessionToBeValid/refresh has real work to do. + backendContext.set({ userAuth: { ...backendContext.value.userAuth, accessToken: undefined } }); + + const refreshed = await niceBackendFetch("/api/v1/auth/sessions/current/refresh", { + method: "POST", + accessType: "client", + }); + expect(refreshed.status).toBe(200); + const newAccessToken = refreshed.body.access_token as string; + expect(newAccessToken).toBeDefined(); + + const jwks = await niceBackendFetch(INTERNAL_JWKS_PATH); + const jwkSet = jose.createLocalJWKSet(jwks.body); + await expect(jose.jwtVerify(newAccessToken, jwkSet)).resolves.toBeDefined(); + + // Session should be fully usable after refresh. + backendContext.set({ userAuth: { ...backendContext.value.userAuth, accessToken: newAccessToken } }); + await Auth.expectSessionToBeValid(); + await Auth.expectToBeSignedIn(); +}); + +it("revocation blocks refresh on the revoked session", async ({ expect }) => { + const signUp = await Auth.Password.signUpWithEmail(); + + // Create an additional session so we can revoke it without touching the current one. + const additionalSession = await niceBackendFetch("/api/v1/auth/sessions", { + accessType: "server", + method: "POST", + body: { user_id: signUp.userId }, + }); + expect(additionalSession.status).toBe(200); + + // Sanity: that session's refresh token works before we revoke it. + const beforeRevoke = await niceBackendFetch("/api/v1/auth/sessions/current/refresh", { + method: "POST", + accessType: "client", + headers: { "x-stack-refresh-token": additionalSession.body.refresh_token }, + }); + expect(beforeRevoke.status).toBe(200); + + const listResponse = await niceBackendFetch("/api/v1/auth/sessions", { + accessType: "client", + method: "GET", + query: { user_id: signUp.userId }, + }); + expect(listResponse.status).toBe(200); + const nonCurrent = listResponse.body.items.find( + (s: { is_current_session: boolean }) => !s.is_current_session, + ); + expect(nonCurrent).toBeDefined(); + + const deleteResponse = await niceBackendFetch(`/api/v1/auth/sessions/${nonCurrent.id}`, { + accessType: "client", + method: "DELETE", + query: { user_id: signUp.userId }, + }); + expect(deleteResponse.status).toBe(200); + + // Post-revoke: the revoked session's refresh token is rejected. + const afterRevoke = await niceBackendFetch("/api/v1/auth/sessions/current/refresh", { + method: "POST", + accessType: "client", + headers: { "x-stack-refresh-token": additionalSession.body.refresh_token }, + }); + expect(afterRevoke.status).toBe(401); + expect(afterRevoke.body.code).toBe("REFRESH_TOKEN_NOT_FOUND_OR_EXPIRED"); + + // Current session should remain usable (revocation didn't cascade). + const currentRefresh = await niceBackendFetch("/api/v1/auth/sessions/current/refresh", { + method: "POST", + accessType: "client", + }); + expect(currentRefresh.status).toBe(200); + expect(currentRefresh.body.access_token).toBeDefined(); +}); diff --git a/packages/stack-shared/src/utils/jwt.test.ts b/packages/stack-shared/src/utils/jwt.test.ts index e14f2c67b7..84c323c461 100644 --- a/packages/stack-shared/src/utils/jwt.test.ts +++ b/packages/stack-shared/src/utils/jwt.test.ts @@ -8,7 +8,6 @@ import { getOldStackServerSecret, getPrivateJwks, getPublicJwkSet, signJWT, veri const randomSecret = () => jose.base64url.encode(crypto.randomBytes(32)); // Mirrors the derivation used in apps/backend/src/app/api/latest/integrations/idp.ts. -// Keeping it identical here pins the algorithm contract across the two call sites. async function deriveOidcCookieKey(secret: string): Promise { return toHexString(await sha512(`oidc-idp-cookie-encryption-key:${secret}`)); } @@ -23,6 +22,20 @@ async function buildOidcCookieKeys(): Promise { ]; } +// `STACK_SERVER_SECRET_OLD` is a required env var in this codebase. To model a backend +// that isn't mid-rotation, set both env vars to the same value — the derivation produces +// duplicate kids which collapse when we treat the result as a set. +function setSteadyStateEnv(secret: string) { + process.env.STACK_SERVER_SECRET = secret; + process.env.STACK_SERVER_SECRET_OLD = secret; +} + +// During an active rotation, primary is the new secret and _OLD is the previous one. +function setRotatingEnv(primary: string, previous: string) { + process.env.STACK_SERVER_SECRET = primary; + process.env.STACK_SERVER_SECRET_OLD = previous; +} + // signJWT only accepts string expirations; for the expiry test we need an explicit past // timestamp, so we drop down to jose directly, reusing the same primary private JWK. async function signJWTWithExplicitExp(options: { @@ -58,10 +71,7 @@ describe("STACK_SERVER_SECRET rotation — Deploy 1 invariants", () => { }); it("1. new login after Deploy 1: fresh JWT signs with new secret, verifies, and carries the new kid", async () => { - const newSecret = randomSecret(); - const oldSecret = randomSecret(); - process.env.STACK_SERVER_SECRET = newSecret; - process.env.STACK_SERVER_SECRET_OLD = oldSecret; + setRotatingEnv(randomSecret(), randomSecret()); const jwt = await signJWT({ issuer: "iss", audience: "aud", payload: { sub: "user-1" } }); const payload = await verifyJWT({ allowedIssuers: ["iss"], jwt }); @@ -75,11 +85,12 @@ describe("STACK_SERVER_SECRET rotation — Deploy 1 invariants", () => { const oldSecret = randomSecret(); const newSecret = randomSecret(); - process.env.STACK_SERVER_SECRET = oldSecret; + // Pre-rotation steady state: both env vars point at the old secret. + setSteadyStateEnv(oldSecret); const oldJwt = await signJWT({ issuer: "iss", audience: "aud", payload: { sub: "user-2" } }); - process.env.STACK_SERVER_SECRET = newSecret; - process.env.STACK_SERVER_SECRET_OLD = oldSecret; + // Rotate. + setRotatingEnv(newSecret, oldSecret); const payload = await verifyJWT({ allowedIssuers: ["iss"], jwt: oldJwt }); expect(payload.sub).toBe("user-2"); @@ -92,13 +103,12 @@ describe("STACK_SERVER_SECRET rotation — Deploy 1 invariants", () => { const oldSecret = randomSecret(); const newSecret = randomSecret(); - process.env.STACK_SERVER_SECRET = oldSecret; + setSteadyStateEnv(oldSecret); const preRotationKids = new Set( (await getPrivateJwks({ audience: "aud" })).map(j => j.kid), ); - process.env.STACK_SERVER_SECRET = newSecret; - process.env.STACK_SERVER_SECRET_OLD = oldSecret; + setRotatingEnv(newSecret, oldSecret); const mintedJwt = await signJWT({ issuer: "iss", audience: "aud", payload: { sub: "user-3" } }); const header = jose.decodeProtectedHeader(mintedJwt); @@ -110,11 +120,10 @@ describe("STACK_SERVER_SECRET rotation — Deploy 1 invariants", () => { const oldSecret = randomSecret(); const newSecret = randomSecret(); - process.env.STACK_SERVER_SECRET = oldSecret; + setSteadyStateEnv(oldSecret); const oldJwt = await signJWT({ issuer: "iss", audience: "aud", payload: { kind: "old" } }); - process.env.STACK_SERVER_SECRET = newSecret; - process.env.STACK_SERVER_SECRET_OLD = oldSecret; + setRotatingEnv(newSecret, oldSecret); const newJwt = await signJWT({ issuer: "iss", audience: "aud", payload: { kind: "new" } }); expect((await verifyJWT({ allowedIssuers: ["iss"], jwt: oldJwt })).kind).toBe("old"); @@ -125,14 +134,12 @@ describe("STACK_SERVER_SECRET rotation — Deploy 1 invariants", () => { const oldSecret = randomSecret(); const newSecret = randomSecret(); - process.env.STACK_SERVER_SECRET = oldSecret; + setSteadyStateEnv(oldSecret); const oldSecretKids = new Set( (await getPrivateJwks({ audience: "aud" })).map(j => j.kid), ); - process.env.STACK_SERVER_SECRET = newSecret; - process.env.STACK_SERVER_SECRET_OLD = oldSecret; - + setRotatingEnv(newSecret, oldSecret); const jwt = await signJWT({ issuer: "iss", audience: "aud", payload: {} }); expect(oldSecretKids.has(jose.decodeProtectedHeader(jwt).kid as string)).toBe(false); }); @@ -140,8 +147,7 @@ describe("STACK_SERVER_SECRET rotation — Deploy 1 invariants", () => { it("6. in-progress OIDC flow: cookie key derived from the old secret stays in the verify set during overlap", async () => { const oldSecret = randomSecret(); const newSecret = randomSecret(); - process.env.STACK_SERVER_SECRET = newSecret; - process.env.STACK_SERVER_SECRET_OLD = oldSecret; + setRotatingEnv(newSecret, oldSecret); const keys = await buildOidcCookieKeys(); // Koa keygrip (used by oidc-provider for `cookies.keys`) verifies against any entry. @@ -151,8 +157,7 @@ describe("STACK_SERVER_SECRET rotation — Deploy 1 invariants", () => { it("7. new OIDC flow after Deploy 1 signs cookies with the new-secret-derived key", async () => { const oldSecret = randomSecret(); const newSecret = randomSecret(); - process.env.STACK_SERVER_SECRET = newSecret; - process.env.STACK_SERVER_SECRET_OLD = oldSecret; + setRotatingEnv(newSecret, oldSecret); const keys = await buildOidcCookieKeys(); // Koa keygrip signs using keys[0], so keys[0] must be the new-secret derivation. @@ -166,11 +171,10 @@ describe("STACK_SERVER_SECRET rotation — Deploy 1 invariants", () => { const unrelatedSecret = randomSecret(); // (a) signed by a totally unrelated secret — not in the verify set - process.env.STACK_SERVER_SECRET = unrelatedSecret; + setSteadyStateEnv(unrelatedSecret); const unrelatedJwt = await signJWT({ issuer: "iss", audience: "aud", payload: {} }); - process.env.STACK_SERVER_SECRET = newSecret; - process.env.STACK_SERVER_SECRET_OLD = oldSecret; + setRotatingEnv(newSecret, oldSecret); await expect(verifyJWT({ allowedIssuers: ["iss"], jwt: unrelatedJwt })).rejects.toThrow(); // (b) tampered signature on an otherwise-valid JWT @@ -187,52 +191,46 @@ describe("STACK_SERVER_SECRET rotation — Deploy 1 invariants", () => { const oldSecret = randomSecret(); const newSecret = randomSecret(); - process.env.STACK_SERVER_SECRET = oldSecret; + setSteadyStateEnv(oldSecret); const expiredJwt = await signJWTWithExplicitExp({ audience: "aud", issuer: "iss", expUnixSeconds: Math.floor(Date.now() / 1000) - 60, }); - process.env.STACK_SERVER_SECRET = newSecret; - process.env.STACK_SERVER_SECRET_OLD = oldSecret; - + setRotatingEnv(newSecret, oldSecret); await expect(verifyJWT({ allowedIssuers: ["iss"], jwt: expiredJwt })).rejects.toThrow(/exp/i); }); - it("10. overlap JWKS equals the union of the new-secret-only and old-secret-only public sets, with no private scalars", async () => { + it("10. overlap JWKS equals the union of the new-secret-derived and old-secret-derived public sets, with no private scalars", async () => { const oldSecret = randomSecret(); const newSecret = randomSecret(); - // New-secret-only public set (what the JWKS looks like before Deploy 1 and after Deploy 2). - process.env.STACK_SERVER_SECRET = newSecret; - const newOnly = await getPublicJwkSet(await getPrivateJwks({ audience: "aud" })); - expect(newOnly.keys).toHaveLength(2); + // Unique kids derivable from a single secret (steady-state config; the 4 entries + // collapse to 2 unique kids because primary and _OLD are the same value). + setSteadyStateEnv(newSecret); + const newDerivedKids = new Set( + (await getPublicJwkSet(await getPrivateJwks({ audience: "aud" }))).keys.map(k => k.kid), + ); + expect(newDerivedKids.size).toBe(2); - // Old-secret-only public set (what the JWKS looked like before the rotation started). - process.env.STACK_SERVER_SECRET = oldSecret; - delete process.env.STACK_SERVER_SECRET_OLD; - const oldOnly = await getPublicJwkSet(await getPrivateJwks({ audience: "aud" })); - expect(oldOnly.keys).toHaveLength(2); + setSteadyStateEnv(oldSecret); + const oldDerivedKids = new Set( + (await getPublicJwkSet(await getPrivateJwks({ audience: "aud" }))).keys.map(k => k.kid), + ); + expect(oldDerivedKids.size).toBe(2); - // Overlap set during Deploy 1. - process.env.STACK_SERVER_SECRET = newSecret; - process.env.STACK_SERVER_SECRET_OLD = oldSecret; + // Overlap during Deploy 1. + setRotatingEnv(newSecret, oldSecret); const overlap = await getPublicJwkSet(await getPrivateJwks({ audience: "aud" })); expect(overlap.keys).toHaveLength(4); - - // Identity: overlap kids == (new-only kids) ∪ (old-only kids). const overlapKids = new Set(overlap.keys.map(k => k.kid)); - const expectedUnionKids = new Set([ - ...newOnly.keys.map(k => k.kid), - ...oldOnly.keys.map(k => k.kid), - ]); - expect(overlapKids).toEqual(expectedUnionKids); - - // Sanity: the two secrets produce disjoint kids (they're derived by hashing the secret). - const newKids = new Set(newOnly.keys.map(k => k.kid)); - const oldKids = new Set(oldOnly.keys.map(k => k.kid)); - for (const k of newKids) expect(oldKids.has(k)).toBe(false); + + // Identity: overlap kids == new-derived kids ∪ old-derived kids. + expect(overlapKids).toEqual(new Set([...newDerivedKids, ...oldDerivedKids])); + + // Sanity: the two secrets produce disjoint kid sets. + for (const k of newDerivedKids) expect(oldDerivedKids.has(k)).toBe(false); // The public JWKs must not leak the private scalar `d`. for (const k of overlap.keys) expect((k as unknown as { d?: unknown }).d).toBeUndefined(); From e30dbd3f251dbb380191cc78672e5dcd1053e005 Mon Sep 17 00:00:00 2001 From: Aadesh Kheria Date: Sun, 19 Apr 2026 21:52:54 -0700 Subject: [PATCH 6/8] Refactor getOldStackServerSecret to handle empty secret and update JWT key derivation logic --- .../backend/endpoints/api/v1/secret-rotation.test.ts | 11 ++++++----- packages/stack-shared/src/utils/jwt.tsx | 8 +++++--- 2 files changed, 11 insertions(+), 8 deletions(-) diff --git a/apps/e2e/tests/backend/endpoints/api/v1/secret-rotation.test.ts b/apps/e2e/tests/backend/endpoints/api/v1/secret-rotation.test.ts index 4bb17f6761..1f84bfe0a2 100644 --- a/apps/e2e/tests/backend/endpoints/api/v1/secret-rotation.test.ts +++ b/apps/e2e/tests/backend/endpoints/api/v1/secret-rotation.test.ts @@ -1,4 +1,4 @@ - import { isBase64Url } from "@stackframe/stack-shared/dist/utils/bytes"; +import { isBase64Url } from "@stackframe/stack-shared/dist/utils/bytes"; import * as jose from "jose"; import { it } from "../../../../helpers"; import { Auth, backendContext, niceBackendFetch } from "../../../backend-helpers"; @@ -26,12 +26,11 @@ import { Auth, backendContext, niceBackendFetch } from "../../../backend-helpers const INTERNAL_JWKS_PATH = "/api/v1/projects/internal/.well-known/jwks.json"; -it("JWKS publishes 4 ES256 P-256 entries (primary-secret pair + _OLD-secret pair), no private scalars", async ({ expect }) => { +it("JWKS publishes 2 entries in steady state or 4 during rotation, all ES256 P-256, no duplicates, no private scalars", async ({ expect }) => { const response = await niceBackendFetch(INTERNAL_JWKS_PATH); expect(response.status).toBe(200); expect(response.headers.get("content-type")).includes("application/json"); expect(response.headers.get("cache-control")).toBe("public, max-age=3600"); - expect(response.body.keys).toHaveLength(4); for (const key of response.body.keys) { expect(key).toEqual({ alg: "ES256", @@ -44,9 +43,11 @@ it("JWKS publishes 4 ES256 P-256 entries (primary-secret pair + _OLD-secret pair // Must not leak the private scalar. expect((key as { d?: unknown }).d).toBeUndefined(); } - // In steady state (primary === _OLD) uniqueness is 2; mid-rotation it is 4. const kids = response.body.keys.map((k: { kid: string }) => k.kid); - expect(new Set(kids).size).toBeGreaterThanOrEqual(2); + // `getPrivateJwks` dedups when primary === _OLD, so published count matches the + // unique kid count in every configuration. Either we're steady (2) or rotating (4). + expect(new Set(kids).size).toBe(kids.length); + expect([2, 4]).toContain(kids.length); }); it("a fresh sign-up's access token verifies against the live JWKS", async ({ expect }) => { diff --git a/packages/stack-shared/src/utils/jwt.tsx b/packages/stack-shared/src/utils/jwt.tsx index 510e622576..7b503a9b4b 100644 --- a/packages/stack-shared/src/utils/jwt.tsx +++ b/packages/stack-shared/src/utils/jwt.tsx @@ -28,7 +28,8 @@ function getStackServerSecret() { * elapsed — see the self-host rotation runbook. */ export function getOldStackServerSecret(): string { - const STACK_SERVER_SECRET_OLD = getEnvVariable("STACK_SERVER_SECRET_OLD"); + const STACK_SERVER_SECRET_OLD = getEnvVariable("STACK_SERVER_SECRET_OLD", ""); + if (!STACK_SERVER_SECRET_OLD) return ""; try { jose.base64url.decode(STACK_SERVER_SECRET_OLD); } catch (e) { @@ -143,9 +144,10 @@ export async function getPrivateJwks(options: { ]; }; - const primaryPair = await derivePairForSecret(getStackServerSecret()); + const primarySecret = getStackServerSecret(); const oldSecret = getOldStackServerSecret(); - const oldPair = oldSecret ? await derivePairForSecret(oldSecret) : []; + const primaryPair = await derivePairForSecret(primarySecret); + const oldPair = oldSecret && oldSecret !== primarySecret ? await derivePairForSecret(oldSecret) : []; // Signing uses index 0 (primary secret, legacy derivation). Verify accepts all entries. return [...primaryPair, ...oldPair]; From aab8b3dd402a704eba8664a16091ae07dbcac8d4 Mon Sep 17 00:00:00 2001 From: Aadesh Kheria Date: Sun, 19 Apr 2026 23:07:30 -0700 Subject: [PATCH 7/8] fixed tests --- .../endpoints/api/v1/secret-rotation.test.ts | 31 ++++++++++++------- packages/stack-shared/src/utils/jwt.test.ts | 8 ++--- 2 files changed, 23 insertions(+), 16 deletions(-) diff --git a/apps/e2e/tests/backend/endpoints/api/v1/secret-rotation.test.ts b/apps/e2e/tests/backend/endpoints/api/v1/secret-rotation.test.ts index 1f84bfe0a2..39ab83a227 100644 --- a/apps/e2e/tests/backend/endpoints/api/v1/secret-rotation.test.ts +++ b/apps/e2e/tests/backend/endpoints/api/v1/secret-rotation.test.ts @@ -50,23 +50,30 @@ it("JWKS publishes 2 entries in steady state or 4 during rotation, all ES256 P-2 expect([2, 4]).toContain(kids.length); }); -it("a fresh sign-up's access token verifies against the live JWKS", async ({ expect }) => { +it("a client that cached the JWKS before sign-up still validates the minted access token", async ({ expect }) => { + // Snapshot the JWKS first, as a client/relying-party would have. + const cachedJwks = await niceBackendFetch(INTERNAL_JWKS_PATH); + expect(cachedJwks.status).toBe(200); + const cachedJwkSet = jose.createLocalJWKSet(cachedJwks.body); + const cachedKids = cachedJwks.body.keys.map((k: { kid: string }) => k.kid); + + // Now mint a token. await Auth.Password.signUpWithEmail(); const accessToken = backendContext.value.userAuth?.accessToken; expect(accessToken).toBeDefined(); - const jwks = await niceBackendFetch(INTERNAL_JWKS_PATH); - expect(jwks.status).toBe(200); - - // Token's kid must be one of the published keys — proves signing used the - // in-memory JWK array that getPrivateJwks produced. + // The token's kid must already be in the cached set (signing cannot produce a kid + // outside the currently-published JWKS), and its signature must verify against the + // cached public keys — this is the invariant external verifiers rely on. const header = jose.decodeProtectedHeader(accessToken!); - const kids = jwks.body.keys.map((k: { kid: string }) => k.kid); - expect(kids).toContain(header.kid); - - // Full cryptographic verification against the public JWKS. - const jwkSet = jose.createLocalJWKSet(jwks.body); - await expect(jose.jwtVerify(accessToken!, jwkSet)).resolves.toBeDefined(); + expect(cachedKids).toContain(header.kid); + await expect(jose.jwtVerify(accessToken!, cachedJwkSet)).resolves.toBeDefined(); + + // Sanity: re-fetch the live JWKS; since no rotation occurred mid-test, it should + // match the cached snapshot (same kids). This also pins that sign-up doesn't rotate. + const liveJwks = await niceBackendFetch(INTERNAL_JWKS_PATH); + const liveKids = new Set(liveJwks.body.keys.map((k: { kid: string }) => k.kid)); + expect(liveKids).toEqual(new Set(cachedKids)); }); it("refresh returns a verifiable access token", async ({ expect }) => { diff --git a/packages/stack-shared/src/utils/jwt.test.ts b/packages/stack-shared/src/utils/jwt.test.ts index 84c323c461..4d5e81ff77 100644 --- a/packages/stack-shared/src/utils/jwt.test.ts +++ b/packages/stack-shared/src/utils/jwt.test.ts @@ -22,12 +22,12 @@ async function buildOidcCookieKeys(): Promise { ]; } -// `STACK_SERVER_SECRET_OLD` is a required env var in this codebase. To model a backend -// that isn't mid-rotation, set both env vars to the same value — the derivation produces -// duplicate kids which collapse when we treat the result as a set. +// Steady state (not mid-rotation): primary is set, `_OLD` is unset. This is the code +// path a deployment is in between rotations — exercises the early-return `""` branch in +// `getOldStackServerSecret` and the falsy short-circuit in `getPrivateJwks`. function setSteadyStateEnv(secret: string) { process.env.STACK_SERVER_SECRET = secret; - process.env.STACK_SERVER_SECRET_OLD = secret; + delete process.env.STACK_SERVER_SECRET_OLD; } // During an active rotation, primary is the new secret and _OLD is the previous one. From c9b4643b379bbb8ac5161aa7b4e13f2853c6e6d0 Mon Sep 17 00:00:00 2001 From: Aadesh Kheria Date: Sun, 19 Apr 2026 23:13:40 -0700 Subject: [PATCH 8/8] Update STACK_SERVER_SECRET_OLD in .env.example with the previous secret value --- docker/server/.env.example | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docker/server/.env.example b/docker/server/.env.example index 9c48ece45a..f7b4888d9b 100644 --- a/docker/server/.env.example +++ b/docker/server/.env.example @@ -5,9 +5,9 @@ NEXT_PUBLIC_STACK_DASHBOARD_URL=http://localhost:8101 STACK_DATABASE_CONNECTION_STRING=postgres://postgres:password@host.docker.internal:8128/stackframe -STACK_SERVER_SECRET=23-wuNpik0gIW4mruTz25rbIvhuuvZFrLOLtL7J4tyo +STACK_SERVER_SECRET=_q4Ujch47RpWiydX_FJZDH6gKm1q5z1Ve6y8hfqWpks # Remove after the grace window -STACK_SERVER_SECRET_OLD= +STACK_SERVER_SECRET_OLD=23-wuNpik0gIW4mruTz25rbIvhuuvZFrLOLtL7J4tyo STACK_SEED_INTERNAL_PROJECT_ALLOW_LOCALHOST=true STACK_SEED_INTERNAL_PROJECT_SIGN_UP_ENABLED=true