-
Notifications
You must be signed in to change notification settings - Fork 514
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
Changes from 8 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,16 @@ | ||
| -- 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); |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,84 @@ | ||
| 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. | ||
| const tenancyId = "00000000-0000-0000-0000-000000000001"; | ||
| const otherTenancyId = "00000000-0000-0000-0000-000000000002"; | ||
| 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 aggregate = await sql<Array<{ policyId: string, lastAt: Date, total: bigint }>>` | ||
| SELECT "policyId", MAX("createdAt") 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.toISOString()).toBe("2026-01-02T00:00:00.000Z"); | ||
| expect(aggregate[1].policyId).toBe("policy-b"); | ||
| expect(Number(aggregate[1].total)).toBe(1); | ||
|
|
||
| // Clean up so later tests see an empty table. | ||
| await sql`DELETE FROM "OidcFederationExchangeAudit"`; | ||
| }; | ||
| Original file line number | Diff line number | Diff line change | ||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,181 @@ | ||||||||||||||
| 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(), | ||||||||||||||
| requested_token_type: yupString().optional(), | ||||||||||||||
| audience: yupString().optional(), | ||||||||||||||
| resource: yupString().optional(), | ||||||||||||||
| scope: yupString().optional(), | ||||||||||||||
|
coderabbitai[bot] marked this conversation as resolved.
Outdated
|
||||||||||||||
| }).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 trustPolicies = tenancy.config.oidcFederation.trustPolicies; | ||||||||||||||
| const policyEntries = Object.entries(trustPolicies).filter(([_, policy]) => policy.enabled); | ||||||||||||||
| if (policyEntries.length === 0) { | ||||||||||||||
| throw new StatusError(400, "invalid_request: no enabled OIDC federation trust policies for this project"); | ||||||||||||||
| } | ||||||||||||||
|
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" }; | ||||||||||||||
|
|
||||||||||||||
| 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, | ||||||||||||||
| })); | ||||||||||||||
| throw new StatusError(400, `invalid_request: ${failureContext.reason}`); | ||||||||||||||
|
mantrakp04 marked this conversation as resolved.
Outdated
|
||||||||||||||
| }, | ||||||||||||||
| }); | ||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,71 @@ | ||
| import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler"; | ||
| import { adaptSchema, adminAuthTypeSchema, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields"; | ||
|
|
||
| function stripTrailingSlash(s: string): string { | ||
| return s.endsWith("/") ? s.slice(0, -1) : s; | ||
| } | ||
|
|
||
| export const POST = createSmartRouteHandler({ | ||
| metadata: { | ||
| hidden: true, | ||
| }, | ||
| request: yupObject({ | ||
| auth: yupObject({ | ||
| type: adminAuthTypeSchema, | ||
| tenancy: adaptSchema.defined(), | ||
| }).defined(), | ||
| body: yupObject({ | ||
| issuer_url: yupString().defined(), | ||
| }).defined(), | ||
| method: yupString().oneOf(["POST"]).defined(), | ||
| }), | ||
| response: yupObject({ | ||
| statusCode: yupNumber().oneOf([200]).defined(), | ||
| bodyType: yupString().oneOf(["json"]).defined(), | ||
| body: yupObject({ | ||
| ok: yupObject({ | ||
| issuer: yupString().defined(), | ||
| jwks_uri: yupString().defined(), | ||
| }).optional(), | ||
| error: yupString().optional(), | ||
| }).defined(), | ||
| }), | ||
| handler: async ({ body }) => { | ||
| const trimmed = body.issuer_url.trim(); | ||
| if (!trimmed) { | ||
| return { statusCode: 200, bodyType: "json" as const, body: { error: "issuer URL is empty" } }; | ||
| } | ||
| let discoveryUrl: string; | ||
| try { | ||
| discoveryUrl = `${stripTrailingSlash(trimmed)}/.well-known/openid-configuration`; | ||
| new URL(discoveryUrl); | ||
| } catch { | ||
| return { statusCode: 200, bodyType: "json" as const, body: { error: "issuer URL is not a valid URL" } }; | ||
| } | ||
|
|
||
| try { | ||
| const response = await fetch(discoveryUrl, { | ||
| method: "GET", | ||
| headers: { accept: "application/json" }, | ||
| signal: AbortSignal.timeout(5000), | ||
| }); | ||
| if (!response.ok) { | ||
| return { statusCode: 200, bodyType: "json" as const, body: { error: `HTTP ${response.status} from ${discoveryUrl}` } }; | ||
| } | ||
| const doc = await response.json() as { issuer?: unknown, jwks_uri?: unknown }; | ||
| if (typeof doc.issuer !== "string") { | ||
| return { statusCode: 200, bodyType: "json" as const, body: { error: "discovery doc missing `issuer`" } }; | ||
| } | ||
| if (typeof doc.jwks_uri !== "string") { | ||
| return { statusCode: 200, bodyType: "json" as const, body: { error: "discovery doc missing `jwks_uri`" } }; | ||
| } | ||
| return { | ||
| statusCode: 200, | ||
| bodyType: "json" as const, | ||
| body: { ok: { issuer: doc.issuer, jwks_uri: doc.jwks_uri } }, | ||
| }; | ||
| } catch (e) { | ||
| return { statusCode: 200, bodyType: "json" as const, body: { error: e instanceof Error ? e.message : String(e) } }; | ||
| } | ||
|
coderabbitai[bot] marked this conversation as resolved.
Outdated
mantrakp04 marked this conversation as resolved.
|
||
| }, | ||
| }); | ||
Uh oh!
There was an error while loading. Please reload this page.