feat: add mock OIDC IdP for local development

- Introduced a new mock OIDC Identity Provider in `apps/mock-oidc-idp` for OIDC federation testing.
- Updated `package.json` scripts to include the new mock server.
- Enhanced backend seed script to integrate with the mock IdP for trust policy setup.
- Added demo pages and API routes for minting and exchanging tokens with the mock IdP.
- Updated environment configurations to support the new mock IdP.

This setup facilitates local testing of OIDC federation features without external dependencies.
This commit is contained in:
mantrakp04 2026-04-20 19:37:45 -07:00
parent c7f5d2f6b7
commit 92cd9965fb
13 changed files with 445 additions and 3 deletions

View File

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

View File

@ -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({

View File

@ -0,0 +1,7 @@
module.exports = {
"extends": [
"../../configs/eslint/defaults.js",
"../../configs/eslint/next.js",
],
"ignorePatterns": ['/*', '!/src']
};

View File

@ -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"
}

View File

@ -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<string, unknown>)
: {};
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);
},
);

View File

@ -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"]
}

View File

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

View File

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

View File

@ -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,
});
}

View File

@ -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);
}

View File

@ -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<MintResult | null>(null);
const [exchangeResult, setExchangeResult] = useState<ExchangeResult | null>(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 (
<div className="stack-scope min-h-screen flex items-start justify-center p-6 w-full">
<div className="w-full max-w-3xl flex flex-col gap-6">
<div>
<Typography type="h2" className="mb-1">OIDC Federation demo</Typography>
<Typography variant="secondary">
Step 1 mints a mock workload JWT from <code>apps/mock-oidc-idp</code>. 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 <code>workload:*</code> subs
from this IdP with audience <code>stack-demo</code>.
</Typography>
</div>
<Card className="p-5 flex flex-col gap-4">
<Typography type="h4">1. Mint mock OIDC token</Typography>
<LabelledInput label="Subject (sub)" value={sub} onChange={setSub} placeholder="workload:demo-1" />
<LabelledInput label="Audience (aud)" value={aud} onChange={setAud} placeholder="stack-demo" />
<Button
onClick={() => runAsynchronouslyWithAlert(mint())}
disabled={minting}
>
{minting ? "Minting…" : "Mint token"}
</Button>
{mintResult && (
<KeyValue label="id_token">
<pre className="text-xs overflow-x-auto p-2 bg-muted rounded whitespace-pre-wrap break-all">
{mintResult.id_token}
</pre>
<Typography variant="secondary" className="text-xs">
issuer <code>{mintResult.issuer}</code> · aud <code>{mintResult.aud}</code> · ttl {mintResult.expires_in}s
</Typography>
</KeyValue>
)}
</Card>
<Card className="p-5 flex flex-col gap-4">
<Typography type="h4">2. Exchange for Stack server access token</Typography>
<LabelledInput label="Project ID" value={projectId} onChange={setProjectId} placeholder="project uuid" />
<Button
onClick={() => runAsynchronouslyWithAlert(exchange())}
disabled={exchanging || !mintResult}
>
{exchanging ? "Exchanging…" : mintResult ? "Exchange token" : "Mint a token first"}
</Button>
{exchangeResult && (exchangeResult.ok ? (
<div className="p-3 rounded bg-green-50 dark:bg-green-900/20 flex flex-col gap-2">
<Typography className="text-green-700 dark:text-green-400 font-medium">
Exchange OK token expires in {exchangeResult.expires_in}s
</Typography>
<pre className="text-xs overflow-x-auto p-2 bg-green-100 dark:bg-green-900/40 rounded whitespace-pre-wrap break-all">
{exchangeResult.access_token}
</pre>
<Typography variant="secondary" className="text-xs">
Use this as <code>x-stack-server-access-token</code> on server-scope API calls no
<code> STACK_SECRET_SERVER_KEY</code> needed.
</Typography>
</div>
) : (
<div className="p-3 rounded bg-red-50 dark:bg-red-900/20 flex flex-col gap-1">
<Typography className="text-red-700 dark:text-red-400 font-medium">
Exchange failed ({exchangeResult.status})
</Typography>
<Typography variant="secondary" className="text-xs">{exchangeResult.error}</Typography>
</div>
))}
</Card>
</div>
</div>
);
}
function LabelledInput({ label, value, onChange, placeholder }: { label: string, value: string, onChange: (v: string) => void, placeholder?: string }) {
return (
<div>
<Typography variant="secondary" className="text-xs mb-1 block">{label}</Typography>
<Input value={value} onChange={(e) => onChange(e.target.value)} placeholder={placeholder} />
</div>
);
}
function KeyValue({ label, children }: { label: string, children: React.ReactNode }) {
return (
<div>
<Typography variant="secondary" className="text-xs mb-1 block">{label}</Typography>
{children}
</div>
);
}

View File

@ -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",

View File

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