@getstrata/core 1.0.9 → 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (90) hide show
  1. package/CHANGELOG.md +48 -0
  2. package/dist/core/auth/authContext.d.ts +5 -2
  3. package/dist/core/auth/emailVerification.d.ts +1 -1
  4. package/dist/core/auth/guard.d.ts +5 -1
  5. package/dist/core/auth/jwtGuard.d.ts +6 -2
  6. package/dist/core/auth/oauth/oidcIdToken.d.ts +19 -0
  7. package/dist/core/auth/oauth/oidcProvider.d.ts +16 -4
  8. package/dist/core/auth/oauth/samlProvider.d.ts +7 -4
  9. package/dist/core/auth/oneTimeToken.d.ts +19 -0
  10. package/dist/core/auth/passwordLogin.d.ts +20 -0
  11. package/dist/core/auth/saml/samlAssertionReplay.d.ts +20 -0
  12. package/dist/core/auth/saml/samlServiceProvider.d.ts +51 -0
  13. package/dist/core/contracts/authUserDirectory.d.ts +2 -3
  14. package/dist/core/database/errors.d.ts +2 -1
  15. package/dist/core/http/response.d.ts +3 -1
  16. package/dist/core/mail/mailer.d.ts +6 -1
  17. package/dist/core/security/oauthState.d.ts +6 -2
  18. package/dist/core/security/safeFetch.d.ts +1 -0
  19. package/dist/core/security/safePath.d.ts +4 -0
  20. package/dist/core/security/safeUrl.d.ts +14 -1
  21. package/dist/core/security/totp.d.ts +1 -1
  22. package/dist/core/tenant/databaseTenantContext.d.ts +2 -1
  23. package/dist/core/tenant/enableTenantRls.d.ts +6 -0
  24. package/dist/core/tenant/postgresAppRole.d.ts +75 -0
  25. package/dist/entries/audit/exportAuditLogs.js +223 -40
  26. package/dist/entries/auth/accessControl.js +1 -0
  27. package/dist/entries/auth/basicAuthGuard.js +638 -17
  28. package/dist/entries/auth/emailVerification.js +1 -1
  29. package/dist/entries/auth/intendedUrlCookie.js +32 -2
  30. package/dist/entries/auth/jwt.js +1 -1
  31. package/dist/entries/auth/jwtGuard.js +192 -3
  32. package/dist/entries/auth/membershipMiddleware.js +1 -0
  33. package/dist/entries/auth/membershipScope.js +1 -0
  34. package/dist/entries/auth/membershipService.js +1 -0
  35. package/dist/entries/auth/oauth/oidcProvider.js +542 -20
  36. package/dist/entries/auth/oauth/providers.js +313 -5
  37. package/dist/entries/auth/oauth/samlProvider.js +552 -74
  38. package/dist/entries/auth/oneTimeToken.js +362 -0
  39. package/dist/entries/auth/password.js +1 -1
  40. package/dist/entries/auth/passwordLogin.js +548 -0
  41. package/dist/entries/auth/policy.js +1 -0
  42. package/dist/entries/auth/saml/samlServiceProvider.js +571 -0
  43. package/dist/entries/auth/scimAuthMiddleware.js +10 -3
  44. package/dist/entries/auth/sessionCookie.js +1 -1
  45. package/dist/entries/auth/sessionGuard.js +1 -1
  46. package/dist/entries/auth/tokenHash.js +2 -6
  47. package/dist/entries/crypto/fieldEncryption.js +1 -1
  48. package/dist/entries/crypto/mfaSecret.js +31 -5
  49. package/dist/entries/database/errors.js +29 -12
  50. package/dist/entries/database/model.js +9 -9
  51. package/dist/entries/facades.js +72 -9
  52. package/dist/entries/http/authMiddleware.js +7 -14
  53. package/dist/entries/http/corsMiddleware.js +69 -2
  54. package/dist/entries/http/csrfMiddleware.js +33 -43
  55. package/dist/entries/http/csrfToken.js +2 -2
  56. package/dist/entries/http/flashMiddleware.js +4 -2
  57. package/dist/entries/http/flashSession.js +4 -2
  58. package/dist/entries/http/formRequest.js +2 -2
  59. package/dist/entries/http/memoryThrottleMiddleware.js +63 -0
  60. package/dist/entries/http/pagination.js +2 -2
  61. package/dist/entries/http/requirePasswordConfirmMiddleware.js +36 -3
  62. package/dist/entries/http/requireWebAuthMiddleware.js +32 -2
  63. package/dist/entries/http/response.js +101 -45
  64. package/dist/entries/http/routeModelBinding.js +2 -2
  65. package/dist/entries/http/safeInternalPath.js +32 -2
  66. package/dist/entries/http/scimThrottleMiddleware.js +7 -1
  67. package/dist/entries/http/securedRouteModelBinding.js +2 -2
  68. package/dist/entries/http/securityHeadersMiddleware.js +3 -64
  69. package/dist/entries/http/signedUrl.js +6 -5
  70. package/dist/entries/http/throttleMiddleware.js +63 -0
  71. package/dist/entries/http/validation.js +2 -2
  72. package/dist/entries/http/webFormRequest.js +2 -2
  73. package/dist/entries/jobs/exportAuditLogsJob.js +223 -40
  74. package/dist/entries/mail/mailer.js +54 -8
  75. package/dist/entries/media/imageTransform.js +12 -1
  76. package/dist/entries/openapi/generator.js +0 -7
  77. package/dist/entries/security/oauthState.js +30 -39
  78. package/dist/entries/security/publicReads.js +1 -1
  79. package/dist/entries/security/recoveryCodes.js +2 -2
  80. package/dist/entries/security/safeFetch.js +195 -94
  81. package/dist/entries/security/safePath.js +25 -0
  82. package/dist/entries/security/scimTenantTokens.js +10 -3
  83. package/dist/entries/security/totp.js +16 -4
  84. package/dist/entries/storage/storage.js +24 -2
  85. package/dist/entries/tenant/databaseTenantContext.js +34 -12
  86. package/dist/entries/tenant/enableTenantRls.js +385 -0
  87. package/dist/entries/tracing/tracingMiddleware.js +308 -5
  88. package/dist/framework/public-api.d.ts +10 -3
  89. package/dist/index.js +2767 -1515
  90. package/package.json +32 -3
