Skip to content
File

Blob: src/worker/routes/authOidc.ts

typescript261 lines
1import { validateSlugForRoute } from "@/shared/slugs";
2import {
3 claimNamespace,
4 findUserByTesseraSub,
5 insertMembershipIfMissing,
6 insertUserIfNew,
7} from "@/worker/db/d1/dal";
8import {
9 clearOidcTransactionCookie,
10 getOidcTransactionCookie,
11 setOidcTransactionCookie,
12} from "@/worker/auth/cookies";
13import {
14 buildAuthorizeUrl,
15 buildCallbackUrl,
16 deriveTransactionCookieSecret,
17 discoverOidcProvider,
18 decodeTransactionPayload,
19 encodeTransactionPayload,
20 exchangeAuthorizationCode,
21 generateNonce,
22 generatePkcePair,
23 generateState,
24 loadOidcConfig,
25 verifyIdTokenClaims,
26} from "@/worker/auth/oidc";
27import { sameOriginViolation } from "@/worker/auth/origin";
28import {
29 createSessionForUser,
30 endSession,
31 generateNamespaceId,
32 generateUserId,
33 loadSessionConfig,
34 loadViewer,
35} from "@/worker/auth/session";
36import type { AppRouter } from "./hono";
37import { renderUiDocumentResponse } from "./uiResponse";
38import { errorRedirect, safeRedirect } from "./authShared";
39 
40export function registerAuthOidcRoutes(router: AppRouter) {
41 // GET /auth: anonymous sees the sign-in page; signed-in goes to account.
42 router.get(`/auth`, async (c) => {
43 try {
44 const viewer = await loadViewer(c);
45 if (viewer) return safeRedirect(c, "/auth/account");
46 const errorCode = c.req.query("error") ?? undefined;
47 return await renderUiDocumentResponse(
48 c.env,
49 "auth-signin",
50 { errorCode },
51 { failureBody: "Failed to render page\n", viewer: null }
52 );
53 } catch {
54 return new Response("Failed to render page\n", { status: 500 });
55 }
56 });
57 
58 router.get(`/auth/start`, async (c) => {
59 const log = c.var.logFor({ service: "AuthOidc" });
60 const result = loadOidcConfig(c.env);
61 if (!result.ok) {
62 log.warn("oidc:start-config-missing", { reason: result.reason });
63 return errorRedirect(c, "oidc_unavailable");
64 }
65 const config = result.config;
66 const providerResult = await discoverOidcProvider(config);
67 if (!providerResult.ok) {
68 log.warn("oidc:start-discovery-failed", { reason: providerResult.reason });
69 return errorRedirect(c, "oidc_unavailable");
70 }
71 const state = generateState();
72 const nonce = generateNonce();
73 const pkce = await generatePkcePair();
74 const redirectUri = buildCallbackUrl(c.req.url);
75 const encodedTransaction = encodeTransactionPayload({
76 state,
77 nonce,
78 codeVerifier: pkce.verifier,
79 redirectUri,
80 createdAt: Date.now(),
81 });
82 try {
83 const transactionCookieSecret = await deriveTransactionCookieSecret(config.clientSecret);
84 await setOidcTransactionCookie(c, encodedTransaction, transactionCookieSecret);
85 } catch (error) {
86 log.error("oidc:start-cookie-sign-failed", { error: String(error) });
87 return errorRedirect(c, "oidc_unavailable");
88 }
89 const authorizeUrl = buildAuthorizeUrl(providerResult.provider, {
90 redirectUri,
91 state,
92 nonce,
93 codeChallenge: pkce.challenge,
94 });
95 return safeRedirect(c, authorizeUrl);
96 });
97 
98 router.get(`/auth/callback`, async (c) => {
99 const log = c.var.logFor({ service: "AuthOidc" });
100 const configResult = loadOidcConfig(c.env);
101 if (!configResult.ok) {
102 // Always clear the signed transaction cookie when bailing out of the
103 // callback. A missing/changed config must not leave a stale transaction
104 // payload in the browser that could be replayed if config recovers.
105 clearOidcTransactionCookie(c);
106 log.warn("oidc:callback-config-missing", { reason: configResult.reason });
107 return errorRedirect(c, "oidc_unavailable");
108 }
109 const config = configResult.config;
110 const code = c.req.query("code") ?? "";
111 const state = c.req.query("state") ?? "";
112 if (!code || !state) {
113 clearOidcTransactionCookie(c);
114 log.warn("oidc:callback-missing-params");
115 return errorRedirect(c, "invalid_request");
116 }
117 
118 let transactionCookieSecret: Uint8Array<ArrayBuffer>;
119 try {
120 transactionCookieSecret = await deriveTransactionCookieSecret(config.clientSecret);
121 } catch (error) {
122 log.error("oidc:callback-cookie-secret-derive-failed", { error: String(error) });
123 clearOidcTransactionCookie(c);
124 return errorRedirect(c, "oidc_unavailable");
125 }
126 
127 const transactionCookie = await getOidcTransactionCookie(c, transactionCookieSecret);
128 if (transactionCookie.kind === "missing") {
129 log.warn("oidc:callback-missing-cookie");
130 return errorRedirect(c, "missing_state");
131 }
132 if (transactionCookie.kind === "invalid_signature") {
133 log.warn("oidc:callback-cookie-signature-failed");
134 clearOidcTransactionCookie(c);
135 return errorRedirect(c, "invalid_state");
136 }
137 const transaction = decodeTransactionPayload(transactionCookie.value);
138 if (!transaction.ok) {
139 log.warn("oidc:callback-transaction-invalid", { reason: transaction.reason });
140 clearOidcTransactionCookie(c);
141 return errorRedirect(c, "invalid_state");
142 }
143 if (transaction.payload.state !== state) {
144 log.warn("oidc:callback-state-mismatch");
145 clearOidcTransactionCookie(c);
146 return errorRedirect(c, "invalid_state");
147 }
148 if (transaction.payload.redirectUri !== buildCallbackUrl(c.req.url)) {
149 log.warn("oidc:callback-redirect-mismatch");
150 clearOidcTransactionCookie(c);
151 return errorRedirect(c, "invalid_state");
152 }
153 const providerResult = await discoverOidcProvider(config);
154 if (!providerResult.ok) {
155 log.warn("oidc:callback-discovery-failed", { reason: providerResult.reason });
156 clearOidcTransactionCookie(c);
157 return errorRedirect(c, "oidc_unavailable");
158 }
159 const tokenResult = await exchangeAuthorizationCode(providerResult.provider, {
160 callbackUrl: c.req.url,
161 codeVerifier: transaction.payload.codeVerifier,
162 nonce: transaction.payload.nonce,
163 state,
164 });
165 if (!tokenResult.ok) {
166 log.warn("oidc:callback-token-exchange-failed", { reason: tokenResult.reason });
167 clearOidcTransactionCookie(c);
168 return errorRedirect(
169 c,
170 tokenResult.reason === "invalid_id_token" ? "invalid_id_token" : "token_exchange_failed"
171 );
172 }
173 const verified = verifyIdTokenClaims(tokenResult.tokens.claims);
174 if (!verified.ok) {
175 log.warn("oidc:callback-verify-failed", { reason: verified.reason });
176 clearOidcTransactionCookie(c);
177 return errorRedirect(c, "invalid_id_token");
178 }
179 const sessionConfig = loadSessionConfig(c.env);
180 if (!sessionConfig.ok) {
181 log.warn("oidc:callback-session-config-missing", { reason: sessionConfig.reason });
182 clearOidcTransactionCookie(c);
183 return errorRedirect(c, "session_create_failed");
184 }
185 
186 // First-login race-safe write model:
187 // 1. Insert user; ON CONFLICT means existing row.
188 // 2. If new + valid pref-name, attempt namespace claim; can lose race.
189 // 3. If claim won, create membership.
190 // 4. Always create a sealed local session cookie.
191 //
192 // These writes stay sequential because each branch depends on prior
193 // results. Batching would either roll back the valid user insert on a
194 // namespace FK conflict or require the same branching after the batch.
195 const db = c.var.db;
196 const now = Date.now();
197 const newUserId = generateUserId();
198 const inserted = await insertUserIfNew(db, {
199 id: newUserId,
200 tesseraSub: verified.verified.sub,
201 createdAt: now,
202 });
203 let userId: string;
204 let wasNewUser = false;
205 if (inserted) {
206 userId = inserted.id;
207 wasNewUser = true;
208 } else {
209 const existing = await findUserByTesseraSub(db, verified.verified.sub);
210 if (!existing) {
211 log.error("oidc:callback-user-upsert-failed");
212 clearOidcTransactionCookie(c);
213 return errorRedirect(c, "session_create_failed");
214 }
215 userId = existing.id;
216 }
217 
218 let claimedNamespaceSlug: string | null = null;
219 if (wasNewUser && verified.verified.preferredUsername) {
220 const candidate = validateSlugForRoute(verified.verified.preferredUsername.toLowerCase());
221 if (candidate.ok) {
222 const claimed = await claimNamespace(db, {
223 id: generateNamespaceId(),
224 slug: candidate.slug,
225 createdBy: userId,
226 createdAt: now,
227 });
228 if (claimed) {
229 await insertMembershipIfMissing(db, {
230 namespaceId: claimed.id,
231 userId,
232 createdAt: now,
233 });
234 claimedNamespaceSlug = claimed.slug;
235 } else {
236 log.info("oidc:callback-namespace-taken", { slug: candidate.slug });
237 }
238 }
239 }
240 
241 try {
242 await createSessionForUser(c.env, c, userId, now);
243 } catch (error) {
244 log.error("oidc:callback-session-create-failed", { error: String(error) });
245 clearOidcTransactionCookie(c);
246 return errorRedirect(c, "session_create_failed");
247 }
248 clearOidcTransactionCookie(c);
249 
250 log.info("oidc:callback-success", { userId, claimedNamespaceSlug });
251 return safeRedirect(c, "/auth/account");
252 });
253 
254 router.post(`/auth/sign-out`, async (c) => {
255 const violation = sameOriginViolation(c);
256 if (violation) return violation;
257 await endSession(c);
258 return safeRedirect(c, "/", 303);
259 });
260}