@better-auth/sso 1.5.0-beta.1 → 1.5.0-beta.10

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.
package/dist/index.mjs CHANGED
@@ -1,16 +1,69 @@
1
- import { APIError, createAuthEndpoint, createAuthMiddleware, sessionMiddleware } from "better-auth/api";
1
+ import { APIError, createAuthEndpoint, createAuthMiddleware, getSessionFromCtx, sessionMiddleware } from "better-auth/api";
2
2
  import { XMLParser, XMLValidator } from "fast-xml-parser";
3
3
  import * as saml from "samlify";
4
+ import { X509Certificate } from "node:crypto";
4
5
  import { generateRandomString } from "better-auth/crypto";
5
6
  import * as z$1 from "zod/v4";
6
7
  import z from "zod/v4";
7
8
  import { base64 } from "@better-auth/utils/base64";
8
9
  import { BetterFetchError, betterFetch } from "@better-fetch/fetch";
9
- import { HIDE_METADATA, createAuthorizationURL, generateState, parseState, validateAuthorizationCode, validateToken } from "better-auth";
10
+ import { HIDE_METADATA, createAuthorizationURL, generateGenericState, generateState, parseGenericState, parseState, validateAuthorizationCode, validateToken } from "better-auth";
10
11
  import { setSessionCookie } from "better-auth/cookies";
11
12
  import { handleOAuthUserInfo } from "better-auth/oauth2";
12
13
  import { decodeJwt } from "jose";
14
+ import { APIError as APIError$1 } from "better-call";
13
15
 
16
+ //#region src/utils.ts
17
+ /**
18
+ * Safely parses a value that might be a JSON string or already a parsed object.
19
+ * This handles cases where ORMs like Drizzle might return already parsed objects
20
+ * instead of JSON strings from TEXT/JSON columns.
21
+ *
22
+ * @param value - The value to parse (string, object, null, or undefined)
23
+ * @returns The parsed object or null
24
+ * @throws Error if string parsing fails
25
+ */
26
+ function safeJsonParse(value) {
27
+ if (!value) return null;
28
+ if (typeof value === "object") return value;
29
+ if (typeof value === "string") try {
30
+ return JSON.parse(value);
31
+ } catch (error) {
32
+ throw new Error(`Failed to parse JSON: ${error instanceof Error ? error.message : "Unknown error"}`);
33
+ }
34
+ return null;
35
+ }
36
+ /**
37
+ * Checks if a domain matches any domain in a comma-separated list.
38
+ */
39
+ const domainMatches = (searchDomain, domainList) => {
40
+ const search = searchDomain.toLowerCase();
41
+ return domainList.split(",").map((d) => d.trim().toLowerCase()).filter(Boolean).some((d) => search === d || search.endsWith(`.${d}`));
42
+ };
43
+ /**
44
+ * Validates email domain against allowed domain(s).
45
+ * Supports comma-separated domains for multi-domain SSO.
46
+ */
47
+ const validateEmailDomain = (email, domain) => {
48
+ const emailDomain = email.split("@")[1]?.toLowerCase();
49
+ if (!emailDomain || !domain) return false;
50
+ return domainMatches(emailDomain, domain);
51
+ };
52
+ function parseCertificate(certPem) {
53
+ const cert = new X509Certificate(certPem.includes("-----BEGIN") ? certPem : `-----BEGIN CERTIFICATE-----\n${certPem}\n-----END CERTIFICATE-----`);
54
+ return {
55
+ fingerprintSha256: cert.fingerprint256,
56
+ notBefore: cert.validFrom,
57
+ notAfter: cert.validTo,
58
+ publicKeyAlgorithm: cert.publicKey.asymmetricKeyType?.toUpperCase() || "UNKNOWN"
59
+ };
60
+ }
61
+ function maskClientId(clientId) {
62
+ if (clientId.length <= 4) return "****";
63
+ return `****${clientId.slice(-4)}`;
64
+ }
65
+
66
+ //#endregion
14
67
  //#region src/linking/org-assignment.ts
15
68
  /**
16
69
  * Assigns a user to an organization based on the SSO provider's organizationId.
@@ -20,7 +73,7 @@ async function assignOrganizationFromProvider(ctx, options) {
20
73
  const { user, profile, provider, token, provisioningOptions } = options;
21
74
  if (!provider.organizationId) return;
22
75
  if (provisioningOptions?.disabled) return;
23
- if (!ctx.context.options.plugins?.find((plugin) => plugin.id === "organization")) return;
76
+ if (!ctx.context.hasPlugin("organization")) return;
24
77
  if (await ctx.context.adapter.findOne({
25
78
  model: "member",
26
79
  where: [{
@@ -58,7 +111,7 @@ async function assignOrganizationFromProvider(ctx, options) {
58
111
  async function assignOrganizationByDomain(ctx, options) {
59
112
  const { user, provisioningOptions, domainVerification } = options;
60
113
  if (provisioningOptions?.disabled) return;
61
- if (!ctx.context.options.plugins?.find((plugin) => plugin.id === "organization")) return;
114
+ if (!ctx.context.hasPlugin("organization")) return;
62
115
  const domain = user.email.split("@")[1];
63
116
  if (!domain) return;
64
117
  const whereClause = [{
@@ -69,10 +122,17 @@ async function assignOrganizationByDomain(ctx, options) {
69
122
  field: "domainVerified",
70
123
  value: true
71
124
  });
72
- const ssoProvider = await ctx.context.adapter.findOne({
125
+ let ssoProvider = await ctx.context.adapter.findOne({
73
126
  model: "ssoProvider",
74
127
  where: whereClause
75
128
  });
129
+ if (!ssoProvider) ssoProvider = (await ctx.context.adapter.findMany({
130
+ model: "ssoProvider",
131
+ where: domainVerification?.enabled ? [{
132
+ field: "domainVerified",
133
+ value: true
134
+ }] : []
135
+ })).find((p) => domainMatches(domain, p.domain)) ?? null;
76
136
  if (!ssoProvider || !ssoProvider.organizationId) return;
77
137
  if (await ctx.context.adapter.findOne({
78
138
  model: "member",
@@ -306,118 +366,761 @@ const DEFAULT_ASSERTION_TTL_MS = 900 * 1e3;
306
366
  * - Distributed systems across timezones
307
367
  */
308
368
  const DEFAULT_CLOCK_SKEW_MS = 300 * 1e3;
309
-
310
- //#endregion
311
- //#region src/oidc/types.ts
312
369
  /**
313
- * Custom error class for OIDC discovery failures.
314
- * Can be caught and mapped to APIError at the edge.
370
+ * Default maximum size for SAML responses (256 KB).
371
+ * Protects against memory exhaustion from oversized SAML payloads.
315
372
  */
316
- var DiscoveryError = class DiscoveryError extends Error {
317
- code;
318
- details;
319
- constructor(code, message, details, options) {
320
- super(message, options);
321
- this.name = "DiscoveryError";
322
- this.code = code;
323
- this.details = details;
324
- if (Error.captureStackTrace) Error.captureStackTrace(this, DiscoveryError);
325
- }
326
- };
373
+ const DEFAULT_MAX_SAML_RESPONSE_SIZE = 256 * 1024;
327
374
  /**
328
- * Required fields that must be present in a valid discovery document.
375
+ * Default maximum size for IdP metadata (100 KB).
376
+ * Protects against oversized metadata documents.
329
377
  */
330
- const REQUIRED_DISCOVERY_FIELDS = [
331
- "issuer",
332
- "authorization_endpoint",
333
- "token_endpoint",
334
- "jwks_uri"
335
- ];
378
+ const DEFAULT_MAX_SAML_METADATA_SIZE = 100 * 1024;
336
379
 
337
380
  //#endregion
