diff --git a/apps/backend/src/app/api/latest/integrations/idp.ts b/apps/backend/src/app/api/latest/integrations/idp.ts index 36a5944123..502a48cdd1 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'; @@ -170,14 +170,21 @@ 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), 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")}`)), + ...(oldStackServerSecret + ? [toHexString(await sha512(`oidc-idp-cookie-encryption-key:${oldStackServerSecret}`))] + : []), ], }, jwks: privateJwkSet, 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..39ab83a227 --- /dev/null +++ b/apps/e2e/tests/backend/endpoints/api/v1/secret-rotation.test.ts @@ -0,0 +1,155 @@ +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 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"); + 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(); + } + const kids = response.body.keys.map((k: { kid: string }) => k.kid); + // `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 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(); + + // 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!); + 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 }) => { + 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/docker/server/.env b/docker/server/.env index 22e29e1348..f32ec96fae 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=# 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..f7b4888d9b 100644 --- a/docker/server/.env.example +++ b/docker/server/.env.example @@ -5,7 +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=23-wuNpik0gIW4mruTz25rbIvhuuvZFrLOLtL7J4tyo 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..4d5e81ff77 --- /dev/null +++ b/packages/stack-shared/src/utils/jwt.test.ts @@ -0,0 +1,244 @@ +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. +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)] : []), + ]; +} + +// 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; + delete process.env.STACK_SERVER_SECRET_OLD; +} + +// 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: { + 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 () => { + setRotatingEnv(randomSecret(), randomSecret()); + + 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(); + + // 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" } }); + + // Rotate. + setRotatingEnv(newSecret, 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(); + + setSteadyStateEnv(oldSecret); + const preRotationKids = new Set( + (await getPrivateJwks({ audience: "aud" })).map(j => j.kid), + ); + + setRotatingEnv(newSecret, 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(); + + setSteadyStateEnv(oldSecret); + const oldJwt = await signJWT({ issuer: "iss", audience: "aud", payload: { kind: "old" } }); + + setRotatingEnv(newSecret, 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(); + + setSteadyStateEnv(oldSecret); + const oldSecretKids = new Set( + (await getPrivateJwks({ audience: "aud" })).map(j => j.kid), + ); + + setRotatingEnv(newSecret, 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(); + setRotatingEnv(newSecret, 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(); + setRotatingEnv(newSecret, 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 + setSteadyStateEnv(unrelatedSecret); + const unrelatedJwt = await signJWT({ issuer: "iss", audience: "aud", payload: {} }); + + setRotatingEnv(newSecret, 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(); + + setSteadyStateEnv(oldSecret); + const expiredJwt = await signJWTWithExplicitExp({ + audience: "aud", + issuer: "iss", + expUnixSeconds: Math.floor(Date.now() / 1000) - 60, + }); + + 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-derived and old-secret-derived public sets, with no private scalars", async () => { + const oldSecret = randomSecret(); + const newSecret = randomSecret(); + + // 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); + + setSteadyStateEnv(oldSecret); + const oldDerivedKids = new Set( + (await getPublicJwkSet(await getPrivateJwks({ audience: "aud" }))).keys.map(k => k.kid), + ); + expect(oldDerivedKids.size).toBe(2); + + // Overlap during Deploy 1. + setRotatingEnv(newSecret, oldSecret); + const overlap = await getPublicJwkSet(await getPrivateJwks({ audience: "aud" })); + expect(overlap.keys).toHaveLength(4); + const overlapKids = new Set(overlap.keys.map(k => k.kid)); + + // 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(); + }); + + 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..7b503a9b4b 100644 --- a/packages/stack-shared/src/utils/jwt.tsx +++ b/packages/stack-shared/src/utils/jwt.tsx @@ -20,6 +20,24 @@ function getStackServerSecret() { return STACK_SERVER_SECRET; } +/** + * 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 { + 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) { + 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 +121,36 @@ 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 primarySecret = getStackServerSecret(); + const oldSecret = getOldStackServerSecret(); + const primaryPair = await derivePairForSecret(primarySecret); + const oldPair = oldSecret && oldSecret !== primarySecret ? 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 +169,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 +179,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() ); };