diff --git a/apps/backend/.env.development b/apps/backend/.env.development index 8ec5f4178..698e20dd7 100644 --- a/apps/backend/.env.development +++ b/apps/backend/.env.development @@ -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 diff --git a/apps/backend/src/lib/seed-dummy-data.ts b/apps/backend/src/lib/seed-dummy-data.ts index 346724680..ce29618f3 100644 --- a/apps/backend/src/lib/seed-dummy-data.ts +++ b/apps/backend/src/lib/seed-dummy-data.ts @@ -1906,6 +1906,29 @@ export async function seedDummyProject(options: SeedDummyProjectOptions): Promis .filter(([, app]) => !options.excludeAlphaApps || app.stage !== "alpha") .map(([key]) => [key, { enabled: true }])), }, + oidcFederation: { + // Default trust policy pointing at the local mock OIDC IdP + // (apps/mock-oidc-idp). The demo at `examples/demo/oidc-federation-demo` + // mints tokens from this IdP and exchanges them here. + trustPolicies: { + "mock-idp-demo": { + displayName: "Mock IdP (local dev)", + enabled: true, + issuerUrl: process.env.STACK_MOCK_OIDC_ISSUER_URL + ?? `http://localhost:${process.env.NEXT_PUBLIC_STACK_PORT_PREFIX ?? "81"}15`, + audiences: { + default: "stack-demo", + }, + claimConditions: { + stringLike: { + sub: { demo: "workload:*" }, + }, + stringEquals: {}, + }, + tokenTtlSeconds: 900, + }, + }, + }, }, }), overrideEnvironmentConfigOverride({ diff --git a/apps/mock-oidc-idp/.eslintrc.js b/apps/mock-oidc-idp/.eslintrc.js new file mode 100644 index 000000000..49bc087f4 --- /dev/null +++ b/apps/mock-oidc-idp/.eslintrc.js @@ -0,0 +1,7 @@ +module.exports = { + "extends": [ + "../../configs/eslint/defaults.js", + "../../configs/eslint/next.js", + ], + "ignorePatterns": ['/*', '!/src'] +}; diff --git a/apps/mock-oidc-idp/package.json b/apps/mock-oidc-idp/package.json new file mode 100644 index 000000000..0733de4c6 --- /dev/null +++ b/apps/mock-oidc-idp/package.json @@ -0,0 +1,23 @@ +{ + "name": "@stackframe/mock-oidc-idp", + "version": "2.8.85", + "repository": "https://github.com/stack-auth/stack-auth", + "private": true, + "main": "index.js", + "scripts": { + "start": "tsx src/index.ts", + "dev": "tsx watch --clear-screen=false src/index.ts", + "typecheck": "tsc --noEmit", + "lint": "eslint .", + "clean": "rimraf dist && rimraf node_modules" + }, + "dependencies": { + "@types/express": "^5.0.0", + "express": "^4.21.2", + "jose": "^6.1.3" + }, + "devDependencies": { + "tsx": "^4.16.2" + }, + "packageManager": "pnpm@10.23.0" +} diff --git a/apps/mock-oidc-idp/src/index.ts b/apps/mock-oidc-idp/src/index.ts new file mode 100644 index 000000000..ad32f84a6 --- /dev/null +++ b/apps/mock-oidc-idp/src/index.ts @@ -0,0 +1,119 @@ +/** + * Mock OIDC Identity Provider for local development of the Stack Auth + * OIDC-federation feature. It mimics the discovery + JWKS + token-minting + * surface that Vercel / GitHub Actions / GCP / any OIDC-compliant IdP expose + * at runtime, so the backend's `/api/v1/auth/oidc-federation/exchange` + * endpoint has something to validate against without network egress. + * + * Endpoints: + * GET /.well-known/openid-configuration — OIDC discovery doc + * GET /jwks — JSON Web Key Set (RSA public key) + * POST /mint — non-standard. Mints an ID-token + * with caller-supplied `sub`, `aud`, + * and extra claims. Used by the + * demo app to simulate a workload + * token without the caller having + * to sign their own JWT. + * + * This server does NOT implement the OAuth 2.0 authorization-code flow. It + * exists solely to serve as a trusted OIDC issuer for federation testing. + */ + +import express from "express"; +import { SignJWT, exportJWK, generateKeyPair } from "jose"; + +const stackPortPrefix = process.env.NEXT_PUBLIC_STACK_PORT_PREFIX ?? "81"; +const defaultPort = Number(`${stackPortPrefix}15`); +const port = Number(process.env.PORT ?? defaultPort); +const issuer = process.env.STACK_MOCK_OIDC_ISSUER_URL ?? `http://localhost:${port}`; + +async function main() { + const { publicKey, privateKey } = await generateKeyPair("RS256", { extractable: true }); + const publicJwk = await exportJWK(publicKey); + publicJwk.kid = "mock-oidc-idp-key-1"; + publicJwk.alg = "RS256"; + publicJwk.use = "sig"; + + const app = express(); + app.use(express.json()); + + app.get("/.well-known/openid-configuration", (_req, res) => { + res.json({ + issuer, + jwks_uri: `${issuer}/jwks`, + id_token_signing_alg_values_supported: ["RS256"], + // The remaining fields are not used by the backend but make this doc + // parseable by generic OIDC clients that probe a few optional keys. + response_types_supported: ["id_token"], + subject_types_supported: ["public"], + token_endpoint_auth_methods_supported: ["none"], + }); + }); + + app.get("/jwks", (_req, res) => { + res.json({ keys: [publicJwk] }); + }); + + // Non-standard mint endpoint. Real IdPs don't expose anything like this — + // they mint tokens at the end of a controlled auth flow. We expose it so + // the demo app can request a workload-style token on demand. + app.post("/mint", async (req, res) => { + const body = (req.body ?? {}) as { + sub?: unknown, + aud?: unknown, + extraClaims?: unknown, + ttlSeconds?: unknown, + }; + const sub = typeof body.sub === "string" && body.sub.length > 0 ? body.sub : "workload:demo"; + const aud = typeof body.aud === "string" && body.aud.length > 0 ? body.aud : `${issuer}/default-audience`; + const ttlSeconds = typeof body.ttlSeconds === "number" && body.ttlSeconds > 0 && body.ttlSeconds <= 3600 ? body.ttlSeconds : 300; + const extraClaims = typeof body.extraClaims === "object" && body.extraClaims !== null && !Array.isArray(body.extraClaims) + ? (body.extraClaims as Record) + : {}; + + const jwt = await new SignJWT(extraClaims) + .setProtectedHeader({ alg: "RS256", kid: publicJwk.kid, typ: "JWT" }) + .setIssuer(issuer) + .setSubject(sub) + .setAudience(aud) + .setIssuedAt() + .setExpirationTime(Math.floor(Date.now() / 1000) + ttlSeconds) + .sign(privateKey); + + res.json({ + id_token: jwt, + issuer, + sub, + aud, + expires_in: ttlSeconds, + }); + }); + + app.get("/", (_req, res) => { + res.type("text/plain").send( + [ + "Mock OIDC IdP (Stack Auth OIDC federation local dev)", + `issuer: ${issuer}`, + "", + "GET /.well-known/openid-configuration", + "GET /jwks", + "POST /mint { sub, aud, extraClaims, ttlSeconds } -> { id_token, ... }", + ].join("\n"), + ); + }); + + app.listen(port, () => { + console.log(`Mock OIDC IdP listening on ${issuer}`); + }); +} + +void main().then( + () => { + // started OK — listener logs its own ready message + }, + (err: unknown) => { + // eslint-disable-next-line no-console + console.error("Mock OIDC IdP failed to start:", err); + process.exit(1); + }, +); diff --git a/apps/mock-oidc-idp/tsconfig.json b/apps/mock-oidc-idp/tsconfig.json new file mode 100644 index 000000000..86cb42742 --- /dev/null +++ b/apps/mock-oidc-idp/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "lib": ["dom", "dom.iterable", "esnext"], + "allowJs": true, + "strict": true, + "noEmit": true, + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "bundler", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "preserve", + "incremental": true, + "noErrorTruncation": true, + "skipLibCheck": true + }, + "include": ["src/**/*.ts"], + "exclude": ["node_modules"] +} diff --git a/examples/demo/.env b/examples/demo/.env index 13af3140c..85deb00f9 100644 --- a/examples/demo/.env +++ b/examples/demo/.env @@ -1,3 +1,7 @@ NEXT_PUBLIC_STACK_API_URL=# enter your stack endpoint here, e.g. http://localhost:8102 NEXT_PUBLIC_STACK_PROJECT_ID=# enter your stack project id here NEXT_PUBLIC_STACK_PUBLISHABLE_CLIENT_KEY=# enter your stack publishable client key here + +# OIDC federation demo — /oidc-federation-demo +# URL of the local mock OIDC IdP (apps/mock-oidc-idp). Only needed for that demo route. +STACK_MOCK_OIDC_ISSUER_URL=# e.g. http://localhost:8115 diff --git a/examples/demo/.env.development b/examples/demo/.env.development index 0220ad2bc..f788a0593 100644 --- a/examples/demo/.env.development +++ b/examples/demo/.env.development @@ -5,3 +5,7 @@ NEXT_PUBLIC_STACK_PROJECT_ID=internal NEXT_PUBLIC_STACK_PUBLISHABLE_CLIENT_KEY=this-publishable-client-key-is-for-local-development-only STACK_SECRET_SERVER_KEY=this-secret-server-key-is-for-local-development-only NEXT_PUBLIC_STACK_HOSTED_HANDLER_URL_TEMPLATE=http://{projectId}.localhost:${NEXT_PUBLIC_STACK_PORT_PREFIX:-81}09/{hostedPath} + +# OIDC federation demo — /oidc-federation-demo +# URL of the local mock OIDC IdP (apps/mock-oidc-idp). +STACK_MOCK_OIDC_ISSUER_URL=http://localhost:${NEXT_PUBLIC_STACK_PORT_PREFIX:-81}15 diff --git a/examples/demo/src/app/oidc-federation-demo/api/exchange/route.ts b/examples/demo/src/app/oidc-federation-demo/api/exchange/route.ts new file mode 100644 index 000000000..03fcb509e --- /dev/null +++ b/examples/demo/src/app/oidc-federation-demo/api/exchange/route.ts @@ -0,0 +1,36 @@ +import { NextResponse } from "next/server"; + +const BACKEND_URL = process.env.NEXT_PUBLIC_STACK_API_URL + ?? `http://localhost:${process.env.NEXT_PUBLIC_STACK_PORT_PREFIX ?? "81"}02`; + +export async function POST(request: Request) { + const body = await request.json() as { projectId?: string, subjectToken?: string }; + if (!body.projectId || !body.subjectToken) { + return NextResponse.json({ ok: false, status: 400, error: "projectId and subjectToken are required" }, { status: 400 }); + } + + const res = await fetch(`${BACKEND_URL}/api/v1/auth/oidc-federation/exchange`, { + method: "POST", + headers: { + "Content-Type": "application/json", + "x-stack-project-id": body.projectId, + }, + body: JSON.stringify({ + grant_type: "urn:ietf:params:oauth:grant-type:token-exchange", + subject_token: body.subjectToken, + subject_token_type: "urn:ietf:params:oauth:token-type:jwt", + }), + }); + + if (!res.ok) { + const text = await res.text(); + return NextResponse.json({ ok: false, status: res.status, error: text }, { status: 200 }); + } + const data = await res.json() as { access_token: string, expires_in: number, token_type: string }; + return NextResponse.json({ + ok: true, + access_token: data.access_token, + expires_in: data.expires_in, + token_type: data.token_type, + }); +} diff --git a/examples/demo/src/app/oidc-federation-demo/api/mint/route.ts b/examples/demo/src/app/oidc-federation-demo/api/mint/route.ts new file mode 100644 index 000000000..07df57691 --- /dev/null +++ b/examples/demo/src/app/oidc-federation-demo/api/mint/route.ts @@ -0,0 +1,23 @@ +import { NextResponse } from "next/server"; + +const MOCK_IDP_URL = process.env.STACK_MOCK_OIDC_ISSUER_URL + ?? `http://localhost:${process.env.NEXT_PUBLIC_STACK_PORT_PREFIX ?? "81"}15`; + +export async function POST(request: Request) { + const body = await request.json() as { sub?: string, aud?: string }; + const res = await fetch(`${MOCK_IDP_URL}/mint`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + sub: body.sub, + aud: body.aud, + extraClaims: { environment: "demo" }, + ttlSeconds: 300, + }), + }); + const data = await res.json(); + if (!res.ok) { + return NextResponse.json({ error: data?.error ?? `mock IdP returned ${res.status}` }, { status: 502 }); + } + return NextResponse.json(data); +} diff --git a/examples/demo/src/app/oidc-federation-demo/page.tsx b/examples/demo/src/app/oidc-federation-demo/page.tsx new file mode 100644 index 000000000..7668209e9 --- /dev/null +++ b/examples/demo/src/app/oidc-federation-demo/page.tsx @@ -0,0 +1,163 @@ +"use client"; + +import { runAsynchronouslyWithAlert } from "@stackframe/stack-shared/dist/utils/promises"; +import { Button, Card, Input, Typography } from "@stackframe/stack-ui"; +import { useState } from "react"; + +type MintResult = { + id_token: string, + issuer: string, + sub: string, + aud: string, + expires_in: number, +}; + +type ExchangeOk = { + ok: true, + access_token: string, + expires_in: number, + token_type: string, +}; + +type ExchangeErr = { + ok: false, + status: number, + error: string, +}; + +type ExchangeResult = ExchangeOk | ExchangeErr; + +const DEFAULT_PROJECT_ID = "6fbbf22e-f4b2-4c6e-95a1-beab6fa41063"; + +export default function OidcFederationDemoPage() { + const [projectId, setProjectId] = useState(DEFAULT_PROJECT_ID); + const [sub, setSub] = useState("workload:demo-1"); + const [aud, setAud] = useState("stack-demo"); + const [minting, setMinting] = useState(false); + const [exchanging, setExchanging] = useState(false); + const [mintResult, setMintResult] = useState(null); + const [exchangeResult, setExchangeResult] = useState(null); + + const mint = async () => { + setMinting(true); + setMintResult(null); + setExchangeResult(null); + try { + const res = await fetch("/oidc-federation-demo/api/mint", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ sub, aud }), + }); + const data = await res.json(); + if (!res.ok) throw new Error(data.error ?? "mint failed"); + setMintResult(data); + } finally { + setMinting(false); + } + }; + + const exchange = async () => { + if (!mintResult) return; + setExchanging(true); + setExchangeResult(null); + try { + const res = await fetch("/oidc-federation-demo/api/exchange", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ projectId, subjectToken: mintResult.id_token }), + }); + const data = await res.json(); + setExchangeResult(data); + } finally { + setExchanging(false); + } + }; + + return ( +
+
+
+ OIDC Federation demo + + Step 1 mints a mock workload JWT from apps/mock-oidc-idp. Step 2 exchanges + it at the backend for a short-lived Stack server access token via RFC 8693. The + dummy project is pre-seeded with a trust policy accepting workload:* subs + from this IdP with audience stack-demo. + +
+ + + 1. Mint mock OIDC token + + + + {mintResult && ( + +
+                {mintResult.id_token}
+              
+ + issuer {mintResult.issuer} · aud {mintResult.aud} · ttl {mintResult.expires_in}s + +
+ )} +
+ + + 2. Exchange for Stack server access token + + + {exchangeResult && (exchangeResult.ok ? ( +
+ + Exchange OK — token expires in {exchangeResult.expires_in}s + +
+                {exchangeResult.access_token}
+              
+ + Use this as x-stack-server-access-token on server-scope API calls — no + STACK_SECRET_SERVER_KEY needed. + +
+ ) : ( +
+ + Exchange failed ({exchangeResult.status}) + + {exchangeResult.error} +
+ ))} +
+
+
+ ); +} + +function LabelledInput({ label, value, onChange, placeholder }: { label: string, value: string, onChange: (v: string) => void, placeholder?: string }) { + return ( +
+ {label} + onChange(e.target.value)} placeholder={placeholder} /> +
+ ); +} + +function KeyValue({ label, children }: { label: string, children: React.ReactNode }) { + return ( +
+ {label} + {children} +
+ ); +} diff --git a/package.json b/package.json index 7828d0154..e398eedff 100644 --- a/package.json +++ b/package.json @@ -54,15 +54,16 @@ "dev:tui": "pnpm pre && (trap 'kill 0' EXIT; pnpm run generate-sdks:watch & pnpm run generate-openapi-docs:watch & turbo run dev --ui tui --concurrency 99999 --filter=./apps/* --filter=@stackframe/stack-docs --filter=./packages/* --filter=./examples/demo)", "dev:inspect": "pnpm pre && STACK_BACKEND_DEV_EXTRA_ARGS=\"--inspect\" pnpm run dev", "dev:profile": "pnpm pre && STACK_BACKEND_DEV_EXTRA_ARGS=\"--experimental-cpu-prof\" pnpm run dev", - "dev:basic": "pnpm pre && concurrently -k \"pnpm run generate-sdks:watch\" \"turbo run dev --concurrency 99999 --filter=@stackframe/backend --filter=@stackframe/dashboard --filter=@stackframe/mock-oauth-server\"", + "dev:basic": "pnpm pre && concurrently -k \"pnpm run generate-sdks:watch\" \"turbo run dev --concurrency 99999 --filter=@stackframe/backend --filter=@stackframe/dashboard --filter=@stackframe/mock-oauth-server --filter=@stackframe/mock-oidc-idp\"", "dev:docs": "pnpm pre && concurrently -k \"pnpm run generate-openapi-docs:watch\" \"turbo run dev --concurrency 99999 --filter=@stackframe/stack-docs\"", "dev:named": "pnpm pre && concurrently -k \"pnpm run dev\" \"node -e \\\"process.title='node (stack-named-dev-server)'; process.stdin.resume();\\\"\"", "kill-dev:named": "(pgrep -f 'stack-named-dev-server' | xargs -r -n1 pkill -P); echo 'Killed named dev server (if found). Sleeping to give some time for it to shut down...' && sleep 10", - "kms": "PREFIX=${NEXT_PUBLIC_STACK_PORT_PREFIX:-81}; for p in 00 01 02 03 04 06 14; do pids=$(lsof -i :$PREFIX$p 2>/dev/null | grep LISTEN | awk '$1 != \"OrbStack\" {print $2}' | sort -u); [ -n \"$pids\" ] && echo $pids | xargs kill -9 2>/dev/null; done; echo Done.", + "kms": "PREFIX=${NEXT_PUBLIC_STACK_PORT_PREFIX:-81}; for p in 00 01 02 03 04 06 14 15; do pids=$(lsof -i :$PREFIX$p 2>/dev/null | grep LISTEN | awk '$1 != \"OrbStack\" {print $2}' | sort -u); [ -n \"$pids\" ] && echo $pids | xargs kill -9 2>/dev/null; done; echo Done.", "start": "pnpm pre && turbo run start --concurrency 99999", "start:backend": "pnpm pre && turbo run start --concurrency 99999 --filter=@stackframe/backend", "start:dashboard": "pnpm pre && turbo run start --concurrency 99999 --filter=@stackframe/dashboard", "start:mock-oauth-server": "pnpm pre && turbo run start --concurrency 99999 --filter=@stackframe/mock-oauth-server", + "start:mock-oidc-idp": "pnpm pre && turbo run start --concurrency 99999 --filter=@stackframe/mock-oidc-idp", "lint": "pnpm pre && turbo run lint --continue -- --max-warnings=0", "release": "pnpm pre && release", "dotenv": "dotenv", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 57c63c24b..cd6dd05be 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -862,6 +862,22 @@ importers: specifier: ^4.16.2 version: 4.16.2 + apps/mock-oidc-idp: + dependencies: + '@types/express': + specifier: ^5.0.0 + version: 5.0.0 + express: + specifier: ^4.21.2 + version: 4.21.2 + jose: + specifier: ^6.1.3 + version: 6.1.3 + devDependencies: + tsx: + specifier: ^4.16.2 + version: 4.21.0 + docs: dependencies: 2027-track: @@ -40842,7 +40858,7 @@ snapshots: tsx@4.21.0: dependencies: esbuild: 0.27.1 - get-tsconfig: 4.8.1 + get-tsconfig: 4.13.6 optionalDependencies: fsevents: 2.3.3