Skip to content
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
147 changes: 147 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,147 @@
import { isBase64Url } from "@stackframe/stack-shared/dist/utils/bytes";

Check failure on line 1 in apps/e2e/tests/backend/endpoints/api/v1/secret-rotation.test.ts

View workflow job for this annotation

GitHub Actions / lint_and_build (24)

Expected indentation of 0 spaces but found 2
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);
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated

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

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
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
Loading
Loading