-
Notifications
You must be signed in to change notification settings - Fork 513
feat: OIDC federation (exchange API, dashboard, audit) #1360
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: dev
Are you sure you want to change the base?
Changes from all commits
c7f5d2f
92cd996
61147a3
62874ba
88d30e4
83d17a7
96731f5
a2694c0
0913b58
344af93
eae316a
56562d0
75ddfbe
8998828
beb7d04
651621a
eeec4eb
8f0f9ff
06ceaf7
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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"); | ||
|
|
||
| // 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)`; | ||
| } | ||
| }; | ||
| 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; | ||||||||||||||
|
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"); | ||||||||||||||
| } | ||||||||||||||
|
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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Suggested change
Prompt To Fix With AIThis 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. |
||||||||||||||
| 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"); | ||||||||||||||
| }, | ||||||||||||||
| }); | ||||||||||||||
Uh oh!
There was an error while loading. Please reload this page.