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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 9 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 @@ -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}`))]
: []),
Comment thread
aadesh18 marked this conversation as resolved.
],
},
jwks: privateJwkSet,
Expand Down
155 changes: 155 additions & 0 deletions apps/e2e/tests/backend/endpoints/api/v1/secret-rotation.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
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
4 changes: 3 additions & 1 deletion docker/server/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,9 @@ 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
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
Expand Down
Loading
Loading