import * as Effect from "effect/Effect"; import { loadControlPlaneOrigin } from "../configuration/control-plane.ts"; import { handleBridge, resumeBridge } from "./bridge.ts"; import { exchange, revoke, subject, type CloudflareFetch, } from "./cloudflare.ts"; import { configuration, type Env } from "./config.ts"; import { hash, opaque, random } from "./crypto.ts"; import { messages, OAuthError, safeCode, type ErrorCode } from "./errors.ts"; import { checkOrigin, form, json, redirect } from "./http-response.ts"; import { beginOAuth } from "./oauth-authorization.ts"; import { authenticatedPrincipal, authorizedGrant, cookie, grantedAccounts, readCookie, SESSION_COOKIE, TRANSACTION_COOKIE, vault, } from "./session.ts"; 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 function handleOAuth( request: Request, env: Env, network: CloudflareFetch = fetch, ) { return Effect.gen(function* () { const url = new URL(request.url); const bridgeResponse = yield* handleBridge(request, env); if (bridgeResponse) return bridgeResponse; if (!( url.pathname.startsWith("/auth/") || url.pathname.startsWith("/api/") )) return null; if (url.pathname === "/auth/disconnect" && request.method === "POST") { yield* checkOrigin(request, loadControlPlaneOrigin(env)); const ref = readCookie(request, SESSION_COOKIE); let revoked = true; if (ref) { const principal = yield* vault(env, "session", ref).retireSession(); if (principal) { const grant = yield* vault(env, "grant", principal.grantRef).grant( principal.subject, ); yield* vault(env, "grant", principal.grantRef).destroy(); if (grant) { revoked = yield* configuration(env).pipe( Effect.flatMap((config) => revoke(config, grant.accessToken, network), ), Effect.catch(() => Effect.succeed(false)), ); } } } return redirect( `/connect${revoked ? "" : "?notice=revocation_pending"}`, [cookie(SESSION_COOKIE, "", 0), cookie(TRANSACTION_COOKIE, "", 0)], ); } const config = yield* configuration(env); if (url.origin !== config.publicOrigin) return yield* new OAuthError("forbidden"); if (request.method === "POST") yield* checkOrigin(request, config.publicOrigin); if (url.pathname === "/auth/start" && request.method === "POST") { const input = yield* 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"))) ) return yield* new OAuthError("invalid_request"); return yield* beginOAuth( request, env, undefined, providerInstallationId ?? undefined, domainInstallationId ?? undefined, ); } if (url.pathname === "/auth/callback" && request.method === "GET") return yield* oauthCallback(request, env, network, config, url); if (url.pathname === "/api/connection" && request.method === "GET") { if (!readCookie(request, SESSION_COOKIE)) return json({ error: "not_connected" }, 401); const principal = yield* authenticatedPrincipal(request, env); const available = yield* grantedAccounts(env, principal, network); return json({ ownerSubject: principal.subject, accounts: available, selectedAccountId: available.some( (a) => a.id === principal.selectedAccountId, ) ? principal.selectedAccountId : null, grantExpiresAt: (yield* authorizedGrant(env, principal)).expiresAt, }); } if (url.pathname === "/api/account" && request.method === "POST") { const principal = yield* authenticatedPrincipal(request, env); const input = yield* form(request); if ( [...input.keys()].some((key) => key !== "accountId") || input.getAll("accountId").length !== 1 || !/^[a-f0-9]{32}$/.test(input.get("accountId") ?? "") ) return yield* new OAuthError("invalid_request"); const accountId = input.get("accountId")!; const available = yield* grantedAccounts(env, principal, network); if (!available.some((account) => account.id === accountId)) return yield* new OAuthError("account_denied"); if ( !(yield* vault( env, "session", readCookie(request, SESSION_COOKIE)!, ).selectAccount(principal.subject, principal.grantRef, accountId)) ) return yield* new OAuthError("reauthorization_required"); return json({ ownerSubject: principal.subject, selectedAccountId: accountId, }); } return failure("invalid_request", 404); }).pipe( Effect.catch(oauthFailureEffect), Effect.catchDefect(oauthFailureEffect), ); } function oauthFailure(error: unknown) { 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, ); } function oauthCallback( request: Request, env: Env, network: CloudflareFetch, config: import("./config.ts").OAuthConfiguration, url: URL, ) { return Effect.gen(function* () { 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, ) ) return yield* 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") ) return yield* new OAuthError("oauth_invalid_callback"); if (url.searchParams.has("code")) one(url.searchParams, "code"); else one(url.searchParams, "error"); const transaction = yield* vault( env, "transaction", state, ).claimTransaction( yield* hash(binding), readCookie(request, SESSION_COOKIE), ); if (!transaction) return yield* new OAuthError("oauth_invalid_callback"); if (url.searchParams.has("error")) return yield* new OAuthError( url.searchParams.get("error") === "access_denied" ? "oauth_denied" : "oauth_invalid_callback", ); const grant = yield* exchange( config, one(url.searchParams, "code"), transaction.verifier, network, ); const owner = yield* subject(grant.accessToken, network).pipe( Effect.onError(() => revoke(config, grant.accessToken, network)), ); const sessionRef = random(); const grantRef = random(); // Expiring grants are not refreshed/offline. Browser identity lasts 8h; // an expired grant explicitly requests another OAuth connection. yield* vault(env, "grant", grantRef).createGrant({ ...grant, subject: owner, }); yield* vault(env, "session", sessionRef).createSession({ subject: owner, grantRef, selectedAccountId: null, expiresAt: Date.now() + 8 * 60 * 60_000, }); if (transaction.previousSession) { const previous = yield* vault( env, "session", transaction.previousSession, ).session(); yield* vault(env, "session", transaction.previousSession).destroy(); if (previous) { const oldGrant = yield* vault(env, "grant", previous.grantRef).grant( previous.subject, ); yield* vault(env, "grant", previous.grantRef).destroy(); if (oldGrant && oldGrant.accessToken !== grant.accessToken) yield* revoke(config, oldGrant.accessToken, network); } } const destination = transaction.bridgeContinuation ? yield* resumeBridge( env, owner, transaction.bridgeContinuation, yield* 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), ]); }).pipe(Effect.catch(callbackFailure), Effect.catchDefect(callbackFailure)); } const oauthFailureEffect = (error: unknown) => Effect.succeed(oauthFailure(error)); const callbackFailure = (error: unknown) => Effect.succeed( redirect(`/connect?error=${safeCode(error)}`, [ cookie(TRANSACTION_COOKIE, "", 0), ]), );