import type { OAuthConfiguration } from "./config.ts"; import { OAuthError } from "./errors.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; async function requestJson( network: CloudflareFetch, url: string, init: RequestInit, kind: "token" | "identity" | "accounts", ) { let response: Response; let value: unknown; try { response = await network(url, { ...init, redirect: "manual", signal: AbortSignal.timeout(10_000), }); if (!response.body) { if (response.ok) throw new Error(); value = {}; } else { const reader = response.body.getReader(); const chunks: Uint8Array[] = []; let size = 0; while (true) { const { done, value } = await reader.read(); if (done) break; size += value.byteLength; if (size > MAX_RESPONSE) { await reader.cancel(); throw new Error(); } chunks.push(value); } const bytes = new Uint8Array(size); let offset = 0; for (const chunk of chunks) { bytes.set(chunk, offset); offset += chunk.byteLength; } try { value = JSON.parse(new TextDecoder().decode(bytes)); } catch { if (response.ok) throw new Error(); value = {}; } } } catch { throw new OAuthError("temporarily_unavailable"); } 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), ) ) throw new OAuthError("oauth_capability_unavailable"); if ( response.status === 401 || body.error === "invalid_grant" || body.error === "invalid_token" ) throw new OAuthError("reauthorization_required"); if (response.status === 403) throw new OAuthError( kind === "accounts" ? "account_denied" : "oauth_capability_unavailable", ); throw new OAuthError("temporarily_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 async function exchange( config: OAuthConfiguration, code: string, verifier: string, network: CloudflareFetch = fetch, ) { const form = new URLSearchParams({ grant_type: "authorization_code", code, redirect_uri: config.oauthRedirectUri, code_verifier: verifier, }); const result = await 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 ) throw 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))) throw 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 async function subject( accessToken: string, network: CloudflareFetch = fetch, ) { const result = await 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) ) throw 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 async function accounts( accessToken: string, network: CloudflareFetch = fetch, ): Promise { const found = new Map(); for (let page = 1; page <= 20; page++) { const result = await requestJson( network, `${endpoints.accounts}?page=${page}&per_page=50`, { headers: { Authorization: `Bearer ${accessToken}`, Accept: "application/json", }, }, "accounts", ); if ( result.success !== true || !Array.isArray(result.result) || result.result.length > 50 ) throw 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 ) throw new OAuthError("temporarily_unavailable"); found.set(account.id, { id: account.id, name: account.name }); } const info = result.result_info as { total_pages?: unknown } | undefined; if ( info?.total_pages !== undefined && (!Number.isInteger(info.total_pages) || (Number(info.total_pages) < page && !( page === 1 && info.total_pages === 0 && result.result.length === 0 )) || Number(info.total_pages) > 20) ) throw new OAuthError("temporarily_unavailable"); if ( info?.total_pages === 0 || info?.total_pages === page || (!info?.total_pages && result.result.length < 50) ) return [...found.values()]; } throw new OAuthError("temporarily_unavailable"); } export async function revoke( config: OAuthConfiguration, accessToken: string, network: CloudflareFetch = fetch, ) { const form = new URLSearchParams({ token: accessToken, token_type_hint: "access_token", }); try { const response = await network(endpoints.revoke, { method: "POST", headers: clientAuthentication(config, form), body: form.toString(), redirect: "manual", signal: AbortSignal.timeout(10_000), }); await response.body?.cancel(); return response.ok; } catch { return false; } }