File
Blob: src/worker/routes/oidc.ts
| 1 | import { Hono, type Context } from "hono"; |
| 2 | import { deleteCookie, getCookie, setCookie } from "hono/cookie"; |
| 3 | import { eq } from "drizzle-orm"; |
| 4 | import { DrizzleQueryError } from "drizzle-orm/errors"; |
| 5 | import { ulid } from "ulid"; |
| 6 | import * as oidc from "openid-client"; |
| 7 | import type { Configuration, IDToken } from "openid-client"; |
| 8 | |
| 9 | import type { AppContext } from "@/worker/app-context"; |
| 10 | import type { Db } from "@/worker/db/d1/client"; |
| 11 | import { memberships, tesseraIdentities, users, workspaces } from "@/worker/db/d1/schema"; |
| 12 | import { createRefreshToken, setRefreshCookie } from "@/worker/lib/auth"; |
| 13 | import { rateLimit } from "@/worker/middleware/rate-limit"; |
| 14 | import { isAllowedOrigin } from "@/worker/lib/origins"; |
| 15 | import { createLogger } from "@/worker/lib/logger"; |
| 16 | import { |
| 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 | |
| 32 | const log = createLogger("oidc"); |
| 33 | |
| 34 | const oidcRouter = new Hono<AppContext>(); |
| 35 | |
| 36 | const 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 | |
| 44 | const TX_COOKIE_CLEAR_OPTIONS = { path: "/" as const, secure: true }; |
| 45 | |
| 46 | function clearTxCookie(c: Context<AppContext>): void { |
| 47 | deleteCookie(c, OIDC_TX_COOKIE, TX_COOKIE_CLEAR_OPTIONS); |
| 48 | } |
| 49 | |
| 50 | function loginErrorRedirect(c: Context<AppContext>, code: string): Response { |
| 51 | clearTxCookie(c); |
| 52 | return c.redirect(`/login?error=${encodeURIComponent(code)}`, 302); |
| 53 | } |
| 54 | |
| 55 | oidcRouter.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 | |
| 103 | oidcRouter.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 | |
| 165 | type BindOutcomeKind = "signed_in" | "email_updated" | "bound_legacy" | "created" | "raced_to_existing"; |
| 166 | |
| 167 | type BindOutcome = |
| 168 | | { ok: true; kind: BindOutcomeKind; userId: string } |
| 169 | | { ok: false; code: "tessera_email_conflict" | "identity_conflict" }; |
| 170 | |
| 171 | export 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 | |
| 257 | async 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 | |
| 266 | function 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 | |
| 275 | function 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 | |
| 285 | const SLUG_SUFFIX_LENGTH = 6; |
| 286 | |
| 287 | function 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 | |
| 298 | export { oidcRouter }; |