import { env } from "cloudflare:workers"; import { createLocalJWKSet, jwtVerify, type JSONWebKeySet } from "jose"; import { beforeAll, describe, expect, it } from "vitest"; import { DEFAULT_PASSWORD, ISSUER, SELF, authorizeWithPkce, createOAuthClientAsAdmin, exchangeAuthorizationCode, signInForCookie, signUpAdmin, testHeaders, type OAuthTokenResponse, type TestCredential, } from "./helpers"; const UUID_V4 = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; const REDIRECT_URI = "https://rp.example.com/cb"; const SCOPE_CASES = ["openid profile email", "openid", "openid profile", "openid email"]; describe("OIDC claim issuance — full code flow", () => { const credential = { email: "claims-tester@example.com", password: DEFAULT_PASSWORD, name: "Claims Tester", } satisfies TestCredential; let clientId: string; let clientSecret: string; beforeAll(async () => { // Seed a credential user. Better Auth's create-user hook stamps the // slug-safe `preferredUsername` we assert on below. await signUpAdmin(credential); // Sign in to get an admin session cookie. const cookie = await signInForCookie(credential.email, credential.password, "10.0.99.1"); // Register a one-shot OAuth client through the admin API with // skipConsent so /authorize redirects straight to the callback (the // /oauth/consent UI is exercised separately via Playwright). const created = await createOAuthClientAsAdmin(cookie, { name: "claims test client", redirectUris: [REDIRECT_URI], skipConsent: true, }); clientId = created.client_id; clientSecret = created.client_secret; // Production's 1.6 clients have a null type; the generated migration // preserves that value. Verify those clients work without a backfill. await env.DB.prepare("UPDATE oauth_clients SET application_type = NULL WHERE client_id = ?").bind(clientId).run(); }); it.each(SCOPE_CASES)("preserves ID token and UserInfo claims for scope %s", { timeout: 30_000 }, async (scope) => { // 1. Sign in. const signInCookie = await signInForCookie(credential.email, credential.password, "203.0.113.42"); // 2. /authorize → 302 with `code` (skip_consent is on, so no detour). const authorizeRes = await authorizeWithPkce({ clientId, cookie: signInCookie, redirectUri: REDIRECT_URI, scope, state: "rp-test-state", }); expect(authorizeRes.status).toBe(302); const { code, verifier } = authorizeRes; expect(code).toBeTruthy(); // 3. /token exchange. const tokenRes = await exchangeAuthorizationCode({ code: code!, verifier, clientId, clientSecret, redirectUri: REDIRECT_URI, }); expect(tokenRes.status).toBe(200); const tokens = (await tokenRes.json()) as OAuthTokenResponse; expect(tokens.id_token).toBeTruthy(); expect(tokens.access_token).toBeTruthy(); // 4. Verify ID token signature against JWKS. const jwksRes = await SELF.fetch(`${ISSUER}/api/auth/jwks`); const jwks = createLocalJWKSet((await jwksRes.json()) as JSONWebKeySet); const { payload } = await jwtVerify(tokens.id_token!, jwks, { issuer: ISSUER, audience: clientId, }); // 5. Claim assertions. expect(typeof payload.sub).toBe("string"); expect(payload.sub).toMatch(UUID_V4); expect(payload.iss).toBe(ISSUER); expect(payload.aud).toBe(clientId); expect(payload.email).toBe(scope.includes("email") ? credential.email : undefined); expect(payload.email_verified).toBe(scope.includes("email") ? true : undefined); expect(payload.name).toBe(scope.includes("profile") ? credential.name : undefined); expect(payload.preferred_username).toBe(scope.includes("profile") ? "claims-tester" : undefined); expect(typeof payload.iat).toBe("number"); expect(typeof payload.exp).toBe("number"); expect((payload.exp as number) > (payload.iat as number)).toBe(true); // tessera_sub mirrors `sub` so apps reached via Cloudflare Access — // which replaces `sub` with its own user id and exposes upstream // OIDC claims under `custom` — can still key on the stable // tessera UUID. expect(payload.tessera_sub).toBe(payload.sub); // 6. /userinfo also reflects the same claims. const userinfoRes = await SELF.fetch(`${ISSUER}/api/auth/oauth2/userinfo`, { headers: { authorization: `Bearer ${tokens.access_token}` }, }); expect(userinfoRes.status).toBe(200); const userinfo = (await userinfoRes.json()) as Record; expect(userinfo.sub).toBe(payload.sub); expect(userinfo.email).toBe(scope.includes("email") ? credential.email : undefined); expect(userinfo.email_verified).toBe(scope.includes("email") ? true : undefined); expect(userinfo.preferred_username).toBe(scope.includes("profile") ? "claims-tester" : undefined); expect(userinfo.tessera_sub).toBe(payload.sub); }); it("revokes session-bound access tokens on sign-out", async () => { const cookie = await signInForCookie(credential.email, credential.password, "203.0.113.43"); const { code, verifier } = await authorizeWithPkce({ clientId, cookie, redirectUri: REDIRECT_URI }); expect(code).toBeTruthy(); const tokenRes = await exchangeAuthorizationCode({ code: code!, verifier, clientId, clientSecret, redirectUri: REDIRECT_URI, }); expect(tokenRes.status).toBe(200); const tokens = (await tokenRes.json()) as OAuthTokenResponse; const signOutRes = await SELF.fetch(`${ISSUER}/api/auth/sign-out`, { method: "POST", headers: testHeaders({ cookie, "cf-connecting-ip": "203.0.113.43" }), }); expect(signOutRes.status).toBe(200); const userinfoRes = await SELF.fetch(`${ISSUER}/api/auth/oauth2/userinfo`, { headers: { authorization: `Bearer ${tokens.access_token}` }, }); expect(userinfoRes.status).toBe(401); const introspectRes = await SELF.fetch(`${ISSUER}/api/auth/oauth2/introspect`, { method: "POST", headers: { "content-type": "application/x-www-form-urlencoded", authorization: `Basic ${btoa(`${clientId}:${clientSecret}`)}`, }, body: new URLSearchParams({ token: tokens.access_token! }), }); expect(introspectRes.status).toBe(200); expect(((await introspectRes.json()) as { active: boolean }).active).toBe(false); }); });