Skip to content
File

Blob: scripts/test-client/run.ts

typescript193 lines
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 */
29import { createRemoteJWKSet, jwtVerify } from "jose";
30 
31const ISSUER = process.env.ISSUER ?? "http://localhost:5174";
32const EMAIL = process.env.TEST_EMAIL ?? "rachel@chens.email";
33const PASSWORD = process.env.TEST_PASSWORD;
34const REDIRECT_URI = process.env.TEST_REDIRECT_URI ?? "http://127.0.0.1:0/cb";
35 
36if (!PASSWORD) {
37 console.error("✗ TEST_PASSWORD is required.");
38 process.exit(2);
39}
40 
41const b64url = (bytes: Uint8Array): string =>
42 Buffer.from(bytes).toString("base64").replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
43 
44interface Discovery {
45 issuer: string;
46 authorization_endpoint: string;
47 token_endpoint: string;
48 userinfo_endpoint: string;
49 jwks_uri: string;
50}
51 
52interface CreatedClient {
53 client_id: string;
54 client_secret: string;
55}
56 
57interface Tokens {
58 id_token: string;
59 access_token: string;
60 token_type: string;
61 expires_in?: number;
62 scope?: string;
63}
64 
65const 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 
189main().catch((err: unknown) => {
190 console.error("✗ test client failed:", err instanceof Error ? err.message : err);
191 process.exit(1);
192});