import * as oidc from "openid-client"; import { Hono } from "hono"; import { describeRoute, resolver } from "hono-openapi"; import { clearOidcTransactionCookie, getOidcTransactionCookie, oidcTransactionTtlMs, setOidcTransactionCookie, } from "@/worker/auth/cookies"; import { controlPlaneCallbackUrl, discoverTessera, profileFromClaims, validateReturnTo } from "@/worker/auth/oidc"; import { createApiSession, revokeApiSession } from "@/worker/auth/session-cookie"; import { createControlPlaneDb } from "@/worker/db/d1/client"; import { bootstrapSubject } from "@/worker/db/d1/repository"; import { enforceRateLimit } from "@/worker/middleware/rate-limit"; import { LogoutResponseSchema, MeResponseSchema } from "@/worker/openapi/schemas"; import { meBody } from "@/worker/routes/api/me"; import { describeJson, jsonErrorResponse } from "@/worker/routes/api/openapi"; import { requireSession } from "@/worker/routes/api/session"; import type { AppEnv } from "@/worker/types"; import { safeAudit } from "@/worker/util/audit"; import { clientIp } from "@/worker/util/request-context"; import { apiErrorBody, jsonError } from "@/worker/util/response"; export function createAuthApiRoutes(): Hono { const api = new Hono(); api.get( "/oidc/login", describeRoute({ summary: "Start tessera OIDC login", tags: ["auth"], responses: { 302: { description: "Redirect to tessera authorization endpoint" }, 400: jsonErrorResponse, 500: jsonErrorResponse, }, }), async (c) => { const limited = await enforceRateLimit(c, "RL_AUTH", ["oidc_login", clientIp(c.req.raw)]); if (limited) return limited; let returnTo: string; try { returnTo = validateReturnTo(c.req.query("return_to") ?? null, c.req.url); } catch { return jsonError("invalid_return_to", "return_to must stay on the control-plane origin.", 400); } const config = await discoverTessera(c.env); const codeVerifier = oidc.randomPKCECodeVerifier(); const codeChallenge = await oidc.calculatePKCECodeChallenge(codeVerifier); const state = oidc.randomState(); const nonce = oidc.randomNonce(); const nowMs = Date.now(); await setOidcTransactionCookie(c, { state, nonce, codeVerifier, returnTo, createdAtMs: nowMs, expiresAtMs: nowMs + oidcTransactionTtlMs, }); const redirectTo = oidc.buildAuthorizationUrl(config, { redirect_uri: controlPlaneCallbackUrl(c.req.url), response_type: "code", scope: "openid email profile", code_challenge: codeChallenge, code_challenge_method: "S256", state, nonce, }); return c.redirect(redirectTo.toString(), 302); }, ); api.get( "/oidc/callback", describeRoute({ summary: "Complete tessera OIDC login", tags: ["auth"], responses: { 302: { description: "Redirect after successful login" }, 200: { description: "JSON login response", content: { "application/json": { schema: resolver(MeResponseSchema) } }, }, 400: jsonErrorResponse, 500: jsonErrorResponse, }, }), async (c) => { const limited = await enforceRateLimit(c, "RL_AUTH", ["oidc_callback", clientIp(c.req.raw)]); if (limited) return limited; const transaction = await getOidcTransactionCookie(c); if (!transaction || transaction.expiresAtMs <= Date.now()) { clearOidcTransactionCookie(c); return c.json(apiErrorBody("invalid_oidc_transaction", "OIDC transaction is missing or expired."), 400); } try { const config = await discoverTessera(c.env); const tokens = await oidc.authorizationCodeGrant( config, c.req.raw, { pkceCodeVerifier: transaction.codeVerifier, expectedState: transaction.state, expectedNonce: transaction.nonce, }, { redirect_uri: controlPlaneCallbackUrl(c.req.url) }, ); const claims = tokens.claims(); if (!claims) throw new Error("OIDC callback did not return an ID token"); const db = createControlPlaneDb(c.env.DAV_CONTROL_PLANE); const { subject, created } = await bootstrapSubject(db, profileFromClaims(claims)); const session = await createApiSession(c, { id: subject.id, storageId: subject.storageId }); c.set("session", session); clearOidcTransactionCookie(c); await safeAudit(c, { subjectId: subject.id, actorSubjectId: subject.id, eventType: created ? "subject.bootstrap" : "subject.login", data: { created }, }); if (c.req.query("response") === "json" || c.req.header("accept")?.includes("application/json")) { return c.json(await meBody(c)); } return c.redirect(transaction.returnTo, 302); } catch { clearOidcTransactionCookie(c); return c.json(apiErrorBody("invalid_oidc_callback", "OIDC callback validation failed."), 400); } }, ); api.post("/logout", describeJson("Log out API session", "auth", LogoutResponseSchema), async (c) => { const session = requireSession(c); await revokeApiSession(c, session); await safeAudit(c, { subjectId: session.subjectId, actorSubjectId: session.subjectId, eventType: "auth.logout", data: { revoked: true }, }); return c.json({ ok: true } as const); }); return api; }