File
Blob: scripts/test-client/run.ts
| 1 | #!/usr/bin/env -S npx tsx --tsconfig tsconfig.scripts.json |
| 2 | /** |
| 3 | * Tiny OIDC RP simulator. Self-bootstraps everything it needs: |
| 4 | * 1. Sign in with operator credentials. |
| 5 | * 2. Register a fresh, ephemeral OAuth client (skip_consent=true) via |
| 6 | * /api/admin/clients. |
| 7 | * 3. Run the full PKCE authorization-code flow against tessera. |
| 8 | * 4. Verify the ID token against /api/auth/jwks. |
| 9 | * 5. Hit /userinfo with the access token. |
| 10 | * 6. Delete the ephemeral OAuth client. |
| 11 | * |
| 12 | * Usage: |
| 13 | * npm run dev # in another terminal |
| 14 | * TEST_PASSWORD=... npm run test:client # or call the script directly |
| 15 | * |
| 16 | * Required env: |
| 17 | * TEST_PASSWORD Operator password (no default — fail fast rather than |
| 18 | * guess). |
| 19 | * |
| 20 | * Optional env: |
| 21 | * ISSUER Tessera base URL — default http://localhost:5174. |
| 22 | * TEST_EMAIL Operator email — default rachel@chens.email. |
| 23 | * TEST_REDIRECT_URI |
| 24 | * Redirect URI for the ephemeral client — default |
| 25 | * http://127.0.0.1:0/cb. The script does not actually |
| 26 | * start a server on this URI; it captures the code from |
| 27 | * the 302 Location header before any redirect happens. |
| 28 | */ |
| 29 | import { createRemoteJWKSet, jwtVerify } from "jose"; |
| 30 | |
| 31 | const ISSUER = process.env.ISSUER ?? "http://localhost:5174"; |
| 32 | const EMAIL = process.env.TEST_EMAIL ?? "rachel@chens.email"; |
| 33 | const PASSWORD = process.env.TEST_PASSWORD; |
| 34 | const REDIRECT_URI = process.env.TEST_REDIRECT_URI ?? "http://127.0.0.1:0/cb"; |
| 35 | |
| 36 | if (!PASSWORD) { |
| 37 | console.error("✗ TEST_PASSWORD is required."); |
| 38 | process.exit(2); |
| 39 | } |
| 40 | |
| 41 | const b64url = (bytes: Uint8Array): string => |
| 42 | Buffer.from(bytes).toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); |
| 43 | |
| 44 | interface Discovery { |
| 45 | issuer: string; |
| 46 | authorization_endpoint: string; |
| 47 | token_endpoint: string; |
| 48 | userinfo_endpoint: string; |
| 49 | jwks_uri: string; |
| 50 | } |
| 51 | |
| 52 | interface CreatedClient { |
| 53 | client_id: string; |
| 54 | client_secret: string; |
| 55 | } |
| 56 | |
| 57 | interface Tokens { |
| 58 | id_token: string; |
| 59 | access_token: string; |
| 60 | token_type: string; |
| 61 | expires_in?: number; |
| 62 | scope?: string; |
| 63 | } |
| 64 | |
| 65 | const main = async () => { |
| 66 | // 1. Discovery. |
| 67 | const discoveryRes = await fetch(`${ISSUER}/.well-known/openid-configuration`); |
| 68 | if (!discoveryRes.ok) throw new Error(`discovery failed: ${discoveryRes.status}`); |
| 69 | const discovery = (await discoveryRes.json()) as Discovery; |
| 70 | console.log("✓ discovery", { issuer: discovery.issuer, jwks_uri: discovery.jwks_uri }); |
| 71 | |
| 72 | // 2. Sign in using Cloudflare's always-pass Turnstile test keys locally. |
| 73 | const signInRes = await fetch(`${ISSUER}/api/sign-in`, { |
| 74 | method: "POST", |
| 75 | headers: { "content-type": "application/json", origin: ISSUER }, |
| 76 | body: JSON.stringify({ email: EMAIL, password: PASSWORD, turnstileToken: "loopback" }), |
| 77 | }); |
| 78 | if (!signInRes.ok) { |
| 79 | throw new Error(`sign-in failed: ${signInRes.status} ${await signInRes.text()}`); |
| 80 | } |
| 81 | const setCookies = signInRes.headers.getSetCookie?.().filter((c) => /better-auth\.session/.test(c)); |
| 82 | const cookie = (setCookies && setCookies.length > 0 ? setCookies : [signInRes.headers.get("set-cookie")]) |
| 83 | .filter((c): c is string => Boolean(c)) |
| 84 | .map((c) => c.split(";")[0]) |
| 85 | .join("; "); |
| 86 | if (!cookie) throw new Error("no session cookie returned from sign-in"); |
| 87 | console.log("✓ signed in as", EMAIL); |
| 88 | |
| 89 | // 3. Register an ephemeral OAuth client. |
| 90 | const createRes = await fetch(`${ISSUER}/api/admin/clients`, { |
| 91 | method: "POST", |
| 92 | headers: { "content-type": "application/json", origin: ISSUER, cookie }, |
| 93 | body: JSON.stringify({ |
| 94 | name: `test-client (${new Date().toISOString()})`, |
| 95 | redirectUris: [REDIRECT_URI], |
| 96 | skipConsent: true, |
| 97 | }), |
| 98 | }); |
| 99 | if (!createRes.ok) { |
| 100 | throw new Error(`/api/admin/clients failed: ${createRes.status} ${await createRes.text()}`); |
| 101 | } |
| 102 | const created = (await createRes.json()) as CreatedClient; |
| 103 | console.log("✓ registered ephemeral client", { client_id: created.client_id }); |
| 104 | |
| 105 | try { |
| 106 | // 4. PKCE pair. |
| 107 | const verifierBytes = crypto.getRandomValues(new Uint8Array(32)); |
| 108 | const verifier = b64url(verifierBytes); |
| 109 | const challenge = b64url(new Uint8Array(await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier)))); |
| 110 | |
| 111 | // 5. /authorize. The session cookie carries the user identity; with |
| 112 | // skipConsent the plugin redirects straight to redirect_uri with `code`. |
| 113 | const authorizeUrl = new URL(discovery.authorization_endpoint); |
| 114 | authorizeUrl.searchParams.set("response_type", "code"); |
| 115 | authorizeUrl.searchParams.set("client_id", created.client_id); |
| 116 | authorizeUrl.searchParams.set("redirect_uri", REDIRECT_URI); |
| 117 | authorizeUrl.searchParams.set("scope", "openid profile email"); |
| 118 | authorizeUrl.searchParams.set("state", "rp-state"); |
| 119 | authorizeUrl.searchParams.set("code_challenge", challenge); |
| 120 | authorizeUrl.searchParams.set("code_challenge_method", "S256"); |
| 121 | const authorizeRes = await fetch(authorizeUrl, { headers: { cookie }, redirect: "manual" }); |
| 122 | let location = authorizeRes.status === 302 ? authorizeRes.headers.get("location") : null; |
| 123 | // 1.7 returns a JSON redirect to programmatic callers; browser |
| 124 | // navigations still receive the normal HTTP redirect. |
| 125 | if (authorizeRes.status === 200) { |
| 126 | const result = (await authorizeRes.json()) as { redirect?: boolean; url?: string } | null; |
| 127 | if (result?.redirect === true && typeof result.url === "string") location = result.url; |
| 128 | } |
| 129 | if (!location) throw new Error(`/authorize returned no redirect (HTTP ${authorizeRes.status})`); |
| 130 | const code = new URL(location).searchParams.get("code"); |
| 131 | if (!code) throw new Error("/authorize redirected without an authorization code"); |
| 132 | console.log("✓ got authorization code"); |
| 133 | |
| 134 | // 6. /token. |
| 135 | const tokenRes = await fetch(discovery.token_endpoint, { |
| 136 | method: "POST", |
| 137 | headers: { |
| 138 | "content-type": "application/x-www-form-urlencoded", |
| 139 | authorization: `Basic ${btoa(`${created.client_id}:${created.client_secret}`)}`, |
| 140 | }, |
| 141 | body: new URLSearchParams({ |
| 142 | grant_type: "authorization_code", |
| 143 | code, |
| 144 | redirect_uri: REDIRECT_URI, |
| 145 | code_verifier: verifier, |
| 146 | }), |
| 147 | }); |
| 148 | if (!tokenRes.ok) { |
| 149 | throw new Error(`/token failed: ${tokenRes.status} ${await tokenRes.text()}`); |
| 150 | } |
| 151 | const tokens = (await tokenRes.json()) as Tokens; |
| 152 | console.log("✓ exchanged code for tokens", { token_type: tokens.token_type, scope: tokens.scope }); |
| 153 | |
| 154 | // 7. Verify ID token against published JWKS. |
| 155 | const jwks = createRemoteJWKSet(new URL(discovery.jwks_uri)); |
| 156 | const { payload } = await jwtVerify(tokens.id_token, jwks, { |
| 157 | issuer: discovery.issuer, |
| 158 | audience: created.client_id, |
| 159 | }); |
| 160 | console.log("✓ id_token verified — claims:"); |
| 161 | console.log(JSON.stringify(payload, null, 2)); |
| 162 | |
| 163 | // 8. /userinfo. |
| 164 | const userinfoRes = await fetch(discovery.userinfo_endpoint, { |
| 165 | headers: { authorization: `Bearer ${tokens.access_token}` }, |
| 166 | }); |
| 167 | if (!userinfoRes.ok) throw new Error(`/userinfo failed: ${userinfoRes.status}`); |
| 168 | const userinfo = (await userinfoRes.json()) as Record<string, unknown>; |
| 169 | console.log("✓ /userinfo:"); |
| 170 | console.log(JSON.stringify(userinfo, null, 2)); |
| 171 | |
| 172 | console.log("\n🎉 OIDC roundtrip succeeded"); |
| 173 | } finally { |
| 174 | // 9. Clean up the ephemeral client even if the OIDC flow above failed. |
| 175 | const deleteRes = await fetch(`${ISSUER}/api/admin/clients/${encodeURIComponent(created.client_id)}`, { |
| 176 | method: "DELETE", |
| 177 | headers: { origin: ISSUER, cookie }, |
| 178 | }); |
| 179 | if (deleteRes.ok || deleteRes.status === 204) { |
| 180 | console.log("✓ deleted ephemeral client"); |
| 181 | } else { |
| 182 | console.warn( |
| 183 | `⚠ failed to delete ephemeral client ${created.client_id}: ${deleteRes.status} ${await deleteRes.text()}`, |
| 184 | ); |
| 185 | } |
| 186 | } |
| 187 | }; |
| 188 | |
| 189 | main().catch((err: unknown) => { |
| 190 | console.error("✗ test client failed:", err instanceof Error ? err.message : err); |
| 191 | process.exit(1); |
| 192 | }); |