1import {
2 ALL_SCOPE_IDS,
3 ALL_SCOPES,
4 BASIC_SCOPES,
5 customOAuthScopeDefaults,
6 OAUTH_SCOPE_GROUPS,
7 partitionOAuthScopes,
8} from "@/Cloudflare/Auth/OAuthScopes.ts";
9import {
10 authorize,
11 refresh,
12 revoke,
13 type OAuthCredentials,
14} from "@/Cloudflare/Auth/OAuthClient.ts";
15import { PlatformServices } from "@/Util/PlatformServices.ts";
16import { describe, expect, it } from "alchemy-test";
17import * as Effect from "effect/Effect";
18import * as Redacted from "effect/Redacted";
19import * as FetchHttpClient from "effect/http/FetchHttpClient";
20
21describe(
22 "Cloudflare public OAuth client",
23 {
24 tags: ["unit", "provider:cloudflare", "provider:cloudflare:auth", "local"],
25 },
26 () => {
27 it("uses a duplicate-free, colon-free catalog of allowed scopes", () => {
28 const scopes = OAUTH_SCOPE_GROUPS.flatMap((group) => group.scopes);
29
30 expect(scopes.length).toBeGreaterThan(0);
31 expect(new Set(scopes).size).toBe(scopes.length);
32 expect(scopes.every((scope) => !scope.includes(":"))).toBe(true);
33 });
34
35 it("partitions stored scopes against the current catalog", () => {
36 // Profiles configured against an older OAuth client can hold scopes the
37 // current client rejects; login must drop them instead of building an
38 // authorize URL that Cloudflare refuses wholesale.
39 const { valid, dropped } = partitionOAuthScopes([
40 "workers-scripts.write",
41 "account:read", // wrangler-era scope id, not in the alchemy catalog
42 "zone.read",
43 ]);
44 expect(valid).toEqual(["workers-scripts.write", "zone.read"]);
45 expect(dropped).toEqual(["account:read"]);
46 });
47
48 it("keeps the basic template inside the client allowlist", () => {
49 expect(BASIC_SCOPES.length).toBeGreaterThan(0);
50 expect(BASIC_SCOPES.every((scope) => scope in ALL_SCOPES)).toBe(true);
51 expect(BASIC_SCOPES).toContain("memberships.read");
52 expect(BASIC_SCOPES).toContain("workers-scripts.write");
53 expect(BASIC_SCOPES).toContain("zone.read");
54 });
55
56 it("defaults to all scopes and restores valid scopes when reconfigured", () => {
57 expect(
58 customOAuthScopeDefaults({
59 method: "oauth",
60 scopes: ["workers-scripts.write", "account:read", "zone.read"],
61 accountId: "account-id",
62 }),
63 ).toEqual(["workers-scripts.write", "zone.read"]);
64
65 expect(
66 customOAuthScopeDefaults({
67 method: "stored",
68 credentialType: "apiToken",
69 }),
70 ).toEqual(ALL_SCOPE_IDS);
71 });
72
73 it.effect(
74 "uses Cloudflare's public-client authorization endpoint with S256 PKCE",
75 () =>
76 Effect.gen(function* () {
77 const authorization = yield* authorize(["workers-scripts.write"]);
78 const url = new URL(authorization.url);
79
80 expect(`${url.origin}${url.pathname}`).toBe(
81 "https://dash.cloudflare.com/oauth2/auth",
82 );
83 expect(url.searchParams.get("client_id")).toBe(
84 "e7e25ec474419def6ba38d2d2638b122",
85 );
86 expect(url.searchParams.get("redirect_uri")).toBe(
87 "https://alchemy.run/auth/callback",
88 );
89 expect(url.searchParams.get("response_type")).toBe("code");
90 expect(url.searchParams.get("code_challenge_method")).toBe("S256");
91 }).pipe(Effect.provide(PlatformServices)),
92 );
93
94 it("preserves rotated credentials and uses standard token revocation", async () => {
95 const requests: URLSearchParams[] = [];
96 // SAFETY: the test double implements the callable fetch contract; Bun's
97 // nonstandard static `preconnect` member is not used by FetchHttpClient.
98 const fetch = (async (_input, init) => {
99 requests.push(
100 new URLSearchParams(await new Response(init?.body).text()),
101 );
102 return new Response(
103 JSON.stringify({
104 access_token: "next-access",
105 expires_in: 3600,
106 }),
107 { status: 200, headers: { "Content-Type": "application/json" } },
108 );
109 }) as typeof globalThis.fetch;
110
111 const current: OAuthCredentials = {
112 type: "oauth",
113 clientId: "e7e25ec474419def6ba38d2d2638b122",
114 access: Redacted.make("current-access"),
115 refresh: Redacted.make("current-refresh"),
116 expires: 0,
117 scopes: ["workers-scripts.write"],
118 };
119
120 const withFetch = <A, E>(effect: Effect.Effect<A, E>) =>
121 effect.pipe(Effect.provideService(FetchHttpClient.Fetch, fetch));
122 const next = await Effect.runPromise(withFetch(refresh(current)));
123 await Effect.runPromise(withFetch(revoke(next)));
124
125 expect(Redacted.value(next.refresh)).toBe("current-refresh");
126 expect(next.scopes).toEqual(current.scopes);
127 expect(next.clientId).toBe(current.clientId);
128 expect(requests[0]?.get("grant_type")).toBe("refresh_token");
129 expect(requests[0]?.has("redirect_uri")).toBe(false);
130 expect(requests[1]?.get("token")).toBe("current-refresh");
131 expect(requests[1]?.get("token_type_hint")).toBe("refresh_token");
132 expect(requests[1]?.has("refresh_token")).toBe(false);
133 });
134 },
135);