Skip to content
File

Blob: src/worker/services/oidc.ts

typescript357 lines
1import { eg, type TypeFromCodec } from "@cloudflare/util-en-garde";
2import * as oidc from "openid-client";
3import { isLoopbackHostname } from "@/worker/security";
4 
5const TRANSACTION_COOKIE_PURPOSE = "flamemail-admin-oidc-transaction-cookie-v1";
6const STATE_TTL_MS = 5 * 60 * 1000;
7const TRANSACTION_COOKIE_KEY_BITS = 256;
8const DISCOVERY_CACHE_TTL_MS = 5 * 60 * 1000;
9 
10export 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});
17export type TransactionPayload = TypeFromCodec<typeof TransactionPayload>;
18 
19export interface OidcConfig {
20 issuer: string;
21 clientId: string;
22 clientSecret: string;
23 operatorSubs: readonly string[];
24}
25 
26export type OidcConfigError =
27 | "missing_issuer"
28 | "insecure_issuer"
29 | "missing_client_id"
30 | "missing_client_secret"
31 | "missing_operator_subs";
32 
33export type OidcConfigResult = { ok: true; config: OidcConfig } | { ok: false; reason: OidcConfigError };
34 
35function isAllowedUrl(url: URL): boolean {
36 if (url.protocol === "https:") {
37 return true;
38 }
39 return url.protocol === "http:" && isLoopbackHostname(url.hostname);
40}
41 
42function 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 
56export 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 
88export 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 
98const textEncoder = new TextEncoder();
99const textDecoder = new TextDecoder();
100 
101function 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 
110function 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 
123export interface PkcePair {
124 verifier: string;
125 challenge: string;
126}
127 
128export async function generatePkcePair(): Promise<PkcePair> {
129 const verifier = oidc.randomPKCECodeVerifier();
130 const challenge = await oidc.calculatePKCECodeChallenge(verifier);
131 return { verifier, challenge };
132}
133 
134export function encodeTransactionPayload(payload: TransactionPayload): string {
135 return base64UrlEncode(textEncoder.encode(JSON.stringify(payload)));
136}
137 
138export 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 
155export type DecodeTransactionPayloadError = "malformed" | "invalid_payload" | "expired";
156 
157export type DecodeTransactionPayloadResult =
158 | { ok: true; payload: TransactionPayload }
159 | { ok: false; reason: DecodeTransactionPayloadError };
160 
161export 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 
186export interface OidcProvider {
187 configuration: oidc.Configuration;
188}
189 
190export type OidcDiscoveryError = "discovery_failed" | "invalid_endpoint";
191 
192export type OidcDiscoveryResult = { ok: true; provider: OidcProvider } | { ok: false; reason: OidcDiscoveryError };
193 
194interface CachedOidcProvider {
195 provider: OidcProvider;
196 clientSecret: string;
197 expiresAt: number;
198}
199 
200const oidcProviderByIssuerAndClient = new Map<string, CachedOidcProvider>();
201 
202function 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 
218function cacheKey(config: OidcConfig): string {
219 return `${config.issuer}\0${config.clientId}`;
220}
221 
222function 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 
230function 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 
239export 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 
275export interface TokenExchangeResponse {
276 claims: oidc.IDToken;
277}
278 
279export type ExchangeError = "token_exchange_failed" | "invalid_id_token";
280 
281export type ExchangeResult = { ok: true; tokens: TokenExchangeResponse } | { ok: false; reason: ExchangeError };
282 
283export 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 
308export interface VerifiedIdToken {
309 sub: string;
310}
311 
312export type VerifyError = "missing_sub" | "sub_not_allowed";
313 
314export type VerifyResult = { ok: true; verified: VerifiedIdToken } | { ok: false; reason: VerifyError };
315 
316export 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 
326export 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 
342export function buildCallbackUrl(requestUrl: string): string {
343 return `${new URL(requestUrl).origin}/api/public/admin/callback`;
344}
345 
346export function generateState(): string {
347 return oidc.randomState();
348}
349 
350export function generateNonce(): string {
351 return oidc.randomNonce();
352}
353 
354export function clearOidcCachesForTesting() {
355 oidcProviderByIssuerAndClient.clear();
356}