Something went wrong. Try again.
This repository has no description
Something went wrong. Try again.
11 kB · 381 lines
TypeScript
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382import { loadControlPlaneConfig, loadControlPlaneOrigin,} from "../configuration/control-plane.ts";import { requiredSecret } from "../configuration/secrets.ts";import type { Bindings } from "../configuration/validation.ts";import { encode, decode, hash, random, opaque, LOGIN_PURPOSE, HEALTH_PURPOSE, DOMAIN_HEALTH_PURPOSE, type BridgeClaims,} from "../shared/bridge.ts";import { customDomainOrigin } from "../shared/domain-origin.ts";import type { Env } from "./config.ts";import { OAuthError } from "./errors.ts";import { authenticatedPrincipal, vault } from "./session.ts";import { registryName } from "./installation-registry.ts";import { unwrap, type Installation } from "./installation-metadata.ts";import type { BridgeRequest } from "./vault.ts";import { beginOAuth, form, privateResponse } from "./http.ts";import { domainJson } from "./domain-api.ts";
const denied = () => new OAuthError("forbidden");const registry = (env: Env, subject: string) => env.INSTALLATIONS.get(env.INSTALLATIONS.idFromName(registryName(subject)));async function owned(env: Env, subject: string, id: string) { const record = unwrap(await registry(env, subject).get(subject, id)); // Identity remains useful after deployment-grant expiry, including failed upgrades. if (!record || !record.installedRelease || !record.resources.runtimeOrigin) throw denied(); return record;}async function allowedAudience( env: Env, subject: string, id: string, requested?: string,) { const record = await owned(env, subject, id); const management = record.resources.runtimeOrigin!; const audience = requested ?? management; if (audience === management) return { record, audience }; if (customDomainOrigin(audience) !== audience) throw denied(); const domain = unwrap(await registry(env, subject).getDomain(subject, id)); if (!domain || domain.status !== "active" || domain.origin !== audience) throw denied(); return { record, audience };}
export async function healthDomain( env: Env, record: Installation, origin: string, network: typeof fetch = fetch,) { if (customDomainOrigin(origin) !== origin) throw denied(); const state = random(); const challenge = random(); const assertion = await signBridgeAssertion(env, { aud: origin, sub: record.ownerSubject, installationId: record.installationId, purpose: DOMAIN_HEALTH_PURPOSE, state, challenge, }); const response = await network( new Request(new URL("/auth/domain-health", origin), { method: "POST", redirect: "manual", signal: AbortSignal.timeout(10_000), headers: { Authorization: `Bearer ${assertion}` }, }), ); if (!response.ok || Number(response.headers.get("Content-Length")) > 4096) throw denied(); const result = (await domainJson(response)) as Record<string, unknown>; const expected = { installationId: record.installationId, origin, state, challenge, }; if ( Object.keys(result).sort().join() !== Object.keys(expected).sort().join() || Object.entries(expected).some(([key, value]) => result[key] !== value) ) throw denied();}export async function signBridgeAssertion( env: Env, claims: Omit<BridgeClaims, "iat" | "exp" | "jti" | "iss">,) { const config = loadControlPlaneConfig(env).config; if (!config.bridge) throw denied(); const privateKey = requiredSecret( env as unknown as Bindings, "FLAREBOT_BRIDGE_SIGNING_KEY", 1, ).reveal(); const key = await crypto.subtle.importKey( "pkcs8", decode(privateKey), { name: "Ed25519" }, false, ["sign"], ); const now = Math.floor(Date.now() / 1000); const header = encode( new TextEncoder().encode( JSON.stringify({ alg: "EdDSA", kid: config.bridge.keyId, typ: "JWT" }), ), ); const body = encode( new TextEncoder().encode( JSON.stringify({ ...claims, iss: config.publicOrigin, iat: now, exp: now + 60, jti: random(), }), ), ); const input = `${header}.${body}`; const signature = new Uint8Array( await crypto.subtle.sign("Ed25519", key, new TextEncoder().encode(input)), ); // Fail setup when the deployed secret does not match its deliberately pinned key. const publicKey = await crypto.subtle.importKey( "raw", decode(config.bridge.publicKey), { name: "Ed25519" }, false, ["verify"], ); if ( !(await crypto.subtle.verify( "Ed25519", publicKey, signature, new TextEncoder().encode(input), )) ) throw denied(); return `${input}.${encode(signature)}`;}async function issueCode(env: Env, subject: string, input: BridgeRequest) { if (input.expiresAt <= Date.now()) throw denied(); const { audience } = await allowedAudience( env, subject, input.installationId, input.audience, ); const code = random(); await vault(env, "code", await hash(code)).createCode({ ...input, subject, audience, expiresAt: Date.now() + 60_000, }); const callback = new URL("/auth/callback", audience); callback.search = new URLSearchParams({ code, state: input.state, }).toString(); return callback.href;}export async function resumeBridge( env: Env, subject: string, ref: string, bindingHash: string,) { const input = await vault(env, "continuation", ref).claimContinuation( bindingHash, ); if (!input) throw denied(); return issueCode(env, subject, input);}function exact(params: URLSearchParams, names: string[]) { if ( [...params.keys()].length !== names.length || names.some((name) => params.getAll(name).length !== 1) ) throw denied(); return Object.fromEntries(names.map((name) => [name, params.get(name)!]));}export async function handleBridge( request: Request, env: Env,): Promise<Response | null> { const url = new URL(request.url); if (!["/auth/bridge", "/auth/bridge/exchange"].includes(url.pathname)) return null; try { if (url.origin !== loadControlPlaneOrigin(env) || request.url.length > 2048) throw denied(); if (url.pathname === "/auth/bridge" && request.method === "GET") { if ( request.headers.has("Upgrade") || (request.headers.has("Sec-Fetch-Mode") && request.headers.get("Sec-Fetch-Mode") !== "navigate") ) throw denied(); const names = url.searchParams.has("audience") ? ["installationId", "state", "challenge", "audience"] : ["installationId", "state", "challenge"]; const input = exact(url.searchParams, names); if ( !/^[a-f0-9]{32}$/.test(input.installationId) || !opaque(input.state) || !opaque(input.challenge) ) throw denied(); const pending = { ...input, expiresAt: Date.now() + 600_000, } as BridgeRequest; let principal; try { principal = await authenticatedPrincipal(request, env); } catch (error) { if ( !(error instanceof OAuthError) || error.code !== "reauthorization_required" ) throw error; return beginOAuth(request, env, pending); } return privateResponse( new Response(null, { status: 303, headers: { Location: await issueCode(env, principal.subject, pending), }, }), ); } if ( url.pathname === "/auth/bridge/exchange" && request.method === "POST" && !url.search ) { // Server exchange is deliberately separate from browser-origin mutations. if ( request.headers.has("Origin") || request.headers.has("Cookie") || request.headers.has("Sec-Fetch-Site") ) throw denied(); const input = exact(await form(request), [ "code", "verifier", "installationId", "audience", "state", ]); if ( !opaque(input.code) || !opaque(input.verifier) || !opaque(input.state) || !/^[a-f0-9]{32}$/.test(input.installationId) ) throw denied(); const challenge = await hash(input.verifier); const code = await vault(env, "code", await hash(input.code)).claimCode({ installationId: input.installationId, audience: input.audience, state: input.state, challenge, }); if (!code) throw denied(); await allowedAudience( env, code.subject, code.installationId, code.audience, ); const assertion = await signBridgeAssertion(env, { aud: code.audience, sub: code.subject, installationId: code.installationId, purpose: LOGIN_PURPOSE, state: code.state, challenge: code.challenge, }); return privateResponse(Response.json({ assertion })); } throw denied(); } catch { return privateResponse( Response.json({ error: "bridge_denied" }, { status: 403 }), ); }}
// Tokens stay inside this callback; callers may persist only successful metadata.export async function healthInstallation( env: Env, record: Installation, network: typeof fetch = fetch, deployedOperationId: string | null = record.operationId,): Promise<void> { if ( !record.resources.runtimeOrigin || !record.desiredRelease || !deployedOperationId ) throw denied(); const state = random(); const challenge = random(); const release = record.desiredRelease; const assertion = await signBridgeAssertion(env, { aud: record.resources.runtimeOrigin, sub: record.ownerSubject, installationId: record.installationId, purpose: HEALTH_PURPOSE, state, challenge, operationId: deployedOperationId, artifactDigest: release.artifactDigest, version: release.version, }); const response = await network( new Request( new URL("/auth/bootstrap-health", record.resources.runtimeOrigin), { method: "POST", redirect: "manual", signal: AbortSignal.timeout(60_000), headers: { Authorization: `Bearer ${assertion}` }, }, ), ); if (!response.ok || Number(response.headers.get("Content-Length")) > 4096) throw denied(); const reader = response.body?.getReader(); if (!reader) throw denied(); let body = ""; let size = 0; const decoder = new TextDecoder(); while (true) { const chunk = await reader.read(); if (chunk.done) break; size += chunk.value.byteLength; if (size > 4096) { await reader.cancel(); throw denied(); } body += decoder.decode(chunk.value, { stream: true }); } body += decoder.decode(); const result = JSON.parse(body); const expected = { installationId: record.installationId, operationId: deployedOperationId, artifactDigest: release.artifactDigest, version: release.version, state, challenge, identity: "ready", nativeParent: "ready", sandbox: "booted-and-destroyed", bindings: "present", assets: "ready", authentication: "required", }; if ( JSON.stringify(Object.keys(result).sort()) !== JSON.stringify(Object.keys(expected).sort()) || Object.entries(expected).some(([key, value]) => result[key] !== value) ) throw denied();}