Something went wrong. Try again.
This repository has no description
Something went wrong. Try again.
7.8 kB · 263 lines
TypeScript
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264import 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<string, unknown>) : {}; 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<Account[]> { const found = new Map<string, Account>(); 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; }}