Skip to content
File

Blob: src/worker/auth/oidc.ts

typescript341 lines
1import * as oidc from "openid-client";
2import type { AuthorizationCodeGrantChecks, Configuration, DiscoveryRequestOptions, IDToken } from "openid-client";
3 
4import { decodeBase64Url, encodeBase64Url } from "@/worker/services/crypto";
5 
6const DISCOVERY_TTL_MS = 5 * 60 * 1000;
7const textDecoder = new TextDecoder();
8const textEncoder = new TextEncoder();
9const HKDF_INFO = textEncoder.encode("anvil-oidc-transaction-v1");
10 
11export const OIDC_TX_COOKIE_NAME = "anvil_oidc_tx";
12export const OIDC_TX_COOKIE = `__Host-${OIDC_TX_COOKIE_NAME}`;
13export const OIDC_TX_COOKIE_MAX_AGE_SECONDS = 5 * 60;
14export const OIDC_RETURN_MARKER = "oidc";
15export const OIDC_SCOPE = "openid email profile";
16 
17const LOOPBACK_HOSTNAMES = new Set(["localhost", "127.0.0.1", "::1", "[::1]"]);
18 
19export interface OidcConfigEnv {
20 TESSERA_OIDC_ISSUER?: string;
21 TESSERA_OIDC_CLIENT_ID?: string;
22 TESSERA_OIDC_CLIENT_SECRET?: string;
23}
24 
25export interface OidcSettings {
26 issuer: string;
27 clientId: string;
28 clientSecret: string;
29}
30 
31export interface TxCookiePayload {
32 state: string;
33 nonce: string;
34 codeVerifier: string;
35 redirectUri: string;
36 returnTo: string;
37 createdAt: number;
38}
39 
40export interface ResolvedClaims {
41 sub: string;
42 email: string;
43 email_verified: true;
44 name?: string;
45 preferredUsername?: string;
46}
47 
48export type ClaimValidation = { ok: true; claims: ResolvedClaims } | { ok: false; code: "oidc_unverified_email" };
49 
50interface DiscoveryCacheEntry {
51 promise: Promise<Configuration>;
52 expiresAt: number;
53}
54 
55type AuthorizationCodeGrantImpl = (
56 config: Configuration,
57 url: URL,
58 checks: AuthorizationCodeGrantChecks,
59) => Promise<{ claims(): IDToken | undefined }>;
60 
61const discoveryCache = new Map<string, DiscoveryCacheEntry>();
62let testConfigOverride: { issuer: string; config: Configuration } | null = null;
63let testAuthorizationCodeGrantImpl: AuthorizationCodeGrantImpl | null = null;
64 
65export const isLoopbackHostname = (hostname: string): boolean => LOOPBACK_HOSTNAMES.has(hostname.toLowerCase());
66 
67export const validateIssuerUrl = (rawIssuer: string | undefined): URL => {
68 if (!rawIssuer) {
69 throw new Error("TESSERA_OIDC_ISSUER is required");
70 }
71 
72 const trimmed = rawIssuer.trim().replace(/\/+$/u, "");
73 let url: URL;
74 try {
75 url = new URL(trimmed);
76 } catch {
77 throw new Error(`TESSERA_OIDC_ISSUER is not a valid URL: ${rawIssuer}`);
78 }
79 
80 if (url.search || url.hash || url.username || url.password) {
81 throw new Error(`TESSERA_OIDC_ISSUER must be an issuer URL without credentials, query, or fragment: ${rawIssuer}`);
82 }
83 
84 if (url.protocol !== "http:" && url.protocol !== "https:") {
85 throw new Error(`TESSERA_OIDC_ISSUER must use http(s): ${rawIssuer}`);
86 }
87 
88 if (url.protocol === "http:" && !isLoopbackHostname(url.hostname)) {
89 throw new Error(`TESSERA_OIDC_ISSUER must use https unless loopback: ${rawIssuer}`);
90 }
91 
92 return url;
93};
94 
95export const loadOidcSettings = (env: OidcConfigEnv): OidcSettings => {
96 const issuer = validateIssuerUrl(env.TESSERA_OIDC_ISSUER).toString();
97 const clientId = env.TESSERA_OIDC_CLIENT_ID?.trim();
98 const clientSecret = env.TESSERA_OIDC_CLIENT_SECRET?.trim();
99 
100 if (!clientId) {
101 throw new Error("TESSERA_OIDC_CLIENT_ID is required");
102 }
103 
104 if (!clientSecret) {
105 throw new Error("TESSERA_OIDC_CLIENT_SECRET is required");
106 }
107 
108 return { issuer, clientId, clientSecret };
109};
110 
111const isAllowedEndpoint = (raw: string | undefined, issuerUrl: URL): boolean => {
112 if (!raw) {
113 return false;
114 }
115 
116 let url: URL;
117 try {
118 url = new URL(raw);
119 } catch {
120 return false;
121 }
122 
123 if (url.hash || (url.protocol !== "http:" && url.protocol !== "https:")) {
124 return false;
125 }
126 
127 if (url.protocol === "http:" && !isLoopbackHostname(url.hostname)) {
128 return false;
129 }
130 
131 return url.hostname === issuerUrl.hostname;
132};
133 
134const discoveryOptions = (issuerUrl: URL): DiscoveryRequestOptions =>
135 issuerUrl.protocol === "http:" ? { execute: [oidc.allowInsecureRequests] } : {};
136 
137export const getOidcConfig = async (env: OidcConfigEnv): Promise<Configuration> => {
138 const settings = loadOidcSettings(env);
139 const issuerUrl = new URL(settings.issuer);
140 
141 if (testConfigOverride && testConfigOverride.issuer === settings.issuer) {
142 return testConfigOverride.config;
143 }
144 
145 const now = Date.now();
146 const cached = discoveryCache.get(settings.issuer);
147 if (cached && cached.expiresAt > now) {
148 return cached.promise;
149 }
150 
151 const promise = oidc
152 .discovery(
153 issuerUrl,
154 settings.clientId,
155 undefined,
156 oidc.ClientSecretPost(settings.clientSecret),
157 discoveryOptions(issuerUrl),
158 )
159 .then((config) => {
160 const metadata = config.serverMetadata();
161 if (
162 !isAllowedEndpoint(metadata.authorization_endpoint, issuerUrl) ||
163 !isAllowedEndpoint(metadata.token_endpoint, issuerUrl) ||
164 !isAllowedEndpoint(metadata.jwks_uri, issuerUrl)
165 ) {
166 throw new Error("OIDC discovery returned an invalid endpoint.");
167 }
168 
169 return config;
170 });
171 
172 promise.catch(() => {
173 const current = discoveryCache.get(settings.issuer);
174 if (current?.promise === promise) {
175 discoveryCache.delete(settings.issuer);
176 }
177 });
178 
179 discoveryCache.set(settings.issuer, { promise, expiresAt: now + DISCOVERY_TTL_MS });
180 return promise;
181};
182 
183export const exchangeAuthorizationCode = async (
184 config: Configuration,
185 url: URL,
186 checks: AuthorizationCodeGrantChecks,
187): Promise<{ claims(): IDToken | undefined }> => {
188 if (testAuthorizationCodeGrantImpl) {
189 return await testAuthorizationCodeGrantImpl(config, url, checks);
190 }
191 
192 return await oidc.authorizationCodeGrant(config, url, checks);
193};
194 
195export const validateClaims = (raw: IDToken | undefined): ClaimValidation => {
196 if (!raw) {
197 return { ok: false, code: "oidc_unverified_email" };
198 }
199 
200 const sub = typeof raw.sub === "string" ? raw.sub.trim() : "";
201 const email = typeof raw.email === "string" ? raw.email.trim().toLowerCase() : "";
202 const name = typeof raw.name === "string" && raw.name.trim().length > 0 ? raw.name.trim() : undefined;
203 const preferredUsername =
204 typeof raw.preferred_username === "string" && raw.preferred_username.trim().length > 0
205 ? raw.preferred_username.trim()
206 : undefined;
207 
208 if (!sub || !email || raw.email_verified !== true) {
209 return { ok: false, code: "oidc_unverified_email" };
210 }
211 
212 return {
213 ok: true,
214 claims: {
215 sub,
216 email,
217 email_verified: true,
218 name,
219 preferredUsername,
220 },
221 };
222};
223 
224const isTxCookiePayload = (value: Partial<TxCookiePayload>): value is TxCookiePayload =>
225 typeof value.state === "string" &&
226 typeof value.nonce === "string" &&
227 typeof value.codeVerifier === "string" &&
228 typeof value.redirectUri === "string" &&
229 typeof value.returnTo === "string" &&
230 typeof value.createdAt === "number";
231 
232export const encodeTxCookiePayload = (payload: TxCookiePayload): string =>
233 encodeBase64Url(textEncoder.encode(JSON.stringify(payload)));
234 
235export const decodeTxCookiePayload = (cookieValue: string | false | undefined): TxCookiePayload | null => {
236 if (!cookieValue) {
237 return null;
238 }
239 
240 try {
241 const parsed = JSON.parse(textDecoder.decode(decodeBase64Url(cookieValue))) as Partial<TxCookiePayload>;
242 if (!isTxCookiePayload(parsed)) {
243 return null;
244 }
245 
246 if (Date.now() - parsed.createdAt > OIDC_TX_COOKIE_MAX_AGE_SECONDS * 1000) {
247 return null;
248 }
249 
250 return parsed;
251 } catch {
252 return null;
253 }
254};
255 
256export const deriveTxCookieSecret = async (clientSecret: string): Promise<ArrayBuffer> => {
257 const baseKey = await crypto.subtle.importKey("raw", textEncoder.encode(clientSecret), "HKDF", false, ["deriveBits"]);
258 return await crypto.subtle.deriveBits(
259 { name: "HKDF", hash: "SHA-256", salt: new Uint8Array(), info: HKDF_INFO },
260 baseKey,
261 256,
262 );
263};
264 
265export const getTxCookieSecret = async (env: OidcConfigEnv): Promise<ArrayBuffer> => {
266 const { clientSecret } = loadOidcSettings(env);
267 return await deriveTxCookieSecret(clientSecret);
268};
269 
270export const sanitizeReturnTo = (raw: string | undefined): string => {
271 if (!raw) {
272 return "/app/projects";
273 }
274 
275 const value = raw.trim();
276 if (!value.startsWith("/") || value.startsWith("//") || value.includes("://") || value.includes("\\")) {
277 return "/app/projects";
278 }
279 
280 for (let index = 0; index < value.length; index += 1) {
281 const code = value.charCodeAt(index);
282 if (code < 0x20 || code === 0x7f) {
283 return "/app/projects";
284 }
285 }
286 
287 return value;
288};
289 
290export const appendOidcMarker = (path: string): string => {
291 const hashIndex = path.indexOf("#");
292 const hash = hashIndex >= 0 ? path.slice(hashIndex) : "";
293 const withoutHash = hashIndex >= 0 ? path.slice(0, hashIndex) : path;
294 const queryIndex = withoutHash.indexOf("?");
295 const pathname = queryIndex >= 0 ? withoutHash.slice(0, queryIndex) : withoutHash;
296 const search = queryIndex >= 0 ? withoutHash.slice(queryIndex + 1) : "";
297 const params = new URLSearchParams(search);
298 
299 params.set(OIDC_RETURN_MARKER, "1");
300 return `${pathname}?${params.toString()}${hash}`;
301};
302 
303export const oidcErrorContext = (error: unknown, env?: OidcConfigEnv): Record<string, unknown> => {
304 const fields: Record<string, unknown> = {};
305 const issuer = env?.TESSERA_OIDC_ISSUER?.trim();
306 if (issuer) {
307 fields.configuredIssuer = issuer;
308 }
309 
310 if (error instanceof Error) {
311 fields.errorName = error.name;
312 fields.errorMessage = error.message;
313 const code = (error as Error & { code?: unknown }).code;
314 if (typeof code === "string" && code.length > 0) {
315 fields.errorCode = code;
316 }
317 if (error.cause instanceof Error) {
318 fields.causeErrorName = error.cause.name;
319 fields.causeErrorMessage = error.cause.message;
320 }
321 } else {
322 fields.errorMessage = String(error);
323 }
324 
325 return fields;
326};
327 
328export const __test = {
329 setProviderForTesting(issuer: string, config: Configuration): void {
330 testConfigOverride = { issuer: validateIssuerUrl(issuer).toString(), config };
331 },
332 setAuthorizationCodeGrantImpl(fn: AuthorizationCodeGrantImpl | null): void {
333 testAuthorizationCodeGrantImpl = fn;
334 },
335 clear(): void {
336 testConfigOverride = null;
337 testAuthorizationCodeGrantImpl = null;
338 discoveryCache.clear();
339 },
340};