Skip to content
File

Blob: src/worker/lib/oidc.ts

typescript380 lines
1import * as oidc from "openid-client";
2import type { AuthorizationCodeGrantChecks, Configuration, DiscoveryRequestOptions, IDToken } from "openid-client";
3import { OIDC_RETURN_MARKER, OIDC_TX_COOKIE } from "@/shared/auth";
4import { base64UrlDecode, base64UrlEncode } from "@/lib/encoding";
5 
6const DISCOVERY_TTL_MS = 5 * 60 * 1000;
7const HKDF_PURPOSE = "bland-oidc-transaction-v1";
8const HKDF_INFO = new TextEncoder().encode(HKDF_PURPOSE);
9 
10export const OIDC_TX_COOKIE_MAX_AGE = 300;
11export const OIDC_SCOPE = "openid email profile";
12 
13export { OIDC_TX_COOKIE, OIDC_RETURN_MARKER };
14 
15const LOOPBACK_HOSTNAMES = new Set(["localhost", "127.0.0.1", "::1"]);
16 
17export interface OidcConfigEnv {
18 TESSERA_OIDC_ISSUER?: string;
19 TESSERA_OIDC_CLIENT_ID?: string;
20 TESSERA_OIDC_CLIENT_SECRET?: string;
21}
22 
23export interface OidcSettings {
24 issuer: string;
25 clientId: string;
26 clientSecret: string;
27}
28 
29export interface TxCookiePayload {
30 state: string;
31 nonce: string;
32 codeVerifier: string;
33 redirectUri: string;
34 returnTo: string;
35 createdAt: number;
36}
37 
38export interface ResolvedClaims {
39 sub: string;
40 email: string;
41 email_verified: boolean;
42 name?: string;
43}
44 
45export type ClaimValidation = { ok: true; claims: ResolvedClaims } | { ok: false; code: ClaimRejectionCode };
46 
47export type ClaimRejectionCode = "oidc_unverified_email";
48 
49interface DiscoveryCacheEntry {
50 promise: Promise<Configuration>;
51 expiresAt: number;
52}
53 
54const discoveryCache = new Map<string, DiscoveryCacheEntry>();
55 
56type AuthorizationCodeGrantImpl = (
57 config: Configuration,
58 url: URL,
59 checks: AuthorizationCodeGrantChecks,
60) => Promise<{ claims(): IDToken | undefined }>;
61 
62let testConfigOverride: { issuer: string; config: Configuration } | null = null;
63let testAuthorizationCodeGrantImpl: AuthorizationCodeGrantImpl | null = null;
64let testHkdfKeyOverride: CryptoKey | null = null;
65 
66export function isLoopbackHostname(hostname: string): boolean {
67 return LOOPBACK_HOSTNAMES.has(hostname);
68}
69 
70export function validateIssuerUrl(rawIssuer: string | undefined): URL {
71 if (!rawIssuer) {
72 throw new Error("TESSERA_OIDC_ISSUER is required");
73 }
74 const trimmed = rawIssuer.trim().replace(/\/$/, "");
75 let url: URL;
76 try {
77 url = new URL(trimmed);
78 } catch {
79 throw new Error(`TESSERA_OIDC_ISSUER is not a valid URL: ${rawIssuer}`);
80 }
81 if (url.protocol !== "http:" && url.protocol !== "https:") {
82 throw new Error(`TESSERA_OIDC_ISSUER must use http(s): ${rawIssuer}`);
83 }
84 if (url.protocol === "http:" && !isLoopbackHostname(url.hostname)) {
85 throw new Error(`TESSERA_OIDC_ISSUER must use https unless loopback: ${rawIssuer}`);
86 }
87 return url;
88}
89 
90export function loadOidcSettings(env: OidcConfigEnv): OidcSettings {
91 const issuerUrl = validateIssuerUrl(env.TESSERA_OIDC_ISSUER);
92 const clientId = env.TESSERA_OIDC_CLIENT_ID?.trim();
93 const clientSecret = env.TESSERA_OIDC_CLIENT_SECRET?.trim();
94 if (!clientId) throw new Error("TESSERA_OIDC_CLIENT_ID is required");
95 if (!clientSecret) throw new Error("TESSERA_OIDC_CLIENT_SECRET is required");
96 return { issuer: issuerUrl.toString(), clientId, clientSecret };
97}
98 
99function validateDiscoveredEndpoint(name: string, raw: string | undefined, issuerUrl: URL): void {
100 if (!raw) {
101 throw new Error(`OIDC discovery returned no ${name}`);
102 }
103 let url: URL;
104 try {
105 url = new URL(raw);
106 } catch {
107 throw new Error(`OIDC ${name} is not a valid URL: ${raw}`);
108 }
109 if (url.protocol !== "http:" && url.protocol !== "https:") {
110 throw new Error(`OIDC ${name} must use http(s): ${raw}`);
111 }
112 if (url.protocol === "http:" && !isLoopbackHostname(url.hostname)) {
113 throw new Error(`OIDC ${name} must use https unless loopback: ${raw}`);
114 }
115 if (url.hostname !== issuerUrl.hostname) {
116 throw new Error(`OIDC ${name} host ${url.hostname} does not match issuer host ${issuerUrl.hostname}`);
117 }
118}
119 
120export async function getOidcConfig(env: OidcConfigEnv): Promise<Configuration> {
121 const settings = loadOidcSettings(env);
122 const issuerUrl = new URL(settings.issuer);
123 
124 if (testConfigOverride && testConfigOverride.issuer === settings.issuer) {
125 return testConfigOverride.config;
126 }
127 
128 const cacheKey = settings.issuer;
129 const cached = discoveryCache.get(cacheKey);
130 const now = Date.now();
131 if (cached && cached.expiresAt > now) {
132 return cached.promise;
133 }
134 
135 const discoveryOptions: DiscoveryRequestOptions = isLoopbackHostname(issuerUrl.hostname)
136 ? // openid-client marks this deprecated to make non-TLS use stand out. This
137 // code path is intentionally limited to loopback development/test issuers.
138 { execute: [oidc.allowInsecureRequests] }
139 : {};
140 
141 const promise = oidc
142 .discovery(issuerUrl, settings.clientId, undefined, oidc.ClientSecretPost(settings.clientSecret), discoveryOptions)
143 .then((config) => {
144 const meta = config.serverMetadata();
145 validateDiscoveredEndpoint("authorization_endpoint", meta.authorization_endpoint, issuerUrl);
146 validateDiscoveredEndpoint("token_endpoint", meta.token_endpoint, issuerUrl);
147 validateDiscoveredEndpoint("jwks_uri", meta.jwks_uri, issuerUrl);
148 return config;
149 });
150 
151 promise.catch(() => {
152 const current = discoveryCache.get(cacheKey);
153 if (current?.promise === promise) {
154 discoveryCache.delete(cacheKey);
155 }
156 });
157 
158 discoveryCache.set(cacheKey, { promise, expiresAt: now + DISCOVERY_TTL_MS });
159 return promise;
160}
161 
162export async function exchangeAuthorizationCode(
163 config: Configuration,
164 url: URL,
165 checks: AuthorizationCodeGrantChecks,
166): Promise<{ claims(): IDToken | undefined }> {
167 // ADR: `return await` (not bare `return`) so a synchronous reject from a
168 // test stub is chained into this async function's promise within the same
169 // microtask. Without the await, V8 reports the inner rejection as
170 // unhandled even though the caller awaits the wrapper.
171 if (testAuthorizationCodeGrantImpl) {
172 return await testAuthorizationCodeGrantImpl(config, url, checks);
173 }
174 return await oidc.authorizationCodeGrant(config, url, checks);
175}
176 
177export function validateClaims(raw: IDToken | undefined): ClaimValidation {
178 if (!raw) {
179 return { ok: false, code: "oidc_unverified_email" };
180 }
181 const sub = typeof raw.sub === "string" ? raw.sub.trim() : "";
182 const email = typeof raw.email === "string" ? raw.email.trim().toLowerCase() : "";
183 const emailVerified = raw.email_verified === true;
184 const name = typeof raw.name === "string" && raw.name.trim().length > 0 ? raw.name.trim() : undefined;
185 
186 if (!sub || !email || !emailVerified) {
187 return { ok: false, code: "oidc_unverified_email" };
188 }
189 
190 return { ok: true, claims: { sub, email, email_verified: true, name } };
191}
192 
193export function oidcErrorContext(error: unknown, env?: OidcConfigEnv): Record<string, unknown> {
194 const fields: Record<string, unknown> = {};
195 const issuer = issuerForLog(env);
196 if (issuer) {
197 fields.configuredIssuer = issuer;
198 }
199 
200 addErrorFields(fields, "", error);
201 const issuerComparison = findIssuerComparison(error);
202 if (issuerComparison) {
203 fields.expectedIssuer = issuerComparison.expected;
204 fields.discoveredIssuer = issuerComparison.discovered;
205 }
206 
207 return fields;
208}
209 
210function issuerForLog(env: OidcConfigEnv | undefined): string | undefined {
211 const raw = env?.TESSERA_OIDC_ISSUER?.trim();
212 if (!raw) return undefined;
213 try {
214 return validateIssuerUrl(raw).toString();
215 } catch {
216 return raw;
217 }
218}
219 
220function addErrorFields(fields: Record<string, unknown>, prefix: "" | "cause", error: unknown): void {
221 if (error instanceof Error) {
222 fields[errorField(prefix, "Name")] = error.name;
223 fields[errorField(prefix, "Message")] = error.message;
224 const code = errorCode(error);
225 if (code) {
226 fields[errorField(prefix, "Code")] = code;
227 }
228 const cause = error.cause;
229 if (prefix === "" && cause) {
230 addErrorFields(fields, "cause", cause);
231 }
232 return;
233 }
234 
235 fields[errorField(prefix, "Message")] = String(error);
236}
237 
238function errorField(prefix: "" | "cause", suffix: "Name" | "Message" | "Code"): string {
239 if (!prefix) {
240 return `error${suffix}`;
241 }
242 return `${prefix}Error${suffix}`;
243}
244 
245function errorCode(error: Error): string | undefined {
246 const code = (error as Error & { code?: unknown }).code;
247 return typeof code === "string" && code.length > 0 ? code : undefined;
248}
249 
250function findIssuerComparison(value: unknown, depth = 0): { expected: string; discovered: string } | undefined {
251 if (depth > 4 || !isRecord(value)) return undefined;
252 
253 const cause = value.cause;
254 if (isRecord(cause)) {
255 if (cause.attribute === "issuer" && typeof cause.expected === "string") {
256 const body = cause.body;
257 if (isRecord(body) && typeof body.issuer === "string") {
258 return { expected: cause.expected, discovered: body.issuer };
259 }
260 }
261 return findIssuerComparison(cause, depth + 1);
262 }
263 
264 return undefined;
265}
266 
267function isRecord(value: unknown): value is Record<string, unknown> {
268 return typeof value === "object" && value !== null;
269}
270 
271export function sanitizeReturnTo(raw: string | undefined): string {
272 if (!raw) return "/";
273 const v = raw.trim();
274 if (!v.startsWith("/") || v.startsWith("//")) return "/";
275 if (v.includes("://") || v.includes("\\")) return "/";
276 for (let i = 0; i < v.length; i++) {
277 const code = v.charCodeAt(i);
278 if (code < 0x20 || code === 0x7f) return "/";
279 }
280 return v;
281}
282 
283export function appendOidcMarker(path: string): string {
284 const hashIdx = path.indexOf("#");
285 const hash = hashIdx >= 0 ? path.slice(hashIdx) : "";
286 const withoutHash = hashIdx >= 0 ? path.slice(0, hashIdx) : path;
287 const queryIdx = withoutHash.indexOf("?");
288 const pathname = queryIdx >= 0 ? withoutHash.slice(0, queryIdx) : withoutHash;
289 const search = queryIdx >= 0 ? withoutHash.slice(queryIdx + 1) : "";
290 const params = new URLSearchParams(search);
291 params.set(OIDC_RETURN_MARKER, "1");
292 return `${pathname}?${params.toString()}${hash}`;
293}
294 
295async function getHkdfKey(clientSecret: string): Promise<CryptoKey> {
296 if (testHkdfKeyOverride) return testHkdfKeyOverride;
297 const ikm = new TextEncoder().encode(clientSecret);
298 const baseKey = await crypto.subtle.importKey("raw", ikm, "HKDF", false, ["deriveKey"]);
299 return crypto.subtle.deriveKey(
300 { name: "HKDF", hash: "SHA-256", salt: new Uint8Array(), info: HKDF_INFO },
301 baseKey,
302 { name: "HMAC", hash: "SHA-256", length: 256 },
303 false,
304 ["sign", "verify"],
305 );
306}
307 
308export async function encodeTxCookie(env: OidcConfigEnv, payload: TxCookiePayload): Promise<string> {
309 const { clientSecret } = loadOidcSettings(env);
310 const json = JSON.stringify(payload);
311 const valueB64 = base64UrlEncode(new TextEncoder().encode(json));
312 const key = await getHkdfKey(clientSecret);
313 const signature = await crypto.subtle.sign("HMAC", key, new TextEncoder().encode(valueB64));
314 const sigB64 = base64UrlEncode(new Uint8Array(signature));
315 return `${valueB64}.${sigB64}`;
316}
317 
318export async function decodeTxCookie(
319 env: OidcConfigEnv,
320 cookieValue: string | undefined,
321): Promise<TxCookiePayload | null> {
322 if (!cookieValue) return null;
323 const lastDot = cookieValue.lastIndexOf(".");
324 if (lastDot <= 0) return null;
325 const valueB64 = cookieValue.slice(0, lastDot);
326 const sigB64 = cookieValue.slice(lastDot + 1);
327 
328 let key: CryptoKey;
329 let sig: Uint8Array;
330 try {
331 const { clientSecret } = loadOidcSettings(env);
332 key = await getHkdfKey(clientSecret);
333 sig = base64UrlDecode(sigB64);
334 } catch {
335 return null;
336 }
337 
338 const ok = await crypto.subtle.verify("HMAC", key, sig, new TextEncoder().encode(valueB64));
339 if (!ok) return null;
340 
341 try {
342 const json = new TextDecoder().decode(base64UrlDecode(valueB64));
343 const parsed = JSON.parse(json) as Partial<TxCookiePayload>;
344 if (
345 typeof parsed.state !== "string" ||
346 typeof parsed.nonce !== "string" ||
347 typeof parsed.codeVerifier !== "string" ||
348 typeof parsed.redirectUri !== "string" ||
349 typeof parsed.returnTo !== "string" ||
350 typeof parsed.createdAt !== "number"
351 ) {
352 return null;
353 }
354 if (Date.now() - parsed.createdAt > OIDC_TX_COOKIE_MAX_AGE * 1000) {
355 return null;
356 }
357 return parsed as TxCookiePayload;
358 } catch {
359 return null;
360 }
361}
362 
363export const __test = {
364 setProviderForTesting(issuer: string, config: oidc.Configuration): void {
365 testConfigOverride = { issuer: validateIssuerUrl(issuer).toString(), config };
366 },
367 setAuthorizationCodeGrantImpl(fn: AuthorizationCodeGrantImpl | null): void {
368 testAuthorizationCodeGrantImpl = fn;
369 },
370 setHkdfKeyOverride(key: CryptoKey | null): void {
371 testHkdfKeyOverride = key;
372 },
373 clear(): void {
374 testConfigOverride = null;
375 testAuthorizationCodeGrantImpl = null;
376 testHkdfKeyOverride = null;
377 discoveryCache.clear();
378 },
379};