#!/usr/bin/env -S npx tsx --tsconfig tsconfig.scripts.json /** * Tiny OIDC RP simulator. Self-bootstraps everything it needs: * 1. Sign in with operator credentials. * 2. Register a fresh, ephemeral OAuth client (skip_consent=true) via * /api/admin/clients. * 3. Run the full PKCE authorization-code flow against tessera. * 4. Verify the ID token against /api/auth/jwks. * 5. Hit /userinfo with the access token. * 6. Delete the ephemeral OAuth client. * * Usage: * npm run dev # in another terminal * TEST_PASSWORD=... npm run test:client # or call the script directly * * Required env: * TEST_PASSWORD Operator password (no default — fail fast rather than * guess). * * Optional env: * ISSUER Tessera base URL — default http://localhost:5174. * TEST_EMAIL Operator email — default rachel@chens.email. * TEST_REDIRECT_URI * Redirect URI for the ephemeral client — default * http://127.0.0.1:0/cb. The script does not actually * start a server on this URI; it captures the code from * the 302 Location header before any redirect happens. */ import { createRemoteJWKSet, jwtVerify } from "jose"; const ISSUER = process.env.ISSUER ?? "http://localhost:5174"; const EMAIL = process.env.TEST_EMAIL ?? "rachel@chens.email"; const PASSWORD = process.env.TEST_PASSWORD; const REDIRECT_URI = process.env.TEST_REDIRECT_URI ?? "http://127.0.0.1:0/cb"; if (!PASSWORD) { console.error("✗ TEST_PASSWORD is required."); process.exit(2); } const b64url = (bytes: Uint8Array): string => Buffer.from(bytes).toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); interface Discovery { issuer: string; authorization_endpoint: string; token_endpoint: string; userinfo_endpoint: string; jwks_uri: string; } interface CreatedClient { client_id: string; client_secret: string; } interface Tokens { id_token: string; access_token: string; token_type: string; expires_in?: number; scope?: string; } const main = async () => { // 1. Discovery. const discoveryRes = await fetch(`${ISSUER}/.well-known/openid-configuration`); if (!discoveryRes.ok) throw new Error(`discovery failed: ${discoveryRes.status}`); const discovery = (await discoveryRes.json()) as Discovery; console.log("✓ discovery", { issuer: discovery.issuer, jwks_uri: discovery.jwks_uri }); // 2. Sign in using Cloudflare's always-pass Turnstile test keys locally. const signInRes = await fetch(`${ISSUER}/api/sign-in`, { method: "POST", headers: { "content-type": "application/json", origin: ISSUER }, body: JSON.stringify({ email: EMAIL, password: PASSWORD, turnstileToken: "loopback" }), }); if (!signInRes.ok) { throw new Error(`sign-in failed: ${signInRes.status} ${await signInRes.text()}`); } const setCookies = signInRes.headers.getSetCookie?.().filter((c) => /better-auth\.session/.test(c)); const cookie = (setCookies && setCookies.length > 0 ? setCookies : [signInRes.headers.get("set-cookie")]) .filter((c): c is string => Boolean(c)) .map((c) => c.split(";")[0]) .join("; "); if (!cookie) throw new Error("no session cookie returned from sign-in"); console.log("✓ signed in as", EMAIL); // 3. Register an ephemeral OAuth client. const createRes = await fetch(`${ISSUER}/api/admin/clients`, { method: "POST", headers: { "content-type": "application/json", origin: ISSUER, cookie }, body: JSON.stringify({ name: `test-client (${new Date().toISOString()})`, redirectUris: [REDIRECT_URI], skipConsent: true, }), }); if (!createRes.ok) { throw new Error(`/api/admin/clients failed: ${createRes.status} ${await createRes.text()}`); } const created = (await createRes.json()) as CreatedClient; console.log("✓ registered ephemeral client", { client_id: created.client_id }); try { // 4. PKCE pair. const verifierBytes = crypto.getRandomValues(new Uint8Array(32)); const verifier = b64url(verifierBytes); const challenge = b64url(new Uint8Array(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier)))); // 5. /authorize. The session cookie carries the user identity; with // skipConsent the plugin redirects straight to redirect_uri with `code`. const authorizeUrl = new URL(discovery.authorization_endpoint); authorizeUrl.searchParams.set("response_type", "code"); authorizeUrl.searchParams.set("client_id", created.client_id); authorizeUrl.searchParams.set("redirect_uri", REDIRECT_URI); authorizeUrl.searchParams.set("scope", "openid profile email"); authorizeUrl.searchParams.set("state", "rp-state"); authorizeUrl.searchParams.set("code_challenge", challenge); authorizeUrl.searchParams.set("code_challenge_method", "S256"); const authorizeRes = await fetch(authorizeUrl, { headers: { cookie }, redirect: "manual" }); let location = authorizeRes.status === 302 ? authorizeRes.headers.get("location") : null; // 1.7 returns a JSON redirect to programmatic callers; browser // navigations still receive the normal HTTP redirect. if (authorizeRes.status === 200) { const result = (await authorizeRes.json()) as { redirect?: boolean; url?: string } | null; if (result?.redirect === true && typeof result.url === "string") location = result.url; } if (!location) throw new Error(`/authorize returned no redirect (HTTP ${authorizeRes.status})`); const code = new URL(location).searchParams.get("code"); if (!code) throw new Error("/authorize redirected without an authorization code"); console.log("✓ got authorization code"); // 6. /token. const tokenRes = await fetch(discovery.token_endpoint, { method: "POST", headers: { "content-type": "application/x-www-form-urlencoded", authorization: `Basic ${btoa(`${created.client_id}:${created.client_secret}`)}`, }, body: new URLSearchParams({ grant_type: "authorization_code", code, redirect_uri: REDIRECT_URI, code_verifier: verifier, }), }); if (!tokenRes.ok) { throw new Error(`/token failed: ${tokenRes.status} ${await tokenRes.text()}`); } const tokens = (await tokenRes.json()) as Tokens; console.log("✓ exchanged code for tokens", { token_type: tokens.token_type, scope: tokens.scope }); // 7. Verify ID token against published JWKS. const jwks = createRemoteJWKSet(new URL(discovery.jwks_uri)); const { payload } = await jwtVerify(tokens.id_token, jwks, { issuer: discovery.issuer, audience: created.client_id, }); console.log("✓ id_token verified — claims:"); console.log(JSON.stringify(payload, null, 2)); // 8. /userinfo. const userinfoRes = await fetch(discovery.userinfo_endpoint, { headers: { authorization: `Bearer ${tokens.access_token}` }, }); if (!userinfoRes.ok) throw new Error(`/userinfo failed: ${userinfoRes.status}`); const userinfo = (await userinfoRes.json()) as Record; console.log("✓ /userinfo:"); console.log(JSON.stringify(userinfo, null, 2)); console.log("\n🎉 OIDC roundtrip succeeded"); } finally { // 9. Clean up the ephemeral client even if the OIDC flow above failed. const deleteRes = await fetch(`${ISSUER}/api/admin/clients/${encodeURIComponent(created.client_id)}`, { method: "DELETE", headers: { origin: ISSUER, cookie }, }); if (deleteRes.ok || deleteRes.status === 204) { console.log("✓ deleted ephemeral client"); } else { console.warn( `⚠ failed to delete ephemeral client ${created.client_id}: ${deleteRes.status} ${await deleteRes.text()}`, ); } } }; main().catch((err: unknown) => { console.error("✗ test client failed:", err instanceof Error ? err.message : err); process.exit(1); });