| 1 | import { |
| 2 | ALL_SCOPE_IDS, |
| 3 | ALL_SCOPES, |
| 4 | BASIC_SCOPES, |
| 5 | customOAuthScopeDefaults, |
| 6 | OAUTH_SCOPE_GROUPS, |
| 7 | partitionOAuthScopes, |
| 8 | } from "@/Cloudflare/Auth/OAuthScopes.ts"; |
| 9 | import { |
| 10 | authorize, |
| 11 | refresh, |
| 12 | revoke, |
| 13 | type OAuthCredentials, |
| 14 | } from "@/Cloudflare/Auth/OAuthClient.ts"; |
| 15 | import { PlatformServices } from "@/Util/PlatformServices.ts"; |
| 16 | import { describe, expect, it } from "alchemy-test"; |
| 17 | import * as Effect from "effect/Effect"; |
| 18 | import * as Redacted from "effect/Redacted"; |
| 19 | import * as FetchHttpClient from "effect/http/FetchHttpClient"; |
| 20 | |
| 21 | describe( |
| 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 | ); |