Skip to content
File

Blob: src/worker/routes/api/auth.ts

typescript157 lines
1import * as oidc from "openid-client";
2import { Hono } from "hono";
3import { describeRoute, resolver } from "hono-openapi";
4 
5import {
6 clearOidcTransactionCookie,
7 getOidcTransactionCookie,
8 oidcTransactionTtlMs,
9 setOidcTransactionCookie,
10} from "@/worker/auth/cookies";
11import { controlPlaneCallbackUrl, discoverTessera, profileFromClaims, validateReturnTo } from "@/worker/auth/oidc";
12import { createApiSession, revokeApiSession } from "@/worker/auth/session-cookie";
13import { createControlPlaneDb } from "@/worker/db/d1/client";
14import { bootstrapSubject } from "@/worker/db/d1/repository";
15import { enforceRateLimit } from "@/worker/middleware/rate-limit";
16import { LogoutResponseSchema, MeResponseSchema } from "@/worker/openapi/schemas";
17import { meBody } from "@/worker/routes/api/me";
18import { describeJson, jsonErrorResponse } from "@/worker/routes/api/openapi";
19import { requireSession } from "@/worker/routes/api/session";
20import type { AppEnv } from "@/worker/types";
21import { safeAudit } from "@/worker/util/audit";
22import { clientIp } from "@/worker/util/request-context";
23import { apiErrorBody, jsonError } from "@/worker/util/response";
24 
25export function createAuthApiRoutes(): Hono<AppEnv> {
26 const api = new Hono<AppEnv>();
27 
28 api.get(
29 "/oidc/login",
30 describeRoute({
31 summary: "Start tessera OIDC login",
32 tags: ["auth"],
33 responses: {
34 302: { description: "Redirect to tessera authorization endpoint" },
35 400: jsonErrorResponse,
36 500: jsonErrorResponse,
37 },
38 }),
39 async (c) => {
40 const limited = await enforceRateLimit(c, "RL_AUTH", ["oidc_login", clientIp(c.req.raw)]);
41 if (limited) return limited;
42 
43 let returnTo: string;
44 try {
45 returnTo = validateReturnTo(c.req.query("return_to") ?? null, c.req.url);
46 } catch {
47 return jsonError("invalid_return_to", "return_to must stay on the control-plane origin.", 400);
48 }
49 
50 const config = await discoverTessera(c.env);
51 const codeVerifier = oidc.randomPKCECodeVerifier();
52 const codeChallenge = await oidc.calculatePKCECodeChallenge(codeVerifier);
53 const state = oidc.randomState();
54 const nonce = oidc.randomNonce();
55 const nowMs = Date.now();
56 await setOidcTransactionCookie(c, {
57 state,
58 nonce,
59 codeVerifier,
60 returnTo,
61 createdAtMs: nowMs,
62 expiresAtMs: nowMs + oidcTransactionTtlMs,
63 });
64 
65 const redirectTo = oidc.buildAuthorizationUrl(config, {
66 redirect_uri: controlPlaneCallbackUrl(c.req.url),
67 response_type: "code",
68 scope: "openid email profile",
69 code_challenge: codeChallenge,
70 code_challenge_method: "S256",
71 state,
72 nonce,
73 });
74 
75 return c.redirect(redirectTo.toString(), 302);
76 },
77 );
78 
79 api.get(
80 "/oidc/callback",
81 describeRoute({
82 summary: "Complete tessera OIDC login",
83 tags: ["auth"],
84 responses: {
85 302: { description: "Redirect after successful login" },
86 200: {
87 description: "JSON login response",
88 content: { "application/json": { schema: resolver(MeResponseSchema) } },
89 },
90 400: jsonErrorResponse,
91 500: jsonErrorResponse,
92 },
93 }),
94 async (c) => {
95 const limited = await enforceRateLimit(c, "RL_AUTH", ["oidc_callback", clientIp(c.req.raw)]);
96 if (limited) return limited;
97 
98 const transaction = await getOidcTransactionCookie(c);
99 if (!transaction || transaction.expiresAtMs <= Date.now()) {
100 clearOidcTransactionCookie(c);
101 return c.json(apiErrorBody("invalid_oidc_transaction", "OIDC transaction is missing or expired."), 400);
102 }
103 
104 try {
105 const config = await discoverTessera(c.env);
106 const tokens = await oidc.authorizationCodeGrant(
107 config,
108 c.req.raw,
109 {
110 pkceCodeVerifier: transaction.codeVerifier,
111 expectedState: transaction.state,
112 expectedNonce: transaction.nonce,
113 },
114 { redirect_uri: controlPlaneCallbackUrl(c.req.url) },
115 );
116 const claims = tokens.claims();
117 if (!claims) throw new Error("OIDC callback did not return an ID token");
118 
119 const db = createControlPlaneDb(c.env.DAV_CONTROL_PLANE);
120 const { subject, created } = await bootstrapSubject(db, profileFromClaims(claims));
121 const session = await createApiSession(c, { id: subject.id, storageId: subject.storageId });
122 c.set("session", session);
123 clearOidcTransactionCookie(c);
124 await safeAudit(c, {
125 subjectId: subject.id,
126 actorSubjectId: subject.id,
127 eventType: created ? "subject.bootstrap" : "subject.login",
128 data: { created },
129 });
130 
131 if (c.req.query("response") === "json" || c.req.header("accept")?.includes("application/json")) {
132 return c.json(await meBody(c));
133 }
134 
135 return c.redirect(transaction.returnTo, 302);
136 } catch {
137 clearOidcTransactionCookie(c);
138 return c.json(apiErrorBody("invalid_oidc_callback", "OIDC callback validation failed."), 400);
139 }
140 },
141 );
142 
143 api.post("/logout", describeJson("Log out API session", "auth", LogoutResponseSchema), async (c) => {
144 const session = requireSession(c);
145 await revokeApiSession(c, session);
146 await safeAudit(c, {
147 subjectId: session.subjectId,
148 actorSubjectId: session.subjectId,
149 eventType: "auth.logout",
150 data: { revoked: true },
151 });
152 return c.json({ ok: true } as const);
153 });
154 
155 return api;
156}