Skip to content
File

Blob: tests/worker/oidc-claims.test.ts

typescript161 lines
1import { env } from "cloudflare:workers";
2import { createLocalJWKSet, jwtVerify, type JSONWebKeySet } from "jose";
3import { beforeAll, describe, expect, it } from "vitest";
4 
5import {
6 DEFAULT_PASSWORD,
7 ISSUER,
8 SELF,
9 authorizeWithPkce,
10 createOAuthClientAsAdmin,
11 exchangeAuthorizationCode,
12 signInForCookie,
13 signUpAdmin,
14 testHeaders,
15 type OAuthTokenResponse,
16 type TestCredential,
17} from "./helpers";
18 
19const 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;
20 
21const REDIRECT_URI = "https://rp.example.com/cb";
22const SCOPE_CASES = ["openid profile email", "openid", "openid profile", "openid email"];
23 
24describe("OIDC claim issuance — full code flow", () => {
25 const credential = {
26 email: "claims-tester@example.com",
27 password: DEFAULT_PASSWORD,
28 name: "Claims Tester",
29 } satisfies TestCredential;
30 let clientId: string;
31 let clientSecret: string;
32 
33 beforeAll(async () => {
34 // Seed a credential user. Better Auth's create-user hook stamps the
35 // slug-safe `preferredUsername` we assert on below.
36 await signUpAdmin(credential);
37 
38 // Sign in to get an admin session cookie.
39 const cookie = await signInForCookie(credential.email, credential.password, "10.0.99.1");
40 
41 // Register a one-shot OAuth client through the admin API with
42 // skipConsent so /authorize redirects straight to the callback (the
43 // /oauth/consent UI is exercised separately via Playwright).
44 const created = await createOAuthClientAsAdmin(cookie, {
45 name: "claims test client",
46 redirectUris: [REDIRECT_URI],
47 skipConsent: true,
48 });
49 clientId = created.client_id;
50 clientSecret = created.client_secret;
51 // Production's 1.6 clients have a null type; the generated migration
52 // preserves that value. Verify those clients work without a backfill.
53 await env.DB.prepare("UPDATE oauth_clients SET application_type = NULL WHERE client_id = ?").bind(clientId).run();
54 });
55 
56 it.each(SCOPE_CASES)("preserves ID token and UserInfo claims for scope %s", { timeout: 30_000 }, async (scope) => {
57 // 1. Sign in.
58 const signInCookie = await signInForCookie(credential.email, credential.password, "203.0.113.42");
59 
60 // 2. /authorize → 302 with `code` (skip_consent is on, so no detour).
61 const authorizeRes = await authorizeWithPkce({
62 clientId,
63 cookie: signInCookie,
64 redirectUri: REDIRECT_URI,
65 scope,
66 state: "rp-test-state",
67 });
68 expect(authorizeRes.status).toBe(302);
69 const { code, verifier } = authorizeRes;
70 expect(code).toBeTruthy();
71 
72 // 3. /token exchange.
73 const tokenRes = await exchangeAuthorizationCode({
74 code: code!,
75 verifier,
76 clientId,
77 clientSecret,
78 redirectUri: REDIRECT_URI,
79 });
80 expect(tokenRes.status).toBe(200);
81 const tokens = (await tokenRes.json()) as OAuthTokenResponse;
82 expect(tokens.id_token).toBeTruthy();
83 expect(tokens.access_token).toBeTruthy();
84 
85 // 4. Verify ID token signature against JWKS.
86 const jwksRes = await SELF.fetch(`${ISSUER}/api/auth/jwks`);
87 const jwks = createLocalJWKSet((await jwksRes.json()) as JSONWebKeySet);
88 const { payload } = await jwtVerify(tokens.id_token!, jwks, {
89 issuer: ISSUER,
90 audience: clientId,
91 });
92 
93 // 5. Claim assertions.
94 expect(typeof payload.sub).toBe("string");
95 expect(payload.sub).toMatch(UUID_V4);
96 expect(payload.iss).toBe(ISSUER);
97 expect(payload.aud).toBe(clientId);
98 expect(payload.email).toBe(scope.includes("email") ? credential.email : undefined);
99 expect(payload.email_verified).toBe(scope.includes("email") ? true : undefined);
100 expect(payload.name).toBe(scope.includes("profile") ? credential.name : undefined);
101 expect(payload.preferred_username).toBe(scope.includes("profile") ? "claims-tester" : undefined);
102 expect(typeof payload.iat).toBe("number");
103 expect(typeof payload.exp).toBe("number");
104 expect((payload.exp as number) > (payload.iat as number)).toBe(true);
105 
106 // tessera_sub mirrors `sub` so apps reached via Cloudflare Access —
107 // which replaces `sub` with its own user id and exposes upstream
108 // OIDC claims under `custom` — can still key on the stable
109 // tessera UUID.
110 expect(payload.tessera_sub).toBe(payload.sub);
111 
112 // 6. /userinfo also reflects the same claims.
113 const userinfoRes = await SELF.fetch(`${ISSUER}/api/auth/oauth2/userinfo`, {
114 headers: { authorization: `Bearer ${tokens.access_token}` },
115 });
116 expect(userinfoRes.status).toBe(200);
117 const userinfo = (await userinfoRes.json()) as Record<string, unknown>;
118 expect(userinfo.sub).toBe(payload.sub);
119 expect(userinfo.email).toBe(scope.includes("email") ? credential.email : undefined);
120 expect(userinfo.email_verified).toBe(scope.includes("email") ? true : undefined);
121 expect(userinfo.preferred_username).toBe(scope.includes("profile") ? "claims-tester" : undefined);
122 expect(userinfo.tessera_sub).toBe(payload.sub);
123 });
124 
125 it("revokes session-bound access tokens on sign-out", async () => {
126 const cookie = await signInForCookie(credential.email, credential.password, "203.0.113.43");
127 const { code, verifier } = await authorizeWithPkce({ clientId, cookie, redirectUri: REDIRECT_URI });
128 expect(code).toBeTruthy();
129 const tokenRes = await exchangeAuthorizationCode({
130 code: code!,
131 verifier,
132 clientId,
133 clientSecret,
134 redirectUri: REDIRECT_URI,
135 });
136 expect(tokenRes.status).toBe(200);
137 const tokens = (await tokenRes.json()) as OAuthTokenResponse;
138 
139 const signOutRes = await SELF.fetch(`${ISSUER}/api/auth/sign-out`, {
140 method: "POST",
141 headers: testHeaders({ cookie, "cf-connecting-ip": "203.0.113.43" }),
142 });
143 expect(signOutRes.status).toBe(200);
144 const userinfoRes = await SELF.fetch(`${ISSUER}/api/auth/oauth2/userinfo`, {
145 headers: { authorization: `Bearer ${tokens.access_token}` },
146 });
147 expect(userinfoRes.status).toBe(401);
148 
149 const introspectRes = await SELF.fetch(`${ISSUER}/api/auth/oauth2/introspect`, {
150 method: "POST",
151 headers: {
152 "content-type": "application/x-www-form-urlencoded",
153 authorization: `Basic ${btoa(`${clientId}:${clientSecret}`)}`,
154 },
155 body: new URLSearchParams({ token: tokens.access_token! }),
156 });
157 expect(introspectRes.status).toBe(200);
158 expect(((await introspectRes.json()) as { active: boolean }).active).toBe(false);
159 });
160});