import { GitHubClient, GitHubError } from "../github/client"; import { StorageError } from "../storage/errors"; import { Admission, AdmissionDenied } from "./admission"; import { OAuthAttempts, Sessions } from "./session"; import { DeviceAuthorization } from "./device"; import { LocalAuth } from "./local"; import { LocalCredentials } from "./local-store"; import type { AuthConfig } from "./config"; import type { GitHub, GitHubConditional } from "../github/client"; import type { Router } from "../http/router"; import type { StorageAdapter } from "../storage/port"; type Clock = () => Date; const RETURN_PATH_MAX_LENGTH = 2_048; const RESERVED_RETURN_PATHS = ["/api", "/auth", "/ws", "/mcp"]; type Dependencies = { config: AuthConfig; storage: StorageAdapter; github?: GitHub; clock?: Clock; agent?: boolean; device?: DeviceAuthorization; credentials?: LocalCredentials; delay?: (ms: number, signal: AbortSignal) => Promise; onSessionRevoked?: (sessionId: string) => Promise; onCredentialsWillRotate?: (sessionId: string, revision: number) => Promise; }; export type HostedAuth = { config: AuthConfig; storage: StorageAdapter; github: GitHub; admission: Admission; sessions: Sessions; clock: Clock; }; function headers(): Headers { return new Headers({ "cache-control": "no-store", "content-type": "application/json; charset=utf-8", "referrer-policy": "no-referrer", "x-content-type-options": "nosniff", }); } function json(value: unknown, status = 200, cookie?: string): Response { let responseHeaders = headers(); if (cookie) responseHeaders.append("set-cookie", cookie); return Response.json(value, { status, headers: responseHeaders }); } function empty(status: number, cookie?: string): Response { let responseHeaders = headers(); responseHeaders.delete("content-type"); if (cookie) responseHeaders.append("set-cookie", cookie); return new Response(null, { status, headers: responseHeaders }); } function conditional(value: GitHubConditional): Response { let responseHeaders = headers(); if (value.etag) responseHeaders.set("etag", value.etag); if ("notModified" in value) { responseHeaders.delete("content-type"); return new Response(null, { status: 304, headers: responseHeaders }); } let { etag: _etag, ...body } = value; return Response.json(body, { headers: responseHeaders }); } function redirected(location: string, status: 302 | 303, cookies: string[]): Response { let responseHeaders = new Headers({ "cache-control": "no-store", location, "referrer-policy": "no-referrer", }); for (let cookie of cookies) responseHeaders.append("set-cookie", cookie); return new Response(null, { status, headers: responseHeaders }); } function failure(err: unknown): Response { if (err instanceof AdmissionDenied) { return json({ error: err.message }, 403); } if (err instanceof GitHubError) { return json({ error: err.message }, err.status); } if (err instanceof StorageError) { return json( { error: err.failure === "unavailable" ? "storage is unavailable" : "session storage failed", }, err.failure === "unavailable" ? 503 : 500, ); } return json({ error: "request failed" }, 500); } function page(url: URL): number | undefined { let values = url.searchParams.getAll("page"); if (values.length === 0) return 1; if (values.length !== 1 || !/^\d+$/.test(values[0]!)) return undefined; let parsed = Number(values[0]); return Number.isSafeInteger(parsed) && parsed >= 1 && parsed <= 10_000 ? parsed : undefined; } function parameter(url: URL, name: string): string | undefined { let values = url.searchParams.getAll(name); return values.length === 1 && values[0] ? values[0] : undefined; } function returnPath(url: URL, origin: string): string { let values = url.searchParams.getAll("return_to"); if (values.length !== 1) return "/"; let value = values[0]!; if ( !value || value.length > RETURN_PATH_MAX_LENGTH || !value.startsWith("/") || value.startsWith("//") || value.includes("\\") ) return "/"; let hasControl = [...value].some(character => { let code = character.charCodeAt(0); return code <= 0x1f || (code >= 0x7f && code <= 0x9f); }); if (hasControl) return "/"; let target: URL; try { target = new URL(value, origin); } catch { return "/"; } if (target.origin !== origin || target.pathname.startsWith("//")) return "/"; if ( RESERVED_RETURN_PATHS.some(path => target.pathname === path || target.pathname.startsWith(`${path}/`) ) ) return "/"; let canonical = target.pathname + target.search + target.hash; return canonical.length <= RETURN_PATH_MAX_LENGTH ? canonical : "/"; } /** Register the GitHub OAuth and authenticated session surface. */ export function registerAuthRoutes( router: Router, dependencies: Dependencies, ): HostedAuth { let clock = dependencies.clock ?? (() => new Date()); let config = dependencies.config; let storage = dependencies.storage; let github = dependencies.github ?? new GitHubClient(); let admission = new Admission(config, github, () => clock().getTime()); let secure = new URL(config.origin).protocol === "https:"; let local: LocalAuth | undefined; let sessions = new Sessions(storage, secure, clock, { persistence: config.local ? undefined : { key: config.encryptionKey, origin: config.origin, clientId: config.clientId, }, refresh: refreshToken => github.refresh({ clientId: config.clientId, ...(config.clientSecret && !config.local ? { clientSecret: config.clientSecret } : {}), refreshToken, }), beforeRefresh: dependencies.onCredentialsWillRotate, onRevoked: async id => { await local?.revoked(id); await dependencies.onSessionRevoked?.(id); }, persist: config.local ? (id, grant, revision) => local!.persist(id, grant, revision) : undefined, cookieSuffix: config.local ? `_${config.local.port}` : undefined, authorize: async (user, token) => { if (config.local) return admission.allowed(token, user.id); try { return (await admission.user(token)).id === user.id; } catch (err) { if (err instanceof AdmissionDenied) return false; throw err; } }, invalidate: token => admission.invalidate(token), }); if (config.local) { local = new LocalAuth({ config, storage, sessions, admission, github, device: dependencies.device, credentials: dependencies.credentials, clock: () => clock().getTime(), delay: dependencies.delay, }); } let attempts = new OAuthAttempts(config.encryptionKey, secure, clock); let redirectUri = `${config.origin}/auth/github/callback`; let context: HostedAuth = { config, storage, github, admission, sessions, clock }; if (local) { let flow = local; let allowed = (request: Request) => (request.headers.get("host") ?? new URL(request.url).host) === new URL(config.origin).host; let mutating = (request: Request) => allowed(request) && request.headers.get("origin") === config.origin; let response = (result: { status: string; cookies?: string[]; [key: string]: unknown }) => { let { cookies = [], ...body } = result; let answer = json(body); for (let value of cookies) answer.headers.append("set-cookie", value); return answer; }; router.on("POST", "/auth/device", async request => { if (!mutating(request)) return json({ error: "origin is not allowed" }, 403); try { let result = await flow.start(request); return response({ ...result.body, cookies: [result.cookie] }); } catch { return json( { status: "failed", message: "GitHub device authorization is unavailable" }, 502, ); } }); router.on("GET", "/auth/device", request => { if (!allowed(request)) return json({ error: "origin is not allowed" }, 403); return response(flow.status(request) ?? { status: "cancelled" }); }); router.on("POST", "/auth/device/cancel", request => { if (!mutating(request)) return json({ error: "origin is not allowed" }, 403); return response({ ...flow.cancel(request), cookies: [flow.clearAttempt] }); }); router.on("POST", "/auth/device/complete", async request => { if (!mutating(request)) return json({ error: "origin is not allowed" }, 403); let result = await flow.complete(request); return response(result ?? { status: "cancelled" }); }); router.on("POST", "/auth/device/consent", async request => { if (!mutating(request)) return json({ error: "origin is not allowed" }, 403); let body: unknown; try { body = await request.json(); } catch { return json({ error: "invalid choice" }, 400); } if ( !body || typeof body !== "object" || typeof (body as { accept?: unknown }).accept !== "boolean" ) { return json({ error: "invalid choice" }, 400); } let result = await flow.complete(request, (body as { accept: boolean }).accept); return response(result ?? { status: "cancelled" }); }); } if (!local) { router.on("GET", "/auth/github", async (_request, url) => { let issued = await attempts.issue(returnPath(url, config.origin)); let location = github.authorize({ clientId: config.clientId, redirectUri, state: issued.state, challenge: issued.challenge, }); return redirected(location, 302, [issued.cookie]); }); } router.on("GET", "/auth/github/install", () => redirected( `https://github.com/apps/${encodeURIComponent(config.appSlug)}/installations/new`, 302, [], )); // GitHub's installation_id query parameter is intentionally ignored. API access is // always re-established from the signed-in user's token. router.on("GET", "/auth/github/setup", async request => { let authenticated = await sessions.authenticate(request).catch(() => undefined); if (authenticated) admission.invalidate(authenticated.access.token); return redirected("/?repository_access=changed", 303, []); }); if (!local) { router.on("GET", "/auth/github/callback", async (request, url) => { let clear = attempts.clearCookie(); try { if (url.searchParams.has("error")) { return json({ error: "GitHub authorization was denied" }, 400, clear); } let code = parameter(url, "code"); let state = parameter(url, "state"); let stored = await attempts.read(request); if (!code || !state || !stored || stored.state !== state) { return json({ error: "OAuth state is missing or invalid" }, 400, clear); } let grant = await github.exchange({ clientId: config.clientId, clientSecret: config.clientSecret!, redirectUri, code, verifier: stored.verifier, }); let profile = await admission.user(grant.accessToken); let now = clock(); await storage.users.put({ ...profile, now }); let session = await sessions.issue(profile.id, grant); return redirected(stored.returnPath ?? "/", 303, [clear, session.cookie]); } catch (err) { let response = failure(err); response.headers.append("set-cookie", clear); return response; } }); } router.on("GET", "/api/session", async request => { try { let authenticated = await sessions.authenticate(request); let restoredCookie: string | undefined; let clearBinding = false; if (!authenticated && local) { let restored = await local.restore(request); restoredCookie = restored.cookie; clearBinding = !!restored.clear; if (restored.cookie) { authenticated = await sessions.authenticate( new Request(request.url, { headers: { cookie: restored.cookie.split(";", 1)[0]! }, }), ); } } let reply = authenticated ? json({ agent: dependencies.agent ?? true, ...(local ? { auth: "local" } : {}), installUrl: "/auth/github/install", user: { id: authenticated.user.id, login: authenticated.user.login, avatarUrl: authenticated.user.avatarUrl, }, expiresAt: authenticated.session.expiresAt.toISOString(), }) : json({ user: null, agent: dependencies.agent ?? true, ...(local ? { auth: "local" } : {}), }); if (restoredCookie) reply.headers.append("set-cookie", restoredCookie); if (clearBinding) reply.headers.append("set-cookie", local!.clearBinding); return reply; } catch (err) { return failure(err); } }); router.on("GET", "/api/github/installations", async (request, url) => { try { let requestedPage = page(url); if (!requestedPage) { return json({ error: "page must be an integer between 1 and 10000" }, 400); } let authenticated = await sessions.authenticate(request); if (!authenticated) return json({ error: "authentication required" }, 401); try { let result = await sessions.use( authenticated, token => github.installations(token, requestedPage, { ifNoneMatch: request.headers.get("if-none-match") ?? undefined, }), ); return conditional(result.value); } catch (err) { if (err instanceof GitHubError && err.status === 401) { return json({ error: "GitHub authorization expired" }, 401, sessions.clearCookie()); } throw err; } } catch (err) { return failure(err); } }); router.on( "GET", "/api/github/installations/:installationId/repositories", async (request, url, params) => { try { let requestedPage = page(url); if (!requestedPage) { return json({ error: "page must be an integer between 1 and 10000" }, 400); } let installationId = params.installationId!; if (!/^\d+$/.test(installationId)) { return json({ error: "installation not found" }, 404); } let authenticated = await sessions.authenticate(request); if (!authenticated) return json({ error: "authentication required" }, 401); try { let result = await sessions.use( authenticated, token => github.installationRepositories(token, installationId, requestedPage, { ifNoneMatch: request.headers.get("if-none-match") ?? undefined, }), ); return conditional(result.value); } catch (err) { if (err instanceof GitHubError && err.status === 401) { return json( { error: "GitHub authorization expired" }, 401, sessions.clearCookie(), ); } throw err; } } catch (err) { return failure(err); } }, ); router.on("POST", "/auth/logout", async request => { if (request.headers.get("origin") !== config.origin) { return json({ error: "origin is not allowed" }, 403); } try { await sessions.revoke(request); await local?.logout(request).catch(() => {}); let response = empty(204, sessions.clearCookie()); if (local) response.headers.append("set-cookie", local.clearBinding); return response; } catch (err) { return failure(err); } }); return context; }