@@ -1,5 +1,493 @@
1
1
  // @bun
2
2
  // ../../src/core/auth/oauth/oidcProvider.ts
3
+ import { createHash as createHash2, randomBytes } from "crypto";
4
+
5
+ // ../../src/core/runtime/appEnv.ts
6
+ var NON_PRODUCTION_APP_ENVS = new Set(["local", "development", "dev", "test", "testing", "ci"]);
7
+ function normalizeEnvValue(value) {
8
+ return (value ?? "").trim().toLowerCase();
9
+ }
10
+ function isProductionEnv(env = process.env) {
11
+ const appEnv = normalizeEnvValue(env.APP_ENV);
12
+ const nodeEnv = normalizeEnvValue(env.NODE_ENV);
13
+ if (appEnv === "production" || nodeEnv === "production") {
14
+ return true;
15
+ }
16
+ if (appEnv === "") {
17
+ return false;
18
+ }
19
+ return !NON_PRODUCTION_APP_ENVS.has(appEnv);
20
+ }
21
+ function envFlagEnabled(value) {
22
+ return value === "true";
23
+ }
24
+
25
+ // ../../src/core/security/safeUrl.ts
26
+ import { lookup as dnsLookupImpl } from "dns/promises";
27
+ import { BadRequestError } from "@getstrata/core/errors/http";
28
+ var dnsLookup = dnsLookupImpl;
29
+ var BLOCKED_HOSTNAMES = new Set([
30
+ "localhost",
31
+ "127.0.0.1",
32
+ "0.0.0.0",
33
+ "::1",
34
+ "metadata.google.internal"
35
+ ]);
36
+ function parseCanonicalDottedDecimal(hostname) {
37
+ const parts = hostname.split(".");
38
+ if (parts.length !== 4) {
39
+ return null;
40
+ }
41
+ const octets = [];
42
+ for (const part of parts) {
43
+ if (!/^(0|[1-9]\d{0,2})$/u.test(part)) {
44
+ return null;
45
+ }
46
+ const value = Number.parseInt(part, 10);
47
+ if (!Number.isInteger(value) || value < 0 || value > 255) {
48
+ return null;
49
+ }
50
+ octets.push(value);
51
+ }
52
+ return octets;
53
+ }
54
+ function parseDottedIpv4(hostname) {
55
+ return parseCanonicalDottedDecimal(hostname);
56
+ }
57
+ function isBlockedIpv4Octets(octets) {
58
+ const [a = 0, b = 0] = octets;
59
+ if (a === 10 || a === 127 || a === 0) {
60
+ return true;
61
+ }
62
+ if (a === 169 && b === 254) {
63
+ return true;
64
+ }
65
+ if (a === 172 && b >= 16 && b <= 31) {
66
+ return true;
67
+ }
68
+ if (a === 192 && b === 168) {
69
+ return true;
70
+ }
71
+ if (a === 100 && b >= 64 && b <= 127) {
72
+ return true;
73
+ }
74
+ if (a === 198 && (b === 18 || b === 19)) {
75
+ return true;
76
+ }
77
+ return false;
78
+ }
79
+ function expandIpv6(hostname) {
80
+ const trimmed = hostname.trim().toLowerCase();
81
+ const unbracketed = trimmed.startsWith("[") && trimmed.endsWith("]") ? trimmed.slice(1, -1) : trimmed;
82
+ if (unbracketed.includes("%")) {
83
+ return null;
84
+ }
85
+ const mapped = unbracketed.match(/^::ffff:([0-9a-fx.]+)$/iu);
86
+ if (mapped?.[1]) {
87
+ const octets = parseDottedIpv4(mapped[1]);
88
+ if (!octets) {
89
+ return null;
90
+ }
91
+ return [
92
+ 0,
93
+ 0,
94
+ 0,
95
+ 0,
96
+ 0,
97
+ 65535,
98
+ octets[0] << 8 | octets[1],
99
+ octets[2] << 8 | octets[3]
100
+ ];
101
+ }
102
+ const halves = unbracketed.split("::");
103
+ if (halves.length > 2) {
104
+ return null;
105
+ }
106
+ const parseGroup = (part) => {
107
+ if (!/^[0-9a-f]{1,4}$/u.test(part)) {
108
+ return null;
109
+ }
110
+ return Number.parseInt(part, 16);
111
+ };
112
+ if (halves.length === 1) {
113
+ const groups = unbracketed.split(":");
114
+ if (groups.length !== 8) {
115
+ return null;
116
+ }
117
+ const values = groups.map(parseGroup);
118
+ return values.every((value) => value !== null) ? values : null;
119
+ }
120
+ const head = halves[0] ? halves[0].split(":").filter(Boolean) : [];
121
+ const tail = halves[1] ? halves[1].split(":").filter(Boolean) : [];
122
+ if (head.length + tail.length > 8) {
123
+ return null;
124
+ }
125
+ const values = [];
126
+ for (const part of head) {
127
+ const value = parseGroup(part);
128
+ if (value === null) {
129
+ return null;
130
+ }
131
+ values.push(value);
132
+ }
133
+ while (values.length < 8 - tail.length) {
134
+ values.push(0);
135
+ }
136
+ for (const part of tail) {
137
+ const value = parseGroup(part);
138
+ if (value === null) {
139
+ return null;
140
+ }
141
+ values.push(value);
142
+ }
143
+ return values.length === 8 ? values : null;
144
+ }
145
+ function isBlockedIpv6(hostname) {
146
+ const groups = expandIpv6(hostname);
147
+ if (!groups) {
148
+ return hostname.includes(":");
149
+ }
150
+ const first = groups[0] ?? 0;
151
+ const second = groups[1] ?? 0;
152
+ if (groups.every((group) => group === 0)) {
153
+ return true;
154
+ }
155
+ if (groups.slice(0, 7).every((group) => group === 0) && groups[7] === 1) {
156
+ return true;
157
+ }
158
+ if ((first & 65472) === 65152) {
159
+ return true;
160
+ }
161
+ if ((first & 65024) === 64512) {
162
+ return true;
163
+ }
164
+ if ((first & 65280) === 65280) {
165
+ return true;
166
+ }
167
+ if (first === 0 && second === 0 && groups[2] === 0 && groups[3] === 0 && groups[4] === 0 && groups[5] === 65535) {
168
+ const a = (groups[6] ?? 0) >> 8;
169
+ const b = (groups[6] ?? 0) & 255;
170
+ const c = (groups[7] ?? 0) >> 8;
171
+ const d = (groups[7] ?? 0) & 255;
172
+ return isBlockedIpv4Octets([a, b, c, d]);
173
+ }
174
+ return false;
175
+ }
176
+ function isBlockedHostname(hostname) {
177
+ const normalized = hostname.trim().toLowerCase();
178
+ if (normalized.length === 0) {
179
+ return true;
180
+ }
181
+ if (BLOCKED_HOSTNAMES.has(normalized)) {
182
+ return true;
183
+ }
184
+ if (normalized.endsWith(".local") || normalized.endsWith(".internal")) {
185
+ return true;
186
+ }
187
+ if (normalized.includes(":")) {
188
+ return isBlockedIpv6(normalized);
189
+ }
190
+ if (/^\d+$/u.test(normalized)) {
191
+ return true;
192
+ }
193
+ if (normalized.includes(".")) {
194
+ const canonical = parseCanonicalDottedDecimal(normalized);
195
+ if (canonical) {
196
+ return isBlockedIpv4Octets(canonical);
197
+ }
198
+ if (/^[0-9a-fx.]+$/iu.test(normalized)) {
199
+ return true;
200
+ }
201
+ }
202
+ return false;
203
+ }
204
+ function productionSafeOutboundOptions(options) {
205
+ if (!isProductionEnv()) {
206
+ return options;
207
+ }
208
+ return { ...options, allowPrivate: false, resolveDns: true };
209
+ }
210
+ function assertSafeOutboundUrl(rawUrl, options = {}) {
211
+ const resolved = productionSafeOutboundOptions(options);
212
+ let parsed;
213
+ try {
214
+ parsed = new URL(rawUrl);
215
+ } catch {
216
+ throw new BadRequestError("Webhook URL is invalid.");
217
+ }
218
+ if (parsed.protocol !== "https:" && !(resolved.allowHttp && parsed.protocol === "http:")) {
219
+ throw new BadRequestError("Webhook URL must use HTTPS.");
220
+ }
221
+ if (parsed.username || parsed.password) {
222
+ throw new BadRequestError("Webhook URL must not include credentials.");
223
+ }
224
+ if (!resolved.allowPrivate && isBlockedHostname(parsed.hostname)) {
225
+ throw new BadRequestError("Webhook URL targets a blocked host.");
226
+ }
227
+ return parsed;
228
+ }
229
+ function isBlockedIpAddress(address) {
230
+ return isBlockedHostname(address.trim().toLowerCase());
231
+ }
232
+ function isLiteralIpHostname(hostname) {
233
+ return parseCanonicalDottedDecimal(hostname) !== null || expandIpv6(hostname) !== null;
234
+ }
235
+ function pinUrlToAddress(url, address) {
236
+ const pinned = new URL(url.toString());
237
+ const ipv6 = expandIpv6(address.replace(/^\[|\]$/gu, ""));
238
+ pinned.hostname = ipv6 ? `[${address.replace(/^\[|\]$/gu, "")}]` : address;
239
+ return pinned;
240
+ }
241
+ async function resolveSafeOutboundTarget(rawUrl, options = {}) {
242
+ const resolvedOptions = productionSafeOutboundOptions(options);
243
+ const parsed = assertSafeOutboundUrl(rawUrl, resolvedOptions);
244
+ if (resolvedOptions.resolveDns === false || resolvedOptions.allowPrivate) {
245
+ return { url: parsed, addresses: [] };
246
+ }
247
+ const hostname = parsed.hostname.trim().toLowerCase();
248
+ if (isLiteralIpHostname(hostname)) {
249
+ return { url: parsed, addresses: [hostname] };
250
+ }
251
+ let results;
252
+ try {
253
+ results = await dnsLookup(hostname, { all: true, verbatim: true });
254
+ } catch {
255
+ throw new BadRequestError("Webhook URL targets a blocked host.");
256
+ }
257
+ if (results.length === 0 || results.some((result) => isBlockedIpAddress(result.address))) {
258
+ throw new BadRequestError("Webhook URL targets a blocked host.");
259
+ }
260
+ return { url: parsed, addresses: results.map((result) => result.address) };
261
+ }
262
+
263
+ // ../../src/core/security/safeFetch.ts
264
+ var DEFAULT_FETCH_TIMEOUT_MS = 1e4;
265
+ async function safeFetch(input, init = {}, options = {}) {
266
+ const timeoutMs = options.timeoutMs ?? DEFAULT_FETCH_TIMEOUT_MS;
267
+ const maxRedirects = options.maxRedirects ?? 0;
268
+ const resolveDns = options.resolveDns ?? true;
269
+ const urlOptions = {
270
+ allowHttp: options.allowHttp,
271
+ resolveDns,
272
+ allowPrivate: options.allowPrivate
273
+ };
274
+ const controller = new AbortController;
275
+ const timeout = setTimeout(() => controller.abort(), timeoutMs);
276
+ try {
277
+ let current = await resolveSafeOutboundTarget(input, urlOptions);
278
+ let redirectCount = 0;
279
+ while (true) {
280
+ const address = current.addresses[0];
281
+ const fetchUrl = address ? pinUrlToAddress(current.url, address).toString() : current.url.toString();
282
+ const headers = new Headers(init.headers);
283
+ if (address && !headers.has("host")) {
284
+ headers.set("Host", current.url.host);
285
+ }
286
+ const fetchInit = {
287
+ ...init,
288
+ headers,
289
+ signal: controller.signal,
290
+ redirect: "manual"
291
+ };
292
+ if (address) {
293
+ fetchInit.tls = { serverName: current.url.hostname };
294
+ }
295
+ const response = await fetch(fetchUrl, fetchInit);
296
+ if (response.status >= 300 && response.status < 400) {
297
+ const location = response.headers.get("location");
298
+ if (!location || redirectCount >= maxRedirects) {
299
+ return response;
300
+ }
301
+ current = await resolveSafeOutboundTarget(new URL(location, current.url).toString(), urlOptions);
302
+ redirectCount += 1;
303
+ continue;
304
+ }
305
+ return response;
306
+ }
307
+ } finally {
308
+ clearTimeout(timeout);
309
+ }
310
+ }
311
+
312
+ // ../../src/core/auth/oauth/oidcIdToken.ts
313
+ import { createHash, createPublicKey, createVerify } from "crypto";
314
+
315
+ // ../../src/core/security/timingSafeCompare.ts
316
+ import { timingSafeEqual } from "crypto";
317
+ function timingSafeCompareString(left, right) {
318
+ const leftBuffer = Buffer.from(left);
319
+ const rightBuffer = Buffer.from(right);
320
+ if (leftBuffer.length !== rightBuffer.length) {
321
+ return false;
322
+ }
323
+ return timingSafeEqual(leftBuffer, rightBuffer);
324
+ }
325
+
326
+ // ../../src/core/auth/oauth/oidcIdToken.ts
327
+ var JWKS_TTL_MS = 60 * 60 * 1000;
328
+ var discoveryCache = new Map;
329
+ var jwksCache = new Map;
330
+ function issuerOrigin(issuer) {
331
+ return issuer.replace(/\/$/, "");
332
+ }
333
+ async function fetchJson(url) {
334
+ const response = await safeFetch(url, { headers: { accept: "application/json" } }, { allowHttp: !isProductionEnv(), timeoutMs: 1e4, maxRedirects: 0 });
335
+ return await response.json();
336
+ }
337
+ async function loadOidcDiscovery(issuer) {
338
+ const key = issuerOrigin(issuer);
339
+ const cached = discoveryCache.get(key);
340
+ if (cached && Date.now() - cached.fetchedAt < JWKS_TTL_MS) {
341
+ return cached.discovery;
342
+ }
343
+ const discovery = await fetchJson(`${key}/.well-known/openid-configuration`);
344
+ if (!discovery?.authorization_endpoint?.trim() || !discovery.jwks_uri?.trim() || !discovery.token_endpoint?.trim()) {
345
+ throw new Error("OIDC discovery document did not include authorization_endpoint, jwks_uri, and token_endpoint.");
346
+ }
347
+ discoveryCache.set(key, { discovery, fetchedAt: Date.now() });
348
+ return discovery;
349
+ }
350
+ async function loadOidcJwks(jwksUri, options = {}) {
351
+ const cached = jwksCache.get(jwksUri);
352
+ if (!options.force && cached && Date.now() - cached.fetchedAt < JWKS_TTL_MS) {
353
+ return cached.keys;
354
+ }
355
+ const body = await fetchJson(jwksUri);
356
+ const keys = Array.isArray(body.keys) ? body.keys : [];
357
+ if (keys.length === 0) {
358
+ throw new Error("OIDC JWKS document did not include signing keys.");
359
+ }
360
+ jwksCache.set(jwksUri, { keys, fetchedAt: Date.now() });
361
+ return keys;
362
+ }
363
+ function decodeJwtPart(value) {
364
+ try {
365
+ return JSON.parse(Buffer.from(value, "base64url").toString("utf8"));
366
+ } catch {
367
+ return null;
368
+ }
369
+ }
370
+ function publicKeyFromJwk(jwk) {
371
+ if (jwk.kty !== "RSA" || !jwk.n || !jwk.e) {
372
+ return null;
373
+ }
374
+ return createPublicKey({
375
+ format: "jwk",
376
+ key: {
377
+ kty: "RSA",
378
+ n: jwk.n,
379
+ e: jwk.e
380
+ }
381
+ });
382
+ }
383
+ function verifyRs256Signature(signingInput, signature, key) {
384
+ const verifier = createVerify("RSA-SHA256");
385
+ verifier.update(signingInput);
386
+ verifier.end();
387
+ return verifier.verify(key, Buffer.from(signature, "base64url"));
388
+ }
389
+ function oidcAccessTokenHash(accessToken) {
390
+ const digest = createHash("sha256").update(accessToken).digest();
391
+ return digest.subarray(0, digest.length / 2).toString("base64url");
392
+ }
393
+ function assertIdTokenClaims(payload, options) {
394
+ if (payload.iss !== issuerOrigin(options.issuer)) {
395
+ throw new Error("OIDC ID token issuer mismatch.");
396
+ }
397
+ const audience = payload.aud;
398
+ const audiences = (Array.isArray(audience) ? audience : [audience]).filter((value) => typeof value === "string" && value.length > 0);
399
+ if (!audiences.includes(options.clientId)) {
400
+ throw new Error("OIDC ID token audience mismatch.");
401
+ }
402
+ if (audiences.length > 1 && payload.azp !== options.clientId) {
403
+ throw new Error("OIDC ID token authorized party mismatch.");
404
+ }
405
+ if (payload.azp !== undefined && payload.azp !== options.clientId) {
406
+ throw new Error("OIDC ID token authorized party mismatch.");
407
+ }
408
+ if (typeof payload.at_hash === "string" && !options.accessToken) {
409
+ throw new Error("OIDC ID token access token hash mismatch.");
410
+ }
411
+ if (options.accessToken) {
412
+ const expected = oidcAccessTokenHash(options.accessToken);
413
+ if (typeof payload.at_hash !== "string" || !timingSafeCompareString(payload.at_hash, expected)) {
414
+ throw new Error("OIDC ID token access token hash mismatch.");
415
+ }
416
+ }
417
+ if (typeof payload.nonce !== "string" || payload.nonce.length === 0) {
418
+ throw new Error("OIDC ID token nonce mismatch.");
419
+ }
420
+ if (payload.nonce !== options.nonce) {
421
+ throw new Error("OIDC ID token nonce mismatch.");
422
+ }
423
+ if (typeof payload.exp !== "number" || !Number.isFinite(payload.exp) || payload.exp * 1000 <= Date.now()) {
424
+ throw new Error("OIDC ID token is expired.");
425
+ }
426
+ if (typeof payload.nbf === "number" && payload.nbf * 1000 > Date.now()) {
427
+ throw new Error("OIDC ID token is not yet valid.");
428
+ }
429
+ }
430
+ async function verifyOidcIdToken(token, options) {
431
+ const parts = token.split(".");
432
+ if (parts.length !== 3 || !parts[0] || !parts[1] || !parts[2]) {
433
+ throw new Error("OIDC ID token signature is invalid.");
434
+ }
435
+ const header = decodeJwtPart(parts[0]);
436
+ if (header?.alg !== "RS256") {
437
+ throw new Error("OIDC ID token algorithm must be RS256.");
438
+ }
439
+ let keys = await loadOidcJwks(options.jwksUri);
440
+ let candidates = header.kid ? keys.filter((key) => key.kid === header.kid) : keys;
441
+ if (header.kid && candidates.length === 0) {
442
+ keys = await loadOidcJwks(options.jwksUri, { force: true });
443
+ candidates = keys.filter((key) => key.kid === header.kid);
444
+ }
445
+ const signingInput = `${parts[0]}.${parts[1]}`;
446
+ let verified = false;
447
+ for (const jwk of candidates) {
448
+ const key = publicKeyFromJwk(jwk);
449
+ if (key && verifyRs256Signature(signingInput, parts[2], key)) {
450
+ verified = true;
451
+ break;
452
+ }
453
+ }
454
+ if (!verified) {
455
+ throw new Error("OIDC ID token signature is invalid.");
456
+ }
457
+ const payload = decodeJwtPart(parts[1]);
458
+ if (!payload || payload.sub === undefined || payload.sub === null) {
459
+ throw new Error("OIDC ID token signature is invalid.");
460
+ }
461
+ assertIdTokenClaims(payload, options);
462
+ return payload;
463
+ }
464
+ function resetOidcDiscoveryCacheForTests() {
465
+ discoveryCache.clear();
466
+ jwksCache.clear();
467
+ }
468
+
469
+ // ../../src/core/auth/oauth/oidcProvider.ts
470
+ function base64Url(bytes) {
471
+ return Buffer.from(bytes).toString("base64url");
472
+ }
473
+ function createOidcHandshake() {
474
+ return {
475
+ state: randomBytes(24).toString("hex"),
476
+ nonce: randomBytes(24).toString("hex"),
477
+ codeVerifier: base64Url(randomBytes(32))
478
+ };
479
+ }
480
+ function codeChallenge(verifier) {
481
+ return createHash2("sha256").update(verifier).digest("base64url");
482
+ }
483
+ function requireOidcEndpoint(value, name) {
484
+ const trimmed = value?.trim() ?? "";
485
+ if (!trimmed) {
486
+ throw new Error(`OIDC discovery document did not include ${name}.`);
487
+ }
488
+ return trimmed;
489
+ }
490
+
3
491
  class OidcProvider {
4
492
  options;
5
493
  name;
@@ -7,43 +495,77 @@ class OidcProvider {
7
495
  this.options = options;
8
496
  this.name = options.name;
9
497
  }
10
- getAuthorizationUrl(state, redirectUri = this.options.redirectUri) {
498
+ getAuthorizationUrl(_state, _redirectUri = this.options.redirectUri) {
499
+ throw new Error("OidcProvider.getAuthorizationUrl cannot complete PKCE. Use createAuthorization() and pass the handshake to exchangeCode().");
500
+ }
501
+ async createAuthorization(state = createOidcHandshake().state, redirectUri = this.options.redirectUri) {
502
+ const discovery = await loadOidcDiscovery(this.options.issuer);
503
+ const handshake = { ...createOidcHandshake(), state };
504
+ return {
505
+ url: this.buildAuthorizationUrl(state, handshake, redirectUri, requireOidcEndpoint(discovery.authorization_endpoint, "authorization_endpoint")),
506
+ handshake
507
+ };
508
+ }
509
+ buildAuthorizationUrl(state, handshake, redirectUri, authorizationEndpoint) {
11
510
  const params = new URLSearchParams({
12
511
  client_id: this.options.clientId,
13
512
  redirect_uri: redirectUri,
14
513
  response_type: "code",
15
514
  scope: (this.options.scopes ?? ["openid", "email", "profile"]).join(" "),
16
- state
515
+ state,
516
+ nonce: handshake.nonce,
517
+ code_challenge: codeChallenge(handshake.codeVerifier),
518
+ code_challenge_method: "S256"
17
519
  });
18
- return `${this.options.issuer.replace(/\/$/, "")}/authorize?${params.toString()}`;
520
+ return `${authorizationEndpoint}?${params.toString()}`;
19
521
  }
20
- async exchangeCode(code, redirectUri = this.options.redirectUri) {
21
- const tokenResponse = await fetch(`${this.options.issuer.replace(/\/$/, "")}/token`, {
522
+ async exchangeCode(code, redirectUri = this.options.redirectUri, handshake) {
523
+ if (!handshake?.nonce?.trim() || !handshake.codeVerifier?.trim()) {
524
+ throw new Error("OIDC token exchange requires the PKCE handshake from createAuthorization().");
525
+ }
526
+ const discovery = await loadOidcDiscovery(this.options.issuer);
527
+ const body = new URLSearchParams({
528
+ grant_type: "authorization_code",
529
+ code,
530
+ redirect_uri: redirectUri,
531
+ client_id: this.options.clientId,
532
+ client_secret: this.options.clientSecret,
533
+ code_verifier: handshake.codeVerifier
534
+ });
535
+ const tokenResponse = await safeFetch(requireOidcEndpoint(discovery.token_endpoint, "token_endpoint"), {
22
536
  method: "POST",
23
537
  headers: { "content-type": "application/x-www-form-urlencoded" },
24
- body: new URLSearchParams({
25
- grant_type: "authorization_code",
26
- code,
27
- redirect_uri: redirectUri,
28
- client_id: this.options.clientId,
29
- client_secret: this.options.clientSecret
30
- })
31
- });
538
+ body
539
+ }, { allowHttp: !isProductionEnv(), timeoutMs: 1e4, maxRedirects: 0 });
32
540
  const tokenBody = await tokenResponse.json();
33
541
  if (!tokenBody.access_token) {
34
542
  throw new Error("OIDC token exchange failed.");
35
543
  }
36
- const profileResponse = await fetch(`${this.options.issuer.replace(/\/$/, "")}/userinfo`, {
37
- headers: { authorization: `Bearer ${tokenBody.access_token}` }
544
+ if (!tokenBody.id_token) {
545
+ throw new Error("OIDC token exchange did not return an ID token.");
546
+ }
547
+ const payload = await verifyOidcIdToken(tokenBody.id_token, {
548
+ issuer: this.options.issuer,
549
+ clientId: this.options.clientId,
550
+ nonce: handshake.nonce,
551
+ jwksUri: requireOidcEndpoint(discovery.jwks_uri, "jwks_uri"),
552
+ accessToken: tokenBody.access_token
38
553
  });
39
- const profile = await profileResponse.json();
554
+ if (typeof payload.email !== "string" || !payload.email.trim()) {
555
+ throw new Error("OIDC ID token did not include an email address.");
556
+ }
557
+ if (payload.email_verified !== true) {
558
+ throw new Error("OIDC ID token email is not verified.");
559
+ }
40
560
  return {
41
- providerUserId: profile.sub,
42
- email: profile.email ?? `${profile.sub}@oidc.local`,
43
- name: profile.name ?? profile.sub
561
+ providerUserId: String(payload.sub),
562
+ email: payload.email,
563
+ name: typeof payload.name === "string" && payload.name ? payload.name : String(payload.sub)
44
564
  };
45
565
  }
46
566
  }
47
567
  export {
48
- OidcProvider
568
+ OidcProvider,
569
+ createOidcHandshake,
570
+ resetOidcDiscoveryCacheForTests
49
571
  };