Skip to content
File

Blob: src/worker/routes/oidc.ts

typescript299 lines
1import { Hono, type Context } from "hono";
2import { deleteCookie, getCookie, setCookie } from "hono/cookie";
3import { eq } from "drizzle-orm";
4import { DrizzleQueryError } from "drizzle-orm/errors";
5import { ulid } from "ulid";
6import * as oidc from "openid-client";
7import type { Configuration, IDToken } from "openid-client";
8 
9import type { AppContext } from "@/worker/app-context";
10import type { Db } from "@/worker/db/d1/client";
11import { memberships, tesseraIdentities, users, workspaces } from "@/worker/db/d1/schema";
12import { createRefreshToken, setRefreshCookie } from "@/worker/lib/auth";
13import { rateLimit } from "@/worker/middleware/rate-limit";
14import { isAllowedOrigin } from "@/worker/lib/origins";
15import { createLogger } from "@/worker/lib/logger";
16import {
17 OIDC_SCOPE,
18 OIDC_TX_COOKIE,
19 OIDC_TX_COOKIE_MAX_AGE,
20 appendOidcMarker,
21 decodeTxCookie,
22 encodeTxCookie,
23 exchangeAuthorizationCode,
24 getOidcConfig,
25 oidcErrorContext,
26 sanitizeReturnTo,
27 validateClaims,
28 type ResolvedClaims,
29 type TxCookiePayload,
30} from "@/worker/lib/oidc";
31 
32const log = createLogger("oidc");
33 
34const oidcRouter = new Hono<AppContext>();
35 
36const TX_COOKIE_OPTIONS = {
37 path: "/" as const,
38 httpOnly: true,
39 secure: true,
40 sameSite: "Lax" as const,
41 maxAge: OIDC_TX_COOKIE_MAX_AGE,
42};
43 
44const TX_COOKIE_CLEAR_OPTIONS = { path: "/" as const, secure: true };
45 
46function clearTxCookie(c: Context<AppContext>): void {
47 deleteCookie(c, OIDC_TX_COOKIE, TX_COOKIE_CLEAR_OPTIONS);
48}
49 
50function loginErrorRedirect(c: Context<AppContext>, code: string): Response {
51 clearTxCookie(c);
52 return c.redirect(`/login?error=${encodeURIComponent(code)}`, 302);
53}
54 
55oidcRouter.get("/oidc/start", rateLimit("RL_AUTH"), async (c) => {
56 const requestUrl = new URL(c.req.url);
57 const origin = requestUrl.origin;
58 if (!isAllowedOrigin(origin, c.env)) {
59 return c.json({ error: "forbidden", message: "Origin not allowed" }, 403);
60 }
61 
62 const returnTo = sanitizeReturnTo(c.req.query("return_to"));
63 const redirectUri = `${origin}/api/v1/oidc/callback`;
64 
65 let config: Configuration;
66 try {
67 config = await getOidcConfig(c.env);
68 } catch (err) {
69 log.error("discovery_failed", oidcErrorContext(err, c.env));
70 return c.redirect("/login?error=oidc_provider_error", 302);
71 }
72 
73 const codeVerifier = oidc.randomPKCECodeVerifier();
74 const codeChallenge = await oidc.calculatePKCECodeChallenge(codeVerifier);
75 const state = oidc.randomState();
76 const nonce = oidc.randomNonce();
77 
78 const payload: TxCookiePayload = {
79 state,
80 nonce,
81 codeVerifier,
82 redirectUri,
83 returnTo,
84 createdAt: Date.now(),
85 };
86 
87 const cookieValue = await encodeTxCookie(c.env, payload);
88 setCookie(c, OIDC_TX_COOKIE, cookieValue, TX_COOKIE_OPTIONS);
89 
90 const authorizationUrl = oidc.buildAuthorizationUrl(config, {
91 redirect_uri: redirectUri,
92 code_challenge: codeChallenge,
93 code_challenge_method: "S256",
94 state,
95 nonce,
96 scope: OIDC_SCOPE,
97 });
98 
99 log.info("oidc_start", { returnTo });
100 return c.redirect(authorizationUrl.toString(), 302);
101});
102 
103oidcRouter.get("/oidc/callback", rateLimit("RL_AUTH"), async (c) => {
104 const requestUrl = new URL(c.req.url);
105 const cookieValue = getCookie(c, OIDC_TX_COOKIE);
106 const payload = await decodeTxCookie(c.env, cookieValue);
107 if (!payload) {
108 return loginErrorRedirect(c, "oidc_session_expired");
109 }
110 
111 const queryState = c.req.query("state");
112 if (!queryState || queryState !== payload.state) {
113 return loginErrorRedirect(c, "oidc_session_expired");
114 }
115 
116 const expectedRedirect = new URL(payload.redirectUri);
117 if (expectedRedirect.host !== requestUrl.host) {
118 return loginErrorRedirect(c, "oidc_session_expired");
119 }
120 
121 let config: Configuration;
122 try {
123 config = await getOidcConfig(c.env);
124 } catch (err) {
125 log.error("discovery_failed", oidcErrorContext(err, c.env));
126 return loginErrorRedirect(c, "oidc_provider_error");
127 }
128 
129 let tokens: { claims(): IDToken | undefined };
130 try {
131 tokens = await exchangeAuthorizationCode(config, requestUrl, {
132 pkceCodeVerifier: payload.codeVerifier,
133 expectedNonce: payload.nonce,
134 expectedState: payload.state,
135 });
136 } catch (err) {
137 log.info("oidc_token_exchange_failed", oidcErrorContext(err, c.env));
138 return loginErrorRedirect(c, "oidc_provider_error");
139 }
140 
141 const claimsResult = validateClaims(tokens.claims());
142 if (!claimsResult.ok) {
143 return loginErrorRedirect(c, claimsResult.code);
144 }
145 
146 const db = c.get("db");
147 const outcome = await bindIdentity(db, claimsResult.claims);
148 if (!outcome.ok) {
149 return loginErrorRedirect(c, outcome.code);
150 }
151 
152 // Browser JS cannot read response bodies/headers from a 302, so the access
153 // token is materialized on the SPA's subsequent /auth/refresh call after
154 // the oidc=1 marker forces a blocking refresh. Only the refresh cookie is
155 // set here.
156 const refreshToken = await createRefreshToken(outcome.userId, c.env);
157 
158 clearTxCookie(c);
159 setRefreshCookie(c, refreshToken);
160 
161 log.info("oidc_callback_success", { userId: outcome.userId, outcome: outcome.kind });
162 return c.redirect(appendOidcMarker(sanitizeReturnTo(payload.returnTo)), 302);
163});
164 
165type BindOutcomeKind = "signed_in" | "email_updated" | "bound_legacy" | "created" | "raced_to_existing";
166 
167type BindOutcome =
168 | { ok: true; kind: BindOutcomeKind; userId: string }
169 | { ok: false; code: "tessera_email_conflict" | "identity_conflict" };
170 
171export async function bindIdentity(db: Db, claims: ResolvedClaims): Promise<BindOutcome> {
172 const nowIso = new Date().toISOString();
173 
174 const existing = await db
175 .select({ id: users.id, email: users.email })
176 .from(tesseraIdentities)
177 .innerJoin(users, eq(tesseraIdentities.user_id, users.id))
178 .where(eq(tesseraIdentities.sub, claims.sub))
179 .get();
180 
181 if (existing) {
182 if (existing.email !== claims.email) {
183 try {
184 await db.update(users).set({ email: claims.email, updated_at: nowIso }).where(eq(users.id, existing.id));
185 } catch (err) {
186 if (isUniqueViolation(err)) {
187 return { ok: false, code: "tessera_email_conflict" };
188 }
189 throw err;
190 }
191 await db.update(tesseraIdentities).set({ last_seen_at: nowIso }).where(eq(tesseraIdentities.sub, claims.sub));
192 return { ok: true, kind: "email_updated", userId: existing.id };
193 }
194 await db.update(tesseraIdentities).set({ last_seen_at: nowIso }).where(eq(tesseraIdentities.sub, claims.sub));
195 return { ok: true, kind: "signed_in", userId: existing.id };
196 }
197 
198 const legacyByEmail = await db.select({ id: users.id }).from(users).where(eq(users.email, claims.email)).get();
199 
200 if (legacyByEmail) {
201 const inserted = await db
202 .insert(tesseraIdentities)
203 .values({ sub: claims.sub, user_id: legacyByEmail.id, last_seen_at: nowIso })
204 .onConflictDoNothing()
205 .returning({ sub: tesseraIdentities.sub });
206 if (inserted.length === 1) {
207 return { ok: true, kind: "bound_legacy", userId: legacyByEmail.id };
208 }
209 const recheck = await findUserIdByTesseraSub(db, claims.sub);
210 if (recheck && recheck === legacyByEmail.id) {
211 return { ok: true, kind: "bound_legacy", userId: legacyByEmail.id };
212 }
213 return { ok: false, code: "identity_conflict" };
214 }
215 
216 const userId = ulid();
217 const workspaceId = ulid();
218 const displayName = claims.name ?? friendlyNameFromEmail(claims.email);
219 const workspaceName = `${displayName}'s workspace`;
220 const workspaceSlug = defaultWorkspaceSlug(displayName, workspaceId);
221 
222 try {
223 await db.batch([
224 db.insert(users).values({
225 id: userId,
226 email: claims.email,
227 name: displayName,
228 }),
229 db.insert(tesseraIdentities).values({
230 sub: claims.sub,
231 user_id: userId,
232 last_seen_at: nowIso,
233 }),
234 db.insert(workspaces).values({
235 id: workspaceId,
236 name: workspaceName,
237 slug: workspaceSlug,
238 owner_id: userId,
239 }),
240 db.insert(memberships).values({
241 user_id: userId,
242 workspace_id: workspaceId,
243 role: "owner",
244 }),
245 ]);
246 return { ok: true, kind: "created", userId };
247 } catch (err) {
248 if (!isUniqueViolation(err)) throw err;
249 const recheck = await findUserIdByTesseraSub(db, claims.sub);
250 if (recheck) {
251 return { ok: true, kind: "raced_to_existing", userId: recheck };
252 }
253 return { ok: false, code: "identity_conflict" };
254 }
255}
256 
257async function findUserIdByTesseraSub(db: Db, sub: string): Promise<string | null> {
258 const row = await db
259 .select({ user_id: tesseraIdentities.user_id })
260 .from(tesseraIdentities)
261 .where(eq(tesseraIdentities.sub, sub))
262 .get();
263 return row?.user_id ?? null;
264}
265 
266function isUniqueViolation(err: unknown): boolean {
267 // DrizzleQueryError wraps the underlying D1 error with a "Failed query: ..."
268 // message and exposes the original on `.cause`. The SQLite text we need
269 // ("UNIQUE constraint failed") lives there.
270 const cause = err instanceof DrizzleQueryError ? err.cause : err;
271 if (!(cause instanceof Error)) return false;
272 return /UNIQUE|SQLITE_CONSTRAINT/i.test(cause.message);
273}
274 
275function friendlyNameFromEmail(email: string): string {
276 const local = email.split("@")[0] ?? "user";
277 const cleaned = local.replace(/[._-]+/g, " ").trim();
278 if (!cleaned) return "User";
279 return cleaned
280 .split(/\s+/)
281 .map((part) => (part.length === 0 ? part : part[0].toUpperCase() + part.slice(1)))
282 .join(" ");
283}
284 
285const SLUG_SUFFIX_LENGTH = 6;
286 
287function defaultWorkspaceSlug(name: string, workspaceId: string): string {
288 const base = name
289 .toLowerCase()
290 .replace(/[^a-z0-9]+/g, "-")
291 .replace(/^-+|-+$/g, "")
292 .slice(0, 32);
293 const safeBase = base.length > 0 ? base : "workspace";
294 const suffix = workspaceId.slice(-SLUG_SUFFIX_LENGTH).toLowerCase();
295 return `${safeBase}-${suffix}`;
296}
297 
298export { oidcRouter };