Skip to content
Open
Show file tree
Hide file tree
Changes from 2 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
10 changes: 8 additions & 2 deletions apps/backend/src/app/api/latest/integrations/idp.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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()}`))]
: []),
Comment thread
aadesh18 marked this conversation as resolved.
Outdated
Comment thread
aadesh18 marked this conversation as resolved.
],
},
jwks: privateJwkSet,
Expand Down
1 change: 1 addition & 0 deletions docker/server/.env
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions docker/server/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ NEXT_PUBLIC_STACK_DASHBOARD_URL=http://localhost:8101
STACK_DATABASE_CONNECTION_STRING=postgres://postgres:password@host.docker.internal:8128/stackframe

Comment thread
aadesh18 marked this conversation as resolved.
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
Expand Down
246 changes: 246 additions & 0 deletions packages/stack-shared/src/utils/jwt.test.ts
Original file line number Diff line number Diff line change
@@ -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<string> {
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<string[]> {
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(
Comment thread
mantrakp04 marked this conversation as resolved.
Comment thread
vercel[bot] marked this conversation as resolved.
(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();
Comment thread
aadesh18 marked this conversation as resolved.
});

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);
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.

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);
Comment thread
greptile-apps[bot] marked this conversation as resolved.
Comment thread
mantrakp04 marked this conversation as resolved.
Comment thread
mantrakp04 marked this conversation as resolved.
});

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<string>([
...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/);
});
});
65 changes: 46 additions & 19 deletions packages/stack-shared/src/utils/jwt.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Comment thread
aadesh18 marked this conversation as resolved.
Comment thread
aadesh18 marked this conversation as resolved.
}
Comment thread
aadesh18 marked this conversation as resolved.
Comment thread
aadesh18 marked this conversation as resolved.

export async function getJwtInfo(options: {
jwt: string,
}) {
Expand Down Expand Up @@ -103,26 +120,35 @@ async function getPrivateJwkFromDerivedSecret(derivedSecret: string, kid: string
export async function getPrivateJwks(options: {
audience: string,
}): Promise<PrivateJwk[]> {
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<PrivateJwk[]> => {
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 = {
Expand All @@ -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");
Expand All @@ -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()
);
};
Expand Down
Loading