Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
c7f5d2f
feat: OIDC federation (exchange API, dashboard, audit, docs)
mantrakp04 Apr 21, 2026
92cd996
feat: add mock OIDC IdP for local development
mantrakp04 Apr 21, 2026
61147a3
refactor: remove unused DesignBadge and icons from PolicyCard component
mantrakp04 Apr 21, 2026
62874ba
feat: implement OIDC discovery probe and refactor claim conditions ha…
mantrakp04 Apr 21, 2026
88d30e4
refactor: remove OIDC Federation section and update sidebar logic
mantrakp04 Apr 21, 2026
83d17a7
refactor: clean up OIDC policy dialog layout and remove unused compon…
mantrakp04 Apr 21, 2026
96731f5
Merge branch 'dev' into feat/oidc-federation
mantrakp04 Apr 21, 2026
a2694c0
refactor: enhance OIDC JWT validation and caching mechanisms
mantrakp04 Apr 21, 2026
0913b58
feat: enhance OIDC federation features with new audit model and safe …
mantrakp04 Apr 22, 2026
344af93
chore: update pnpm-lock.yaml with dependency version changes
mantrakp04 Apr 22, 2026
eae316a
refactor: improve safe-fetch URL validation and enhance OIDC federati…
mantrakp04 Apr 22, 2026
56562d0
test: enhance shape-and-index tests with dynamic tenancy and project IDs
mantrakp04 Apr 22, 2026
75ddfbe
fix: harden oidc federation fetches
mantrakp04 Apr 23, 2026
8998828
Add backend undici dependency
mantrakp04 Apr 23, 2026
beb7d04
Fix safe fetch DNS guard lint
mantrakp04 Apr 23, 2026
651621a
Merge branch 'dev' into feat/oidc-federation
mantrakp04 Apr 28, 2026
eeec4eb
fix oidc federation review feedback
mantrakp04 Apr 28, 2026
8f0f9ff
fix oidc federation e2e error assertions
mantrakp04 Apr 28, 2026
06ceaf7
Refactor OidcFederationExchangeAudit schema and migration files
mantrakp04 Apr 30, 2026
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
9 changes: 9 additions & 0 deletions .claude/CLAUDE-KNOWLEDGE.md
Original file line number Diff line number Diff line change
Expand Up @@ -199,6 +199,12 @@ await niceBackendFetch("/api/v1/internal/config/override/environment", {
### Q: Where does domain validation logic belong?
A: Core validation functions (`isValidHostnameWithWildcards`, `matchHostnamePattern`) belong in the shared utils package (`packages/stack-shared/src/utils/urls.tsx`) so they can be used by both frontend and backend.

### Q: How should OIDC federation exchange failures be exposed to callers?
A: Keep detailed failure reasons in audit rows and system events, but return a generic `invalid_grant` to unauthenticated token-exchange callers. This avoids leaking IdP fetch/JWKS details or trust-policy claim condition details while preserving operator diagnostics.

### Q: What should OIDC JWT verification do on a JWKS key miss?
A: Restrict `jwtVerify` to asymmetric OIDC algorithms and, on `ERR_JWKS_NO_MATCHING_KEY`, invalidate both the JWKS cache and the discovery cache before retrying. If an IdP moved `jwks_uri`, clearing only the JWKS row keeps retrying the stale URI.

### Q: How do you simplify validation logic with wildcards?
A: Replace wildcards with valid placeholders before validation:
```typescript
Expand Down Expand Up @@ -361,3 +367,6 @@ A: Invalid `tools` entries are rejected by `requestBodySchema` in `apps/backend/

## Q: Why did the internal metrics E2E snapshots need to change in April 2026?
A: The `/api/v1/internal/metrics` response now intentionally includes `analytics_overview.daily_anonymous_visitors_fallback`, `analytics_overview.anonymous_visitors_fallback`, and `active_users_by_country`. Those additions are reflected in `packages/stack-shared/src/interface/admin-metrics.ts` and the backend route, so the E2E snapshots must include them instead of treating them as regressions.

## Q: What body shape should E2E tests expect from backend `StatusError` responses?
A: `StatusError.getBody()` returns the error message as a plain text response body with `Content-Type: text/plain; charset=utf-8`. E2E tests using `niceBackendFetch` should assert against `response.body` directly, for example `expect(response.body).toBe("invalid_grant")`, not `response.body.error`.
4 changes: 4 additions & 0 deletions apps/backend/.env.development
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,10 @@ STACK_SEED_INTERNAL_PROJECT_SUPER_SECRET_ADMIN_KEY=this-super-secret-admin-key-i
STACK_OAUTH_MOCK_URL=http://localhost:${NEXT_PUBLIC_STACK_PORT_PREFIX:-81}14
STACK_TURNSTILE_SITEVERIFY_URL=http://localhost:${NEXT_PUBLIC_STACK_PORT_PREFIX:-81}14/turnstile/siteverify

# Local mock OIDC IdP for OIDC federation testing (apps/mock-oidc-idp).
# Read by the seed script to install a default trust policy on the dummy project.
STACK_MOCK_OIDC_ISSUER_URL=http://localhost:${NEXT_PUBLIC_STACK_PORT_PREFIX:-81}15

# Cloudflare Turnstile test keys — always-pass widgets, no real challenges
# See https://developers.cloudflare.com/turnstile/troubleshooting/testing/
NEXT_PUBLIC_STACK_BOT_CHALLENGE_SITE_KEY=1x00000000000000000000AA
Expand Down
1 change: 1 addition & 0 deletions apps/backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -115,6 +115,7 @@
"sharp": "^0.34.4",
"stripe": "^18.3.0",
"svix": "^1.89.0",
"undici": "^6.19.8",
"vite": "^6.1.0",
"yaml": "^2.4.5",
"yup": "^1.7.1",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
-- CreateTable
CREATE TABLE "OidcFederationExchangeAudit" (
"id" UUID NOT NULL,
"tenancyId" UUID NOT NULL,
"policyId" TEXT NOT NULL,
"issuer" TEXT NOT NULL,
"subject" TEXT NOT NULL,
"outcome" TEXT NOT NULL,
"reason" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,

CONSTRAINT "OidcFederationExchangeAudit_pkey" PRIMARY KEY ("id")
);

-- CreateIndex
CREATE INDEX "OidcFederationExchangeAudit_tenancy_policy_createdAt_idx" ON "OidcFederationExchangeAudit"("tenancyId", "policyId", "createdAt" DESC);

-- Constrain outcome to the current vocabulary. `NOT VALID` skips the backfill scan for existing
-- rows; a follow-up migration can VALIDATE once we're confident all historical rows comply.
ALTER TABLE "OidcFederationExchangeAudit" ADD CONSTRAINT "OidcFederationExchangeAudit_outcome_check"
CHECK ("outcome" IN ('success', 'failure')) NOT VALID;
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
import { randomUUID } from "crypto";
import type { Sql } from "postgres";
import { expect } from "vitest";

/**
* Migration-level test for `20260420000000_add_oidc_federation_audit`.
*
* Verifies that:
* - `OidcFederationExchangeAudit` exists with the expected columns + types,
* - `createdAt` defaults to now and is non-nullable,
* - the lookup index on (tenancyId, policyId, createdAt DESC) exists,
* - inserts + a per-tenancy-per-policy MAX(createdAt) aggregate work (this is the
* query shape the dashboard will use to show "last used at" per policy).
*/
export const postMigration = async (sql: Sql) => {
// 1. Column shape.
const columnRows = await sql<Array<{ column_name: string, is_nullable: string, data_type: string }>>`
SELECT column_name, is_nullable, data_type
FROM information_schema.columns
WHERE table_schema = 'public'
AND table_name = 'OidcFederationExchangeAudit'
ORDER BY ordinal_position
`;
// Columns are validated as a set — Prisma may reorder ordinals when fields are reshuffled,
// and the set is what the application actually depends on.
expect(columnRows.map(r => r.column_name).sort()).toEqual([
"createdAt",
"id",
"issuer",
"outcome",
"policyId",
"reason",
"subject",
"tenancyId",
]);
for (const row of columnRows) {
expect(row.is_nullable).toBe("NO");
}
const byName = Object.fromEntries(columnRows.map(r => [r.column_name, r]));
expect(byName["id"].data_type).toBe("uuid");
expect(byName["tenancyId"].data_type).toBe("uuid");
expect(byName["createdAt"].data_type).toBe("timestamp without time zone");
Comment thread
mantrakp04 marked this conversation as resolved.

// 2. Index exists with the expected column list + ordering.
const indexRows = await sql<Array<{ indexdef: string }>>`
SELECT indexdef
FROM pg_indexes
WHERE schemaname = 'public'
AND tablename = 'OidcFederationExchangeAudit'
AND indexname = 'OidcFederationExchangeAudit_tenancy_policy_createdAt_idx'
`;
expect(indexRows).toHaveLength(1);
expect(indexRows[0].indexdef).toContain('"tenancyId"');
expect(indexRows[0].indexdef).toContain('"policyId"');
expect(indexRows[0].indexdef).toContain('"createdAt" DESC');

// 3. Insert + aggregate — the dashboard "last used at" query shape.
// The audit table intentionally stores tenancyId as a scalar without an FK: audit writes
// should not add delete/update trigger overhead to the hot Tenancy table.
const tenancyId = randomUUID();
const otherTenancyId = randomUUID();

try {
await sql.unsafe(`
INSERT INTO "OidcFederationExchangeAudit" ("id", "tenancyId", "policyId", "issuer", "subject", "outcome", "reason", "createdAt")
VALUES
(gen_random_uuid(), '${tenancyId}', 'policy-a', 'https://idp', 'sub-1', 'success', '', '2026-01-01 00:00:00'),
(gen_random_uuid(), '${tenancyId}', 'policy-a', 'https://idp', 'sub-2', 'success', '', '2026-01-02 00:00:00'),
(gen_random_uuid(), '${tenancyId}', 'policy-b', '', '', 'failure', 'nope', '2026-01-03 00:00:00'),
(gen_random_uuid(), '${otherTenancyId}', 'policy-a', 'https://idp', 'sub-3', 'success', '', '2026-01-05 00:00:00');
`);

const defaultRows = await sql<Array<{ createdAt: Date }>>`
INSERT INTO "OidcFederationExchangeAudit" ("id", "tenancyId", "policyId", "issuer", "subject", "outcome", "reason")
VALUES (gen_random_uuid(), ${otherTenancyId}::uuid, 'policy-default', 'https://idp', 'sub-default', 'success', '')
RETURNING "createdAt"
`;
expect(defaultRows).toHaveLength(1);
expect(defaultRows[0].createdAt).toBeInstanceOf(Date);

const aggregate = await sql<Array<{ policyId: string, lastAt: string, total: bigint }>>`
SELECT "policyId", to_char(MAX("createdAt"), 'YYYY-MM-DD HH24:MI:SS') AS "lastAt", COUNT(*)::bigint AS total
FROM "OidcFederationExchangeAudit"
WHERE "tenancyId" = ${tenancyId}
GROUP BY "policyId"
ORDER BY "policyId"
`;
expect(aggregate).toHaveLength(2);
expect(aggregate[0].policyId).toBe("policy-a");
expect(Number(aggregate[0].total)).toBe(2);
expect(aggregate[0].lastAt).toBe("2026-01-02 00:00:00");
expect(aggregate[1].policyId).toBe("policy-b");
expect(Number(aggregate[1].total)).toBe(1);
} finally {
await sql`DELETE FROM "OidcFederationExchangeAudit" WHERE "tenancyId" IN (${tenancyId}::uuid, ${otherTenancyId}::uuid)`;
}
};
25 changes: 25 additions & 0 deletions apps/backend/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -630,6 +630,31 @@ model OAuthOuterInfo {
updatedAt DateTime @updatedAt
}

// Durable audit row for OIDC federation exchange attempts. Gives the dashboard a simple
// "last used at" + count aggregate per trust policy without having to scan the global
// Event log by JSON claim. Written best-effort — a failure here must not break the exchange.
model OidcFederationExchangeAudit {
id String @id @default(uuid()) @db.Uuid

tenancyId String @db.Uuid

// Matched trust-policy id on success; "" when the exchange failed before any policy matched.
policyId String
// OIDC issuer (post-discovery) on success; "" on failure.
issuer String
// OIDC `sub` claim on success; "" on failure.
subject String
// "success" | "failure". Constrained at the DB layer via a CHECK constraint in the audit
// migration; extending the vocabulary requires a follow-up ALTER CONSTRAINT.
outcome String
// Human-readable failure reason. Empty on success.
reason String

createdAt DateTime @default(now())

@@index([tenancyId, policyId, createdAt(sort: Desc)], map: "OidcFederationExchangeAudit_tenancy_policy_createdAt_idx")
}

model ProjectUserRefreshToken {
id String @default(uuid()) @db.Uuid
tenancyId String @db.Uuid
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,192 @@
import { Prisma } from "@/generated/prisma/client";
import { SystemEventTypes, logEvent } from "@/lib/events";
import { validateOidcJwt } from "@/lib/oidc-jwt";
import { mintServerAccessToken } from "@/lib/server-access-token";
import { DEFAULT_BRANCH_ID, getSoleTenancyFromProjectBranch } from "@/lib/tenancies";
import { globalPrismaClient } from "@/prisma-client";
import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler";
import { yupNumber, yupObject, yupString, yupTuple } from "@stackframe/stack-shared/dist/schema-fields";
import { StatusError, captureError } from "@stackframe/stack-shared/dist/utils/errors";
import { matchClaims } from "@stackframe/stack-shared/dist/utils/oidc-federation";
import { runAsynchronously } from "@stackframe/stack-shared/dist/utils/promises";

type AuditRow = Omit<Prisma.OidcFederationExchangeAuditUncheckedCreateInput, "id" | "createdAt" | "outcome"> & {
outcome: "success" | "failure",
};

async function writeAudit(row: AuditRow): Promise<void> {
try {
await globalPrismaClient.oidcFederationExchangeAudit.create({ data: row });
} catch (error) {
captureError("oidc-federation-audit-write-failed", error);
}
}

const GRANT_TYPE = "urn:ietf:params:oauth:grant-type:token-exchange";
const SUBJECT_TOKEN_TYPE = "urn:ietf:params:oauth:token-type:jwt";
const ISSUED_TOKEN_TYPE = "urn:ietf:params:oauth:token-type:access_token";

function flattenClaimConditions(
conds: Record<string, Record<string, string | undefined> | undefined> | undefined,
): Map<string, string[]> {
const out = new Map<string, string[]>();
for (const [claimKey, valueRecord] of Object.entries(conds ?? {})) {
const values = Object.values(valueRecord ?? {}).filter((v): v is string => typeof v === "string");
if (values.length > 0) out.set(claimKey, values);
}
return out;
Comment thread
mantrakp04 marked this conversation as resolved.
}

export const POST = createSmartRouteHandler({
metadata: {
summary: "OIDC Federation token exchange",
description:
"Exchange an OIDC JWT issued by a project-trusted identity provider for a short-lived Stack server access token. " +
"Follows RFC 8693 (OAuth 2.0 Token Exchange).",
tags: ["Auth"],
},
request: yupObject({
method: yupString().oneOf(["POST"]).defined(),
headers: yupObject({
"x-stack-project-id": yupTuple([yupString().defined()]).defined(),
"x-stack-branch-id": yupTuple([yupString().defined()]).optional(),
}).defined(),
body: yupObject({
grant_type: yupString().oneOf([GRANT_TYPE]).defined(),
subject_token: yupString().defined(),
subject_token_type: yupString().oneOf([SUBJECT_TOKEN_TYPE]).defined(),
// RFC 8693 optional params. Only `requested_token_type` is accepted, and only with
// the access-token value we actually issue. Audience/resource/scope are not
// negotiable per-request — they're fixed by the trust policy — so reject them
// outright to avoid giving callers a false sense of configurability.
requested_token_type: yupString().oneOf([ISSUED_TOKEN_TYPE]).optional(),
}).defined(),
}),
response: yupObject({
statusCode: yupNumber().oneOf([200]).defined(),
bodyType: yupString().oneOf(["json"]).defined(),
body: yupObject({
access_token: yupString().defined(),
issued_token_type: yupString().oneOf([ISSUED_TOKEN_TYPE]).defined(),
token_type: yupString().oneOf(["Bearer"]).defined(),
expires_in: yupNumber().defined(),
}).defined(),
}),
handler: async (req) => {
const projectId = req.headers["x-stack-project-id"][0];
const branchId = req.headers["x-stack-branch-id"]?.[0] ?? DEFAULT_BRANCH_ID;

const tenancy = await getSoleTenancyFromProjectBranch(projectId, branchId, true);
if (!tenancy) {
throw new StatusError(400, "invalid_request: project or branch not found");
}

const recordFailure = (failureContext: { policyId: string, issuer: string, subject: string, reason: string }) => {
runAsynchronously(logEvent([SystemEventTypes.OidcFederationExchange], {
projectId: tenancy.project.id,
policyId: failureContext.policyId,
issuer: failureContext.issuer,
subject: failureContext.subject,
outcome: "failure",
reason: failureContext.reason,
}));
runAsynchronously(writeAudit({
tenancyId: tenancy.id,
policyId: failureContext.policyId,
issuer: failureContext.issuer,
subject: failureContext.subject,
outcome: "failure",
reason: failureContext.reason,
}));
};

const trustPolicies = tenancy.config.oidcFederation.trustPolicies;
const policyEntries = Object.entries(trustPolicies).filter(([_, policy]) => policy.enabled);
if (policyEntries.length === 0) {
recordFailure({
policyId: "",
issuer: "",
subject: "",
reason: "no enabled OIDC federation trust policies for this project",
});
throw new StatusError(400, "invalid_grant");
}
Comment thread
mantrakp04 marked this conversation as resolved.

const attemptReasons: Array<{ policyId: string, reason: string }> = [];
let bestAttempt: { policyId: string, issuer: string, subject: string } | null = null;
for (const [policyId, policy] of policyEntries) {
const issuerUrl = policy.issuerUrl;
const audiences = Object.values(policy.audiences ?? {}).filter((v): v is string => typeof v === "string");
if (typeof issuerUrl !== "string" || audiences.length === 0) {
attemptReasons.push({ policyId, reason: "policy is missing issuerUrl or audiences" });
continue;
}

let validated: Awaited<ReturnType<typeof validateOidcJwt>>;
try {
validated = await validateOidcJwt({ issuerUrl, audiences, token: req.body.subject_token, prisma: globalPrismaClient });
} catch (error) {
attemptReasons.push({ policyId, reason: error instanceof Error ? error.message : String(error) });
Comment on lines +125 to +129
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 security Empty audience strings are not rejected

Object.values(policy.audiences ?? {}).filter((v): v is string => typeof v === "string") keeps empty-string values (""). When audiences = [""], jose's jwtVerify will accept any token whose aud claim is also "". An admin who accidentally leaves an audience row blank ends up with a policy that can be satisfied by a zero-length audience, making the audience check vacuous for those matching tokens.

Suggested change
let validated: Awaited<ReturnType<typeof validateOidcJwt>>;
try {
validated = await validateOidcJwt({ issuerUrl, audiences, token: req.body.subject_token, prisma: globalPrismaClient });
} catch (error) {
attemptReasons.push({ policyId, reason: error instanceof Error ? error.message : String(error) });
const audiences = Object.values(policy.audiences ?? {}).filter((v): v is string => typeof v === "string" && v.length > 0);
Prompt To Fix With AI
This is a comment left during a code review.
Path: apps/backend/src/app/api/latest/auth/oidc-federation/exchange/route.tsx
Line: 125-129

Comment:
**Empty audience strings are not rejected**

`Object.values(policy.audiences ?? {}).filter((v): v is string => typeof v === "string")` keeps empty-string values (`""`). When `audiences = [""]`, jose's `jwtVerify` will accept any token whose `aud` claim is also `""`. An admin who accidentally leaves an audience row blank ends up with a policy that can be satisfied by a zero-length audience, making the audience check vacuous for those matching tokens.

```suggestion
      const audiences = Object.values(policy.audiences ?? {}).filter((v): v is string => typeof v === "string" && v.length > 0);
```

How can I resolve this? If you propose a fix, please make it concise.

Fix in Claude Code Fix in Cursor Fix in Codex

continue;
}
bestAttempt = { policyId, issuer: validated.issuer, subject: validated.subject };

const stringEquals = flattenClaimConditions(policy.claimConditions.stringEquals);
const stringLike = flattenClaimConditions(policy.claimConditions.stringLike);
const match = matchClaims({ stringEquals, stringLike }, validated.claims);
if (!match.matched) {
attemptReasons.push({ policyId, reason: match.reason });
continue;
}

const minted = await mintServerAccessToken({
projectId: tenancy.project.id,
branchId: tenancy.branchId,
federation: {
policyId,
issuer: validated.issuer,
subject: validated.subject,
audience: validated.audience,
},
ttlSeconds: policy.tokenTtlSeconds ?? 900,
});

runAsynchronously(logEvent([SystemEventTypes.OidcFederationExchange], {
projectId: tenancy.project.id,
policyId,
issuer: validated.issuer,
subject: validated.subject,
outcome: "success",
reason: "",
}));
runAsynchronously(writeAudit({
tenancyId: tenancy.id,
policyId,
issuer: validated.issuer,
subject: validated.subject,
outcome: "success",
reason: "",
}));

return {
statusCode: 200,
bodyType: "json" as const,
body: {
access_token: minted.accessToken,
issued_token_type: ISSUED_TOKEN_TYPE,
token_type: "Bearer" as const,
expires_in: minted.ttlSeconds,
},
};
}

const reasonForPolicy = (policyId: string): string =>
attemptReasons.find(a => a.policyId === policyId)?.reason ?? "no trust policy matched";
const failureContext = bestAttempt
? { policyId: bestAttempt.policyId, issuer: bestAttempt.issuer, subject: bestAttempt.subject, reason: reasonForPolicy(bestAttempt.policyId) }
: { policyId: attemptReasons[0]?.policyId ?? "", issuer: "", subject: "", reason: attemptReasons[0]?.reason ?? "no trust policy matched" };

recordFailure(failureContext);
throw new StatusError(400, "invalid_grant");
},
});
Loading
Loading