File
Blob: src/worker/services/oidc.ts
| 1 | import { eg, type TypeFromCodec } from "@cloudflare/util-en-garde"; |
| 2 | import * as oidc from "openid-client"; |
| 3 | import { isLoopbackHostname } from "@/worker/security"; |
| 4 | |
| 5 | const TRANSACTION_COOKIE_PURPOSE = "flamemail-admin-oidc-transaction-cookie-v1"; |
| 6 | const STATE_TTL_MS = 5 * 60 * 1000; |
| 7 | const TRANSACTION_COOKIE_KEY_BITS = 256; |
| 8 | const DISCOVERY_CACHE_TTL_MS = 5 * 60 * 1000; |
| 9 | |
| 10 | export const TransactionPayload = eg.object({ |
| 11 | state: eg.string, |
| 12 | nonce: eg.string, |
| 13 | codeVerifier: eg.string, |
| 14 | redirectUri: eg.string, |
| 15 | createdAt: eg.number, |
| 16 | }); |
| 17 | export type TransactionPayload = TypeFromCodec<typeof TransactionPayload>; |
| 18 | |
| 19 | export interface OidcConfig { |
| 20 | issuer: string; |
| 21 | clientId: string; |
| 22 | clientSecret: string; |
| 23 | operatorSubs: readonly string[]; |
| 24 | } |
| 25 | |
| 26 | export type OidcConfigError = |
| 27 | | "missing_issuer" |
| 28 | | "insecure_issuer" |
| 29 | | "missing_client_id" |
| 30 | | "missing_client_secret" |
| 31 | | "missing_operator_subs"; |
| 32 | |
| 33 | export type OidcConfigResult = { ok: true; config: OidcConfig } | { ok: false; reason: OidcConfigError }; |
| 34 | |
| 35 | function isAllowedUrl(url: URL): boolean { |
| 36 | if (url.protocol === "https:") { |
| 37 | return true; |
| 38 | } |
| 39 | return url.protocol === "http:" && isLoopbackHostname(url.hostname); |
| 40 | } |
| 41 | |
| 42 | function normalizeIssuer(rawIssuer: string): string | null { |
| 43 | let url: URL; |
| 44 | try { |
| 45 | url = new URL(rawIssuer); |
| 46 | } catch { |
| 47 | return null; |
| 48 | } |
| 49 | if (url.search || url.hash || url.username || url.password) { |
| 50 | return null; |
| 51 | } |
| 52 | const pathname = url.pathname.replace(/\/+$/, ""); |
| 53 | return `${url.protocol}//${url.host}${pathname}`; |
| 54 | } |
| 55 | |
| 56 | export function loadOidcConfig(env: Env): OidcConfigResult { |
| 57 | const rawIssuer = env.TESSERA_OIDC_ISSUER?.trim(); |
| 58 | if (!rawIssuer) { |
| 59 | return { ok: false, reason: "missing_issuer" }; |
| 60 | } |
| 61 | const issuer = normalizeIssuer(rawIssuer); |
| 62 | if (!issuer) { |
| 63 | return { ok: false, reason: "insecure_issuer" }; |
| 64 | } |
| 65 | const issuerUrl = new URL(issuer); |
| 66 | // Plaintext HTTP only allowed for loopback (local dev / e2e fake provider). |
| 67 | // Production deployments must use https; otherwise the client secret |
| 68 | // would be POSTed to a plaintext token endpoint and ID tokens would be |
| 69 | // verified against a plaintext JWKS. |
| 70 | if (!isAllowedUrl(issuerUrl)) { |
| 71 | return { ok: false, reason: "insecure_issuer" }; |
| 72 | } |
| 73 | const clientId = env.TESSERA_OIDC_CLIENT_ID?.trim(); |
| 74 | if (!clientId) { |
| 75 | return { ok: false, reason: "missing_client_id" }; |
| 76 | } |
| 77 | const clientSecret = env.TESSERA_OIDC_CLIENT_SECRET?.trim(); |
| 78 | if (!clientSecret) { |
| 79 | return { ok: false, reason: "missing_client_secret" }; |
| 80 | } |
| 81 | const operatorSubs = parseOperatorSubs(env.TESSERA_OPERATOR_SUBS); |
| 82 | if (operatorSubs.length === 0) { |
| 83 | return { ok: false, reason: "missing_operator_subs" }; |
| 84 | } |
| 85 | return { ok: true, config: { issuer, clientId, clientSecret, operatorSubs } }; |
| 86 | } |
| 87 | |
| 88 | export function parseOperatorSubs(raw: string | undefined | null): string[] { |
| 89 | if (!raw) { |
| 90 | return []; |
| 91 | } |
| 92 | return raw |
| 93 | .split(",") |
| 94 | .map((value) => value.trim()) |
| 95 | .filter((value) => value.length > 0); |
| 96 | } |
| 97 | |
| 98 | const textEncoder = new TextEncoder(); |
| 99 | const textDecoder = new TextDecoder(); |
| 100 | |
| 101 | function base64UrlEncode(bytes: Uint8Array | ArrayBuffer): string { |
| 102 | const view = bytes instanceof Uint8Array ? bytes : new Uint8Array(bytes); |
| 103 | let binary = ""; |
| 104 | for (let i = 0; i < view.length; i += 1) { |
| 105 | binary += String.fromCharCode(view[i]!); |
| 106 | } |
| 107 | return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); |
| 108 | } |
| 109 | |
| 110 | function base64UrlDecode(input: string): Uint8Array<ArrayBuffer> { |
| 111 | const padded = input |
| 112 | .replace(/-/g, "+") |
| 113 | .replace(/_/g, "/") |
| 114 | .padEnd(input.length + ((4 - (input.length % 4)) % 4), "="); |
| 115 | const binary = atob(padded); |
| 116 | const bytes = new Uint8Array(new ArrayBuffer(binary.length)); |
| 117 | for (let i = 0; i < binary.length; i += 1) { |
| 118 | bytes[i] = binary.charCodeAt(i); |
| 119 | } |
| 120 | return bytes; |
| 121 | } |
| 122 | |
| 123 | export interface PkcePair { |
| 124 | verifier: string; |
| 125 | challenge: string; |
| 126 | } |
| 127 | |
| 128 | export async function generatePkcePair(): Promise<PkcePair> { |
| 129 | const verifier = oidc.randomPKCECodeVerifier(); |
| 130 | const challenge = await oidc.calculatePKCECodeChallenge(verifier); |
| 131 | return { verifier, challenge }; |
| 132 | } |
| 133 | |
| 134 | export function encodeTransactionPayload(payload: TransactionPayload): string { |
| 135 | return base64UrlEncode(textEncoder.encode(JSON.stringify(payload))); |
| 136 | } |
| 137 | |
| 138 | export async function deriveTransactionCookieSecret(clientSecret: string): Promise<Uint8Array<ArrayBuffer>> { |
| 139 | const baseKey = await crypto.subtle.importKey("raw", textEncoder.encode(clientSecret), { name: "HKDF" }, false, [ |
| 140 | "deriveBits", |
| 141 | ]); |
| 142 | const derived = await crypto.subtle.deriveBits( |
| 143 | { |
| 144 | name: "HKDF", |
| 145 | hash: "SHA-256", |
| 146 | salt: new Uint8Array(0), |
| 147 | info: textEncoder.encode(TRANSACTION_COOKIE_PURPOSE), |
| 148 | }, |
| 149 | baseKey, |
| 150 | TRANSACTION_COOKIE_KEY_BITS, |
| 151 | ); |
| 152 | return new Uint8Array(derived); |
| 153 | } |
| 154 | |
| 155 | export type DecodeTransactionPayloadError = "malformed" | "invalid_payload" | "expired"; |
| 156 | |
| 157 | export type DecodeTransactionPayloadResult = |
| 158 | | { ok: true; payload: TransactionPayload } |
| 159 | | { ok: false; reason: DecodeTransactionPayloadError }; |
| 160 | |
| 161 | export function decodeTransactionPayload(encoded: string, now: number = Date.now()): DecodeTransactionPayloadResult { |
| 162 | let raw: Uint8Array<ArrayBuffer>; |
| 163 | try { |
| 164 | raw = base64UrlDecode(encoded); |
| 165 | } catch { |
| 166 | return { ok: false, reason: "malformed" }; |
| 167 | } |
| 168 | let parsed: unknown; |
| 169 | try { |
| 170 | parsed = JSON.parse(textDecoder.decode(raw)); |
| 171 | } catch { |
| 172 | return { ok: false, reason: "invalid_payload" }; |
| 173 | } |
| 174 | let payload: TransactionPayload; |
| 175 | try { |
| 176 | payload = TransactionPayload.assertDecode(parsed); |
| 177 | } catch { |
| 178 | return { ok: false, reason: "invalid_payload" }; |
| 179 | } |
| 180 | if (now - payload.createdAt > STATE_TTL_MS) { |
| 181 | return { ok: false, reason: "expired" }; |
| 182 | } |
| 183 | return { ok: true, payload }; |
| 184 | } |
| 185 | |
| 186 | export interface OidcProvider { |
| 187 | configuration: oidc.Configuration; |
| 188 | } |
| 189 | |
| 190 | export type OidcDiscoveryError = "discovery_failed" | "invalid_endpoint"; |
| 191 | |
| 192 | export type OidcDiscoveryResult = { ok: true; provider: OidcProvider } | { ok: false; reason: OidcDiscoveryError }; |
| 193 | |
| 194 | interface CachedOidcProvider { |
| 195 | provider: OidcProvider; |
| 196 | clientSecret: string; |
| 197 | expiresAt: number; |
| 198 | } |
| 199 | |
| 200 | const oidcProviderByIssuerAndClient = new Map<string, CachedOidcProvider>(); |
| 201 | |
| 202 | function isAllowedDiscoveredEndpoint(rawUrl: string | undefined): boolean { |
| 203 | if (!rawUrl) { |
| 204 | return false; |
| 205 | } |
| 206 | let url: URL; |
| 207 | try { |
| 208 | url = new URL(rawUrl); |
| 209 | } catch { |
| 210 | return false; |
| 211 | } |
| 212 | if (url.hash) { |
| 213 | return false; |
| 214 | } |
| 215 | return isAllowedUrl(url); |
| 216 | } |
| 217 | |
| 218 | function cacheKey(config: OidcConfig): string { |
| 219 | return `${config.issuer}\0${config.clientId}`; |
| 220 | } |
| 221 | |
| 222 | function discoveryOptions(config: OidcConfig): oidc.DiscoveryRequestOptions | undefined { |
| 223 | const issuerUrl = new URL(config.issuer); |
| 224 | if (issuerUrl.protocol !== "http:") { |
| 225 | return undefined; |
| 226 | } |
| 227 | return { execute: [oidc.allowInsecureRequests] }; |
| 228 | } |
| 229 | |
| 230 | function hasRequiredSecureEndpoints(configuration: oidc.Configuration): boolean { |
| 231 | const metadata = configuration.serverMetadata(); |
| 232 | return ( |
| 233 | isAllowedDiscoveredEndpoint(metadata.authorization_endpoint) && |
| 234 | isAllowedDiscoveredEndpoint(metadata.token_endpoint) && |
| 235 | isAllowedDiscoveredEndpoint(metadata.jwks_uri) |
| 236 | ); |
| 237 | } |
| 238 | |
| 239 | export async function discoverOidcProvider(config: OidcConfig, now: number = Date.now()): Promise<OidcDiscoveryResult> { |
| 240 | const key = cacheKey(config); |
| 241 | const cached = oidcProviderByIssuerAndClient.get(key); |
| 242 | if (cached && cached.clientSecret === config.clientSecret && cached.expiresAt > now) { |
| 243 | return { ok: true, provider: cached.provider }; |
| 244 | } |
| 245 | if (cached) { |
| 246 | oidcProviderByIssuerAndClient.delete(key); |
| 247 | } |
| 248 | |
| 249 | let configuration: oidc.Configuration; |
| 250 | try { |
| 251 | configuration = await oidc.discovery( |
| 252 | new URL(config.issuer), |
| 253 | config.clientId, |
| 254 | undefined, |
| 255 | oidc.ClientSecretPost(config.clientSecret), |
| 256 | discoveryOptions(config), |
| 257 | ); |
| 258 | } catch { |
| 259 | return { ok: false, reason: "discovery_failed" }; |
| 260 | } |
| 261 | |
| 262 | if (!hasRequiredSecureEndpoints(configuration)) { |
| 263 | return { ok: false, reason: "invalid_endpoint" }; |
| 264 | } |
| 265 | |
| 266 | const provider = { configuration }; |
| 267 | oidcProviderByIssuerAndClient.set(key, { |
| 268 | provider, |
| 269 | clientSecret: config.clientSecret, |
| 270 | expiresAt: now + DISCOVERY_CACHE_TTL_MS, |
| 271 | }); |
| 272 | return { ok: true, provider }; |
| 273 | } |
| 274 | |
| 275 | export interface TokenExchangeResponse { |
| 276 | claims: oidc.IDToken; |
| 277 | } |
| 278 | |
| 279 | export type ExchangeError = "token_exchange_failed" | "invalid_id_token"; |
| 280 | |
| 281 | export type ExchangeResult = { ok: true; tokens: TokenExchangeResponse } | { ok: false; reason: ExchangeError }; |
| 282 | |
| 283 | export async function exchangeAuthorizationCode( |
| 284 | provider: OidcProvider, |
| 285 | options: { callbackUrl: string; codeVerifier: string; state: string; nonce: string }, |
| 286 | ): Promise<ExchangeResult> { |
| 287 | let tokens: oidc.TokenEndpointResponse & oidc.TokenEndpointResponseHelpers; |
| 288 | try { |
| 289 | tokens = await oidc.authorizationCodeGrant(provider.configuration, new URL(options.callbackUrl), { |
| 290 | expectedNonce: options.nonce, |
| 291 | expectedState: options.state, |
| 292 | pkceCodeVerifier: options.codeVerifier, |
| 293 | }); |
| 294 | } catch (error) { |
| 295 | if (error instanceof oidc.ClientError) { |
| 296 | return { ok: false, reason: "invalid_id_token" }; |
| 297 | } |
| 298 | return { ok: false, reason: "token_exchange_failed" }; |
| 299 | } |
| 300 | |
| 301 | const claims = tokens.claims(); |
| 302 | if (!claims) { |
| 303 | return { ok: false, reason: "invalid_id_token" }; |
| 304 | } |
| 305 | return { ok: true, tokens: { claims } }; |
| 306 | } |
| 307 | |
| 308 | export interface VerifiedIdToken { |
| 309 | sub: string; |
| 310 | } |
| 311 | |
| 312 | export type VerifyError = "missing_sub" | "sub_not_allowed"; |
| 313 | |
| 314 | export type VerifyResult = { ok: true; verified: VerifiedIdToken } | { ok: false; reason: VerifyError }; |
| 315 | |
| 316 | export function verifyIdTokenClaims(config: OidcConfig, claims: oidc.IDToken): VerifyResult { |
| 317 | if (typeof claims.sub !== "string" || claims.sub.length === 0) { |
| 318 | return { ok: false, reason: "missing_sub" }; |
| 319 | } |
| 320 | if (!config.operatorSubs.includes(claims.sub)) { |
| 321 | return { ok: false, reason: "sub_not_allowed" }; |
| 322 | } |
| 323 | return { ok: true, verified: { sub: claims.sub } }; |
| 324 | } |
| 325 | |
| 326 | export function buildAuthorizeUrl( |
| 327 | provider: OidcProvider, |
| 328 | options: { redirectUri: string; state: string; nonce: string; codeChallenge: string }, |
| 329 | ): string { |
| 330 | return oidc |
| 331 | .buildAuthorizationUrl(provider.configuration, { |
| 332 | code_challenge: options.codeChallenge, |
| 333 | code_challenge_method: "S256", |
| 334 | nonce: options.nonce, |
| 335 | redirect_uri: options.redirectUri, |
| 336 | scope: "openid profile email", |
| 337 | state: options.state, |
| 338 | }) |
| 339 | .toString(); |
| 340 | } |
| 341 | |
| 342 | export function buildCallbackUrl(requestUrl: string): string { |
| 343 | return `${new URL(requestUrl).origin}/api/public/admin/callback`; |
| 344 | } |
| 345 | |
| 346 | export function generateState(): string { |
| 347 | return oidc.randomState(); |
| 348 | } |
| 349 | |
| 350 | export function generateNonce(): string { |
| 351 | return oidc.randomNonce(); |
| 352 | } |
| 353 | |
| 354 | export function clearOidcCachesForTesting() { |
| 355 | oidcProviderByIssuerAndClient.clear(); |
| 356 | } |