Something went wrong. Try again.
This repository has no description
Something went wrong. Try again.
14 kB · 391 lines
TypeScript
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392import { handleBridge, resumeBridge } from "./bridge.ts";import type { BridgeRequest } from "./vault.ts";import { loadControlPlaneOrigin } from "../configuration/control-plane.ts";import { configuration, type Env } from "./config.ts";import { endpoints, exchange, revoke, subject, type CloudflareFetch,} from "./cloudflare.ts";import { hash, opaque, random } from "./crypto.ts";import { messages, OAuthError, safeCode, type ErrorCode } from "./errors.ts";import { authenticatedPrincipal, authorizedGrant, cookie, grantedAccounts, readCookie, SESSION_COOKIE, TRANSACTION_COOKIE, vault,} from "./session.ts";
const privateHeaders = { "Cache-Control": "no-store", "Referrer-Policy": "no-referrer", "X-Content-Type-Options": "nosniff", "Content-Security-Policy": "frame-ancestors 'none'; base-uri 'self'; form-action 'self' https://dash.cloudflare.com",};export function privateResponse(response: Response) { const result = new Response(response.body, response); for (const [key, value] of Object.entries(privateHeaders)) result.headers.set(key, value); return result;}function json(body: unknown, status = 200) { return privateResponse(Response.json(body, { status }));}function redirect(path: string, cookies: string[] = []) { const response = new Response(null, { status: 303, headers: { Location: path }, }); for (const value of cookies) response.headers.append("Set-Cookie", value); return privateResponse(response);}export function checkOrigin(request: Request, origin: string) { if ( new URL(request.url).origin !== origin || request.headers.get("Origin") !== origin ) throw new OAuthError("forbidden");}export async function form(request: Request) { if ( !request.headers .get("Content-Type") ?.startsWith("application/x-www-form-urlencoded") ) throw new OAuthError("invalid_request"); const size = Number(request.headers.get("Content-Length")); if (size > 2048) throw new OAuthError("invalid_request"); const reader = request.body?.getReader(); let body = ""; let bytes = 0; if (reader) { const decoder = new TextDecoder(); while (true) { const chunk = await reader.read(); if (chunk.done) break; bytes += chunk.value.length; if (bytes > 2048) { await reader.cancel(); throw new OAuthError("invalid_request"); } body += decoder.decode(chunk.value, { stream: true }); } body += decoder.decode(); } return new URLSearchParams(body);}function one(params: URLSearchParams, key: string) { const values = params.getAll(key); if ( values.length !== 1 || !values[0] || values[0].length > 2048 || /[\u0000-\u0020\u007f]/.test(values[0]) ) throw new OAuthError("oauth_invalid_callback"); return values[0];}function failure(code: ErrorCode, status = 400) { return json({ error: code, message: messages[code] }, status);}
export async function handleOAuth( request: Request, env: Env, network: CloudflareFetch = fetch,): Promise<Response | null> { const url = new URL(request.url); const bridgeResponse = await handleBridge(request, env); if (bridgeResponse) return bridgeResponse; if (!(url.pathname.startsWith("/auth/") || url.pathname.startsWith("/api/"))) return null; try { if (url.pathname === "/auth/disconnect" && request.method === "POST") { checkOrigin(request, loadControlPlaneOrigin(env)); const ref = readCookie(request, SESSION_COOKIE); let revoked = true; if (ref) { const principal = await vault(env, "session", ref).retireSession(); if (principal) { const grant = await vault(env, "grant", principal.grantRef).grant( principal.subject, ); await vault(env, "grant", principal.grantRef).destroy(); if (grant) { try { revoked = await revoke( configuration(env), grant.accessToken, network, ); } catch { revoked = false; } } } } return redirect( `/connect${revoked ? "" : "?notice=revocation_pending"}`, [cookie(SESSION_COOKIE, "", 0), cookie(TRANSACTION_COOKIE, "", 0)], ); } const config = configuration(env); if (url.origin !== config.publicOrigin) throw new OAuthError("forbidden"); if (request.method === "POST") checkOrigin(request, config.publicOrigin); if (url.pathname === "/auth/start" && request.method === "POST") { const input = await form(request); const providerInstallationId = input.get("providerInstallationId"); const domainInstallationId = input.get("domainInstallationId"); // Only a fixed local setup route may survive reconnect; never accept an // arbitrary return URL or a caller-supplied account/owner. if ( [...input.keys()].some( (key) => ![ "returnTo", "providerInstallationId", "domainInstallationId", ].includes(key), ) || input.getAll("returnTo").length > 1 || (input.has("returnTo") && input.get("returnTo") !== "/connect") || input.getAll("providerInstallationId").length > 1 || input.getAll("domainInstallationId").length > 1 || (providerInstallationId !== null && (!/^[a-f0-9]{32}$/.test(providerInstallationId) || input.has("returnTo") || input.has("domainInstallationId"))) || (domainInstallationId !== null && (!/^[a-f0-9]{32}$/.test(domainInstallationId) || input.has("returnTo") || input.has("providerInstallationId"))) ) throw new OAuthError("invalid_request"); return beginOAuth( request, env, undefined, providerInstallationId ?? undefined, domainInstallationId ?? undefined, ); } if (url.pathname === "/auth/callback" && request.method === "GET") { try { if ( request.url.length > 8192 || [...url.searchParams.keys()].some( (key) => ![ "state", "code", // Cloudflare returns scope metadata here. Granted permissions // remain authoritative only in the token exchange response. "scope", "error", "error_description", "error_uri", "iss", ].includes(key), ) || [...new Set(url.searchParams.keys())].some( (key) => url.searchParams.getAll(key).length !== 1, ) ) throw new OAuthError("oauth_invalid_callback"); const state = one(url.searchParams, "state"); const binding = readCookie(request, TRANSACTION_COOKIE); if ( !opaque(state) || !binding || (url.searchParams.has("iss") && url.searchParams.get("iss") !== "https://dash.cloudflare.com") || (url.searchParams.has("code") && (url.searchParams.has("error_description") || url.searchParams.has("error_uri"))) || url.searchParams.has("code") === url.searchParams.has("error") ) throw new OAuthError("oauth_invalid_callback"); if (url.searchParams.has("code")) one(url.searchParams, "code"); else one(url.searchParams, "error"); const transaction = await vault( env, "transaction", state, ).claimTransaction( await hash(binding), readCookie(request, SESSION_COOKIE), ); if (!transaction) throw new OAuthError("oauth_invalid_callback"); if (url.searchParams.has("error")) throw new OAuthError( url.searchParams.get("error") === "access_denied" ? "oauth_denied" : "oauth_invalid_callback", ); const grant = await exchange( config, one(url.searchParams, "code"), transaction.verifier, network, ); let owner: string; try { owner = await subject(grant.accessToken, network); } catch (error) { await revoke(config, grant.accessToken, network); throw error; } const sessionRef = random(); const grantRef = random(); // Expiring grants are not refreshed/offline. Browser identity lasts 8h; // an expired grant explicitly requests another OAuth connection. await vault(env, "grant", grantRef).createGrant({ ...grant, subject: owner, }); await vault(env, "session", sessionRef).createSession({ subject: owner, grantRef, selectedAccountId: null, expiresAt: Date.now() + 8 * 60 * 60_000, }); if (transaction.previousSession) { const previous = await vault( env, "session", transaction.previousSession, ).session(); await vault(env, "session", transaction.previousSession).destroy(); if (previous) { const oldGrant = await vault(env, "grant", previous.grantRef).grant( previous.subject, ); await vault(env, "grant", previous.grantRef).destroy(); if (oldGrant && oldGrant.accessToken !== grant.accessToken) await revoke(config, oldGrant.accessToken, network); } } const destination = transaction.bridgeContinuation ? await resumeBridge( env, owner, transaction.bridgeContinuation, await hash(binding), ) : transaction.providerInstallationId ? `/connect?enableProvider=openrouter&installationId=${transaction.providerInstallationId}` : transaction.domainInstallationId ? `/connect?configureDomain=${transaction.domainInstallationId}` : transaction.returnTo; return redirect(destination, [ cookie(SESSION_COOKIE, sessionRef, 8 * 60 * 60), cookie(TRANSACTION_COOKIE, "", 0), ]); } catch (error) { return redirect(`/connect?error=${safeCode(error)}`, [ cookie(TRANSACTION_COOKIE, "", 0), ]); } } if (url.pathname === "/api/connection" && request.method === "GET") { if (!readCookie(request, SESSION_COOKIE)) return json({ error: "not_connected" }, 401); const principal = await authenticatedPrincipal(request, env); const available = await grantedAccounts(env, principal, network); return json({ accounts: available, selectedAccountId: available.some( (a) => a.id === principal.selectedAccountId, ) ? principal.selectedAccountId : null, grantExpiresAt: (await authorizedGrant(env, principal)).expiresAt, }); } if (url.pathname === "/api/account" && request.method === "POST") { const principal = await authenticatedPrincipal(request, env); const input = await form(request); if ( [...input.keys()].some((key) => key !== "accountId") || input.getAll("accountId").length !== 1 || !/^[a-f0-9]{32}$/.test(input.get("accountId") ?? "") ) throw new OAuthError("invalid_request"); const accountId = input.get("accountId")!; const available = await grantedAccounts(env, principal, network); if (!available.some((account) => account.id === accountId)) throw new OAuthError("account_denied"); if ( !(await vault( env, "session", readCookie(request, SESSION_COOKIE)!, ).selectAccount(principal.subject, principal.grantRef, accountId)) ) throw new OAuthError("reauthorization_required"); return json({ selectedAccountId: accountId }); } return failure("invalid_request", 404); } catch (error) { const code = safeCode(error); return failure( code, code === "forbidden" ? 403 : code === "reauthorization_required" ? 401 : code.includes("setup") || code === "oauth_capability_unavailable" || code === "temporarily_unavailable" ? 503 : 400, ); }}
export async function beginOAuth( request: Request, env: Env, bridge?: BridgeRequest, providerInstallationId?: string, domainInstallationId?: string,) { const config = configuration(env); const state = random(); const binding = random(); const verifier = random(); const continuationRef = bridge ? random() : undefined; if (bridge && continuationRef) await vault(env, "continuation", continuationRef).createContinuation({ ...bridge, bindingHash: await hash(binding), }); await vault(env, "transaction", state).createTransaction({ bindingHash: await hash(binding), verifier, previousSession: readCookie(request, SESSION_COOKIE), returnTo: "/connect", ...(providerInstallationId ? { providerInstallationId } : {}), ...(domainInstallationId ? { domainInstallationId } : {}), ...(continuationRef ? { bridgeContinuation: continuationRef } : {}), expiresAt: Date.now() + 10 * 60_000, }); const destination = new URL(endpoints.authorization); destination.search = new URLSearchParams({ response_type: "code", client_id: config.oauthClientId, redirect_uri: config.oauthRedirectUri, scope: config.oauthScopes.join(" "), state, code_challenge: await hash(verifier), code_challenge_method: "S256", }).toString(); return redirect(destination.href, [cookie(TRANSACTION_COOKIE, binding, 600)]);}