338
- //#region src/oidc/discovery.ts
339
- /**
340
- * OIDC Discovery Pipeline
341
- *
342
- * Implements OIDC discovery document fetching, validation, and hydration.
343
- * This module is used both at provider registration time (to persist validated config)
344
- * and at runtime (to hydrate legacy providers that are missing metadata).
345
- *
346
- * @see https://openid.net/specs/openid-connect-discovery-1_0.html
347
- */
348
- /** Default timeout for discovery requests (10 seconds) */
349
- const DEFAULT_DISCOVERY_TIMEOUT = 1e4;
350
- /**
351
- * Main entry point: Discover and hydrate OIDC configuration from an issuer.
352
- *
353
- * This function:
354
- * 1. Computes the discovery URL from the issuer
355
- * 2. Validates the discovery URL
356
- * 3. Fetches the discovery document
357
- * 4. Validates the discovery document (issuer match + required fields)
358
- * 5. Normalizes URLs
359
- * 6. Selects token endpoint auth method
360
- * 7. Merges with existing config (existing values take precedence)
361
- *
362
- * @param params - Discovery parameters
363
- * @param isTrustedOrigin - Origin verification tester function
364
- * @returns Hydrated OIDC configuration ready for persistence
365
- * @throws DiscoveryError on any failure
366
- */
367
- async function discoverOIDCConfig(params) {
368
- const { issuer, existingConfig, timeout = DEFAULT_DISCOVERY_TIMEOUT } = params;
369
- const discoveryUrl = params.discoveryEndpoint || existingConfig?.discoveryEndpoint || computeDiscoveryUrl(issuer);
370
- validateDiscoveryUrl(discoveryUrl, params.isTrustedOrigin);
371
- const discoveryDoc = await fetchDiscoveryDocument(discoveryUrl, timeout);
372
- validateDiscoveryDocument(discoveryDoc, issuer);
373
- const normalizedDoc = normalizeDiscoveryUrls(discoveryDoc, issuer, params.isTrustedOrigin);
374
- const tokenEndpointAuth = selectTokenEndpointAuthMethod(normalizedDoc, existingConfig?.tokenEndpointAuthentication);
375
- return {
376
- issuer: existingConfig?.issuer ?? normalizedDoc.issuer,
377
- discoveryEndpoint: existingConfig?.discoveryEndpoint ?? discoveryUrl,
378
- authorizationEndpoint: existingConfig?.authorizationEndpoint ?? normalizedDoc.authorization_endpoint,
379
- tokenEndpoint: existingConfig?.tokenEndpoint ?? normalizedDoc.token_endpoint,
380
- jwksEndpoint: existingConfig?.jwksEndpoint ?? normalizedDoc.jwks_uri,
381
- userInfoEndpoint: existingConfig?.userInfoEndpoint ?? normalizedDoc.userinfo_endpoint,
382
- tokenEndpointAuthentication: existingConfig?.tokenEndpointAuthentication ?? tokenEndpointAuth,
383
- scopesSupported: existingConfig?.scopesSupported ?? normalizedDoc.scopes_supported
384
- };
381
+ //#region src/saml/parser.ts
382
+ const xmlParser = new XMLParser({
383
+ ignoreAttributes: false,
384
+ attributeNamePrefix: "@_",
385
+ removeNSPrefix: true,
386
+ processEntities: false
387
+ });
388
+ function findNode(obj, nodeName) {
389
+ if (!obj || typeof obj !== "object") return null;
390
+ const record = obj;
391
+ if (nodeName in record) return record[nodeName];
392
+ for (const value of Object.values(record)) if (Array.isArray(value)) for (const item of value) {
393
+ const found = findNode(item, nodeName);
394
+ if (found) return found;
395
+ }
396
+ else if (typeof value === "object" && value !== null) {
397
+ const found = findNode(value, nodeName);
398
+ if (found) return found;
399
+ }
400
+ return null;
385
401
  }
386
- /**
387
- * Compute the discovery URL from an issuer URL.
388
- *
389
- * Per OIDC Discovery spec, the discovery document is located at:
390
- * <issuer>/.well-known/openid-configuration
391
- *
392
- * Handles trailing slashes correctly.
393
- */
394
- function computeDiscoveryUrl(issuer) {
395
- return `${issuer.endsWith("/") ? issuer.slice(0, -1) : issuer}/.well-known/openid-configuration`;
402
+ function countAllNodes(obj, nodeName) {
403
+ if (!obj || typeof obj !== "object") return 0;
404
+ let count = 0;
405
+ const record = obj;
406
+ if (nodeName in record) {
407
+ const node = record[nodeName];
408
+ count += Array.isArray(node) ? node.length : 1;
409
+ }
410
+ for (const value of Object.values(record)) if (Array.isArray(value)) for (const item of value) count += countAllNodes(item, nodeName);
411
+ else if (typeof value === "object" && value !== null) count += countAllNodes(value, nodeName);
412
+ return count;
396
413
  }
397
- /**
398
- * Validate a discovery URL before fetching.
399
- *
400
- * @param url - The discovery URL to validate
401
- * @param isTrustedOrigin - Origin verification tester function
402
- * @throws DiscoveryError if URL is invalid
403
- */
404
- function validateDiscoveryUrl(url, isTrustedOrigin) {
405
- const discoveryEndpoint = parseURL("discoveryEndpoint", url).toString();
406
- if (!isTrustedOrigin(discoveryEndpoint)) throw new DiscoveryError("discovery_untrusted_origin", `The main discovery endpoint "${discoveryEndpoint}" is not trusted by your trusted origins configuration.`, { url: discoveryEndpoint });
414
+
415
+ //#endregion
416
+ //#region src/saml/algorithms.ts
417
+ const SignatureAlgorithm = {
418
+ RSA_SHA1: "http://www.w3.org/2000/09/xmldsig#rsa-sha1",
419
+ RSA_SHA256: "http://www.w3.org/2001/04/xmldsig-more#rsa-sha256",
420
+ RSA_SHA384: "http://www.w3.org/2001/04/xmldsig-more#rsa-sha384",
421
+ RSA_SHA512: "http://www.w3.org/2001/04/xmldsig-more#rsa-sha512",
422
+ ECDSA_SHA256: "http://www.w3.org/2001/04/xmldsig-more#ecdsa-sha256",
423
+ ECDSA_SHA384: "http://www.w3.org/2001/04/xmldsig-more#ecdsa-sha384",
424
+ ECDSA_SHA512: "http://www.w3.org/2001/04/xmldsig-more#ecdsa-sha512"
425
+ };
426
+ const DigestAlgorithm = {
427
+ SHA1: "http://www.w3.org/2000/09/xmldsig#sha1",
428
+ SHA256: "http://www.w3.org/2001/04/xmlenc#sha256",
429
+ SHA384: "http://www.w3.org/2001/04/xmldsig-more#sha384",
430
+ SHA512: "http://www.w3.org/2001/04/xmlenc#sha512"
431
+ };
432
+ const KeyEncryptionAlgorithm = {
433
+ RSA_1_5: "http://www.w3.org/2001/04/xmlenc#rsa-1_5",
434
+ RSA_OAEP: "http://www.w3.org/2001/04/xmlenc#rsa-oaep-mgf1p",
435
+ RSA_OAEP_SHA256: "http://www.w3.org/2009/xmlenc11#rsa-oaep"
436
+ };
437
+ const DataEncryptionAlgorithm = {
438
+ TRIPLEDES_CBC: "http://www.w3.org/2001/04/xmlenc#tripledes-cbc",
439
+ AES_128_CBC: "http://www.w3.org/2001/04/xmlenc#aes128-cbc",
440
+ AES_192_CBC: "http://www.w3.org/2001/04/xmlenc#aes192-cbc",
441
+ AES_256_CBC: "http://www.w3.org/2001/04/xmlenc#aes256-cbc",
442
+ AES_128_GCM: "http://www.w3.org/2009/xmlenc11#aes128-gcm",
443
+ AES_192_GCM: "http://www.w3.org/2009/xmlenc11#aes192-gcm",
444
+ AES_256_GCM: "http://www.w3.org/2009/xmlenc11#aes256-gcm"
445
+ };
446
+ const DEPRECATED_SIGNATURE_ALGORITHMS = [SignatureAlgorithm.RSA_SHA1];
447
+ const DEPRECATED_KEY_ENCRYPTION_ALGORITHMS = [KeyEncryptionAlgorithm.RSA_1_5];
448
+ const DEPRECATED_DATA_ENCRYPTION_ALGORITHMS = [DataEncryptionAlgorithm.TRIPLEDES_CBC];
449
+ const DEPRECATED_DIGEST_ALGORITHMS = [DigestAlgorithm.SHA1];
450
+ const SECURE_SIGNATURE_ALGORITHMS = [
451
+ SignatureAlgorithm.RSA_SHA256,
452
+ SignatureAlgorithm.RSA_SHA384,
453
+ SignatureAlgorithm.RSA_SHA512,
454
+ SignatureAlgorithm.ECDSA_SHA256,
455
+ SignatureAlgorithm.ECDSA_SHA384,
456
+ SignatureAlgorithm.ECDSA_SHA512
457
+ ];
458
+ const SECURE_DIGEST_ALGORITHMS = [
459
+ DigestAlgorithm.SHA256,
460
+ DigestAlgorithm.SHA384,
461
+ DigestAlgorithm.SHA512
462
+ ];
463
+ const SHORT_FORM_SIGNATURE_TO_URI = {
464
+ sha1: SignatureAlgorithm.RSA_SHA1,
465
+ sha256: SignatureAlgorithm.RSA_SHA256,
466
+ sha384: SignatureAlgorithm.RSA_SHA384,
467
+ sha512: SignatureAlgorithm.RSA_SHA512,
468
+ "rsa-sha1": SignatureAlgorithm.RSA_SHA1,
469
+ "rsa-sha256": SignatureAlgorithm.RSA_SHA256,
470
+ "rsa-sha384": SignatureAlgorithm.RSA_SHA384,
471
+ "rsa-sha512": SignatureAlgorithm.RSA_SHA512,
472
+ "ecdsa-sha256": SignatureAlgorithm.ECDSA_SHA256,
473
+ "ecdsa-sha384": SignatureAlgorithm.ECDSA_SHA384,
474
+ "ecdsa-sha512": SignatureAlgorithm.ECDSA_SHA512
475
+ };
476
+ const SHORT_FORM_DIGEST_TO_URI = {
477
+ sha1: DigestAlgorithm.SHA1,
478
+ sha256: DigestAlgorithm.SHA256,
479
+ sha384: DigestAlgorithm.SHA384,
480
+ sha512: DigestAlgorithm.SHA512
481
+ };
482
+ function normalizeSignatureAlgorithm(alg) {
483
+ return SHORT_FORM_SIGNATURE_TO_URI[alg.toLowerCase()] ?? alg;
407
484
  }
408
- /**
409
- * Fetch the OIDC discovery document from the IdP.
410
- *
411
- * @param url - The discovery endpoint URL
412
- * @param timeout - Request timeout in milliseconds
413
- * @returns The parsed discovery document
414
- * @throws DiscoveryError on network errors, timeouts, or invalid responses
415
- */
416
- async function fetchDiscoveryDocument(url, timeout = DEFAULT_DISCOVERY_TIMEOUT) {
485
+ function normalizeDigestAlgorithm(alg) {
486
+ return SHORT_FORM_DIGEST_TO_URI[alg.toLowerCase()] ?? alg;
487
+ }
488
+ function extractEncryptionAlgorithms(xml) {
417
489
  try {
418
- const response = await betterFetch(url, {
419
- method: "GET",
420
- timeout
490
+ const parsed = xmlParser.parse(xml);
491
+ const keyAlg = (findNode(parsed, "EncryptedKey")?.EncryptionMethod)?.["@_Algorithm"];
492
+ const dataAlg = (findNode(parsed, "EncryptedData")?.EncryptionMethod)?.["@_Algorithm"];
493
+ return {
494
+ keyEncryption: keyAlg || null,
495
+ dataEncryption: dataAlg || null
496
+ };
497
+ } catch {
498
+ return {
499
+ keyEncryption: null,
500
+ dataEncryption: null
501
+ };
502
+ }
503
+ }
504
+ function hasEncryptedAssertion(xml) {
505
+ try {
506
+ return findNode(xmlParser.parse(xml), "EncryptedAssertion") !== null;
507
+ } catch {
508
+ return false;
509
+ }
510
+ }
511
+ function handleDeprecatedAlgorithm(message, behavior, errorCode) {
512
+ switch (behavior) {
513
+ case "reject": throw new APIError("BAD_REQUEST", {
514
+ message,
515
+ code: errorCode
516
+ });
517
+ case "warn":
518
+ console.warn(`[SAML Security Warning] ${message}`);
519
+ break;
520
+ case "allow": break;
521
+ }
522
+ }
523
+ function validateSignatureAlgorithm(algorithm, options = {}) {
524
+ if (!algorithm) return;
525
+ const { onDeprecated = "warn", allowedSignatureAlgorithms } = options;
526
+ if (allowedSignatureAlgorithms) {
527
+ if (!allowedSignatureAlgorithms.includes(algorithm)) throw new APIError("BAD_REQUEST", {
528
+ message: `SAML signature algorithm not in allow-list: ${algorithm}`,
529
+ code: "SAML_ALGORITHM_NOT_ALLOWED"
530
+ });
531
+ return;
532
+ }
533
+ if (DEPRECATED_SIGNATURE_ALGORITHMS.includes(algorithm)) {
534
+ handleDeprecatedAlgorithm(`SAML response uses deprecated signature algorithm: ${algorithm}. Please configure your IdP to use SHA-256 or stronger.`, onDeprecated, "SAML_DEPRECATED_ALGORITHM");
535
+ return;
536
+ }
537
+ if (!SECURE_SIGNATURE_ALGORITHMS.includes(algorithm)) throw new APIError("BAD_REQUEST", {
538
+ message: `SAML signature algorithm not recognized: ${algorithm}`,
539
+ code: "SAML_UNKNOWN_ALGORITHM"
540
+ });
541
+ }
542
+ function validateEncryptionAlgorithms(algorithms, options = {}) {
543
+ const { onDeprecated = "warn", allowedKeyEncryptionAlgorithms, allowedDataEncryptionAlgorithms } = options;
544
+ const { keyEncryption, dataEncryption } = algorithms;
545
+ if (keyEncryption) {
546
+ if (allowedKeyEncryptionAlgorithms) {
547
+ if (!allowedKeyEncryptionAlgorithms.includes(keyEncryption)) throw new APIError("BAD_REQUEST", {
548
+ message: `SAML key encryption algorithm not in allow-list: ${keyEncryption}`,
549
+ code: "SAML_ALGORITHM_NOT_ALLOWED"
550
+ });
551
+ } else if (DEPRECATED_KEY_ENCRYPTION_ALGORITHMS.includes(keyEncryption)) handleDeprecatedAlgorithm(`SAML response uses deprecated key encryption algorithm: ${keyEncryption}. Please configure your IdP to use RSA-OAEP.`, onDeprecated, "SAML_DEPRECATED_ALGORITHM");
552
+ }
553
+ if (dataEncryption) {
554
+ if (allowedDataEncryptionAlgorithms) {
555
+ if (!allowedDataEncryptionAlgorithms.includes(dataEncryption)) throw new APIError("BAD_REQUEST", {
556
+ message: `SAML data encryption algorithm not in allow-list: ${dataEncryption}`,
557
+ code: "SAML_ALGORITHM_NOT_ALLOWED"
558
+ });
559
+ } else if (DEPRECATED_DATA_ENCRYPTION_ALGORITHMS.includes(dataEncryption)) handleDeprecatedAlgorithm(`SAML response uses deprecated data encryption algorithm: ${dataEncryption}. Please configure your IdP to use AES-GCM.`, onDeprecated, "SAML_DEPRECATED_ALGORITHM");
560
+ }
561
+ }
562
+ function validateSAMLAlgorithms(response, options) {
563
+ validateSignatureAlgorithm(response.sigAlg, options);
564
+ if (hasEncryptedAssertion(response.samlContent)) validateEncryptionAlgorithms(extractEncryptionAlgorithms(response.samlContent), options);
565
+ }
566
+ function validateConfigAlgorithms(config, options = {}) {
567
+ const { onDeprecated = "warn", allowedSignatureAlgorithms, allowedDigestAlgorithms } = options;
568
+ if (config.signatureAlgorithm) {
569
+ const normalized = normalizeSignatureAlgorithm(config.signatureAlgorithm);
570
+ if (allowedSignatureAlgorithms) {
571
+ if (!allowedSignatureAlgorithms.map(normalizeSignatureAlgorithm).includes(normalized)) throw new APIError("BAD_REQUEST", {
572
+ message: `SAML signature algorithm not in allow-list: ${config.signatureAlgorithm}`,
573
+ code: "SAML_ALGORITHM_NOT_ALLOWED"
574
+ });
575
+ } else if (DEPRECATED_SIGNATURE_ALGORITHMS.includes(normalized)) handleDeprecatedAlgorithm(`SAML config uses deprecated signature algorithm: ${config.signatureAlgorithm}. Consider using SHA-256 or stronger.`, onDeprecated, "SAML_DEPRECATED_CONFIG_ALGORITHM");
576
+ else if (!SECURE_SIGNATURE_ALGORITHMS.includes(normalized)) throw new APIError("BAD_REQUEST", {
577
+ message: `SAML signature algorithm not recognized: ${config.signatureAlgorithm}`,
578
+ code: "SAML_UNKNOWN_ALGORITHM"
579
+ });
580
+ }
581
+ if (config.digestAlgorithm) {
582
+ const normalized = normalizeDigestAlgorithm(config.digestAlgorithm);
583
+ if (allowedDigestAlgorithms) {
584
+ if (!allowedDigestAlgorithms.map(normalizeDigestAlgorithm).includes(normalized)) throw new APIError("BAD_REQUEST", {
585
+ message: `SAML digest algorithm not in allow-list: ${config.digestAlgorithm}`,
586
+ code: "SAML_ALGORITHM_NOT_ALLOWED"
587
+ });
588
+ } else if (DEPRECATED_DIGEST_ALGORITHMS.includes(normalized)) handleDeprecatedAlgorithm(`SAML config uses deprecated digest algorithm: ${config.digestAlgorithm}. Consider using SHA-256 or stronger.`, onDeprecated, "SAML_DEPRECATED_CONFIG_ALGORITHM");
589
+ else if (!SECURE_DIGEST_ALGORITHMS.includes(normalized)) throw new APIError("BAD_REQUEST", {
590
+ message: `SAML digest algorithm not recognized: ${config.digestAlgorithm}`,
591
+ code: "SAML_UNKNOWN_ALGORITHM"
592
+ });
593
+ }
594
+ }
595
+
596
+ //#endregion
597
+ //#region src/saml/assertions.ts
598
+ /** @lintignore used in tests */
599
+ function countAssertions(xml) {
600
+ let parsed;
601
+ try {
602
+ parsed = xmlParser.parse(xml);
603
+ } catch {
604
+ throw new APIError("BAD_REQUEST", {
605
+ message: "Failed to parse SAML response XML",
606
+ code: "SAML_INVALID_XML"
607
+ });
608
+ }
609
+ const assertions = countAllNodes(parsed, "Assertion");
610
+ const encryptedAssertions = countAllNodes(parsed, "EncryptedAssertion");
611
+ return {
612
+ assertions,
613
+ encryptedAssertions,
614
+ total: assertions + encryptedAssertions
615
+ };
616
+ }
617
+ function validateSingleAssertion(samlResponse) {
618
+ let xml;
619
+ try {
620
+ xml = new TextDecoder().decode(base64.decode(samlResponse));
621
+ if (!xml.includes("<")) throw new Error("Not XML");
622
+ } catch {
623
+ throw new APIError("BAD_REQUEST", {
624
+ message: "Invalid base64-encoded SAML response",
625
+ code: "SAML_INVALID_ENCODING"
626
+ });
627
+ }
628
+ const counts = countAssertions(xml);
629
+ if (counts.total === 0) throw new APIError("BAD_REQUEST", {
630
+ message: "SAML response contains no assertions",
631
+ code: "SAML_NO_ASSERTION"
632
+ });
633
+ if (counts.total > 1) throw new APIError("BAD_REQUEST", {
634
+ message: `SAML response contains ${counts.total} assertions, expected exactly 1`,
635
+ code: "SAML_MULTIPLE_ASSERTIONS"
636
+ });
637
+ }
638
+
639
+ //#endregion
640
+ //#region src/routes/schemas.ts
641
+ const oidcMappingSchema = z.object({
642
+ id: z.string().optional(),
643
+ email: z.string().optional(),
644
+ emailVerified: z.string().optional(),
645
+ name: z.string().optional(),
646
+ image: z.string().optional(),
647
+ extraFields: z.record(z.string(), z.any()).optional()
648
+ }).optional();
649
+ const samlMappingSchema = z.object({
650
+ id: z.string().optional(),
651
+ email: z.string().optional(),
652
+ emailVerified: z.string().optional(),
653
+ name: z.string().optional(),
654
+ firstName: z.string().optional(),
655
+ lastName: z.string().optional(),
656
+ extraFields: z.record(z.string(), z.any()).optional()
657
+ }).optional();
658
+ const oidcConfigSchema = z.object({
659
+ clientId: z.string().optional(),
660
+ clientSecret: z.string().optional(),
661
+ authorizationEndpoint: z.string().url().optional(),
662
+ tokenEndpoint: z.string().url().optional(),
663
+ userInfoEndpoint: z.string().url().optional(),
664
+ tokenEndpointAuthentication: z.enum(["client_secret_post", "client_secret_basic"]).optional(),
665
+ jwksEndpoint: z.string().url().optional(),
666
+ discoveryEndpoint: z.string().url().optional(),
667
+ scopes: z.array(z.string()).optional(),
668
+ pkce: z.boolean().optional(),
669
+ overrideUserInfo: z.boolean().optional(),
670
+ mapping: oidcMappingSchema
671
+ });
672
+ const samlConfigSchema = z.object({
673
+ entryPoint: z.string().url().optional(),
674
+ cert: z.string().optional(),
675
+ callbackUrl: z.string().url().optional(),
676
+ audience: z.string().optional(),
677
+ idpMetadata: z.object({
678
+ metadata: z.string().optional(),
679
+ entityID: z.string().optional(),
680
+ cert: z.string().optional(),
681
+ privateKey: z.string().optional(),
682
+ privateKeyPass: z.string().optional(),
683
+ isAssertionEncrypted: z.boolean().optional(),
684
+ encPrivateKey: z.string().optional(),
685
+ encPrivateKeyPass: z.string().optional(),
686
+ singleSignOnService: z.array(z.object({
687
+ Binding: z.string(),
688
+ Location: z.string().url()
689
+ })).optional()
690
+ }).optional(),
691
+ spMetadata: z.object({
692
+ metadata: z.string().optional(),
693
+ entityID: z.string().optional(),
694
+ binding: z.string().optional(),
695
+ privateKey: z.string().optional(),
696
+ privateKeyPass: z.string().optional(),
697
+ isAssertionEncrypted: z.boolean().optional(),
698
+ encPrivateKey: z.string().optional(),
699
+ encPrivateKeyPass: z.string().optional()
700
+ }).optional(),
701
+ wantAssertionsSigned: z.boolean().optional(),
702
+ authnRequestsSigned: z.boolean().optional(),
703
+ signatureAlgorithm: z.string().optional(),
704
+ digestAlgorithm: z.string().optional(),
705
+ identifierFormat: z.string().optional(),
706
+ privateKey: z.string().optional(),
707
+ decryptionPvk: z.string().optional(),
708
+ additionalParams: z.record(z.string(), z.any()).optional(),
709
+ mapping: samlMappingSchema
710
+ });
711
+ const updateSSOProviderBodySchema = z.object({
712
+ issuer: z.string().url().optional(),
713
+ domain: z.string().optional(),
714
+ oidcConfig: oidcConfigSchema.optional(),
715
+ samlConfig: samlConfigSchema.optional()
716
+ });
717
+
718
+ //#endregion
719
+ //#region src/routes/providers.ts
720
+ const ADMIN_ROLES = ["owner", "admin"];
721
+ async function isOrgAdmin(ctx, userId, organizationId) {
722
+ const member = await ctx.context.adapter.findOne({
723
+ model: "member",
724
+ where: [{
725
+ field: "userId",
726
+ value: userId
727
+ }, {
728
+ field: "organizationId",
729
+ value: organizationId
730
+ }]
731
+ });
732
+ if (!member) return false;
733
+ return member.role.split(",").some((r) => ADMIN_ROLES.includes(r.trim()));
734
+ }
735
+ async function batchCheckOrgAdmin(ctx, userId, organizationIds) {
736
+ if (organizationIds.length === 0) return /* @__PURE__ */ new Set();
737
+ const members = await ctx.context.adapter.findMany({
738
+ model: "member",
739
+ where: [{
740
+ field: "userId",
741
+ value: userId
742
+ }, {
743
+ field: "organizationId",
744
+ value: organizationIds,
745
+ operator: "in"
746
+ }]
747
+ });
748
+ const adminOrgIds = /* @__PURE__ */ new Set();
749
+ for (const member of members) if (member.role.split(",").some((r) => ADMIN_ROLES.includes(r.trim()))) adminOrgIds.add(member.organizationId);
750
+ return adminOrgIds;
751
+ }
752
+ function sanitizeProvider(provider, baseURL) {
753
+ let oidcConfig = null;
754
+ let samlConfig = null;
755
+ try {
756
+ oidcConfig = safeJsonParse(provider.oidcConfig);
757
+ } catch {
758
+ oidcConfig = null;
759
+ }
760
+ try {
761
+ samlConfig = safeJsonParse(provider.samlConfig);
762
+ } catch {
763
+ samlConfig = null;
764
+ }
765
+ const type = samlConfig ? "saml" : "oidc";
766
+ return {
767
+ providerId: provider.providerId,
768
+ type,
769
+ issuer: provider.issuer,
770
+ domain: provider.domain,
771
+ organizationId: provider.organizationId || null,
772
+ domainVerified: provider.domainVerified ?? false,
773
+ oidcConfig: oidcConfig ? {
774
+ discoveryEndpoint: oidcConfig.discoveryEndpoint,
775
+ clientIdLastFour: maskClientId(oidcConfig.clientId),
776
+ pkce: oidcConfig.pkce,
777
+ authorizationEndpoint: oidcConfig.authorizationEndpoint,
778
+ tokenEndpoint: oidcConfig.tokenEndpoint,
779
+ userInfoEndpoint: oidcConfig.userInfoEndpoint,
780
+ jwksEndpoint: oidcConfig.jwksEndpoint,
781
+ scopes: oidcConfig.scopes,
782
+ tokenEndpointAuthentication: oidcConfig.tokenEndpointAuthentication
783
+ } : void 0,
784
+ samlConfig: samlConfig ? {
785
+ entryPoint: samlConfig.entryPoint,
786
+ callbackUrl: samlConfig.callbackUrl,
787
+ audience: samlConfig.audience,
788
+ wantAssertionsSigned: samlConfig.wantAssertionsSigned,
789
+ authnRequestsSigned: samlConfig.authnRequestsSigned,
790
+ identifierFormat: samlConfig.identifierFormat,
791
+ signatureAlgorithm: samlConfig.signatureAlgorithm,
792
+ digestAlgorithm: samlConfig.digestAlgorithm,
793
+ certificate: (() => {
794
+ try {
795
+ return parseCertificate(samlConfig.cert);
796
+ } catch {
797
+ return { error: "Failed to parse certificate" };
798
+ }
799
+ })()
800
+ } : void 0,
801
+ spMetadataUrl: `${baseURL}/sso/saml2/sp/metadata?providerId=${encodeURIComponent(provider.providerId)}`
802
+ };
803
+ }
804
+ const listSSOProviders = () => {
805
+ return createAuthEndpoint("/sso/providers", {
806
+ method: "GET",
807
+ use: [sessionMiddleware],
808
+ metadata: { openapi: {
809
+ operationId: "listSSOProviders",
810
+ summary: "List SSO providers",
811
+ description: "Returns a list of SSO providers the user has access to",
812
+ responses: { "200": { description: "List of SSO providers" } }
813
+ } }
814
+ }, async (ctx) => {
815
+ const userId = ctx.context.session.user.id;
816
+ const allProviders = await ctx.context.adapter.findMany({ model: "ssoProvider" });
817
+ const userOwnedProviders = allProviders.filter((p) => p.userId === userId && !p.organizationId);
818
+ const orgProviders = allProviders.filter((p) => p.organizationId !== null && p.organizationId !== void 0);
819
+ const orgPluginEnabled = ctx.context.hasPlugin("organization");
820
+ let accessibleProviders = [...userOwnedProviders];
821
+ if (orgPluginEnabled && orgProviders.length > 0) {
822
+ const adminOrgIds = await batchCheckOrgAdmin(ctx, userId, [...new Set(orgProviders.map((p) => p.organizationId).filter((id) => id !== null && id !== void 0))]);
823
+ const orgAccessibleProviders = orgProviders.filter((provider) => provider.organizationId && adminOrgIds.has(provider.organizationId));
824
+ accessibleProviders = [...accessibleProviders, ...orgAccessibleProviders];
825
+ } else if (!orgPluginEnabled) {
826
+ const userOwnedOrgProviders = orgProviders.filter((p) => p.userId === userId);
827
+ accessibleProviders = [...accessibleProviders, ...userOwnedOrgProviders];
828
+ }
829
+ const providers = accessibleProviders.map((p) => sanitizeProvider(p, ctx.context.baseURL));
830
+ return ctx.json({ providers });
831
+ });
832
+ };
833
+ const getSSOProviderParamsSchema = z.object({ providerId: z.string() });
834
+ async function checkProviderAccess(ctx, providerId) {
835
+ const userId = ctx.context.session.user.id;
836
+ const provider = await ctx.context.adapter.findOne({
837
+ model: "ssoProvider",
838
+ where: [{
839
+ field: "providerId",
840
+ value: providerId
841
+ }]
842
+ });
843
+ if (!provider) throw new APIError("NOT_FOUND", { message: "Provider not found" });
844
+ let hasAccess = false;
845
+ if (provider.organizationId) if (ctx.context.hasPlugin("organization")) hasAccess = await isOrgAdmin(ctx, userId, provider.organizationId);
846
+ else hasAccess = provider.userId === userId;
847
+ else hasAccess = provider.userId === userId;
848
+ if (!hasAccess) throw new APIError("FORBIDDEN", { message: "You don't have access to this provider" });
849
+ return provider;
850
+ }
851
+ const getSSOProvider = () => {
852
+ return createAuthEndpoint("/sso/providers/:providerId", {
853
+ method: "GET",
854
+ use: [sessionMiddleware],
855
+ params: getSSOProviderParamsSchema,
856
+ metadata: { openapi: {
857
+ operationId: "getSSOProvider",
858
+ summary: "Get SSO provider details",
859
+ description: "Returns sanitized details for a specific SSO provider",
860
+ responses: {
861
+ "200": { description: "SSO provider details" },
862
+ "404": { description: "Provider not found" },
863
+ "403": { description: "Access denied" }
864
+ }
865
+ } }
866
+ }, async (ctx) => {
867
+ const { providerId } = ctx.params;
868
+ const provider = await checkProviderAccess(ctx, providerId);
869
+ return ctx.json(sanitizeProvider(provider, ctx.context.baseURL));
870
+ });
871
+ };
872
+ function parseAndValidateConfig(configString, configType) {
873
+ let config = null;
874
+ try {
875
+ config = safeJsonParse(configString);
876
+ } catch {
877
+ config = null;
878
+ }
879
+ if (!config) throw new APIError("BAD_REQUEST", { message: `Cannot update ${configType} config for a provider that doesn't have ${configType} configured` });
880
+ return config;
881
+ }
882
+ function mergeSAMLConfig(current, updates, issuer) {
883
+ return {
884
+ ...current,
885
+ ...updates,
886
+ issuer,
887
+ entryPoint: updates.entryPoint ?? current.entryPoint,
888
+ cert: updates.cert ?? current.cert,
889
+ callbackUrl: updates.callbackUrl ?? current.callbackUrl,
890
+ spMetadata: updates.spMetadata ?? current.spMetadata,
891
+ idpMetadata: updates.idpMetadata ?? current.idpMetadata,
892
+ mapping: updates.mapping ?? current.mapping,
893
+ audience: updates.audience ?? current.audience,
894
+ wantAssertionsSigned: updates.wantAssertionsSigned ?? current.wantAssertionsSigned,
895
+ authnRequestsSigned: updates.authnRequestsSigned ?? current.authnRequestsSigned,
896
+ identifierFormat: updates.identifierFormat ?? current.identifierFormat,
897
+ signatureAlgorithm: updates.signatureAlgorithm ?? current.signatureAlgorithm,
898
+ digestAlgorithm: updates.digestAlgorithm ?? current.digestAlgorithm
899
+ };
900
+ }
901
+ function mergeOIDCConfig(current, updates, issuer) {
902
+ return {
903
+ ...current,
904
+ ...updates,
905
+ issuer,
906
+ pkce: updates.pkce ?? current.pkce ?? true,
907
+ clientId: updates.clientId ?? current.clientId,
908
+ clientSecret: updates.clientSecret ?? current.clientSecret,
909
+ discoveryEndpoint: updates.discoveryEndpoint ?? current.discoveryEndpoint,
910
+ mapping: updates.mapping ?? current.mapping,
911
+ scopes: updates.scopes ?? current.scopes,
912
+ authorizationEndpoint: updates.authorizationEndpoint ?? current.authorizationEndpoint,
913
+ tokenEndpoint: updates.tokenEndpoint ?? current.tokenEndpoint,
914
+ userInfoEndpoint: updates.userInfoEndpoint ?? current.userInfoEndpoint,
915
+ jwksEndpoint: updates.jwksEndpoint ?? current.jwksEndpoint,
916
+ tokenEndpointAuthentication: updates.tokenEndpointAuthentication ?? current.tokenEndpointAuthentication
917
+ };
918
+ }
919
+ const updateSSOProvider = (options) => {
920
+ return createAuthEndpoint("/sso/providers/:providerId", {
921
+ method: "PATCH",
922
+ use: [sessionMiddleware],
923
+ params: getSSOProviderParamsSchema,
924
+ body: updateSSOProviderBodySchema,
925
+ metadata: { openapi: {
926
+ operationId: "updateSSOProvider",
927
+ summary: "Update SSO provider",
928
+ description: "Partially update an SSO provider. Only provided fields are updated. If domain changes, domainVerified is reset to false.",
929
+ responses: {
930
+ "200": { description: "SSO provider updated successfully" },
931
+ "404": { description: "Provider not found" },
932
+ "403": { description: "Access denied" }
933
+ }
934
+ } }
935
+ }, async (ctx) => {
936
+ const { providerId } = ctx.params;
937
+ const body = ctx.body;
938
+ const { issuer, domain, samlConfig, oidcConfig } = body;
939
+ if (!issuer && !domain && !samlConfig && !oidcConfig) throw new APIError("BAD_REQUEST", { message: "No fields provided for update" });
940
+ const existingProvider = await checkProviderAccess(ctx, providerId);
941
+ const updateData = {};
942
+ if (body.issuer !== void 0) updateData.issuer = body.issuer;
943
+ if (body.domain !== void 0) {
944
+ updateData.domain = body.domain;
945
+ if (body.domain !== existingProvider.domain) updateData.domainVerified = false;
946
+ }
947
+ if (body.samlConfig) {
948
+ if (body.samlConfig.idpMetadata?.metadata) {
949
+ const maxMetadataSize = options?.saml?.maxMetadataSize ?? DEFAULT_MAX_SAML_METADATA_SIZE;
950
+ if (new TextEncoder().encode(body.samlConfig.idpMetadata.metadata).length > maxMetadataSize) throw new APIError("BAD_REQUEST", { message: `IdP metadata exceeds maximum allowed size (${maxMetadataSize} bytes)` });
951
+ }
952
+ if (body.samlConfig.signatureAlgorithm !== void 0 || body.samlConfig.digestAlgorithm !== void 0) validateConfigAlgorithms({
953
+ signatureAlgorithm: body.samlConfig.signatureAlgorithm,
954
+ digestAlgorithm: body.samlConfig.digestAlgorithm
955
+ }, options?.saml?.algorithms);
956
+ const currentSamlConfig = parseAndValidateConfig(existingProvider.samlConfig, "SAML");
957
+ const updatedSamlConfig = mergeSAMLConfig(currentSamlConfig, body.samlConfig, updateData.issuer || currentSamlConfig.issuer || existingProvider.issuer);
958
+ updateData.samlConfig = JSON.stringify(updatedSamlConfig);
959
+ }
960
+ if (body.oidcConfig) {
961
+ const currentOidcConfig = parseAndValidateConfig(existingProvider.oidcConfig, "OIDC");
962
+ const updatedOidcConfig = mergeOIDCConfig(currentOidcConfig, body.oidcConfig, updateData.issuer || currentOidcConfig.issuer || existingProvider.issuer);
963
+ updateData.oidcConfig = JSON.stringify(updatedOidcConfig);
964
+ }
965
+ await ctx.context.adapter.update({
966
+ model: "ssoProvider",
967
+ where: [{
968
+ field: "providerId",
969
+ value: providerId
970
+ }],
971
+ update: updateData
972
+ });
973
+ const fullProvider = await ctx.context.adapter.findOne({
974
+ model: "ssoProvider",
975
+ where: [{
976
+ field: "providerId",
977
+ value: providerId
978
+ }]
979
+ });
980
+ if (!fullProvider) throw new APIError("NOT_FOUND", { message: "Provider not found after update" });
981
+ return ctx.json(sanitizeProvider(fullProvider, ctx.context.baseURL));
982
+ });
983
+ };
984
+ const deleteSSOProvider = () => {
985
+ return createAuthEndpoint("/sso/providers/:providerId", {
986
+ method: "DELETE",
987
+ use: [sessionMiddleware],
988
+ params: getSSOProviderParamsSchema,
989
+ metadata: { openapi: {
990
+ operationId: "deleteSSOProvider",
991
+ summary: "Delete SSO provider",
992
+ description: "Deletes an SSO provider",
993
+ responses: {
994
+ "200": { description: "SSO provider deleted successfully" },
995
+ "404": { description: "Provider not found" },
996
+ "403": { description: "Access denied" }
997
+ }
998
+ } }
999
+ }, async (ctx) => {
1000
+ const { providerId } = ctx.params;
1001
+ await checkProviderAccess(ctx, providerId);
1002
+ await ctx.context.adapter.delete({
1003
+ model: "ssoProvider",
1004
+ where: [{
1005
+ field: "providerId",
1006
+ value: providerId
1007
+ }]
1008
+ });
1009
+ return ctx.json({ success: true });
1010
+ });
1011
+ };
1012
+
1013
+ //#endregion
1014
+ //#region src/oidc/types.ts
1015
+ /**
1016
+ * Custom error class for OIDC discovery failures.
1017
+ * Can be caught and mapped to APIError at the edge.
1018
+ */
1019
+ var DiscoveryError = class DiscoveryError extends Error {
1020
+ code;
1021
+ details;
1022
+ constructor(code, message, details, options) {
1023
+ super(message, options);
1024
+ this.name = "DiscoveryError";
1025
+ this.code = code;
1026
+ this.details = details;
1027
+ if (Error.captureStackTrace) Error.captureStackTrace(this, DiscoveryError);
1028
+ }
1029
+ };
1030
+ /**
1031
+ * Required fields that must be present in a valid discovery document.
1032
+ */
1033
+ const REQUIRED_DISCOVERY_FIELDS = [
1034
+ "issuer",
1035
+ "authorization_endpoint",
1036
+ "token_endpoint",
1037
+ "jwks_uri"
1038
+ ];
1039
+
1040
+ //#endregion
1041
+ //#region src/oidc/discovery.ts
1042
+ /**
1043
+ * OIDC Discovery Pipeline
1044
+ *
1045
+ * Implements OIDC discovery document fetching, validation, and hydration.
1046
+ * This module is used both at provider registration time (to persist validated config)
1047
+ * and at runtime (to hydrate legacy providers that are missing metadata).
1048
+ *
1049
+ * @see https://openid.net/specs/openid-connect-discovery-1_0.html
1050
+ */
1051
+ /** Default timeout for discovery requests (10 seconds) */
1052
+ const DEFAULT_DISCOVERY_TIMEOUT = 1e4;
1053
+ /**
1054
+ * Main entry point: Discover and hydrate OIDC configuration from an issuer.
1055
+ *
1056
+ * This function:
1057
+ * 1. Computes the discovery URL from the issuer
1058
+ * 2. Validates the discovery URL
1059
+ * 3. Fetches the discovery document
1060
+ * 4. Validates the discovery document (issuer match + required fields)
1061
+ * 5. Normalizes URLs
1062
+ * 6. Selects token endpoint auth method
1063
+ * 7. Merges with existing config (existing values take precedence)
1064
+ *
1065
+ * @param params - Discovery parameters
1066
+ * @param isTrustedOrigin - Origin verification tester function
1067
+ * @returns Hydrated OIDC configuration ready for persistence
1068
+ * @throws DiscoveryError on any failure
1069
+ */
1070
+ async function discoverOIDCConfig(params) {
1071
+ const { issuer, existingConfig, timeout = DEFAULT_DISCOVERY_TIMEOUT } = params;
1072
+ const discoveryUrl = params.discoveryEndpoint || existingConfig?.discoveryEndpoint || computeDiscoveryUrl(issuer);
1073
+ validateDiscoveryUrl(discoveryUrl, params.isTrustedOrigin);
1074
+ const discoveryDoc = await fetchDiscoveryDocument(discoveryUrl, timeout);
1075
+ validateDiscoveryDocument(discoveryDoc, issuer);
1076
+ const normalizedDoc = normalizeDiscoveryUrls(discoveryDoc, issuer, params.isTrustedOrigin);
1077
+ const tokenEndpointAuth = selectTokenEndpointAuthMethod(normalizedDoc, existingConfig?.tokenEndpointAuthentication);
1078
+ return {
1079
+ issuer: existingConfig?.issuer ?? normalizedDoc.issuer,
1080
+ discoveryEndpoint: existingConfig?.discoveryEndpoint ?? discoveryUrl,
1081
+ authorizationEndpoint: existingConfig?.authorizationEndpoint ?? normalizedDoc.authorization_endpoint,
1082
+ tokenEndpoint: existingConfig?.tokenEndpoint ?? normalizedDoc.token_endpoint,
1083
+ jwksEndpoint: existingConfig?.jwksEndpoint ?? normalizedDoc.jwks_uri,
1084
+ userInfoEndpoint: existingConfig?.userInfoEndpoint ?? normalizedDoc.userinfo_endpoint,
1085
+ tokenEndpointAuthentication: existingConfig?.tokenEndpointAuthentication ?? tokenEndpointAuth,
1086
+ scopesSupported: existingConfig?.scopesSupported ?? normalizedDoc.scopes_supported
1087
+ };
1088
+ }
1089
+ /**
1090
+ * Compute the discovery URL from an issuer URL.
1091
+ *
1092
+ * Per OIDC Discovery spec, the discovery document is located at:
1093
+ * <issuer>/.well-known/openid-configuration
1094
+ *
1095
+ * Handles trailing slashes correctly.
1096
+ */
1097
+ function computeDiscoveryUrl(issuer) {
1098
+ return `${issuer.endsWith("/") ? issuer.slice(0, -1) : issuer}/.well-known/openid-configuration`;
1099
+ }
1100
+ /**
1101
+ * Validate a discovery URL before fetching.
1102
+ *
1103
+ * @param url - The discovery URL to validate
1104
+ * @param isTrustedOrigin - Origin verification tester function
1105
+ * @throws DiscoveryError if URL is invalid
1106
+ */
1107
+ function validateDiscoveryUrl(url, isTrustedOrigin) {
1108
+ const discoveryEndpoint = parseURL("discoveryEndpoint", url).toString();
1109
+ if (!isTrustedOrigin(discoveryEndpoint)) throw new DiscoveryError("discovery_untrusted_origin", `The main discovery endpoint "${discoveryEndpoint}" is not trusted by your trusted origins configuration.`, { url: discoveryEndpoint });
1110
+ }
1111
+ /**
1112
+ * Fetch the OIDC discovery document from the IdP.
1113
+ *
1114
+ * @param url - The discovery endpoint URL
1115
+ * @param timeout - Request timeout in milliseconds
1116
+ * @returns The parsed discovery document
1117
+ * @throws DiscoveryError on network errors, timeouts, or invalid responses
1118
+ */
1119
+ async function fetchDiscoveryDocument(url, timeout = DEFAULT_DISCOVERY_TIMEOUT) {
1120
+ try {
1121
+ const response = await betterFetch(url, {
1122
+ method: "GET",
1123
+ timeout
421
1124
  });
422
1125
  if (response.error) {
423
1126
  const { status } = response.error;
@@ -523,363 +1226,178 @@ function normalizeUrl(name, endpoint, issuer) {
523
1226
  } catch {
524
1227
  const issuerURL = parseURL(name, issuer);
525
1228
  const basePath = issuerURL.pathname.replace(/\/+$/, "");
526
- const endpointPath = endpoint.replace(/^\/+/, "");
527
- return parseURL(name, basePath + "/" + endpointPath, issuerURL.origin).toString();
528
- }
529
- }
530
- /**
531
- * Parses the given URL or throws in case of invalid or unsupported protocols
532
- *
533
- * @param name the url name
534
- * @param endpoint the endpoint url
535
- * @param [base] optional base path
536
- * @returns
537
- */
538
- function parseURL(name, endpoint, base) {
539
- let endpointURL;
540
- try {
541
- endpointURL = new URL(endpoint, base);
542
- if (endpointURL.protocol === "http:" || endpointURL.protocol === "https:") return endpointURL;
543
- } catch (error) {
544
- throw new DiscoveryError("discovery_invalid_url", `The url "${name}" must be valid: ${endpoint}`, { url: endpoint }, { cause: error });
545
- }
546
- throw new DiscoveryError("discovery_invalid_url", `The url "${name}" must use the http or https supported protocols: ${endpoint}`, {
547
- url: endpoint,
548
- protocol: endpointURL.protocol
549
- });
550
- }
551
- /**
552
- * Select the token endpoint authentication method.
553
- *
554
- * @param doc - The discovery document
555
- * @param existing - Existing authentication method from config
556
- * @returns The selected authentication method
557
- */
558
- function selectTokenEndpointAuthMethod(doc, existing) {
559
- if (existing) return existing;
560
- const supported = doc.token_endpoint_auth_methods_supported;
561
- if (!supported || supported.length === 0) return "client_secret_basic";
562
- if (supported.includes("client_secret_basic")) return "client_secret_basic";
563
- if (supported.includes("client_secret_post")) return "client_secret_post";
564
- return "client_secret_basic";
565
- }
566
- /**
567
- * Check if a provider configuration needs runtime discovery.
568
- *
569
- * Returns true if we need discovery at runtime to complete the token exchange
570
- * and validation. Specifically checks for:
571
- * - `tokenEndpoint` - required for exchanging authorization code for tokens
572
- * - `jwksEndpoint` - required for validating ID token signatures
573
- *
574
- * Note: `authorizationEndpoint` is handled separately in the sign-in flow,
575
- * so it's not checked here.
576
- *
577
- * @param config - Partial OIDC config from the provider
578
- * @returns true if runtime discovery should be performed
579
- */
580
- function needsRuntimeDiscovery(config) {
581
- if (!config) return true;
582
- return !config.tokenEndpoint || !config.jwksEndpoint;
583
- }
584
-
585
- //#endregion
586
- //#region src/oidc/errors.ts
587
- /**
588
- * OIDC Discovery Error Mapping
589
- *
590
- * Maps DiscoveryError codes to appropriate APIError responses.
591
- * Used at the boundary between the discovery pipeline and HTTP handlers.
592
- */
593
- /**
594
- * Maps a DiscoveryError to an appropriate APIError for HTTP responses.
595
- *
596
- * Error code mapping:
597
- * - discovery_invalid_url → 400 BAD_REQUEST
598
- * - discovery_not_found → 400 BAD_REQUEST
599
- * - discovery_invalid_json → 400 BAD_REQUEST
600
- * - discovery_incomplete → 400 BAD_REQUEST
601
- * - issuer_mismatch → 400 BAD_REQUEST
602
- * - unsupported_token_auth_method → 400 BAD_REQUEST
603
- * - discovery_timeout → 502 BAD_GATEWAY
604
- * - discovery_unexpected_error → 502 BAD_GATEWAY
605
- *
606
- * @param error - The DiscoveryError to map
607
- * @returns An APIError with appropriate status and message
608
- */
609
- function mapDiscoveryErrorToAPIError(error) {
610
- switch (error.code) {
611
- case "discovery_timeout": return new APIError("BAD_GATEWAY", {
612
- message: `OIDC discovery timed out: ${error.message}`,
613
- code: error.code
614
- });
615
- case "discovery_unexpected_error": return new APIError("BAD_GATEWAY", {
616
- message: `OIDC discovery failed: ${error.message}`,
617
- code: error.code
618
- });
619
- case "discovery_not_found": return new APIError("BAD_REQUEST", {
620
- message: `OIDC discovery endpoint not found. The issuer may not support OIDC discovery, or the URL is incorrect. ${error.message}`,
621
- code: error.code
622
- });
623
- case "discovery_invalid_url": return new APIError("BAD_REQUEST", {
624
- message: `Invalid OIDC discovery URL: ${error.message}`,
625
- code: error.code
626
- });
627
- case "discovery_untrusted_origin": return new APIError("BAD_REQUEST", {
628
- message: `Untrusted OIDC discovery URL: ${error.message}`,
629
- code: error.code
630
- });
631
- case "discovery_invalid_json": return new APIError("BAD_REQUEST", {
632
- message: `OIDC discovery returned invalid data: ${error.message}`,
633
- code: error.code
634
- });
635
- case "discovery_incomplete": return new APIError("BAD_REQUEST", {
636
- message: `OIDC discovery document is missing required fields: ${error.message}`,
637
- code: error.code
638
- });
639
- case "issuer_mismatch": return new APIError("BAD_REQUEST", {
640
- message: `OIDC issuer mismatch: ${error.message}`,
641
- code: error.code
642
- });
643
- case "unsupported_token_auth_method": return new APIError("BAD_REQUEST", {
644
- message: `Incompatible OIDC provider: ${error.message}`,
645
- code: error.code
646
- });
647
- default:
648
- error.code;
649
- return new APIError("INTERNAL_SERVER_ERROR", {
650
- message: `Unexpected discovery error: ${error.message}`,
651
- code: "discovery_unexpected_error"
652
- });
653
- }
654
- }
655
-
656
- //#endregion
657
- //#region src/saml/algorithms.ts
658
- const SignatureAlgorithm = {
659
- RSA_SHA1: "http://www.w3.org/2000/09/xmldsig#rsa-sha1",
660
- RSA_SHA256: "http://www.w3.org/2001/04/xmldsig-more#rsa-sha256",
661
- RSA_SHA384: "http://www.w3.org/2001/04/xmldsig-more#rsa-sha384",
662
- RSA_SHA512: "http://www.w3.org/2001/04/xmldsig-more#rsa-sha512",
663
- ECDSA_SHA256: "http://www.w3.org/2001/04/xmldsig-more#ecdsa-sha256",
664
- ECDSA_SHA384: "http://www.w3.org/2001/04/xmldsig-more#ecdsa-sha384",
665
- ECDSA_SHA512: "http://www.w3.org/2001/04/xmldsig-more#ecdsa-sha512"
666
- };
667
- const DigestAlgorithm = {
668
- SHA1: "http://www.w3.org/2000/09/xmldsig#sha1",
669
- SHA256: "http://www.w3.org/2001/04/xmlenc#sha256",
670
- SHA384: "http://www.w3.org/2001/04/xmldsig-more#sha384",
671
- SHA512: "http://www.w3.org/2001/04/xmlenc#sha512"
672
- };
673
- const KeyEncryptionAlgorithm = {
674
- RSA_1_5: "http://www.w3.org/2001/04/xmlenc#rsa-1_5",
675
- RSA_OAEP: "http://www.w3.org/2001/04/xmlenc#rsa-oaep-mgf1p",
676
- RSA_OAEP_SHA256: "http://www.w3.org/2009/xmlenc11#rsa-oaep"
677
- };
678
- const DataEncryptionAlgorithm = {
679
- TRIPLEDES_CBC: "http://www.w3.org/2001/04/xmlenc#tripledes-cbc",
680
- AES_128_CBC: "http://www.w3.org/2001/04/xmlenc#aes128-cbc",
681
- AES_192_CBC: "http://www.w3.org/2001/04/xmlenc#aes192-cbc",
682
- AES_256_CBC: "http://www.w3.org/2001/04/xmlenc#aes256-cbc",
683
- AES_128_GCM: "http://www.w3.org/2009/xmlenc11#aes128-gcm",
684
- AES_192_GCM: "http://www.w3.org/2009/xmlenc11#aes192-gcm",
685
- AES_256_GCM: "http://www.w3.org/2009/xmlenc11#aes256-gcm"
686
- };
687
- const DEPRECATED_SIGNATURE_ALGORITHMS = [SignatureAlgorithm.RSA_SHA1];
688
- const DEPRECATED_KEY_ENCRYPTION_ALGORITHMS = [KeyEncryptionAlgorithm.RSA_1_5];
689
- const DEPRECATED_DATA_ENCRYPTION_ALGORITHMS = [DataEncryptionAlgorithm.TRIPLEDES_CBC];
690
- const DEPRECATED_DIGEST_ALGORITHMS = [DigestAlgorithm.SHA1];
691
- const SECURE_SIGNATURE_ALGORITHMS = [
692
- SignatureAlgorithm.RSA_SHA256,
693
- SignatureAlgorithm.RSA_SHA384,
694
- SignatureAlgorithm.RSA_SHA512,
695
- SignatureAlgorithm.ECDSA_SHA256,
696
- SignatureAlgorithm.ECDSA_SHA384,
697
- SignatureAlgorithm.ECDSA_SHA512
698
- ];
699
- const SECURE_DIGEST_ALGORITHMS = [
700
- DigestAlgorithm.SHA256,
701
- DigestAlgorithm.SHA384,
702
- DigestAlgorithm.SHA512
703
- ];
704
- const SHORT_FORM_SIGNATURE_TO_URI = {
705
- sha1: SignatureAlgorithm.RSA_SHA1,
706
- sha256: SignatureAlgorithm.RSA_SHA256,
707
- sha384: SignatureAlgorithm.RSA_SHA384,
708
- sha512: SignatureAlgorithm.RSA_SHA512,
709
- "rsa-sha1": SignatureAlgorithm.RSA_SHA1,
710
- "rsa-sha256": SignatureAlgorithm.RSA_SHA256,
711
- "rsa-sha384": SignatureAlgorithm.RSA_SHA384,
712
- "rsa-sha512": SignatureAlgorithm.RSA_SHA512,
713
- "ecdsa-sha256": SignatureAlgorithm.ECDSA_SHA256,
714
- "ecdsa-sha384": SignatureAlgorithm.ECDSA_SHA384,
715
- "ecdsa-sha512": SignatureAlgorithm.ECDSA_SHA512
716
- };
717
- const SHORT_FORM_DIGEST_TO_URI = {
718
- sha1: DigestAlgorithm.SHA1,
719
- sha256: DigestAlgorithm.SHA256,
720
- sha384: DigestAlgorithm.SHA384,
721
- sha512: DigestAlgorithm.SHA512
722
- };
723
- function normalizeSignatureAlgorithm(alg) {
724
- return SHORT_FORM_SIGNATURE_TO_URI[alg.toLowerCase()] ?? alg;
725
- }
726
- function normalizeDigestAlgorithm(alg) {
727
- return SHORT_FORM_DIGEST_TO_URI[alg.toLowerCase()] ?? alg;
728
- }
729
- const xmlParser = new XMLParser({
730
- ignoreAttributes: false,
731
- attributeNamePrefix: "@_",
732
- removeNSPrefix: true
733
- });
734
- function findNode(obj, nodeName) {
735
- if (!obj || typeof obj !== "object") return null;
736
- const record = obj;
737
- if (nodeName in record) return record[nodeName];
738
- for (const value of Object.values(record)) if (Array.isArray(value)) for (const item of value) {
739
- const found = findNode(item, nodeName);
740
- if (found) return found;
741
- }
742
- else if (typeof value === "object" && value !== null) {
743
- const found = findNode(value, nodeName);
744
- if (found) return found;
745
- }
746
- return null;
747
- }
748
- function extractEncryptionAlgorithms(xml) {
749
- try {
750
- const parsed = xmlParser.parse(xml);
751
- const keyAlg = (findNode(parsed, "EncryptedKey")?.EncryptionMethod)?.["@_Algorithm"];
752
- const dataAlg = (findNode(parsed, "EncryptedData")?.EncryptionMethod)?.["@_Algorithm"];
753
- return {
754
- keyEncryption: keyAlg || null,
755
- dataEncryption: dataAlg || null
756
- };
757
- } catch {
758
- return {
759
- keyEncryption: null,
760
- dataEncryption: null
761
- };
762
- }
763
- }
764
- function hasEncryptedAssertion(xml) {
765
- try {
766
- return findNode(xmlParser.parse(xml), "EncryptedAssertion") !== null;
767
- } catch {
768
- return false;
769
- }
770
- }
771
- function handleDeprecatedAlgorithm(message, behavior, errorCode) {
772
- switch (behavior) {
773
- case "reject": throw new APIError("BAD_REQUEST", {
774
- message,
775
- code: errorCode
776
- });
777
- case "warn":
778
- console.warn(`[SAML Security Warning] ${message}`);
779
- break;
780
- case "allow": break;
1229
+ const endpointPath = endpoint.replace(/^\/+/, "");
1230
+ return parseURL(name, basePath + "/" + endpointPath, issuerURL.origin).toString();
781
1231
  }
782
1232
  }
783
- function validateSignatureAlgorithm(algorithm, options = {}) {
784
- if (!algorithm) return;
785
- const { onDeprecated = "warn", allowedSignatureAlgorithms } = options;
786
- if (allowedSignatureAlgorithms) {
787
- if (!allowedSignatureAlgorithms.includes(algorithm)) throw new APIError("BAD_REQUEST", {
788
- message: `SAML signature algorithm not in allow-list: ${algorithm}`,
789
- code: "SAML_ALGORITHM_NOT_ALLOWED"
790
- });
791
- return;
792
- }
793
- if (DEPRECATED_SIGNATURE_ALGORITHMS.includes(algorithm)) {
794
- handleDeprecatedAlgorithm(`SAML response uses deprecated signature algorithm: ${algorithm}. Please configure your IdP to use SHA-256 or stronger.`, onDeprecated, "SAML_DEPRECATED_ALGORITHM");
795
- return;
1233
+ /**
1234
+ * Parses the given URL or throws in case of invalid or unsupported protocols
1235
+ *
1236
+ * @param name the url name
1237
+ * @param endpoint the endpoint url
1238
+ * @param [base] optional base path
1239
+ * @returns
1240
+ */
1241
+ function parseURL(name, endpoint, base) {
1242
+ let endpointURL;
1243
+ try {
1244
+ endpointURL = new URL(endpoint, base);
1245
+ if (endpointURL.protocol === "http:" || endpointURL.protocol === "https:") return endpointURL;
1246
+ } catch (error) {
1247
+ throw new DiscoveryError("discovery_invalid_url", `The url "${name}" must be valid: ${endpoint}`, { url: endpoint }, { cause: error });
796
1248
  }
797
- if (!SECURE_SIGNATURE_ALGORITHMS.includes(algorithm)) throw new APIError("BAD_REQUEST", {
798
- message: `SAML signature algorithm not recognized: ${algorithm}`,
799
- code: "SAML_UNKNOWN_ALGORITHM"
1249
+ throw new DiscoveryError("discovery_invalid_url", `The url "${name}" must use the http or https supported protocols: ${endpoint}`, {
1250
+ url: endpoint,
1251
+ protocol: endpointURL.protocol
800
1252
  });
801
1253
  }
802
- function validateEncryptionAlgorithms(algorithms, options = {}) {
803
- const { onDeprecated = "warn", allowedKeyEncryptionAlgorithms, allowedDataEncryptionAlgorithms } = options;
804
- const { keyEncryption, dataEncryption } = algorithms;
805
- if (keyEncryption) {
806
- if (allowedKeyEncryptionAlgorithms) {
807
- if (!allowedKeyEncryptionAlgorithms.includes(keyEncryption)) throw new APIError("BAD_REQUEST", {
808
- message: `SAML key encryption algorithm not in allow-list: ${keyEncryption}`,
809
- code: "SAML_ALGORITHM_NOT_ALLOWED"
810
- });
811
- } else if (DEPRECATED_KEY_ENCRYPTION_ALGORITHMS.includes(keyEncryption)) handleDeprecatedAlgorithm(`SAML response uses deprecated key encryption algorithm: ${keyEncryption}. Please configure your IdP to use RSA-OAEP.`, onDeprecated, "SAML_DEPRECATED_ALGORITHM");
812
- }
813
- if (dataEncryption) {
814
- if (allowedDataEncryptionAlgorithms) {
815
- if (!allowedDataEncryptionAlgorithms.includes(dataEncryption)) throw new APIError("BAD_REQUEST", {
816
- message: `SAML data encryption algorithm not in allow-list: ${dataEncryption}`,
817
- code: "SAML_ALGORITHM_NOT_ALLOWED"
818
- });
819
- } else if (DEPRECATED_DATA_ENCRYPTION_ALGORITHMS.includes(dataEncryption)) handleDeprecatedAlgorithm(`SAML response uses deprecated data encryption algorithm: ${dataEncryption}. Please configure your IdP to use AES-GCM.`, onDeprecated, "SAML_DEPRECATED_ALGORITHM");
820
- }
1254
+ /**
1255
+ * Select the token endpoint authentication method.
1256
+ *
1257
+ * @param doc - The discovery document
1258
+ * @param existing - Existing authentication method from config
1259
+ * @returns The selected authentication method
1260
+ */
1261
+ function selectTokenEndpointAuthMethod(doc, existing) {
1262
+ if (existing) return existing;
1263
+ const supported = doc.token_endpoint_auth_methods_supported;
1264
+ if (!supported || supported.length === 0) return "client_secret_basic";
1265
+ if (supported.includes("client_secret_basic")) return "client_secret_basic";
1266
+ if (supported.includes("client_secret_post")) return "client_secret_post";
1267
+ return "client_secret_basic";
821
1268
  }
822
- function validateSAMLAlgorithms(response, options) {
823
- validateSignatureAlgorithm(response.sigAlg, options);
824
- if (hasEncryptedAssertion(response.samlContent)) validateEncryptionAlgorithms(extractEncryptionAlgorithms(response.samlContent), options);
1269
+ /**
1270
+ * Check if a provider configuration needs runtime discovery.
1271
+ *
1272
+ * Returns true if we need discovery at runtime to complete the token exchange
1273
+ * and validation. Specifically checks for:
1274
+ * - `tokenEndpoint` - required for exchanging authorization code for tokens
1275
+ * - `jwksEndpoint` - required for validating ID token signatures
1276
+ *
1277
+ * Note: `authorizationEndpoint` is handled separately in the sign-in flow,
1278
+ * so it's not checked here.
1279
+ *
1280
+ * @param config - Partial OIDC config from the provider
1281
+ * @returns true if runtime discovery should be performed
1282
+ */
1283
+ function needsRuntimeDiscovery(config) {
1284
+ if (!config) return true;
1285
+ return !config.tokenEndpoint || !config.jwksEndpoint;
825
1286
  }
826
- function validateConfigAlgorithms(config, options = {}) {
827
- const { onDeprecated = "warn", allowedSignatureAlgorithms, allowedDigestAlgorithms } = options;
828
- if (config.signatureAlgorithm) {
829
- const normalized = normalizeSignatureAlgorithm(config.signatureAlgorithm);
830
- if (allowedSignatureAlgorithms) {
831
- if (!allowedSignatureAlgorithms.map(normalizeSignatureAlgorithm).includes(normalized)) throw new APIError("BAD_REQUEST", {
832
- message: `SAML signature algorithm not in allow-list: ${config.signatureAlgorithm}`,
833
- code: "SAML_ALGORITHM_NOT_ALLOWED"
834
- });
835
- } else if (DEPRECATED_SIGNATURE_ALGORITHMS.includes(normalized)) handleDeprecatedAlgorithm(`SAML config uses deprecated signature algorithm: ${config.signatureAlgorithm}. Consider using SHA-256 or stronger.`, onDeprecated, "SAML_DEPRECATED_CONFIG_ALGORITHM");
836
- else if (!SECURE_SIGNATURE_ALGORITHMS.includes(normalized)) throw new APIError("BAD_REQUEST", {
837
- message: `SAML signature algorithm not recognized: ${config.signatureAlgorithm}`,
838
- code: "SAML_UNKNOWN_ALGORITHM"
1287
+
1288
+ //#endregion
1289
+ //#region src/oidc/errors.ts
1290
+ /**
1291
+ * OIDC Discovery Error Mapping
1292
+ *
1293
+ * Maps DiscoveryError codes to appropriate APIError responses.
1294
+ * Used at the boundary between the discovery pipeline and HTTP handlers.
1295
+ */
1296
+ /**
1297
+ * Maps a DiscoveryError to an appropriate APIError for HTTP responses.
1298
+ *
1299
+ * Error code mapping:
1300
+ * - discovery_invalid_url → 400 BAD_REQUEST
1301
+ * - discovery_not_found → 400 BAD_REQUEST
1302
+ * - discovery_invalid_json → 400 BAD_REQUEST
1303
+ * - discovery_incomplete → 400 BAD_REQUEST
1304
+ * - issuer_mismatch → 400 BAD_REQUEST
1305
+ * - unsupported_token_auth_method → 400 BAD_REQUEST
1306
+ * - discovery_timeout → 502 BAD_GATEWAY
1307
+ * - discovery_unexpected_error → 502 BAD_GATEWAY
1308
+ *
1309
+ * @param error - The DiscoveryError to map
1310
+ * @returns An APIError with appropriate status and message
1311
+ */
1312
+ function mapDiscoveryErrorToAPIError(error) {
1313
+ switch (error.code) {
1314
+ case "discovery_timeout": return new APIError("BAD_GATEWAY", {
1315
+ message: `OIDC discovery timed out: ${error.message}`,
1316
+ code: error.code
839
1317
  });
840
- }
841
- if (config.digestAlgorithm) {
842
- const normalized = normalizeDigestAlgorithm(config.digestAlgorithm);
843
- if (allowedDigestAlgorithms) {
844
- if (!allowedDigestAlgorithms.map(normalizeDigestAlgorithm).includes(normalized)) throw new APIError("BAD_REQUEST", {
845
- message: `SAML digest algorithm not in allow-list: ${config.digestAlgorithm}`,
846
- code: "SAML_ALGORITHM_NOT_ALLOWED"
847
- });
848
- } else if (DEPRECATED_DIGEST_ALGORITHMS.includes(normalized)) handleDeprecatedAlgorithm(`SAML config uses deprecated digest algorithm: ${config.digestAlgorithm}. Consider using SHA-256 or stronger.`, onDeprecated, "SAML_DEPRECATED_CONFIG_ALGORITHM");
849
- else if (!SECURE_DIGEST_ALGORITHMS.includes(normalized)) throw new APIError("BAD_REQUEST", {
850
- message: `SAML digest algorithm not recognized: ${config.digestAlgorithm}`,
851
- code: "SAML_UNKNOWN_ALGORITHM"
1318
+ case "discovery_unexpected_error": return new APIError("BAD_GATEWAY", {
1319
+ message: `OIDC discovery failed: ${error.message}`,
1320
+ code: error.code
1321
+ });
1322
+ case "discovery_not_found": return new APIError("BAD_REQUEST", {
1323
+ message: `OIDC discovery endpoint not found. The issuer may not support OIDC discovery, or the URL is incorrect. ${error.message}`,
1324
+ code: error.code
1325
+ });
1326
+ case "discovery_invalid_url": return new APIError("BAD_REQUEST", {
1327
+ message: `Invalid OIDC discovery URL: ${error.message}`,
1328
+ code: error.code
1329
+ });
1330
+ case "discovery_untrusted_origin": return new APIError("BAD_REQUEST", {
1331
+ message: `Untrusted OIDC discovery URL: ${error.message}`,
1332
+ code: error.code
1333
+ });
1334
+ case "discovery_invalid_json": return new APIError("BAD_REQUEST", {
1335
+ message: `OIDC discovery returned invalid data: ${error.message}`,
1336
+ code: error.code
1337
+ });
1338
+ case "discovery_incomplete": return new APIError("BAD_REQUEST", {
1339
+ message: `OIDC discovery document is missing required fields: ${error.message}`,
1340
+ code: error.code
1341
+ });
1342
+ case "issuer_mismatch": return new APIError("BAD_REQUEST", {
1343
+ message: `OIDC issuer mismatch: ${error.message}`,
1344
+ code: error.code
1345
+ });
1346
+ case "unsupported_token_auth_method": return new APIError("BAD_REQUEST", {
1347
+ message: `Incompatible OIDC provider: ${error.message}`,
1348
+ code: error.code
852
1349
  });
1350
+ default:
1351
+ error.code;
1352
+ return new APIError("INTERNAL_SERVER_ERROR", {
1353
+ message: `Unexpected discovery error: ${error.message}`,
1354
+ code: "discovery_unexpected_error"
1355
+ });
853
1356
  }
854
1357
  }
855
1358
 
856
1359
  //#endregion
857
- //#region src/utils.ts
858
- /**
859
- * Safely parses a value that might be a JSON string or already a parsed object.
860
- * This handles cases where ORMs like Drizzle might return already parsed objects
861
- * instead of JSON strings from TEXT/JSON columns.
862
- *
863
- * @param value - The value to parse (string, object, null, or undefined)
864
- * @returns The parsed object or null
865
- * @throws Error if string parsing fails
866
- */
867
- function safeJsonParse(value) {
868
- if (!value) return null;
869
- if (typeof value === "object") return value;
870
- if (typeof value === "string") try {
871
- return JSON.parse(value);
1360
+ //#region src/saml-state.ts
1361
+ async function generateRelayState(c, link, additionalData) {
1362
+ const callbackURL = c.body.callbackURL;
1363
+ if (!callbackURL) throw new APIError$1("BAD_REQUEST", { message: "callbackURL is required" });
1364
+ const codeVerifier = generateRandomString(128);
1365
+ const stateData = {
1366
+ ...additionalData ? additionalData : {},
1367
+ callbackURL,
1368
+ codeVerifier,
1369
+ errorURL: c.body.errorCallbackURL,
1370
+ newUserURL: c.body.newUserCallbackURL,
1371
+ link,
1372
+ expiresAt: Date.now() + 600 * 1e3,
1373
+ requestSignUp: c.body.requestSignUp
1374
+ };
1375
+ try {
1376
+ return generateGenericState(c, stateData, { cookieName: "relay_state" });
872
1377
  } catch (error) {
873
- throw new Error(`Failed to parse JSON: ${error instanceof Error ? error.message : "Unknown error"}`);
1378
+ c.context.logger.error("Failed to create verification for relay state", error);
1379
+ throw new APIError$1("INTERNAL_SERVER_ERROR", {
1380
+ message: "State error: Unable to create verification for relay state",
1381
+ cause: error
1382
+ });
874
1383
  }
875
- return null;
876
1384
  }
877
- const validateEmailDomain = (email, domain) => {
878
- const emailDomain = email.split("@")[1]?.toLowerCase();
879
- const providerDomain = domain.toLowerCase();
880
- if (!emailDomain || !providerDomain) return false;
881
- return emailDomain === providerDomain || emailDomain.endsWith(`.${providerDomain}`);
882
- };
1385
+ async function parseRelayState(c) {
1386
+ const state = c.body.RelayState;
1387
+ const errorURL = c.context.options.onAPIError?.errorURL || `${c.context.baseURL}/error`;
1388
+ let parsedData;
1389
+ try {
1390
+ parsedData = await parseGenericState(c, state, { cookieName: "relay_state" });
1391
+ } catch (error) {
1392
+ c.context.logger.error("Failed to parse relay state", error);
1393
+ throw new APIError$1("BAD_REQUEST", {
1394
+ message: "State error: failed to validate relay state",
1395
+ cause: error
1396
+ });
1397
+ }
1398
+ if (!parsedData.errorURL) parsedData.errorURL = errorURL;
1399
+ return parsedData;
1400
+ }
883
1401
 
884
1402
  //#endregion
885
1403
  //#region src/routes/sso.ts
@@ -975,6 +1493,7 @@ const spMetadata = () => {
975
1493
  Location: parsedSamlConfig.callbackUrl || `${ctx.context.baseURL}/sso/saml2/sp/acs/${provider.id}`
976
1494
  }],
977
1495
  wantMessageSigned: parsedSamlConfig.wantAssertionsSigned || false,
1496
+ authnRequestsSigned: parsedSamlConfig.authnRequestsSigned || false,
978
1497
  nameIDFormat: parsedSamlConfig.identifierFormat ? [parsedSamlConfig.identifierFormat] : void 0
979
1498
  });
980
1499
  return new Response(sp.getMetadata(), { headers: { "Content-Type": "application/xml" } });
@@ -983,7 +1502,7 @@ const spMetadata = () => {
983
1502
  const ssoProviderBodySchema = z.object({
984
1503
  providerId: z.string({}).meta({ description: "The ID of the provider. This is used to identify the provider during login and callback" }),
985
1504
  issuer: z.string({}).meta({ description: "The issuer of the provider" }),
986
- domain: z.string({}).meta({ description: "The domain of the provider. This is used for email matching" }),
1505
+ domain: z.string({}).meta({ description: "The domain(s) of the provider. For enterprise multi-domain SSO where a single IdP serves multiple email domains, use comma-separated values (e.g., 'company.com,subsidiary.com,acquired-company.com')" }),
987
1506
  oidcConfig: z.object({
988
1507
  clientId: z.string({}).meta({ description: "The client ID" }),
989
1508
  clientSecret: z.string({}).meta({ description: "The client secret" }),
@@ -1035,6 +1554,7 @@ const ssoProviderBodySchema = z.object({
1035
1554
  encPrivateKeyPass: z.string().optional()
1036
1555
  }),
1037
1556
  wantAssertionsSigned: z.boolean().optional(),
1557
+ authnRequestsSigned: z.boolean().optional(),
1038
1558
  signatureAlgorithm: z.string().optional(),
1039
1559
  digestAlgorithm: z.string().optional(),
1040
1560
  identifierFormat: z.string().optional(),
@@ -1239,6 +1759,10 @@ const registerSSOProvider = (options) => {
1239
1759
  })).length >= limit) throw new APIError("FORBIDDEN", { message: "You have reached the maximum number of SSO providers" });
1240
1760
  const body = ctx.body;
1241
1761
  if (z.string().url().safeParse(body.issuer).error) throw new APIError("BAD_REQUEST", { message: "Invalid issuer. Must be a valid URL" });
1762
+ if (body.samlConfig?.idpMetadata?.metadata) {
1763
+ const maxMetadataSize = options?.saml?.maxMetadataSize ?? DEFAULT_MAX_SAML_METADATA_SIZE;
1764
+ if (new TextEncoder().encode(body.samlConfig.idpMetadata.metadata).length > maxMetadataSize) throw new APIError("BAD_REQUEST", { message: `IdP metadata exceeds maximum allowed size (${maxMetadataSize} bytes)` });
1765
+ }
1242
1766
  if (ctx.body.organizationId) {
1243
1767
  if (!await ctx.context.adapter.findOne({
1244
1768
  model: "member",
@@ -1273,7 +1797,7 @@ const registerSSOProvider = (options) => {
1273
1797
  userInfoEndpoint: body.oidcConfig.userInfoEndpoint,
1274
1798
  tokenEndpointAuthentication: body.oidcConfig.tokenEndpointAuthentication
1275
1799
  },
1276
- isTrustedOrigin: ctx.context.isTrustedOrigin
1800
+ isTrustedOrigin: (url) => ctx.context.isTrustedOrigin(url)
1277
1801
  });
1278
1802
  } catch (error) {
1279
1803
  if (error instanceof DiscoveryError) throw mapDiscoveryErrorToAPIError(error);
@@ -1333,6 +1857,7 @@ const registerSSOProvider = (options) => {
1333
1857
  idpMetadata: body.samlConfig.idpMetadata,
1334
1858
  spMetadata: body.samlConfig.spMetadata,
1335
1859
  wantAssertionsSigned: body.samlConfig.wantAssertionsSigned,
1860
+ authnRequestsSigned: body.samlConfig.authnRequestsSigned,
1336
1861
  signatureAlgorithm: body.samlConfig.signatureAlgorithm,
1337
1862
  digestAlgorithm: body.samlConfig.digestAlgorithm,
1338
1863
  identifierFormat: body.samlConfig.identifierFormat,
@@ -1478,20 +2003,33 @@ const signInSSO = (options) => {
1478
2003
  };
1479
2004
  }
1480
2005
  if (!providerId && !orgId && !domain) throw new APIError("BAD_REQUEST", { message: "providerId, orgId or domain is required" });
1481
- if (!provider) provider = await ctx.context.adapter.findOne({
1482
- model: "ssoProvider",
1483
- where: [{
1484
- field: providerId ? "providerId" : orgId ? "organizationId" : "domain",
1485
- value: providerId || orgId || domain
1486
- }]
1487
- }).then((res) => {
1488
- if (!res) return null;
1489
- return {
1490
- ...res,
1491
- oidcConfig: res.oidcConfig ? safeJsonParse(res.oidcConfig) || void 0 : void 0,
1492
- samlConfig: res.samlConfig ? safeJsonParse(res.samlConfig) || void 0 : void 0
2006
+ if (!provider) {
2007
+ const parseProvider = (res) => {
2008
+ if (!res) return null;
2009
+ return {
2010
+ ...res,
2011
+ oidcConfig: res.oidcConfig ? safeJsonParse(res.oidcConfig) || void 0 : void 0,
2012
+ samlConfig: res.samlConfig ? safeJsonParse(res.samlConfig) || void 0 : void 0
2013
+ };
1493
2014
  };
1494
- });
2015
+ if (providerId || orgId) provider = parseProvider(await ctx.context.adapter.findOne({
2016
+ model: "ssoProvider",
2017
+ where: [{
2018
+ field: providerId ? "providerId" : "organizationId",
2019
+ value: providerId || orgId
2020
+ }]
2021
+ }));
2022
+ else if (domain) {
2023
+ provider = parseProvider(await ctx.context.adapter.findOne({
2024
+ model: "ssoProvider",
2025
+ where: [{
2026
+ field: "domain",
2027
+ value: domain
2028
+ }]
2029
+ }));
2030
+ if (!provider) provider = parseProvider((await ctx.context.adapter.findMany({ model: "ssoProvider" })).find((p) => domainMatches(domain, p.domain)) ?? null);
2031
+ }
2032
+ }
1495
2033
  if (!provider) throw new APIError("NOT_FOUND", { message: "No provider found for the issuer" });
1496
2034
  if (body.providerType) {
1497
2035
  if (body.providerType === "oidc" && !provider.oidcConfig) throw new APIError("BAD_REQUEST", { message: "OIDC provider is not configured" });
@@ -1533,6 +2071,7 @@ const signInSSO = (options) => {
1533
2071
  if (provider.samlConfig) {
1534
2072
  const parsedSamlConfig = typeof provider.samlConfig === "object" ? provider.samlConfig : safeJsonParse(provider.samlConfig);
1535
2073
  if (!parsedSamlConfig) throw new APIError("BAD_REQUEST", { message: "Invalid SAML configuration" });
2074
+ if (parsedSamlConfig.authnRequestsSigned && !parsedSamlConfig.spMetadata?.privateKey && !parsedSamlConfig.privateKey) ctx.context.logger.warn("authnRequestsSigned is enabled but no privateKey provided - AuthnRequests will not be signed", { providerId: provider.providerId });
1536
2075
  let metadata = parsedSamlConfig.spMetadata.metadata;
1537
2076
  if (!metadata) metadata = saml.SPMetadata({
1538
2077
  entityID: parsedSamlConfig.spMetadata?.entityID || parsedSamlConfig.issuer,
@@ -1541,11 +2080,14 @@ const signInSSO = (options) => {
1541
2080
  Location: parsedSamlConfig.callbackUrl || `${ctx.context.baseURL}/sso/saml2/sp/acs/${provider.providerId}`
1542
2081
  }],
1543
2082
  wantMessageSigned: parsedSamlConfig.wantAssertionsSigned || false,
2083
+ authnRequestsSigned: parsedSamlConfig.authnRequestsSigned || false,
1544
2084
  nameIDFormat: parsedSamlConfig.identifierFormat ? [parsedSamlConfig.identifierFormat] : void 0
1545
2085
  }).getMetadata() || "";
1546
2086
  const sp = saml.ServiceProvider({
1547
2087
  metadata,
1548
- allowCreate: true
2088
+ allowCreate: true,
2089
+ privateKey: parsedSamlConfig.spMetadata?.privateKey || parsedSamlConfig.privateKey,
2090
+ privateKeyPass: parsedSamlConfig.spMetadata?.privateKeyPass
1549
2091
  });
1550
2092
  const idp = saml.IdentityProvider({
1551
2093
  metadata: parsedSamlConfig.idpMetadata?.metadata,
@@ -1555,6 +2097,7 @@ const signInSSO = (options) => {
1555
2097
  });
1556
2098
  const loginRequest = sp.createLoginRequest(idp, "redirect");
1557
2099
  if (!loginRequest) throw new APIError("BAD_REQUEST", { message: "Invalid SAML request" });
2100
+ const { state: relayState } = await generateRelayState(ctx, void 0, false);
1558
2101
  if (loginRequest.id && options?.saml?.enableInResponseToValidation) {
1559
2102
  const ttl = options?.saml?.requestTTL ?? DEFAULT_AUTHN_REQUEST_TTL_MS;
1560
2103
  const record = {
@@ -1570,7 +2113,7 @@ const signInSSO = (options) => {
1570
2113
  });
1571
2114
  }
1572
2115
  return ctx.json({
1573
- url: `${loginRequest.context}&RelayState=${encodeURIComponent(body.callbackURL)}`,
2116
+ url: `${loginRequest.context}&RelayState=${encodeURIComponent(relayState)}`,
1574
2117
  redirect: true
1575
2118
  });
1576
2119
  }
@@ -1601,8 +2144,8 @@ const callbackSSO = (options) => {
1601
2144
  const { code, error, error_description } = ctx.query;
1602
2145
  const stateData = await parseState(ctx);
1603
2146
  if (!stateData) {
1604
- const errorURL$1 = ctx.context.options.onAPIError?.errorURL || `${ctx.context.baseURL}/error`;
1605
- throw ctx.redirect(`${errorURL$1}?error=invalid_state`);
2147
+ const errorURL = ctx.context.options.onAPIError?.errorURL || `${ctx.context.baseURL}/error`;
2148
+ throw ctx.redirect(`${errorURL}?error=invalid_state`);
1606
2149
  }
1607
2150
  const { callbackURL, errorURL, newUserURL, requestSignUp } = stateData;
1608
2151
  if (!code || error) throw ctx.redirect(`${errorURL || callbackURL}?error=${error}&error_description=${error_description}`);
@@ -1752,17 +2295,46 @@ const callbackSSOSAMLBodySchema = z.object({
1752
2295
  SAMLResponse: z.string(),
1753
2296
  RelayState: z.string().optional()
1754
2297
  });
2298
+ /**
2299
+ * Validates and returns a safe redirect URL.
2300
+ * - Prevents open redirect attacks by validating against trusted origins
2301
+ * - Prevents redirect loops by checking if URL points to callback route
2302
+ * - Falls back to appOrigin if URL is invalid or unsafe
2303
+ */
2304
+ const getSafeRedirectUrl = (url, callbackPath, appOrigin, isTrustedOrigin) => {
2305
+ if (!url) return appOrigin;
2306
+ if (url.startsWith("/") && !url.startsWith("//")) {
2307
+ try {
2308
+ const absoluteUrl = new URL(url, appOrigin);
2309
+ if (absoluteUrl.origin !== appOrigin) return appOrigin;
2310
+ const callbackPathname = new URL(callbackPath).pathname;
2311
+ if (absoluteUrl.pathname === callbackPathname) return appOrigin;
2312
+ } catch {
2313
+ return appOrigin;
2314
+ }
2315
+ return url;
2316
+ }
2317
+ if (!isTrustedOrigin(url, { allowRelativePaths: false })) return appOrigin;
2318
+ try {
2319
+ const callbackPathname = new URL(callbackPath).pathname;
2320
+ if (new URL(url).pathname === callbackPathname) return appOrigin;
2321
+ } catch {
2322
+ if (url === callbackPath || url.startsWith(`${callbackPath}?`)) return appOrigin;
2323
+ }
2324
+ return url;
2325
+ };
1755
2326
  const callbackSSOSAML = (options) => {
1756
2327
  return createAuthEndpoint("/sso/saml2/callback/:providerId", {
1757
- method: "POST",
1758
- body: callbackSSOSAMLBodySchema,
2328
+ method: ["GET", "POST"],
2329
+ body: callbackSSOSAMLBodySchema.optional(),
2330
+ query: z.object({ RelayState: z.string().optional() }).optional(),
1759
2331
  metadata: {
1760
2332
  ...HIDE_METADATA,
1761
2333
  allowedMediaTypes: ["application/x-www-form-urlencoded", "application/json"],
1762
2334
  openapi: {
1763
2335
  operationId: "handleSAMLCallback",
1764
2336
  summary: "Callback URL for SAML provider",
1765
- description: "This endpoint is used as the callback URL for SAML providers.",
2337
+ description: "This endpoint is used as the callback URL for SAML providers. Supports both GET and POST methods for IdP-initiated and SP-initiated flows.",
1766
2338
  responses: {
1767
2339
  "302": { description: "Redirects to the callback URL" },
1768
2340
  "400": { description: "Invalid SAML response" },
@@ -1771,8 +2343,26 @@ const callbackSSOSAML = (options) => {
1771
2343
  }
1772
2344
  }
1773
2345
  }, async (ctx) => {
1774
- const { SAMLResponse, RelayState } = ctx.body;
1775
2346
  const { providerId } = ctx.params;
2347
+ const appOrigin = new URL(ctx.context.baseURL).origin;
2348
+ const errorURL = ctx.context.options.onAPIError?.errorURL || `${appOrigin}/error`;
2349
+ const currentCallbackPath = `${ctx.context.baseURL}/sso/saml2/callback/${providerId}`;
2350
+ if (ctx.method === "GET" && !ctx.body?.SAMLResponse) {
2351
+ if (!(await getSessionFromCtx(ctx))?.session) throw ctx.redirect(`${errorURL}?error=invalid_request`);
2352
+ const relayState = ctx.query?.RelayState;
2353
+ const safeRedirectUrl = getSafeRedirectUrl(relayState, currentCallbackPath, appOrigin, (url, settings) => ctx.context.isTrustedOrigin(url, settings));
2354
+ throw ctx.redirect(safeRedirectUrl);
2355
+ }
2356
+ if (!ctx.body?.SAMLResponse) throw new APIError("BAD_REQUEST", { message: "SAMLResponse is required for POST requests" });
2357
+ const { SAMLResponse } = ctx.body;
2358
+ const maxResponseSize = options?.saml?.maxResponseSize ?? DEFAULT_MAX_SAML_RESPONSE_SIZE;
2359
+ if (new TextEncoder().encode(SAMLResponse).length > maxResponseSize) throw new APIError("BAD_REQUEST", { message: `SAML response exceeds maximum allowed size (${maxResponseSize} bytes)` });
2360
+ let relayState = null;
2361
+ if (ctx.body.RelayState) try {
2362
+ relayState = await parseRelayState(ctx);
2363
+ } catch {
2364
+ relayState = null;
2365
+ }
1776
2366
  let provider = null;
1777
2367
  if (options?.defaultSSO?.length) {
1778
2368
  const matchingDefault = options.defaultSSO.find((defaultProvider) => defaultProvider.providerId === providerId);
@@ -1838,17 +2428,18 @@ const callbackSSOSAML = (options) => {
1838
2428
  wantMessageSigned: parsedSamlConfig.wantAssertionsSigned || false,
1839
2429
  nameIDFormat: parsedSamlConfig.identifierFormat ? [parsedSamlConfig.identifierFormat] : void 0
1840
2430
  });
2431
+ validateSingleAssertion(SAMLResponse);
1841
2432
  let parsedResponse;
1842
2433
  try {
1843
2434
  parsedResponse = await sp.parseLoginResponse(idp, "post", { body: {
1844
2435
  SAMLResponse,
1845
- RelayState: RelayState || void 0
2436
+ RelayState: ctx.body.RelayState || void 0
1846
2437
  } });
1847
2438
  if (!parsedResponse?.extract) throw new Error("Invalid SAML response structure");
1848
2439
  } catch (error) {
1849
2440
  ctx.context.logger.error("SAML response validation failed", {
1850
2441
  error,
1851
- decodedResponse: new TextDecoder().decode(base64.decode(SAMLResponse))
2442
+ decodedResponse: Buffer.from(SAMLResponse, "base64").toString("utf-8")
1852
2443
  });
1853
2444
  throw new APIError("BAD_REQUEST", {
1854
2445
  message: "Invalid SAML response",
@@ -1879,7 +2470,7 @@ const callbackSSOSAML = (options) => {
1879
2470
  inResponseTo,
1880
2471
  providerId: provider.providerId
1881
2472
  });
1882
- const redirectUrl = RelayState || parsedSamlConfig.callbackUrl || ctx.context.baseURL;
2473
+ const redirectUrl = relayState?.callbackURL || parsedSamlConfig.callbackUrl || ctx.context.baseURL;
1883
2474
  throw ctx.redirect(`${redirectUrl}?error=invalid_saml_response&error_description=Unknown+or+expired+request+ID`);
1884
2475
  }
1885
2476
  if (storedRequest.providerId !== provider.providerId) {
@@ -1889,13 +2480,13 @@ const callbackSSOSAML = (options) => {
1889
2480
  actualProvider: provider.providerId
1890
2481
  });
1891
2482
  await ctx.context.internalAdapter.deleteVerificationByIdentifier(`${AUTHN_REQUEST_KEY_PREFIX}${inResponseTo}`);
1892
- const redirectUrl = RelayState || parsedSamlConfig.callbackUrl || ctx.context.baseURL;
2483
+ const redirectUrl = relayState?.callbackURL || parsedSamlConfig.callbackUrl || ctx.context.baseURL;
1893
2484
  throw ctx.redirect(`${redirectUrl}?error=invalid_saml_response&error_description=Provider+mismatch`);
1894
2485
  }
1895
2486
  await ctx.context.internalAdapter.deleteVerificationByIdentifier(`${AUTHN_REQUEST_KEY_PREFIX}${inResponseTo}`);
1896
2487
  } else if (!allowIdpInitiated) {
1897
2488
  ctx.context.logger.error("SAML IdP-initiated SSO rejected: InResponseTo missing and allowIdpInitiated is false", { providerId: provider.providerId });
1898
- const redirectUrl = RelayState || parsedSamlConfig.callbackUrl || ctx.context.baseURL;
2489
+ const redirectUrl = relayState?.callbackURL || parsedSamlConfig.callbackUrl || ctx.context.baseURL;
1899
2490
  throw ctx.redirect(`${redirectUrl}?error=unsolicited_response&error_description=IdP-initiated+SSO+not+allowed`);
1900
2491
  }
1901
2492
  }
@@ -1922,7 +2513,7 @@ const callbackSSOSAML = (options) => {
1922
2513
  issuer,
1923
2514
  providerId: provider.providerId
1924
2515
  });
1925
- const redirectUrl = RelayState || parsedSamlConfig.callbackUrl || ctx.context.baseURL;
2516
+ const redirectUrl = relayState?.callbackURL || parsedSamlConfig.callbackUrl || ctx.context.baseURL;
1926
2517
  throw ctx.redirect(`${redirectUrl}?error=replay_detected&error_description=SAML+assertion+has+already+been+used`);
1927
2518
  }
1928
2519
  await ctx.context.internalAdapter.createVerificationValue({
@@ -1942,7 +2533,7 @@ const callbackSSOSAML = (options) => {
1942
2533
  const userInfo = {
1943
2534
  ...Object.fromEntries(Object.entries(mapping.extraFields || {}).map(([key, value]) => [key, attributes[value]])),
1944
2535
  id: attributes[mapping.id || "nameID"] || extract.nameID,
1945
- email: attributes[mapping.email || "email"] || extract.nameID,
2536
+ email: (attributes[mapping.email || "email"] || extract.nameID).toLowerCase(),
1946
2537
  name: [attributes[mapping.firstName || "givenName"], attributes[mapping.lastName || "surname"]].filter(Boolean).join(" ") || attributes[mapping.name || "displayName"] || extract.nameID,
1947
2538
  emailVerified: options?.trustEmailVerified && mapping.emailVerified ? attributes[mapping.emailVerified] || false : false
1948
2539
  };
@@ -1956,7 +2547,7 @@ const callbackSSOSAML = (options) => {
1956
2547
  throw new APIError("BAD_REQUEST", { message: "Unable to extract user ID or email from SAML response" });
1957
2548
  }
1958
2549
  const isTrustedProvider = !!ctx.context.options.account?.accountLinking?.trustedProviders?.includes(provider.providerId) || "domainVerified" in provider && !!provider.domainVerified && validateEmailDomain(userInfo.email, provider.domain);
1959
- const callbackUrl = RelayState || parsedSamlConfig.callbackUrl || ctx.context.baseURL;
2550
+ const callbackUrl = relayState?.callbackURL || parsedSamlConfig.callbackUrl || ctx.context.baseURL;
1960
2551
  const result = await handleOAuthUserInfo(ctx, {
1961
2552
  userInfo: {
1962
2553
  email: userInfo.email,
@@ -1998,10 +2589,10 @@ const callbackSSOSAML = (options) => {
1998
2589
  session,
1999
2590
  user
2000
2591
  });
2001
- throw ctx.redirect(callbackUrl);
2592
+ const safeRedirectUrl = getSafeRedirectUrl(relayState?.callbackURL || parsedSamlConfig.callbackUrl, currentCallbackPath, appOrigin, (url, settings) => ctx.context.isTrustedOrigin(url, settings));
2593
+ throw ctx.redirect(safeRedirectUrl);
2002
2594
  });
2003
2595
  };
2004
- const acsEndpointParamsSchema = z.object({ providerId: z.string().optional() });
2005
2596
  const acsEndpointBodySchema = z.object({
2006
2597
  SAMLResponse: z.string(),
2007
2598
  RelayState: z.string().optional()
@@ -2009,7 +2600,6 @@ const acsEndpointBodySchema = z.object({
2009
2600
  const acsEndpoint = (options) => {
2010
2601
  return createAuthEndpoint("/sso/saml2/sp/acs/:providerId", {
2011
2602
  method: "POST",
2012
- params: acsEndpointParamsSchema,
2013
2603
  body: acsEndpointBodySchema,
2014
2604
  metadata: {
2015
2605
  ...HIDE_METADATA,
@@ -2024,6 +2614,8 @@ const acsEndpoint = (options) => {
2024
2614
  }, async (ctx) => {
2025
2615
  const { SAMLResponse, RelayState = "" } = ctx.body;
2026
2616
  const { providerId } = ctx.params;
2617
+ const maxResponseSize = options?.saml?.maxResponseSize ?? DEFAULT_MAX_SAML_RESPONSE_SIZE;
2618
+ if (new TextEncoder().encode(SAMLResponse).length > maxResponseSize) throw new APIError("BAD_REQUEST", { message: `SAML response exceeds maximum allowed size (${maxResponseSize} bytes)` });
2027
2619
  let provider = null;
2028
2620
  if (options?.defaultSSO?.length) {
2029
2621
  const matchingDefault = providerId ? options.defaultSSO.find((defaultProvider) => defaultProvider.providerId === providerId) : options.defaultSSO[0];
@@ -2039,7 +2631,7 @@ const acsEndpoint = (options) => {
2039
2631
  model: "ssoProvider",
2040
2632
  where: [{
2041
2633
  field: "providerId",
2042
- value: providerId ?? "sso"
2634
+ value: providerId
2043
2635
  }]
2044
2636
  }).then((res) => {
2045
2637
  if (!res) return null;
@@ -2072,6 +2664,16 @@ const acsEndpoint = (options) => {
2072
2664
  }],
2073
2665
  signingCert: idpData?.cert || parsedSamlConfig.cert
2074
2666
  }) : saml.IdentityProvider({ metadata: idpData.metadata });
2667
+ try {
2668
+ validateSingleAssertion(SAMLResponse);
2669
+ } catch (error) {
2670
+ if (error instanceof APIError) {
2671
+ const redirectUrl = RelayState || parsedSamlConfig.callbackUrl || ctx.context.baseURL;
2672
+ const errorCode = error.body?.code === "SAML_MULTIPLE_ASSERTIONS" ? "multiple_assertions" : "no_assertion";
2673
+ throw ctx.redirect(`${redirectUrl}?error=${errorCode}&error_description=${encodeURIComponent(error.message)}`);
2674
+ }
2675
+ throw error;
2676
+ }
2075
2677
  let parsedResponse;
2076
2678
  try {
2077
2679
  parsedResponse = await sp.parseLoginResponse(idp, "post", { body: {
@@ -2082,7 +2684,7 @@ const acsEndpoint = (options) => {
2082
2684
  } catch (error) {
2083
2685
  ctx.context.logger.error("SAML response validation failed", {
2084
2686
  error,
2085
- decodedResponse: new TextDecoder().decode(base64.decode(SAMLResponse))
2687
+ decodedResponse: Buffer.from(SAMLResponse, "base64").toString("utf-8")
2086
2688
  });
2087
2689
  throw new APIError("BAD_REQUEST", {
2088
2690
  message: "Invalid SAML response",
@@ -2133,7 +2735,7 @@ const acsEndpoint = (options) => {
2133
2735
  throw ctx.redirect(`${redirectUrl}?error=unsolicited_response&error_description=IdP-initiated+SSO+not+allowed`);
2134
2736
  }
2135
2737
  }
2136
- const assertionIdAcs = extractAssertionId(new TextDecoder().decode(base64.decode(SAMLResponse)));
2738
+ const assertionIdAcs = extractAssertionId(Buffer.from(SAMLResponse, "base64").toString("utf-8"));
2137
2739
  if (assertionIdAcs) {
2138
2740
  const issuer = idp.entityMeta.getEntityID();
2139
2741
  const conditions = extract.conditions;
@@ -2175,7 +2777,7 @@ const acsEndpoint = (options) => {
2175
2777
  const userInfo = {
2176
2778
  ...Object.fromEntries(Object.entries(mapping.extraFields || {}).map(([key, value]) => [key, attributes[value]])),
2177
2779
  id: attributes[mapping.id || "nameID"] || extract.nameID,
2178
- email: attributes[mapping.email || "email"] || extract.nameID,
2780
+ email: (attributes[mapping.email || "email"] || extract.nameID).toLowerCase(),
2179
2781
  name: [attributes[mapping.firstName || "givenName"], attributes[mapping.lastName || "surname"]].filter(Boolean).join(" ") || attributes[mapping.name || "displayName"] || extract.nameID,
2180
2782
  emailVerified: options?.trustEmailVerified && mapping.emailVerified ? attributes[mapping.emailVerified] || false : false
2181
2783
  };
@@ -2241,6 +2843,12 @@ saml.setSchemaValidator({ async validate(xml) {
2241
2843
  if (XMLValidator.validate(xml, { allowBooleanAttributes: true }) === true) return "SUCCESS_VALIDATE_XML";
2242
2844
  throw "ERR_INVALID_XML";
2243
2845
  } });
2846
+ /**
2847
+ * SAML endpoint paths that should skip origin check validation.
2848
+ * These endpoints receive POST requests from external Identity Providers,
2849
+ * which won't have a matching Origin header.
2850
+ */
2851
+ const SAML_SKIP_ORIGIN_CHECK_PATHS = ["/sso/saml2/callback", "/sso/saml2/sp/acs"];
2244
2852
  function sso(options) {
2245
2853
  const optionsWithStore = options;
2246
2854
  let endpoints = {
@@ -2249,7 +2857,11 @@ function sso(options) {
2249
2857
  signInSSO: signInSSO(optionsWithStore),
2250
2858
  callbackSSO: callbackSSO(optionsWithStore),
2251
2859
  callbackSSOSAML: callbackSSOSAML(optionsWithStore),
2252
- acsEndpoint: acsEndpoint(optionsWithStore)
2860
+ acsEndpoint: acsEndpoint(optionsWithStore),
2861
+ listSSOProviders: listSSOProviders(),
2862
+ getSSOProvider: getSSOProvider(),
2863
+ updateSSOProvider: updateSSOProvider(optionsWithStore),
2864
+ deleteSSOProvider: deleteSSOProvider()
2253
2865
  };
2254
2866
  if (options?.domainVerification?.enabled) {
2255
2867
  const domainVerificationEndpoints = {
@@ -2263,6 +2875,11 @@ function sso(options) {
2263
2875
  }
2264
2876
  return {
2265
2877
  id: "sso",
2878
+ init(ctx) {
2879
+ const existing = ctx.skipOriginCheck;
2880
+ if (existing === true) return {};
2881
+ return { context: { skipOriginCheck: [...Array.isArray(existing) ? existing : [], ...SAML_SKIP_ORIGIN_CHECK_PATHS] } };
2882
+ },
2266
2883
  endpoints,
2267
2884
  hooks: { after: [{
2268
2885
  matcher(context) {
@@ -2271,7 +2888,7 @@ function sso(options) {
2271
2888
  handler: createAuthMiddleware(async (ctx) => {
2272
2889
  const newSession = ctx.context.newSession;
2273
2890
  if (!newSession?.user) return;
2274
- if (!ctx.context.options.plugins?.find((plugin) => plugin.id === "organization")) return;
2891
+ if (!ctx.context.hasPlugin("organization")) return;
2275
2892
  await assignOrganizationByDomain(ctx, {
2276
2893
  user: newSession.user,
2277
2894
  provisioningOptions: options?.organizationProvisioning,
@@ -2332,4 +2949,5 @@ function sso(options) {
2332
2949
  }
2333
2950
 
2334
2951
  //#endregion
2335
- export { DataEncryptionAlgorithm, DigestAlgorithm, DiscoveryError, KeyEncryptionAlgorithm, REQUIRED_DISCOVERY_FIELDS, SignatureAlgorithm, computeDiscoveryUrl, discoverOIDCConfig, fetchDiscoveryDocument, needsRuntimeDiscovery, normalizeDiscoveryUrls, normalizeUrl, selectTokenEndpointAuthMethod, sso, validateDiscoveryDocument, validateDiscoveryUrl, validateSAMLTimestamp };
2952
+ export { DEFAULT_CLOCK_SKEW_MS, DEFAULT_MAX_SAML_METADATA_SIZE, DEFAULT_MAX_SAML_RESPONSE_SIZE, DataEncryptionAlgorithm, DigestAlgorithm, DiscoveryError, KeyEncryptionAlgorithm, REQUIRED_DISCOVERY_FIELDS, SignatureAlgorithm, computeDiscoveryUrl, discoverOIDCConfig, fetchDiscoveryDocument, needsRuntimeDiscovery, normalizeDiscoveryUrls, normalizeUrl, selectTokenEndpointAuthMethod, sso, validateDiscoveryDocument, validateDiscoveryUrl, validateSAMLTimestamp };
2953
+ //# sourceMappingURL=index.mjs.map