import { Forbidden, Unauthorized } from "@distilled.cloud/cloudflare/Errors"; import { listAccounts } from "@distilled.cloud/cloudflare/accounts"; import * as Effect from "effect/Effect"; import { cloudflare } from "./cloudflare-sdk.ts"; import type { OAuthConfiguration } from "./config.ts"; import { OAuthError } from "./errors.ts"; import { bodyJson, withResponse } from "../server/http.ts"; export const endpoints = Object.freeze({ authorization: "https://dash.cloudflare.com/oauth2/auth", token: "https://dash.cloudflare.com/oauth2/token", userinfo: "https://dash.cloudflare.com/oauth2/userinfo", revoke: "https://dash.cloudflare.com/oauth2/revoke", accounts: "https://api.cloudflare.com/client/v4/accounts", }); // Test fixtures replace only this network boundary, never production URLs/config. export type CloudflareFetch = typeof fetch; const MAX_RESPONSE = 256 * 1024; function requestJson( network: CloudflareFetch, url: string, init: RequestInit, kind: "token" | "identity", ) { const unavailable = new OAuthError("temporarily_unavailable"); return withResponse(network, url, init, 10_000, unavailable, (response) => Effect.gen(function* () { const value = yield* bodyJson(response, MAX_RESPONSE, unavailable).pipe( Effect.catch((error) => response.ok ? Effect.fail(error) : Effect.succeed({}), ), ); const body = value && typeof value === "object" && !Array.isArray(value) ? (value as Record) : {}; if (!response.ok || body.success === false || body.error) { if ( kind === "token" && ["invalid_client", "invalid_scope", "unauthorized_client"].includes( String(body.error), ) ) return yield* new OAuthError("oauth_capability_unavailable"); if ( response.status === 401 || body.error === "invalid_grant" || body.error === "invalid_token" ) return yield* new OAuthError("reauthorization_required"); if (response.status === 403) return yield* new OAuthError("oauth_capability_unavailable"); return yield* unavailable; } return body; }), ); } function clientAuthentication( config: OAuthConfiguration, form: URLSearchParams, ) { const headers = new Headers({ "Content-Type": "application/x-www-form-urlencoded", Accept: "application/json", }); form.set("client_id", config.oauthClientId); if (config.oauthTokenAuthMethod === "client_secret_post") form.set("client_secret", config.secrets.oauthClientSecret!.reveal()); if (config.oauthTokenAuthMethod === "client_secret_basic") { const escape = (value: string) => new URLSearchParams({ x: value }).toString().slice(2); headers.set( "Authorization", `Basic ${btoa(`${escape(config.oauthClientId)}:${escape(config.secrets.oauthClientSecret!.reveal())}`)}`, ); } return headers; } export function exchange( config: OAuthConfiguration, code: string, verifier: string, network: CloudflareFetch = fetch, ) { return Effect.gen(function* () { const form = new URLSearchParams({ grant_type: "authorization_code", code, redirect_uri: config.oauthRedirectUri, code_verifier: verifier, }); const result = yield* requestJson( network, endpoints.token, { method: "POST", headers: clientAuthentication(config, form), body: form.toString(), }, "token", ); if ( typeof result.access_token !== "string" || !result.access_token || result.access_token.length > 16_384 || /[\s\u0000-\u001f]/.test(result.access_token) || String(result.token_type).toLowerCase() !== "bearer" || typeof result.expires_in !== "number" || !Number.isFinite(result.expires_in) || result.expires_in < 1 ) return yield* new OAuthError("oauth_capability_unavailable"); // OAuth permits omitted scope only when identical to the requested scope. const scopes = result.scope === undefined ? config.oauthScopes : typeof result.scope === "string" ? result.scope.split(" ").filter(Boolean) : []; if (config.oauthScopes.some((scope) => !scopes.includes(scope))) return yield* new OAuthError("oauth_capability_unavailable"); return { accessToken: result.access_token, expiresAt: Date.now() + Math.min(result.expires_in * 1000, 60 * 60_000), scopes: [...scopes], }; }); } export function subject(accessToken: string, network: CloudflareFetch = fetch) { return Effect.gen(function* () { const result = yield* requestJson( network, endpoints.userinfo, { headers: { Authorization: `Bearer ${accessToken}`, Accept: "application/json", }, }, "identity", ); if ( typeof result.sub !== "string" || !result.sub.trim() || result.sub.length > 512 || /[\u0000-\u001f\u007f]/.test(result.sub) ) return yield* new OAuthError("oauth_capability_unavailable"); // ID tokens are never consumed. Identity is the HTTPS UserInfo subject only. return result.sub; }); } export interface Account { id: string; name: string; } export function accounts( accessToken: string, network: CloudflareFetch = fetch, ) { return Effect.gen(function* () { const found = new Map(); for (let page = 1; page <= 20; page++) { const result = yield* cloudflare( listAccounts({ page, perPage: 50 }), accessToken, network, 10_000, MAX_RESPONSE, ).pipe( Effect.mapError( (error) => new OAuthError( error instanceof Unauthorized ? "reauthorization_required" : error instanceof Forbidden ? "account_denied" : "temporarily_unavailable", ), ), ); if (!Array.isArray(result.result) || result.result.length > 50) return yield* new OAuthError("temporarily_unavailable"); for (const account of result.result) { if ( !account || typeof account.id !== "string" || !/^[a-f0-9]{32}$/.test(account.id) || typeof account.name !== "string" || !account.name.trim() || account.name.length > 512 ) return yield* new OAuthError("temporarily_unavailable"); found.set(account.id, { id: account.id, name: account.name }); } const total = result.resultInfo?.totalPages; if ( total !== undefined && (!Number.isInteger(total) || (Number(total) < page && !(page === 1 && total === 0 && result.result.length === 0)) || Number(total) > 20) ) return yield* new OAuthError("temporarily_unavailable"); if ( total === 0 || total === page || (!total && result.result.length < 50) ) return [...found.values()]; } return yield* new OAuthError("temporarily_unavailable"); }); } export function revoke( config: OAuthConfiguration, accessToken: string, network: CloudflareFetch = fetch, ) { return Effect.suspend(() => { const form = new URLSearchParams({ token: accessToken, token_type_hint: "access_token", }); return withResponse( network, endpoints.revoke, { method: "POST", headers: clientAuthentication(config, form), body: form.toString(), }, 10_000, new OAuthError("temporarily_unavailable"), (response) => Effect.succeed(response.ok), ); }).pipe(Effect.catchCause(() => Effect.succeed(false))); }