File
Blob: src/worker/auth/oidc.ts
| 1 | import * as oidc from "openid-client"; |
| 2 | import { z } from "zod"; |
| 3 | |
| 4 | // tessera relying-party module. Mirrors flamemail's OIDC pattern: |
| 5 | // - openid-client v6 owns discovery, PKCE, authorize URL, token exchange |
| 6 | // - Loopback HTTP issuer is allowed only for local dev (tessera-dev runs on |
| 7 | // http://localhost:5174). Production deployments use https://auth.limic.dev. |
| 8 | // - The OIDC transaction (state, nonce, PKCE verifier, redirect) is encoded |
| 9 | // into a Hono signed `__Host-goc_oidc` cookie using |
| 10 | // a purpose-derived key from TESSERA_OIDC_CLIENT_SECRET, so we do not |
| 11 | // need a separate cookie-signing secret. The payload is |
| 12 | // integrity-protected but not encrypted; it is short-lived, HttpOnly, |
| 13 | // Secure, and only used to complete the same browser's authorization-code |
| 14 | // callback. |
| 15 | // - A small `__test` namespace lets vitest swap the discovery cache and the |
| 16 | // token-exchange call without spinning a real OIDC provider. |
| 17 | |
| 18 | const STATE_TTL_MS = 5 * 60 * 1000; |
| 19 | const DISCOVERY_CACHE_TTL_MS = 5 * 60 * 1000; |
| 20 | const TRANSACTION_COOKIE_PURPOSE = "goc-oidc-transaction-cookie-v1"; |
| 21 | const TRANSACTION_COOKIE_KEY_BITS = 256; |
| 22 | |
| 23 | const TransactionPayloadSchema = z.object({ |
| 24 | state: z.string(), |
| 25 | nonce: z.string(), |
| 26 | codeVerifier: z.string(), |
| 27 | redirectUri: z.string(), |
| 28 | createdAt: z.number(), |
| 29 | }); |
| 30 | |
| 31 | export type TransactionPayload = z.infer<typeof TransactionPayloadSchema>; |
| 32 | |
| 33 | export interface OidcConfig { |
| 34 | issuer: string; |
| 35 | clientId: string; |
| 36 | clientSecret: string; |
| 37 | } |
| 38 | |
| 39 | export type OidcConfigError = |
| 40 | | "missing_issuer" |
| 41 | | "insecure_issuer" |
| 42 | | "missing_client_id" |
| 43 | | "missing_client_secret"; |
| 44 | |
| 45 | export type OidcConfigResult = |
| 46 | | { ok: true; config: OidcConfig } |
| 47 | | { ok: false; reason: OidcConfigError }; |
| 48 | |
| 49 | // `URL#hostname` for `http://[::1]:5174` returns `[::1]` (with brackets), so |
| 50 | // we accept both forms. Lowercased for case-insensitive comparison. |
| 51 | const LOOPBACK_HOSTNAMES: ReadonlySet<string> = new Set(["localhost", "127.0.0.1", "::1", "[::1]"]); |
| 52 | |
| 53 | function isLoopbackHostname(hostname: string): boolean { |
| 54 | return LOOPBACK_HOSTNAMES.has(hostname.toLowerCase()); |
| 55 | } |
| 56 | |
| 57 | function isAllowedUrl(url: URL): boolean { |
| 58 | if (url.protocol === "https:") return true; |
| 59 | return url.protocol === "http:" && isLoopbackHostname(url.hostname); |
| 60 | } |
| 61 | |
| 62 | function normalizeIssuer(rawIssuer: string): string | null { |
| 63 | let url: URL; |
| 64 | try { |
| 65 | url = new URL(rawIssuer); |
| 66 | } catch { |
| 67 | return null; |
| 68 | } |
| 69 | if (url.search || url.hash || url.username || url.password) return null; |
| 70 | const pathname = url.pathname.replace(/\/+$/, ""); |
| 71 | return `${url.protocol}//${url.host}${pathname}`; |
| 72 | } |
| 73 | |
| 74 | export function loadOidcConfig(env: Env): OidcConfigResult { |
| 75 | const rawIssuer = env.TESSERA_OIDC_ISSUER?.trim(); |
| 76 | if (!rawIssuer) return { ok: false, reason: "missing_issuer" }; |
| 77 | const issuer = normalizeIssuer(rawIssuer); |
| 78 | if (!issuer) return { ok: false, reason: "insecure_issuer" }; |
| 79 | if (!isAllowedUrl(new URL(issuer))) return { ok: false, reason: "insecure_issuer" }; |
| 80 | const clientId = env.TESSERA_OIDC_CLIENT_ID?.trim(); |
| 81 | if (!clientId) return { ok: false, reason: "missing_client_id" }; |
| 82 | const clientSecret = env.TESSERA_OIDC_CLIENT_SECRET?.trim(); |
| 83 | if (!clientSecret) return { ok: false, reason: "missing_client_secret" }; |
| 84 | return { ok: true, config: { issuer, clientId, clientSecret } }; |
| 85 | } |
| 86 | |
| 87 | const textEncoder = new TextEncoder(); |
| 88 | const textDecoder = new TextDecoder(); |
| 89 | |
| 90 | function base64UrlEncode(bytes: Uint8Array | ArrayBuffer): string { |
| 91 | const view = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes); |
| 92 | let binary = ""; |
| 93 | for (let i = 0; i < view.length; i += 1) { |
| 94 | binary += String.fromCharCode(view[i]!); |
| 95 | } |
| 96 | return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); |
| 97 | } |
| 98 | |
| 99 | function base64UrlDecode(input: string): Uint8Array<ArrayBuffer> { |
| 100 | const padded = input |
| 101 | .replace(/-/g, "+") |
| 102 | .replace(/_/g, "/") |
| 103 | .padEnd(input.length + ((4 - (input.length % 4)) % 4), "="); |
| 104 | const binary = atob(padded); |
| 105 | const bytes = new Uint8Array(new ArrayBuffer(binary.length)); |
| 106 | for (let i = 0; i < binary.length; i += 1) { |
| 107 | bytes[i] = binary.charCodeAt(i); |
| 108 | } |
| 109 | return bytes; |
| 110 | } |
| 111 | |
| 112 | export interface PkcePair { |
| 113 | verifier: string; |
| 114 | challenge: string; |
| 115 | } |
| 116 | |
| 117 | export async function generatePkcePair(): Promise<PkcePair> { |
| 118 | const verifier = oidc.randomPKCECodeVerifier(); |
| 119 | const challenge = await oidc.calculatePKCECodeChallenge(verifier); |
| 120 | return { verifier, challenge }; |
| 121 | } |
| 122 | |
| 123 | export function encodeTransactionPayload(payload: TransactionPayload): string { |
| 124 | return base64UrlEncode(textEncoder.encode(JSON.stringify(payload))); |
| 125 | } |
| 126 | |
| 127 | export async function deriveTransactionCookieSecret( |
| 128 | clientSecret: string |
| 129 | ): Promise<Uint8Array<ArrayBuffer>> { |
| 130 | const baseKey = await crypto.subtle.importKey( |
| 131 | "raw", |
| 132 | textEncoder.encode(clientSecret), |
| 133 | { name: "HKDF" }, |
| 134 | false, |
| 135 | ["deriveBits"] |
| 136 | ); |
| 137 | const derived = await crypto.subtle.deriveBits( |
| 138 | { |
| 139 | name: "HKDF", |
| 140 | hash: "SHA-256", |
| 141 | salt: new Uint8Array(0), |
| 142 | info: textEncoder.encode(TRANSACTION_COOKIE_PURPOSE), |
| 143 | }, |
| 144 | baseKey, |
| 145 | TRANSACTION_COOKIE_KEY_BITS |
| 146 | ); |
| 147 | return new Uint8Array(derived); |
| 148 | } |
| 149 | |
| 150 | export type DecodeTransactionPayloadError = "malformed" | "invalid_payload" | "expired"; |
| 151 | |
| 152 | export type DecodeTransactionPayloadResult = |
| 153 | | { ok: true; payload: TransactionPayload } |
| 154 | | { ok: false; reason: DecodeTransactionPayloadError }; |
| 155 | |
| 156 | export function decodeTransactionPayload( |
| 157 | encoded: string, |
| 158 | now: number = Date.now() |
| 159 | ): DecodeTransactionPayloadResult { |
| 160 | let raw: Uint8Array<ArrayBuffer>; |
| 161 | try { |
| 162 | raw = base64UrlDecode(encoded); |
| 163 | } catch { |
| 164 | return { ok: false, reason: "malformed" }; |
| 165 | } |
| 166 | let parsed: unknown; |
| 167 | try { |
| 168 | parsed = JSON.parse(textDecoder.decode(raw)); |
| 169 | } catch { |
| 170 | return { ok: false, reason: "invalid_payload" }; |
| 171 | } |
| 172 | const payload = TransactionPayloadSchema.safeParse(parsed); |
| 173 | if (!payload.success) return { ok: false, reason: "invalid_payload" }; |
| 174 | if (now - payload.data.createdAt > STATE_TTL_MS) return { ok: false, reason: "expired" }; |
| 175 | return { ok: true, payload: payload.data }; |
| 176 | } |
| 177 | |
| 178 | export interface OidcProvider { |
| 179 | configuration: oidc.Configuration; |
| 180 | } |
| 181 | |
| 182 | export type OidcDiscoveryError = "discovery_failed" | "invalid_endpoint"; |
| 183 | |
| 184 | export type OidcDiscoveryResult = |
| 185 | | { ok: true; provider: OidcProvider } |
| 186 | | { ok: false; reason: OidcDiscoveryError }; |
| 187 | |
| 188 | interface CachedOidcProvider { |
| 189 | provider: OidcProvider; |
| 190 | clientSecret: string; |
| 191 | expiresAt: number; |
| 192 | } |
| 193 | |
| 194 | const oidcProviderByIssuerAndClient = new Map<string, CachedOidcProvider>(); |
| 195 | |
| 196 | function isAllowedDiscoveredEndpoint(rawUrl: string | undefined): boolean { |
| 197 | if (!rawUrl) return false; |
| 198 | let url: URL; |
| 199 | try { |
| 200 | url = new URL(rawUrl); |
| 201 | } catch { |
| 202 | return false; |
| 203 | } |
| 204 | if (url.hash) return false; |
| 205 | return isAllowedUrl(url); |
| 206 | } |
| 207 | |
| 208 | function cacheKey(config: OidcConfig): string { |
| 209 | return `${config.issuer}|${config.clientId}`; |
| 210 | } |
| 211 | |
| 212 | function discoveryOptions(config: OidcConfig): oidc.DiscoveryRequestOptions | undefined { |
| 213 | const issuerUrl = new URL(config.issuer); |
| 214 | if (issuerUrl.protocol !== "http:") return undefined; |
| 215 | // openid-client requires this opt-in for plaintext issuers, used only for |
| 216 | // loopback dev environments. The issuer policy above already gated us to |
| 217 | // `isAllowedUrl`, so this is not an additional security boundary. |
| 218 | return { execute: [oidc.allowInsecureRequests] }; |
| 219 | } |
| 220 | |
| 221 | function hasRequiredSecureEndpoints(configuration: oidc.Configuration): boolean { |
| 222 | const metadata = configuration.serverMetadata(); |
| 223 | return ( |
| 224 | isAllowedDiscoveredEndpoint(metadata.authorization_endpoint) && |
| 225 | isAllowedDiscoveredEndpoint(metadata.token_endpoint) && |
| 226 | isAllowedDiscoveredEndpoint(metadata.jwks_uri) |
| 227 | ); |
| 228 | } |
| 229 | |
| 230 | export async function discoverOidcProvider( |
| 231 | config: OidcConfig, |
| 232 | now: number = Date.now() |
| 233 | ): Promise<OidcDiscoveryResult> { |
| 234 | const key = cacheKey(config); |
| 235 | const cached = oidcProviderByIssuerAndClient.get(key); |
| 236 | if (cached && cached.clientSecret === config.clientSecret && cached.expiresAt > now) { |
| 237 | return { ok: true, provider: cached.provider }; |
| 238 | } |
| 239 | if (cached) oidcProviderByIssuerAndClient.delete(key); |
| 240 | |
| 241 | let configuration: oidc.Configuration; |
| 242 | try { |
| 243 | configuration = await oidc.discovery( |
| 244 | new URL(config.issuer), |
| 245 | config.clientId, |
| 246 | undefined, |
| 247 | oidc.ClientSecretPost(config.clientSecret), |
| 248 | discoveryOptions(config) |
| 249 | ); |
| 250 | } catch { |
| 251 | return { ok: false, reason: "discovery_failed" }; |
| 252 | } |
| 253 | if (!hasRequiredSecureEndpoints(configuration)) { |
| 254 | return { ok: false, reason: "invalid_endpoint" }; |
| 255 | } |
| 256 | const provider: OidcProvider = { configuration }; |
| 257 | oidcProviderByIssuerAndClient.set(key, { |
| 258 | provider, |
| 259 | clientSecret: config.clientSecret, |
| 260 | expiresAt: now + DISCOVERY_CACHE_TTL_MS, |
| 261 | }); |
| 262 | return { ok: true, provider }; |
| 263 | } |
| 264 | |
| 265 | export interface TokenExchangeResponse { |
| 266 | claims: oidc.IDToken; |
| 267 | } |
| 268 | |
| 269 | export type ExchangeError = "token_exchange_failed" | "invalid_id_token"; |
| 270 | |
| 271 | export type ExchangeResult = |
| 272 | | { ok: true; tokens: TokenExchangeResponse } |
| 273 | | { ok: false; reason: ExchangeError }; |
| 274 | |
| 275 | // Indirected so vitest can swap the network call without subclassing |
| 276 | // `oidc.Configuration` (which is a closed shape in openid-client v6). |
| 277 | type GrantImpl = typeof oidc.authorizationCodeGrant; |
| 278 | let authorizationCodeGrantImpl: GrantImpl = oidc.authorizationCodeGrant; |
| 279 | |
| 280 | export async function exchangeAuthorizationCode( |
| 281 | provider: OidcProvider, |
| 282 | options: { callbackUrl: string; codeVerifier: string; state: string; nonce: string } |
| 283 | ): Promise<ExchangeResult> { |
| 284 | let tokens: oidc.TokenEndpointResponse & oidc.TokenEndpointResponseHelpers; |
| 285 | try { |
| 286 | tokens = await authorizationCodeGrantImpl( |
| 287 | provider.configuration, |
| 288 | new URL(options.callbackUrl), |
| 289 | { |
| 290 | expectedNonce: options.nonce, |
| 291 | expectedState: options.state, |
| 292 | pkceCodeVerifier: options.codeVerifier, |
| 293 | } |
| 294 | ); |
| 295 | } catch (error) { |
| 296 | if (error instanceof oidc.ClientError) { |
| 297 | return { ok: false, reason: "invalid_id_token" }; |
| 298 | } |
| 299 | return { ok: false, reason: "token_exchange_failed" }; |
| 300 | } |
| 301 | const claims = tokens.claims(); |
| 302 | if (!claims) return { ok: false, reason: "invalid_id_token" }; |
| 303 | return { ok: true, tokens: { claims } }; |
| 304 | } |
| 305 | |
| 306 | export interface VerifiedIdToken { |
| 307 | sub: string; |
| 308 | preferredUsername?: string; |
| 309 | } |
| 310 | |
| 311 | export type VerifyError = "missing_sub"; |
| 312 | |
| 313 | export type VerifyResult = |
| 314 | | { ok: true; verified: VerifiedIdToken } |
| 315 | | { ok: false; reason: VerifyError }; |
| 316 | |
| 317 | // goc trusts any verified `sub` (no operator allowlist; this is a multi-user |
| 318 | // surface, not an admin tool). `preferred_username` is captured here as a |
| 319 | // candidate slug; ownership is established through the namespace claim, not |
| 320 | // through the claim value. |
| 321 | export function verifyIdTokenClaims(claims: oidc.IDToken): VerifyResult { |
| 322 | if (typeof claims.sub !== "string" || claims.sub.length === 0) { |
| 323 | return { ok: false, reason: "missing_sub" }; |
| 324 | } |
| 325 | const preferredUsername = |
| 326 | typeof claims.preferred_username === "string" ? claims.preferred_username : undefined; |
| 327 | return { ok: true, verified: { sub: claims.sub, preferredUsername } }; |
| 328 | } |
| 329 | |
| 330 | export function buildAuthorizeUrl( |
| 331 | provider: OidcProvider, |
| 332 | options: { redirectUri: string; state: string; nonce: string; codeChallenge: string } |
| 333 | ): string { |
| 334 | return oidc |
| 335 | .buildAuthorizationUrl(provider.configuration, { |
| 336 | code_challenge: options.codeChallenge, |
| 337 | code_challenge_method: "S256", |
| 338 | nonce: options.nonce, |
| 339 | redirect_uri: options.redirectUri, |
| 340 | scope: "openid profile email", |
| 341 | state: options.state, |
| 342 | }) |
| 343 | .toString(); |
| 344 | } |
| 345 | |
| 346 | export function buildCallbackUrl(requestUrl: string): string { |
| 347 | return `${new URL(requestUrl).origin}/auth/callback`; |
| 348 | } |
| 349 | |
| 350 | export function generateState(): string { |
| 351 | return oidc.randomState(); |
| 352 | } |
| 353 | |
| 354 | export function generateNonce(): string { |
| 355 | return oidc.randomNonce(); |
| 356 | } |
| 357 | |
| 358 | // Test-only seam. Production code never imports `__test`. Tests use it to |
| 359 | // preload a fake `Configuration` into the discovery cache and to swap the |
| 360 | // `authorizationCodeGrant` call so they can exercise the callback handler |
| 361 | // without standing up a live OIDC provider. |
| 362 | export const __test = { |
| 363 | setAuthorizationCodeGrantImpl(impl: GrantImpl | null): void { |
| 364 | authorizationCodeGrantImpl = impl ?? oidc.authorizationCodeGrant; |
| 365 | }, |
| 366 | setProviderForTesting(config: OidcConfig, provider: OidcProvider): void { |
| 367 | oidcProviderByIssuerAndClient.set(cacheKey(config), { |
| 368 | provider, |
| 369 | clientSecret: config.clientSecret, |
| 370 | expiresAt: Number.MAX_SAFE_INTEGER, |
| 371 | }); |
| 372 | }, |
| 373 | clearProviderCache(): void { |
| 374 | oidcProviderByIssuerAndClient.clear(); |
| 375 | }, |
| 376 | }; |