Skip to content
File

Blob: src/worker/auth/oidc.ts

typescript377 lines
1import * as oidc from "openid-client";
2import { 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 
18const STATE_TTL_MS = 5 * 60 * 1000;
19const DISCOVERY_CACHE_TTL_MS = 5 * 60 * 1000;
20const TRANSACTION_COOKIE_PURPOSE = "goc-oidc-transaction-cookie-v1";
21const TRANSACTION_COOKIE_KEY_BITS = 256;
22 
23const 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 
31export type TransactionPayload = z.infer<typeof TransactionPayloadSchema>;
32 
33export interface OidcConfig {
34 issuer: string;
35 clientId: string;
36 clientSecret: string;
37}
38 
39export type OidcConfigError =
40 | "missing_issuer"
41 | "insecure_issuer"
42 | "missing_client_id"
43 | "missing_client_secret";
44 
45export 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.
51const LOOPBACK_HOSTNAMES: ReadonlySet<string> = new Set(["localhost", "127.0.0.1", "::1", "[::1]"]);
52 
53function isLoopbackHostname(hostname: string): boolean {
54 return LOOPBACK_HOSTNAMES.has(hostname.toLowerCase());
55}
56 
57function isAllowedUrl(url: URL): boolean {
58 if (url.protocol === "https:") return true;
59 return url.protocol === "http:" && isLoopbackHostname(url.hostname);
60}
61 
62function 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 
74export 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 
87const textEncoder = new TextEncoder();
88const textDecoder = new TextDecoder();
89 
90function 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 
99function 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 
112export interface PkcePair {
113 verifier: string;
114 challenge: string;
115}
116 
117export async function generatePkcePair(): Promise<PkcePair> {
118 const verifier = oidc.randomPKCECodeVerifier();
119 const challenge = await oidc.calculatePKCECodeChallenge(verifier);
120 return { verifier, challenge };
121}
122 
123export function encodeTransactionPayload(payload: TransactionPayload): string {
124 return base64UrlEncode(textEncoder.encode(JSON.stringify(payload)));
125}
126 
127export 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 
150export type DecodeTransactionPayloadError = "malformed" | "invalid_payload" | "expired";
151 
152export type DecodeTransactionPayloadResult =
153 | { ok: true; payload: TransactionPayload }
154 | { ok: false; reason: DecodeTransactionPayloadError };
155 
156export 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 
178export interface OidcProvider {
179 configuration: oidc.Configuration;
180}
181 
182export type OidcDiscoveryError = "discovery_failed" | "invalid_endpoint";
183 
184export type OidcDiscoveryResult =
185 | { ok: true; provider: OidcProvider }
186 | { ok: false; reason: OidcDiscoveryError };
187 
188interface CachedOidcProvider {
189 provider: OidcProvider;
190 clientSecret: string;
191 expiresAt: number;
192}
193 
194const oidcProviderByIssuerAndClient = new Map<string, CachedOidcProvider>();
195 
196function 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 
208function cacheKey(config: OidcConfig): string {
209 return `${config.issuer}|${config.clientId}`;
210}
211 
212function 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 
221function 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 
230export 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 
265export interface TokenExchangeResponse {
266 claims: oidc.IDToken;
267}
268 
269export type ExchangeError = "token_exchange_failed" | "invalid_id_token";
270 
271export 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).
277type GrantImpl = typeof oidc.authorizationCodeGrant;
278let authorizationCodeGrantImpl: GrantImpl = oidc.authorizationCodeGrant;
279 
280export 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 
306export interface VerifiedIdToken {
307 sub: string;
308 preferredUsername?: string;
309}
310 
311export type VerifyError = "missing_sub";
312 
313export 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.
321export 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 
330export 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 
346export function buildCallbackUrl(requestUrl: string): string {
347 return `${new URL(requestUrl).origin}/auth/callback`;
348}
349 
350export function generateState(): string {
351 return oidc.randomState();
352}
353 
354export 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.
362export 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};