@better-auth/sso 1.7.0-beta.3 → 1.7.0-beta.4

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,17 +1,19 @@
1
- import { t as PACKAGE_VERSION } from "./version-CLqkeI3u.mjs";
1
+ import { t as PACKAGE_VERSION } from "./version-5EiO_U3Z.mjs";
2
2
  import { APIError, createAuthEndpoint, createAuthMiddleware, getSessionFromCtx, sessionMiddleware } from "better-auth/api";
3
3
  import { XMLParser, XMLValidator } from "fast-xml-parser";
4
4
  import { X509Certificate } from "node:crypto";
5
5
  import { getHostname } from "tldts";
6
6
  import { generateRandomString } from "better-auth/crypto";
7
7
  import * as z from "zod";
8
- import { base64 } from "@better-auth/utils/base64";
8
+ import { isPublicRoutableHost } from "@better-auth/core/utils/host";
9
9
  import { BetterFetchError, betterFetch } from "@better-fetch/fetch";
10
- import { ASSERTION_SIGNING_ALGORITHMS, HIDE_METADATA, createAuthorizationURL, generateGenericState, generateState, parseGenericState, parseState, validateAuthorizationCode, validateToken } from "better-auth";
10
+ import { base64 } from "@better-auth/utils/base64";
11
+ import { defineErrorCodes } from "@better-auth/core/utils/error-codes";
12
+ import { isAPIError } from "@better-auth/core/utils/is-api-error";
13
+ import { HIDE_METADATA, PRIVATE_KEY_JWT_SIGNING_ALGORITHMS, createAuthorizationURL, createPrivateKeyJwtClientAssertionGetter, generateGenericState, generateState, parseGenericState, parseState, validateAuthorizationCode, validateToken } from "better-auth";
11
14
  import { deleteSessionCookie, setSessionCookie } from "better-auth/cookies";
12
- import { handleOAuthUserInfo } from "better-auth/oauth2";
15
+ import { additionalAuthorizationParamsSchema, handleOAuthUserInfo } from "better-auth/oauth2";
13
16
  import { decodeJwt } from "jose";
14
- import { defineErrorCodes } from "@better-auth/core/utils/error-codes";
15
17
  import * as samlifyNamespace from "samlify";
16
18
  import samlifyDefault from "samlify";
17
19
  //#region src/constants.ts
@@ -55,7 +57,6 @@ const DEFAULT_MAX_SAML_RESPONSE_SIZE = 256 * 1024;
55
57
  * Protects against oversized metadata documents.
56
58
  */
57
59
  const DEFAULT_MAX_SAML_METADATA_SIZE = 100 * 1024;
58
- const SAML_STATUS_SUCCESS = "urn:oasis:names:tc:SAML:2.0:status:Success";
59
60
  //#endregion
60
61
  //#region src/utils.ts
61
62
  /**
@@ -102,6 +103,10 @@ function parseCertificate(certPem) {
102
103
  publicKeyAlgorithm: cert.publicKey.asymmetricKeyType?.toUpperCase() || "UNKNOWN"
103
104
  };
104
105
  }
106
+ function normalizePem(key) {
107
+ if (!key) return key;
108
+ return `${key.split("\n").map((line) => line.trim()).join("\n").trim()}\n`;
109
+ }
105
110
  function getHostnameFromDomain(domain) {
106
111
  return getHostname(domain) || null;
107
112
  }
@@ -371,1097 +376,1218 @@ const verifyDomain = (options) => {
371
376
  });
372
377
  };
373
378
  //#endregion
374
- //#region src/saml/parser.ts
375
- const xmlParser = new XMLParser({
376
- ignoreAttributes: false,
377
- attributeNamePrefix: "@_",
378
- removeNSPrefix: true,
379
- processEntities: false
380
- });
381
- function findNode(obj, nodeName) {
382
- if (!obj || typeof obj !== "object") return null;
383
- const record = obj;
384
- if (nodeName in record) return record[nodeName];
385
- for (const value of Object.values(record)) if (Array.isArray(value)) for (const item of value) {
386
- const found = findNode(item, nodeName);
387
- if (found) return found;
388
- }
389
- else if (typeof value === "object" && value !== null) {
390
- const found = findNode(value, nodeName);
391
- if (found) return found;
392
- }
393
- return null;
394
- }
395
- function countAllNodes(obj, nodeName) {
396
- if (!obj || typeof obj !== "object") return 0;
397
- let count = 0;
398
- const record = obj;
399
- if (nodeName in record) {
400
- const node = record[nodeName];
401
- count += Array.isArray(node) ? node.length : 1;
379
+ //#region src/oidc/types.ts
380
+ /**
381
+ * Custom error class for OIDC discovery failures.
382
+ * Can be caught and mapped to APIError at the edge.
383
+ */
384
+ var DiscoveryError = class DiscoveryError extends Error {
385
+ code;
386
+ details;
387
+ constructor(code, message, details, options) {
388
+ super(message, options);
389
+ this.name = "DiscoveryError";
390
+ this.code = code;
391
+ this.details = details;
392
+ if (Error.captureStackTrace) Error.captureStackTrace(this, DiscoveryError);
402
393
  }
403
- for (const value of Object.values(record)) if (Array.isArray(value)) for (const item of value) count += countAllNodes(item, nodeName);
404
- else if (typeof value === "object" && value !== null) count += countAllNodes(value, nodeName);
405
- return count;
406
- }
407
- //#endregion
408
- //#region src/saml/algorithms.ts
409
- const SignatureAlgorithm = {
410
- RSA_SHA1: "http://www.w3.org/2000/09/xmldsig#rsa-sha1",
411
- RSA_SHA256: "http://www.w3.org/2001/04/xmldsig-more#rsa-sha256",
412
- RSA_SHA384: "http://www.w3.org/2001/04/xmldsig-more#rsa-sha384",
413
- RSA_SHA512: "http://www.w3.org/2001/04/xmldsig-more#rsa-sha512",
414
- ECDSA_SHA256: "http://www.w3.org/2001/04/xmldsig-more#ecdsa-sha256",
415
- ECDSA_SHA384: "http://www.w3.org/2001/04/xmldsig-more#ecdsa-sha384",
416
- ECDSA_SHA512: "http://www.w3.org/2001/04/xmldsig-more#ecdsa-sha512"
417
- };
418
- const DigestAlgorithm = {
419
- SHA1: "http://www.w3.org/2000/09/xmldsig#sha1",
420
- SHA256: "http://www.w3.org/2001/04/xmlenc#sha256",
421
- SHA384: "http://www.w3.org/2001/04/xmldsig-more#sha384",
422
- SHA512: "http://www.w3.org/2001/04/xmlenc#sha512"
423
- };
424
- const KeyEncryptionAlgorithm = {
425
- RSA_1_5: "http://www.w3.org/2001/04/xmlenc#rsa-1_5",
426
- RSA_OAEP: "http://www.w3.org/2001/04/xmlenc#rsa-oaep-mgf1p",
427
- RSA_OAEP_SHA256: "http://www.w3.org/2009/xmlenc11#rsa-oaep"
428
- };
429
- const DataEncryptionAlgorithm = {
430
- TRIPLEDES_CBC: "http://www.w3.org/2001/04/xmlenc#tripledes-cbc",
431
- AES_128_CBC: "http://www.w3.org/2001/04/xmlenc#aes128-cbc",
432
- AES_192_CBC: "http://www.w3.org/2001/04/xmlenc#aes192-cbc",
433
- AES_256_CBC: "http://www.w3.org/2001/04/xmlenc#aes256-cbc",
434
- AES_128_GCM: "http://www.w3.org/2009/xmlenc11#aes128-gcm",
435
- AES_192_GCM: "http://www.w3.org/2009/xmlenc11#aes192-gcm",
436
- AES_256_GCM: "http://www.w3.org/2009/xmlenc11#aes256-gcm"
437
394
  };
438
- const DEPRECATED_SIGNATURE_ALGORITHMS = [SignatureAlgorithm.RSA_SHA1];
439
- const DEPRECATED_KEY_ENCRYPTION_ALGORITHMS = [KeyEncryptionAlgorithm.RSA_1_5];
440
- const DEPRECATED_DATA_ENCRYPTION_ALGORITHMS = [DataEncryptionAlgorithm.TRIPLEDES_CBC];
441
- const DEPRECATED_DIGEST_ALGORITHMS = [DigestAlgorithm.SHA1];
442
- const SECURE_SIGNATURE_ALGORITHMS = [
443
- SignatureAlgorithm.RSA_SHA256,
444
- SignatureAlgorithm.RSA_SHA384,
445
- SignatureAlgorithm.RSA_SHA512,
446
- SignatureAlgorithm.ECDSA_SHA256,
447
- SignatureAlgorithm.ECDSA_SHA384,
448
- SignatureAlgorithm.ECDSA_SHA512
449
- ];
450
- const SECURE_DIGEST_ALGORITHMS = [
451
- DigestAlgorithm.SHA256,
452
- DigestAlgorithm.SHA384,
453
- DigestAlgorithm.SHA512
395
+ /**
396
+ * Required fields that must be present in a valid discovery document.
397
+ */
398
+ const REQUIRED_DISCOVERY_FIELDS = [
399
+ "issuer",
400
+ "authorization_endpoint",
401
+ "token_endpoint",
402
+ "jwks_uri"
454
403
  ];
455
- const SHORT_FORM_SIGNATURE_TO_URI = {
456
- sha1: SignatureAlgorithm.RSA_SHA1,
457
- sha256: SignatureAlgorithm.RSA_SHA256,
458
- sha384: SignatureAlgorithm.RSA_SHA384,
459
- sha512: SignatureAlgorithm.RSA_SHA512,
460
- "rsa-sha1": SignatureAlgorithm.RSA_SHA1,
461
- "rsa-sha256": SignatureAlgorithm.RSA_SHA256,
462
- "rsa-sha384": SignatureAlgorithm.RSA_SHA384,
463
- "rsa-sha512": SignatureAlgorithm.RSA_SHA512,
464
- "ecdsa-sha256": SignatureAlgorithm.ECDSA_SHA256,
465
- "ecdsa-sha384": SignatureAlgorithm.ECDSA_SHA384,
466
- "ecdsa-sha512": SignatureAlgorithm.ECDSA_SHA512
467
- };
468
- const SHORT_FORM_DIGEST_TO_URI = {
469
- sha1: DigestAlgorithm.SHA1,
470
- sha256: DigestAlgorithm.SHA256,
471
- sha384: DigestAlgorithm.SHA384,
472
- sha512: DigestAlgorithm.SHA512
473
- };
474
- function normalizeSignatureAlgorithm(alg) {
475
- return SHORT_FORM_SIGNATURE_TO_URI[alg.toLowerCase()] ?? alg;
476
- }
477
- function normalizeDigestAlgorithm(alg) {
478
- return SHORT_FORM_DIGEST_TO_URI[alg.toLowerCase()] ?? alg;
479
- }
480
- function extractEncryptionAlgorithms(xml) {
481
- try {
482
- const parsed = xmlParser.parse(xml);
483
- const keyAlg = (findNode(parsed, "EncryptedKey")?.EncryptionMethod)?.["@_Algorithm"];
484
- const dataAlg = (findNode(parsed, "EncryptedData")?.EncryptionMethod)?.["@_Algorithm"];
485
- return {
486
- keyEncryption: keyAlg || null,
487
- dataEncryption: dataAlg || null
488
- };
489
- } catch {
490
- return {
491
- keyEncryption: null,
492
- dataEncryption: null
493
- };
494
- }
404
+ //#endregion
405
+ //#region src/oidc/discovery.ts
406
+ /**
407
+ * OIDC Discovery Pipeline
408
+ *
409
+ * Implements OIDC discovery document fetching, validation, and hydration.
410
+ * This module is used both at provider registration time (to persist validated config)
411
+ * and at runtime (to hydrate legacy providers that are missing metadata).
412
+ *
413
+ * @see https://openid.net/specs/openid-connect-discovery-1_0.html
414
+ */
415
+ /** Default timeout for discovery requests (10 seconds) */
416
+ const DEFAULT_DISCOVERY_TIMEOUT = 1e4;
417
+ /**
418
+ * Main entry point: Discover and hydrate OIDC configuration from an issuer.
419
+ *
420
+ * This function:
421
+ * 1. Computes the discovery URL from the issuer
422
+ * 2. Validates the discovery URL
423
+ * 3. Fetches the discovery document
424
+ * 4. Validates the discovery document (issuer match + required fields)
425
+ * 5. Normalizes URLs
426
+ * 6. Selects token endpoint auth method
427
+ * 7. Merges with existing config (existing values take precedence)
428
+ *
429
+ * @param params - Discovery parameters
430
+ * @param isTrustedOrigin - Origin verification tester function
431
+ * @returns Hydrated OIDC configuration ready for persistence
432
+ * @throws DiscoveryError on any failure
433
+ */
434
+ async function discoverOIDCConfig(params) {
435
+ const { issuer, existingConfig, timeout = DEFAULT_DISCOVERY_TIMEOUT } = params;
436
+ const discoveryUrl = params.discoveryEndpoint || existingConfig?.discoveryEndpoint || computeDiscoveryUrl(issuer);
437
+ validateDiscoveryUrl(discoveryUrl, params.isTrustedOrigin);
438
+ const discoveryDoc = await fetchDiscoveryDocument(discoveryUrl, timeout);
439
+ validateDiscoveryDocument(discoveryDoc, issuer);
440
+ const normalizedDoc = normalizeDiscoveryUrls(discoveryDoc, issuer, params.isTrustedOrigin);
441
+ const tokenEndpointAuth = selectTokenEndpointAuthMethod(normalizedDoc, existingConfig?.tokenEndpointAuthentication);
442
+ return {
443
+ issuer: existingConfig?.issuer ?? normalizedDoc.issuer,
444
+ discoveryEndpoint: existingConfig?.discoveryEndpoint ?? discoveryUrl,
445
+ authorizationEndpoint: existingConfig?.authorizationEndpoint ?? normalizedDoc.authorization_endpoint,
446
+ tokenEndpoint: existingConfig?.tokenEndpoint ?? normalizedDoc.token_endpoint,
447
+ jwksEndpoint: existingConfig?.jwksEndpoint ?? normalizedDoc.jwks_uri,
448
+ userInfoEndpoint: existingConfig?.userInfoEndpoint ?? normalizedDoc.userinfo_endpoint,
449
+ tokenEndpointAuthentication: existingConfig?.tokenEndpointAuthentication ?? tokenEndpointAuth,
450
+ scopesSupported: existingConfig?.scopesSupported ?? normalizedDoc.scopes_supported
451
+ };
495
452
  }
496
- function hasEncryptedAssertion(xml) {
497
- try {
498
- return findNode(xmlParser.parse(xml), "EncryptedAssertion") !== null;
499
- } catch {
500
- return false;
501
- }
453
+ /**
454
+ * Compute the discovery URL from an issuer URL.
455
+ *
456
+ * Per OIDC Discovery spec, the discovery document is located at:
457
+ * <issuer>/.well-known/openid-configuration
458
+ *
459
+ * Handles trailing slashes correctly.
460
+ */
461
+ function computeDiscoveryUrl(issuer) {
462
+ return `${issuer.endsWith("/") ? issuer.slice(0, -1) : issuer}/.well-known/openid-configuration`;
502
463
  }
503
- function handleDeprecatedAlgorithm(message, behavior, errorCode) {
504
- switch (behavior) {
505
- case "reject": throw new APIError("BAD_REQUEST", {
506
- message,
507
- code: errorCode
508
- });
509
- case "warn":
510
- console.warn(`[SAML Security Warning] ${message}`);
511
- break;
512
- case "allow": break;
513
- }
464
+ /**
465
+ * Validate a discovery URL before fetching.
466
+ *
467
+ * @param url - The discovery URL to validate
468
+ * @param isTrustedOrigin - Origin verification tester function
469
+ * @throws DiscoveryError if URL is invalid
470
+ */
471
+ function validateDiscoveryUrl(url, isTrustedOrigin) {
472
+ const discoveryEndpoint = parseURL("discoveryEndpoint", url).toString();
473
+ if (!isTrustedOrigin(discoveryEndpoint)) throw new DiscoveryError("discovery_untrusted_origin", `The main discovery endpoint "${discoveryEndpoint}" is not trusted by your trusted origins configuration.`, { url: discoveryEndpoint });
514
474
  }
515
- function validateSignatureAlgorithm(algorithm, options = {}) {
516
- if (!algorithm) return;
517
- const { onDeprecated = "warn", allowedSignatureAlgorithms } = options;
518
- if (allowedSignatureAlgorithms) {
519
- if (!allowedSignatureAlgorithms.includes(algorithm)) throw new APIError("BAD_REQUEST", {
520
- message: `SAML signature algorithm not in allow-list: ${algorithm}`,
521
- code: "SAML_ALGORITHM_NOT_ALLOWED"
522
- });
523
- return;
524
- }
525
- if (DEPRECATED_SIGNATURE_ALGORITHMS.includes(algorithm)) {
526
- handleDeprecatedAlgorithm(`SAML response uses deprecated signature algorithm: ${algorithm}. Please configure your IdP to use SHA-256 or stronger.`, onDeprecated, "SAML_DEPRECATED_ALGORITHM");
527
- return;
528
- }
529
- if (!SECURE_SIGNATURE_ALGORITHMS.includes(algorithm)) throw new APIError("BAD_REQUEST", {
530
- message: `SAML signature algorithm not recognized: ${algorithm}`,
531
- code: "SAML_UNKNOWN_ALGORITHM"
475
+ /**
476
+ * Validate that a user-supplied OIDC endpoint URL is safe to fetch.
477
+ *
478
+ * Layered checks (in order):
479
+ * 1. URL parsing + http(s) scheme → discovery_invalid_url
480
+ * 2. Public-routable host (RFC 6890) → allowed
481
+ * 3. Operator-allowlisted via trustedOrigins → allowed (opt-in for internal IdPs)
482
+ * 4. Otherwise → discovery_private_host
483
+ *
484
+ * Step 2 rejects loopback, RFC 1918, link-local, ULA, shared-address,
485
+ * cloud-metadata FQDNs (e.g. `169.254.169.254`, `metadata.google.internal`),
486
+ * multicast, and reserved ranges. See `isPublicRoutableHost` in
487
+ * `@better-auth/core/utils/host`.
488
+ *
489
+ * Step 3 is the documented escape hatch for customers whose IdP runs on a
490
+ * private network or behind a corporate VPN: they add the IdP origin to their
491
+ * `trustedOrigins` configuration.
492
+ *
493
+ * @param name - The endpoint field name, used in error messages
494
+ * @param endpoint - The URL to validate
495
+ * @param isTrustedOrigin - Predicate matching the configured `trustedOrigins`
496
+ * @throws DiscoveryError(discovery_invalid_url) — malformed URL or non-http(s) scheme
497
+ * @throws DiscoveryError(discovery_private_host) — host is not publicly routable and not allowlisted
498
+ */
499
+ function validateSkipDiscoveryEndpoint(name, endpoint, isTrustedOrigin) {
500
+ const parsed = parseURL(name, endpoint);
501
+ if (isPublicRoutableHost(parsed.hostname)) return;
502
+ if (isTrustedOrigin(parsed.toString())) return;
503
+ throw new DiscoveryError("discovery_private_host", `The ${name} URL (${parsed.toString()}) is not publicly routable: ${parsed.hostname}. If this is an internal IdP, add its origin to trustedOrigins.`, {
504
+ endpoint: name,
505
+ url: endpoint,
506
+ hostname: parsed.hostname
532
507
  });
533
508
  }
534
- function validateEncryptionAlgorithms(algorithms, options = {}) {
535
- const { onDeprecated = "warn", allowedKeyEncryptionAlgorithms, allowedDataEncryptionAlgorithms } = options;
536
- const { keyEncryption, dataEncryption } = algorithms;
537
- if (keyEncryption) {
538
- if (allowedKeyEncryptionAlgorithms) {
539
- if (!allowedKeyEncryptionAlgorithms.includes(keyEncryption)) throw new APIError("BAD_REQUEST", {
540
- message: `SAML key encryption algorithm not in allow-list: ${keyEncryption}`,
541
- code: "SAML_ALGORITHM_NOT_ALLOWED"
509
+ /**
510
+ * Validate every present OIDC endpoint URL in a registration or update body.
511
+ *
512
+ * Each provided URL is checked with {@link validateSkipDiscoveryEndpoint}.
513
+ * Omitted (undefined / null / empty) fields are skipped.
514
+ *
515
+ * @param config - OIDC endpoint URLs from the request body
516
+ * @param isTrustedOrigin - Predicate matching the configured `trustedOrigins`
517
+ * @throws DiscoveryError on the first invalid endpoint
518
+ */
519
+ function validateSkipDiscoveryEndpoints(config, isTrustedOrigin) {
520
+ const fields = [
521
+ ["authorizationEndpoint", config.authorizationEndpoint],
522
+ ["tokenEndpoint", config.tokenEndpoint],
523
+ ["userInfoEndpoint", config.userInfoEndpoint],
524
+ ["jwksEndpoint", config.jwksEndpoint],
525
+ ["discoveryEndpoint", config.discoveryEndpoint]
526
+ ];
527
+ for (const [name, url] of fields) if (url) validateSkipDiscoveryEndpoint(name, url, isTrustedOrigin);
528
+ }
529
+ /**
530
+ * Fetch the OIDC discovery document from the IdP.
531
+ *
532
+ * @param url - The discovery endpoint URL
533
+ * @param timeout - Request timeout in milliseconds
534
+ * @returns The parsed discovery document
535
+ * @throws DiscoveryError on network errors, timeouts, or invalid responses
536
+ */
537
+ async function fetchDiscoveryDocument(url, timeout = DEFAULT_DISCOVERY_TIMEOUT) {
538
+ try {
539
+ const response = await betterFetch(url, {
540
+ method: "GET",
541
+ timeout
542
+ });
543
+ if (response.error) {
544
+ const { status } = response.error;
545
+ if (status === 404) throw new DiscoveryError("discovery_not_found", "Discovery endpoint not found", {
546
+ url,
547
+ status
542
548
  });
543
- } 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");
544
- }
545
- if (dataEncryption) {
546
- if (allowedDataEncryptionAlgorithms) {
547
- if (!allowedDataEncryptionAlgorithms.includes(dataEncryption)) throw new APIError("BAD_REQUEST", {
548
- message: `SAML data encryption algorithm not in allow-list: ${dataEncryption}`,
549
- code: "SAML_ALGORITHM_NOT_ALLOWED"
549
+ if (status === 408) throw new DiscoveryError("discovery_timeout", "Discovery request timed out", {
550
+ url,
551
+ timeout
550
552
  });
551
- } 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");
552
- }
553
- }
554
- function validateSAMLAlgorithms(response, options) {
555
- validateSignatureAlgorithm(response.sigAlg, options);
556
- if (hasEncryptedAssertion(response.samlContent)) validateEncryptionAlgorithms(extractEncryptionAlgorithms(response.samlContent), options);
557
- }
558
- function validateConfigAlgorithms(config, options = {}) {
559
- const { onDeprecated = "warn", allowedSignatureAlgorithms, allowedDigestAlgorithms } = options;
560
- if (config.signatureAlgorithm) {
561
- const normalized = normalizeSignatureAlgorithm(config.signatureAlgorithm);
562
- if (allowedSignatureAlgorithms) {
563
- if (!allowedSignatureAlgorithms.map(normalizeSignatureAlgorithm).includes(normalized)) throw new APIError("BAD_REQUEST", {
564
- message: `SAML signature algorithm not in allow-list: ${config.signatureAlgorithm}`,
565
- code: "SAML_ALGORITHM_NOT_ALLOWED"
553
+ throw new DiscoveryError("discovery_unexpected_error", `Unexpected discovery error: ${response.error.statusText}`, {
554
+ url,
555
+ ...response.error
566
556
  });
567
- } 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");
568
- else if (!SECURE_SIGNATURE_ALGORITHMS.includes(normalized)) throw new APIError("BAD_REQUEST", {
569
- message: `SAML signature algorithm not recognized: ${config.signatureAlgorithm}`,
570
- code: "SAML_UNKNOWN_ALGORITHM"
557
+ }
558
+ if (!response.data) throw new DiscoveryError("discovery_invalid_json", "Discovery endpoint returned an empty response", { url });
559
+ const data = response.data;
560
+ if (typeof data === "string") throw new DiscoveryError("discovery_invalid_json", "Discovery endpoint returned invalid JSON", {
561
+ url,
562
+ bodyPreview: data.slice(0, 200)
571
563
  });
572
- }
573
- if (config.digestAlgorithm) {
574
- const normalized = normalizeDigestAlgorithm(config.digestAlgorithm);
575
- if (allowedDigestAlgorithms) {
576
- if (!allowedDigestAlgorithms.map(normalizeDigestAlgorithm).includes(normalized)) throw new APIError("BAD_REQUEST", {
577
- message: `SAML digest algorithm not in allow-list: ${config.digestAlgorithm}`,
578
- code: "SAML_ALGORITHM_NOT_ALLOWED"
579
- });
580
- } 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");
581
- else if (!SECURE_DIGEST_ALGORITHMS.includes(normalized)) throw new APIError("BAD_REQUEST", {
582
- message: `SAML digest algorithm not recognized: ${config.digestAlgorithm}`,
583
- code: "SAML_UNKNOWN_ALGORITHM"
564
+ return data;
565
+ } catch (error) {
566
+ if (error instanceof DiscoveryError) throw error;
567
+ if (error instanceof Error && error.name === "AbortError") throw new DiscoveryError("discovery_timeout", "Discovery request timed out", {
568
+ url,
569
+ timeout
584
570
  });
571
+ throw new DiscoveryError("discovery_unexpected_error", `Unexpected error during discovery: ${error instanceof Error ? error.message : String(error)}`, { url }, { cause: error });
585
572
  }
586
573
  }
587
- //#endregion
588
- //#region src/saml/assertions.ts
589
- function countAssertions(xml) {
590
- let parsed;
574
+ /**
575
+ * Validate a discovery document.
576
+ *
577
+ * Checks:
578
+ * 1. All required fields are present
579
+ * 2. Issuer matches the configured issuer (case-sensitive, exact match)
580
+ *
581
+ * Invariant: If this function returns without throwing, the document is safe
582
+ * to use for hydrating OIDC config (required fields present, issuer matches
583
+ * configured value, basic structural sanity verified).
584
+ *
585
+ * @param doc - The discovery document to validate
586
+ * @param configuredIssuer - The expected issuer value
587
+ * @throws DiscoveryError if validation fails
588
+ */
589
+ function validateDiscoveryDocument(doc, configuredIssuer) {
590
+ const missingFields = [];
591
+ for (const field of REQUIRED_DISCOVERY_FIELDS) if (!doc[field]) missingFields.push(field);
592
+ if (missingFields.length > 0) throw new DiscoveryError("discovery_incomplete", `Discovery document is missing required fields: ${missingFields.join(", ")}`, { missingFields });
593
+ if ((doc.issuer.endsWith("/") ? doc.issuer.slice(0, -1) : doc.issuer) !== (configuredIssuer.endsWith("/") ? configuredIssuer.slice(0, -1) : configuredIssuer)) throw new DiscoveryError("issuer_mismatch", `Discovered issuer "${doc.issuer}" does not match configured issuer "${configuredIssuer}"`, {
594
+ discovered: doc.issuer,
595
+ configured: configuredIssuer
596
+ });
597
+ }
598
+ /**
599
+ * Normalize URLs in the discovery document.
600
+ *
601
+ * @param document - The discovery document
602
+ * @param issuer - The base issuer URL
603
+ * @param isTrustedOrigin - Origin verification tester function
604
+ * @returns The normalized discovery document
605
+ */
606
+ function normalizeDiscoveryUrls(document, issuer, isTrustedOrigin) {
607
+ const doc = { ...document };
608
+ doc.token_endpoint = normalizeAndValidateUrl("token_endpoint", doc.token_endpoint, issuer, isTrustedOrigin);
609
+ doc.authorization_endpoint = normalizeAndValidateUrl("authorization_endpoint", doc.authorization_endpoint, issuer, isTrustedOrigin);
610
+ doc.jwks_uri = normalizeAndValidateUrl("jwks_uri", doc.jwks_uri, issuer, isTrustedOrigin);
611
+ if (doc.userinfo_endpoint) doc.userinfo_endpoint = normalizeAndValidateUrl("userinfo_endpoint", doc.userinfo_endpoint, issuer, isTrustedOrigin);
612
+ if (doc.revocation_endpoint) doc.revocation_endpoint = normalizeAndValidateUrl("revocation_endpoint", doc.revocation_endpoint, issuer, isTrustedOrigin);
613
+ if (doc.end_session_endpoint) doc.end_session_endpoint = normalizeAndValidateUrl("end_session_endpoint", doc.end_session_endpoint, issuer, isTrustedOrigin);
614
+ if (doc.introspection_endpoint) doc.introspection_endpoint = normalizeAndValidateUrl("introspection_endpoint", doc.introspection_endpoint, issuer, isTrustedOrigin);
615
+ return doc;
616
+ }
617
+ /**
618
+ * Normalizes and validates a single URL endpoint
619
+ * @param name The url name
620
+ * @param endpoint The url to validate
621
+ * @param issuer The issuer base url
622
+ * @param isTrustedOrigin - Origin verification tester function
623
+ * @returns
624
+ */
625
+ function normalizeAndValidateUrl(name, endpoint, issuer, isTrustedOrigin) {
626
+ const url = normalizeUrl(name, endpoint, issuer);
627
+ if (!isTrustedOrigin(url)) throw new DiscoveryError("discovery_untrusted_origin", `The ${name} "${url}" is not trusted by your trusted origins configuration.`, {
628
+ endpoint: name,
629
+ url
630
+ });
631
+ return url;
632
+ }
633
+ /**
634
+ * Normalize a single URL endpoint.
635
+ *
636
+ * @param name - The endpoint name (e.g token_endpoint)
637
+ * @param endpoint - The endpoint URL to normalize
638
+ * @param issuer - The base issuer URL
639
+ * @returns The normalized endpoint URL
640
+ */
641
+ function normalizeUrl(name, endpoint, issuer) {
591
642
  try {
592
- parsed = xmlParser.parse(xml);
643
+ return parseURL(name, endpoint).toString();
593
644
  } catch {
594
- throw new APIError("BAD_REQUEST", {
595
- message: "Failed to parse SAML response XML",
596
- code: "SAML_INVALID_XML"
597
- });
645
+ const issuerURL = parseURL(name, issuer);
646
+ const basePath = issuerURL.pathname.replace(/\/+$/, "");
647
+ const endpointPath = endpoint.replace(/^\/+/, "");
648
+ return parseURL(name, basePath + "/" + endpointPath, issuerURL.origin).toString();
598
649
  }
599
- const assertions = countAllNodes(parsed, "Assertion");
600
- const encryptedAssertions = countAllNodes(parsed, "EncryptedAssertion");
601
- return {
602
- assertions,
603
- encryptedAssertions,
604
- total: assertions + encryptedAssertions
605
- };
606
650
  }
607
- function validateSingleAssertion(samlResponse) {
608
- let xml;
651
+ /**
652
+ * Parses the given URL or throws in case of invalid or unsupported protocols
653
+ *
654
+ * @param name the url name
655
+ * @param endpoint the endpoint url
656
+ * @param [base] optional base path
657
+ * @returns
658
+ */
659
+ function parseURL(name, endpoint, base) {
660
+ let endpointURL;
609
661
  try {
610
- xml = new TextDecoder().decode(base64.decode(samlResponse.replace(/\s+/g, "")));
611
- if (!xml.includes("<")) throw new Error("Not XML");
612
- } catch {
613
- throw new APIError("BAD_REQUEST", {
614
- message: "Invalid base64-encoded SAML response",
615
- code: "SAML_INVALID_ENCODING"
616
- });
662
+ endpointURL = new URL(endpoint, base);
663
+ if (endpointURL.protocol === "http:" || endpointURL.protocol === "https:") return endpointURL;
664
+ } catch (error) {
665
+ throw new DiscoveryError("discovery_invalid_url", `The url "${name}" must be valid: ${endpoint}`, { url: endpoint }, { cause: error });
617
666
  }
618
- const counts = countAssertions(xml);
619
- if (counts.total === 0) throw new APIError("BAD_REQUEST", {
620
- message: "SAML response contains no assertions",
621
- code: "SAML_NO_ASSERTION"
622
- });
623
- if (counts.total > 1) throw new APIError("BAD_REQUEST", {
624
- message: `SAML response contains ${counts.total} assertions, expected exactly 1`,
625
- code: "SAML_MULTIPLE_ASSERTIONS"
667
+ throw new DiscoveryError("discovery_invalid_url", `The url "${name}" must use the http or https supported protocols: ${endpoint}`, {
668
+ url: endpoint,
669
+ protocol: endpointURL.protocol
626
670
  });
627
671
  }
628
- //#endregion
629
- //#region src/saml/response-validation.ts
630
- function errorRedirectUrl(base, error, description) {
631
- try {
632
- const url = new URL(base);
633
- url.searchParams.set("error", error);
634
- url.searchParams.set("error_description", description);
635
- return url.toString();
636
- } catch {
637
- const hashIdx = base.indexOf("#");
638
- const path = hashIdx >= 0 ? base.slice(0, hashIdx) : base;
639
- const hash = hashIdx >= 0 ? base.slice(hashIdx + 1) : void 0;
640
- return `${path}${path.includes("?") ? "&" : "?"}${`error=${encodeURIComponent(error)}&error_description=${encodeURIComponent(description)}`}${hash ? `#${hash}` : ""}`;
641
- }
672
+ /**
673
+ * Select the token endpoint authentication method.
674
+ *
675
+ * @param doc - The discovery document
676
+ * @param existing - Existing authentication method from config
677
+ * @returns The selected authentication method
678
+ */
679
+ function selectTokenEndpointAuthMethod(doc, existing) {
680
+ if (existing === "private_key_jwt") return existing;
681
+ if (existing) return existing;
682
+ const supported = doc.token_endpoint_auth_methods_supported;
683
+ if (!supported || supported.length === 0) return "client_secret_basic";
684
+ if (supported.includes("client_secret_basic")) return "client_secret_basic";
685
+ if (supported.includes("client_secret_post")) return "client_secret_post";
686
+ if (supported.includes("private_key_jwt")) return "private_key_jwt";
687
+ return "client_secret_basic";
642
688
  }
643
689
  /**
644
- * Validates the InResponseTo attribute of a SAML Response.
690
+ * Check if a provider configuration needs runtime discovery.
645
691
  *
646
- * This binds the IdP's Response to a specific SP-initiated AuthnRequest,
647
- * preventing replay attacks, unsolicited response injection, and
648
- * cross-provider assertion swaps.
692
+ * Returns true if we need discovery at runtime to complete the token exchange
693
+ * and validation. Specifically checks for:
694
+ * - `tokenEndpoint` - required for exchanging authorization code for tokens
695
+ * - `jwksEndpoint` - required for validating ID token signatures
696
+ * - `authorizationEndpoint` - required for redirecting users to the IdP for login
649
697
  *
650
- * The InResponseTo value lives at `extract.response.inResponseTo` in
651
- * samlify's parsed output (not at the top level).
698
+ * @param config - Partial OIDC config from the provider
699
+ * @returns true if runtime discovery should be performed
652
700
  */
653
- async function validateInResponseTo(c, ctx) {
654
- if (ctx.options.enableInResponseToValidation === false) return;
655
- const inResponseTo = ctx.extract.response?.inResponseTo;
656
- const allowIdpInitiated = ctx.options.allowIdpInitiated ?? false;
657
- if (inResponseTo) {
658
- let storedRequest = null;
659
- const verification = await c.context.internalAdapter.findVerificationValue(`${AUTHN_REQUEST_KEY_PREFIX}${inResponseTo}`);
660
- if (verification) try {
661
- storedRequest = JSON.parse(verification.value);
662
- if (storedRequest && storedRequest.expiresAt < Date.now()) storedRequest = null;
663
- } catch {
664
- storedRequest = null;
665
- }
666
- if (!storedRequest) {
667
- c.context.logger.error("SAML InResponseTo validation failed: unknown or expired request ID", {
668
- inResponseTo,
669
- providerId: ctx.providerId
670
- });
671
- throw c.redirect(errorRedirectUrl(ctx.redirectUrl, "invalid_saml_response", "Unknown or expired request ID"));
672
- }
673
- if (storedRequest.providerId !== ctx.providerId) {
674
- c.context.logger.error("SAML InResponseTo validation failed: provider mismatch", {
675
- inResponseTo,
676
- expectedProvider: storedRequest.providerId,
677
- actualProvider: ctx.providerId
678
- });
679
- await c.context.internalAdapter.deleteVerificationByIdentifier(`${AUTHN_REQUEST_KEY_PREFIX}${inResponseTo}`);
680
- throw c.redirect(errorRedirectUrl(ctx.redirectUrl, "invalid_saml_response", "Provider mismatch"));
681
- }
682
- await c.context.internalAdapter.deleteVerificationByIdentifier(`${AUTHN_REQUEST_KEY_PREFIX}${inResponseTo}`);
683
- } else if (!allowIdpInitiated) {
684
- c.context.logger.error("SAML IdP-initiated SSO rejected: InResponseTo missing and allowIdpInitiated is false", { providerId: ctx.providerId });
685
- throw c.redirect(errorRedirectUrl(ctx.redirectUrl, "unsolicited_response", "IdP-initiated SSO not allowed"));
686
- }
701
+ function needsRuntimeDiscovery(config) {
702
+ if (!config) return true;
703
+ return !config.tokenEndpoint || !config.jwksEndpoint || !config.authorizationEndpoint;
687
704
  }
688
705
  /**
689
- * Validates the AudienceRestriction of a SAML assertion.
706
+ * Runs runtime OIDC discovery when the stored config is missing required
707
+ * endpoints, and merges the hydrated fields back into the config.
708
+ * Throws if discovery fails.
709
+ */
710
+ async function ensureRuntimeDiscovery(config, issuer, isTrustedOrigin) {
711
+ if (!needsRuntimeDiscovery(config)) return config;
712
+ const hydrated = await discoverOIDCConfig({
713
+ issuer,
714
+ existingConfig: config,
715
+ isTrustedOrigin
716
+ });
717
+ return {
718
+ ...config,
719
+ authorizationEndpoint: hydrated.authorizationEndpoint,
720
+ tokenEndpoint: hydrated.tokenEndpoint,
721
+ tokenEndpointAuthentication: hydrated.tokenEndpointAuthentication,
722
+ userInfoEndpoint: hydrated.userInfoEndpoint,
723
+ jwksEndpoint: hydrated.jwksEndpoint
724
+ };
725
+ }
726
+ //#endregion
727
+ //#region src/oidc/errors.ts
728
+ /**
729
+ * OIDC Discovery Error Mapping
690
730
  *
691
- * Per SAML 2.0 Core §2.5.1, an assertion's Audience element specifies
692
- * the intended recipient SP. Without this check, an assertion issued
693
- * for a different SP (e.g., another application sharing the same IdP)
694
- * could be accepted.
731
+ * Maps DiscoveryError codes to appropriate APIError responses.
732
+ * Used at the boundary between the discovery pipeline and HTTP handlers.
695
733
  */
696
- function validateAudience(c, ctx) {
697
- if (!ctx.expectedAudience) {
698
- c.context.logger.warn("Could not determine SP entity ID for audience validation; skipping", { providerId: ctx.providerId });
699
- return;
700
- }
701
- const audience = ctx.extract.audience;
702
- if (!audience) {
703
- c.context.logger.error("SAML assertion missing AudienceRestriction but audience is configured — rejecting", { providerId: ctx.providerId });
704
- throw c.redirect(errorRedirectUrl(ctx.redirectUrl, "invalid_saml_response", "Audience restriction missing"));
705
- }
706
- const audiences = Array.isArray(audience) ? audience : [audience];
707
- if (!audiences.includes(ctx.expectedAudience)) {
708
- c.context.logger.error("SAML audience mismatch: assertion was issued for a different service provider", {
709
- expected: ctx.expectedAudience,
710
- received: audiences,
711
- providerId: ctx.providerId
734
+ /**
735
+ * Maps a DiscoveryError to an appropriate APIError for HTTP responses.
736
+ *
737
+ * Error code mapping:
738
+ * - discovery_invalid_url → 400 BAD_REQUEST
739
+ * - discovery_not_found → 400 BAD_REQUEST
740
+ * - discovery_untrusted_origin → 400 BAD_REQUEST
741
+ * - discovery_private_host → 400 BAD_REQUEST
742
+ * - discovery_invalid_json → 400 BAD_REQUEST
743
+ * - discovery_incomplete → 400 BAD_REQUEST
744
+ * - issuer_mismatch → 400 BAD_REQUEST
745
+ * - unsupported_token_auth_method → 400 BAD_REQUEST
746
+ * - discovery_timeout → 502 BAD_GATEWAY
747
+ * - discovery_unexpected_error → 502 BAD_GATEWAY
748
+ *
749
+ * @param error - The DiscoveryError to map
750
+ * @returns An APIError with appropriate status and message
751
+ */
752
+ function mapDiscoveryErrorToAPIError(error) {
753
+ switch (error.code) {
754
+ case "discovery_timeout": return new APIError("BAD_GATEWAY", {
755
+ message: `OIDC discovery timed out: ${error.message}`,
756
+ code: error.code
712
757
  });
713
- throw c.redirect(errorRedirectUrl(ctx.redirectUrl, "invalid_saml_response", "Audience mismatch"));
758
+ case "discovery_unexpected_error": return new APIError("BAD_GATEWAY", {
759
+ message: `OIDC discovery failed: ${error.message}`,
760
+ code: error.code
761
+ });
762
+ case "discovery_not_found": return new APIError("BAD_REQUEST", {
763
+ message: `OIDC discovery endpoint not found. The issuer may not support OIDC discovery, or the URL is incorrect. ${error.message}`,
764
+ code: error.code
765
+ });
766
+ case "discovery_invalid_url": return new APIError("BAD_REQUEST", {
767
+ message: `Invalid OIDC endpoint URL: ${error.message}`,
768
+ code: error.code
769
+ });
770
+ case "discovery_untrusted_origin": return new APIError("BAD_REQUEST", {
771
+ message: `Untrusted OIDC discovery URL: ${error.message}`,
772
+ code: error.code
773
+ });
774
+ case "discovery_private_host": return new APIError("BAD_REQUEST", {
775
+ message: error.message,
776
+ code: error.code
777
+ });
778
+ case "discovery_invalid_json": return new APIError("BAD_REQUEST", {
779
+ message: `OIDC discovery returned invalid data: ${error.message}`,
780
+ code: error.code
781
+ });
782
+ case "discovery_incomplete": return new APIError("BAD_REQUEST", {
783
+ message: `OIDC discovery document is missing required fields: ${error.message}`,
784
+ code: error.code
785
+ });
786
+ case "issuer_mismatch": return new APIError("BAD_REQUEST", {
787
+ message: `OIDC issuer mismatch: ${error.message}`,
788
+ code: error.code
789
+ });
790
+ case "unsupported_token_auth_method": return new APIError("BAD_REQUEST", {
791
+ message: `Incompatible OIDC provider: ${error.message}`,
792
+ code: error.code
793
+ });
794
+ default:
795
+ error.code;
796
+ return new APIError("INTERNAL_SERVER_ERROR", {
797
+ message: `Unexpected discovery error: ${error.message}`,
798
+ code: "discovery_unexpected_error"
799
+ });
714
800
  }
715
801
  }
716
802
  //#endregion
717
- //#region src/routes/schemas.ts
718
- const oidcMappingSchema = z.object({
719
- id: z.string().optional(),
720
- email: z.string().optional(),
721
- emailVerified: z.string().optional(),
722
- name: z.string().optional(),
723
- image: z.string().optional(),
724
- extraFields: z.record(z.string(), z.any()).optional()
725
- }).optional();
726
- const samlMappingSchema = z.object({
727
- id: z.string().optional(),
728
- email: z.string().optional(),
729
- emailVerified: z.string().optional(),
730
- name: z.string().optional(),
731
- firstName: z.string().optional(),
732
- lastName: z.string().optional(),
733
- extraFields: z.record(z.string(), z.any()).optional()
734
- }).optional();
735
- const oidcConfigSchema = z.object({
736
- clientId: z.string().optional(),
737
- clientSecret: z.string().optional(),
738
- authorizationEndpoint: z.string().url().optional(),
739
- tokenEndpoint: z.string().url().optional(),
740
- userInfoEndpoint: z.string().url().optional(),
741
- tokenEndpointAuthentication: z.enum([
742
- "client_secret_post",
743
- "client_secret_basic",
744
- "private_key_jwt"
745
- ]).optional(),
746
- privateKeyId: z.string().optional(),
747
- privateKeyAlgorithm: z.string().optional(),
748
- jwksEndpoint: z.string().url().optional(),
749
- discoveryEndpoint: z.string().url().optional(),
750
- scopes: z.array(z.string()).optional(),
751
- pkce: z.boolean().optional(),
752
- overrideUserInfo: z.boolean().optional(),
753
- mapping: oidcMappingSchema
754
- });
755
- const samlConfigSchema = z.object({
756
- entryPoint: z.string().url().optional(),
757
- cert: z.string().optional(),
758
- audience: z.string().optional(),
759
- idpMetadata: z.object({
760
- metadata: z.string().optional(),
761
- entityID: z.string().optional(),
762
- cert: z.string().optional(),
763
- privateKey: z.string().optional(),
764
- privateKeyPass: z.string().optional(),
765
- isAssertionEncrypted: z.boolean().optional(),
766
- encPrivateKey: z.string().optional(),
767
- encPrivateKeyPass: z.string().optional(),
768
- singleSignOnService: z.array(z.object({
769
- Binding: z.string(),
770
- Location: z.string().url()
771
- })).optional()
772
- }).optional(),
773
- spMetadata: z.object({
774
- metadata: z.string().optional(),
775
- entityID: z.string().optional(),
776
- binding: z.string().optional(),
777
- privateKey: z.string().optional(),
778
- privateKeyPass: z.string().optional(),
779
- isAssertionEncrypted: z.boolean().optional(),
780
- encPrivateKey: z.string().optional(),
781
- encPrivateKeyPass: z.string().optional()
782
- }).optional(),
783
- wantAssertionsSigned: z.boolean().optional(),
784
- authnRequestsSigned: z.boolean().optional(),
785
- signatureAlgorithm: z.string().optional(),
786
- digestAlgorithm: z.string().optional(),
787
- identifierFormat: z.string().optional(),
788
- privateKey: z.string().optional(),
789
- mapping: samlMappingSchema
790
- });
791
- const updateSSOProviderBodySchema = z.object({
792
- issuer: z.string().url().optional(),
793
- domain: z.string().optional(),
794
- oidcConfig: oidcConfigSchema.optional(),
795
- samlConfig: samlConfigSchema.optional()
803
+ //#region src/saml/parser.ts
804
+ const xmlParser = new XMLParser({
805
+ ignoreAttributes: false,
806
+ attributeNamePrefix: "@_",
807
+ removeNSPrefix: true,
808
+ processEntities: false
796
809
  });
810
+ function findNode(obj, nodeName) {
811
+ if (!obj || typeof obj !== "object") return null;
812
+ const record = obj;
813
+ if (nodeName in record) return record[nodeName];
814
+ for (const value of Object.values(record)) if (Array.isArray(value)) for (const item of value) {
815
+ const found = findNode(item, nodeName);
816
+ if (found) return found;
817
+ }
818
+ else if (typeof value === "object" && value !== null) {
819
+ const found = findNode(value, nodeName);
820
+ if (found) return found;
821
+ }
822
+ return null;
823
+ }
824
+ function countAllNodes(obj, nodeName) {
825
+ if (!obj || typeof obj !== "object") return 0;
826
+ let count = 0;
827
+ const record = obj;
828
+ if (nodeName in record) {
829
+ const node = record[nodeName];
830
+ count += Array.isArray(node) ? node.length : 1;
831
+ }
832
+ for (const value of Object.values(record)) if (Array.isArray(value)) for (const item of value) count += countAllNodes(item, nodeName);
833
+ else if (typeof value === "object" && value !== null) count += countAllNodes(value, nodeName);
834
+ return count;
835
+ }
797
836
  //#endregion
798
- //#region src/routes/providers.ts
799
- const ADMIN_ROLES = ["owner", "admin"];
800
- async function isOrgAdmin(ctx, userId, organizationId) {
801
- const member = await ctx.context.adapter.findOne({
802
- model: "member",
803
- where: [{
804
- field: "userId",
805
- value: userId
806
- }, {
807
- field: "organizationId",
808
- value: organizationId
809
- }]
810
- });
811
- if (!member) return false;
812
- return member.role.split(",").some((r) => ADMIN_ROLES.includes(r.trim()));
837
+ //#region src/saml/algorithms.ts
838
+ const SignatureAlgorithm = {
839
+ RSA_SHA1: "http://www.w3.org/2000/09/xmldsig#rsa-sha1",
840
+ RSA_SHA256: "http://www.w3.org/2001/04/xmldsig-more#rsa-sha256",
841
+ RSA_SHA384: "http://www.w3.org/2001/04/xmldsig-more#rsa-sha384",
842
+ RSA_SHA512: "http://www.w3.org/2001/04/xmldsig-more#rsa-sha512",
843
+ ECDSA_SHA256: "http://www.w3.org/2001/04/xmldsig-more#ecdsa-sha256",
844
+ ECDSA_SHA384: "http://www.w3.org/2001/04/xmldsig-more#ecdsa-sha384",
845
+ ECDSA_SHA512: "http://www.w3.org/2001/04/xmldsig-more#ecdsa-sha512"
846
+ };
847
+ const DigestAlgorithm = {
848
+ SHA1: "http://www.w3.org/2000/09/xmldsig#sha1",
849
+ SHA256: "http://www.w3.org/2001/04/xmlenc#sha256",
850
+ SHA384: "http://www.w3.org/2001/04/xmldsig-more#sha384",
851
+ SHA512: "http://www.w3.org/2001/04/xmlenc#sha512"
852
+ };
853
+ const KeyEncryptionAlgorithm = {
854
+ RSA_1_5: "http://www.w3.org/2001/04/xmlenc#rsa-1_5",
855
+ RSA_OAEP: "http://www.w3.org/2001/04/xmlenc#rsa-oaep-mgf1p",
856
+ RSA_OAEP_SHA256: "http://www.w3.org/2009/xmlenc11#rsa-oaep"
857
+ };
858
+ const DataEncryptionAlgorithm = {
859
+ TRIPLEDES_CBC: "http://www.w3.org/2001/04/xmlenc#tripledes-cbc",
860
+ AES_128_CBC: "http://www.w3.org/2001/04/xmlenc#aes128-cbc",
861
+ AES_192_CBC: "http://www.w3.org/2001/04/xmlenc#aes192-cbc",
862
+ AES_256_CBC: "http://www.w3.org/2001/04/xmlenc#aes256-cbc",
863
+ AES_128_GCM: "http://www.w3.org/2009/xmlenc11#aes128-gcm",
864
+ AES_192_GCM: "http://www.w3.org/2009/xmlenc11#aes192-gcm",
865
+ AES_256_GCM: "http://www.w3.org/2009/xmlenc11#aes256-gcm"
866
+ };
867
+ const DEPRECATED_SIGNATURE_ALGORITHMS = [SignatureAlgorithm.RSA_SHA1];
868
+ const DEPRECATED_KEY_ENCRYPTION_ALGORITHMS = [KeyEncryptionAlgorithm.RSA_1_5];
869
+ const DEPRECATED_DATA_ENCRYPTION_ALGORITHMS = [DataEncryptionAlgorithm.TRIPLEDES_CBC];
870
+ const DEPRECATED_DIGEST_ALGORITHMS = [DigestAlgorithm.SHA1];
871
+ const SECURE_SIGNATURE_ALGORITHMS = [
872
+ SignatureAlgorithm.RSA_SHA256,
873
+ SignatureAlgorithm.RSA_SHA384,
874
+ SignatureAlgorithm.RSA_SHA512,
875
+ SignatureAlgorithm.ECDSA_SHA256,
876
+ SignatureAlgorithm.ECDSA_SHA384,
877
+ SignatureAlgorithm.ECDSA_SHA512
878
+ ];
879
+ const SECURE_DIGEST_ALGORITHMS = [
880
+ DigestAlgorithm.SHA256,
881
+ DigestAlgorithm.SHA384,
882
+ DigestAlgorithm.SHA512
883
+ ];
884
+ const SHORT_FORM_SIGNATURE_TO_URI = {
885
+ sha1: SignatureAlgorithm.RSA_SHA1,
886
+ sha256: SignatureAlgorithm.RSA_SHA256,
887
+ sha384: SignatureAlgorithm.RSA_SHA384,
888
+ sha512: SignatureAlgorithm.RSA_SHA512,
889
+ "rsa-sha1": SignatureAlgorithm.RSA_SHA1,
890
+ "rsa-sha256": SignatureAlgorithm.RSA_SHA256,
891
+ "rsa-sha384": SignatureAlgorithm.RSA_SHA384,
892
+ "rsa-sha512": SignatureAlgorithm.RSA_SHA512,
893
+ "ecdsa-sha256": SignatureAlgorithm.ECDSA_SHA256,
894
+ "ecdsa-sha384": SignatureAlgorithm.ECDSA_SHA384,
895
+ "ecdsa-sha512": SignatureAlgorithm.ECDSA_SHA512
896
+ };
897
+ const SHORT_FORM_DIGEST_TO_URI = {
898
+ sha1: DigestAlgorithm.SHA1,
899
+ sha256: DigestAlgorithm.SHA256,
900
+ sha384: DigestAlgorithm.SHA384,
901
+ sha512: DigestAlgorithm.SHA512
902
+ };
903
+ function normalizeSignatureAlgorithm(alg) {
904
+ return SHORT_FORM_SIGNATURE_TO_URI[alg.toLowerCase()] ?? alg;
813
905
  }
814
- async function batchCheckOrgAdmin(ctx, userId, organizationIds) {
815
- if (organizationIds.length === 0) return /* @__PURE__ */ new Set();
816
- const members = await ctx.context.adapter.findMany({
817
- model: "member",
818
- where: [{
819
- field: "userId",
820
- value: userId
821
- }, {
822
- field: "organizationId",
823
- value: organizationIds,
824
- operator: "in"
825
- }]
826
- });
827
- const adminOrgIds = /* @__PURE__ */ new Set();
828
- for (const member of members) if (member.role.split(",").some((r) => ADMIN_ROLES.includes(r.trim()))) adminOrgIds.add(member.organizationId);
829
- return adminOrgIds;
906
+ function normalizeDigestAlgorithm(alg) {
907
+ return SHORT_FORM_DIGEST_TO_URI[alg.toLowerCase()] ?? alg;
830
908
  }
831
- function sanitizeProvider(provider, baseURL) {
832
- let oidcConfig = null;
833
- let samlConfig = null;
909
+ function extractEncryptionAlgorithms(xml) {
834
910
  try {
835
- oidcConfig = safeJsonParse(provider.oidcConfig);
911
+ const parsed = xmlParser.parse(xml);
912
+ const keyAlg = (findNode(parsed, "EncryptedKey")?.EncryptionMethod)?.["@_Algorithm"];
913
+ const dataAlg = (findNode(parsed, "EncryptedData")?.EncryptionMethod)?.["@_Algorithm"];
914
+ return {
915
+ keyEncryption: keyAlg || null,
916
+ dataEncryption: dataAlg || null
917
+ };
836
918
  } catch {
837
- oidcConfig = null;
919
+ return {
920
+ keyEncryption: null,
921
+ dataEncryption: null
922
+ };
838
923
  }
924
+ }
925
+ function hasEncryptedAssertion(xml) {
839
926
  try {
840
- samlConfig = safeJsonParse(provider.samlConfig);
927
+ return findNode(xmlParser.parse(xml), "EncryptedAssertion") !== null;
841
928
  } catch {
842
- samlConfig = null;
929
+ return false;
843
930
  }
844
- const type = samlConfig ? "saml" : "oidc";
845
- return {
846
- providerId: provider.providerId,
847
- type,
848
- issuer: provider.issuer,
849
- domain: provider.domain,
850
- organizationId: provider.organizationId || null,
851
- domainVerified: provider.domainVerified ?? false,
852
- oidcConfig: oidcConfig ? {
853
- discoveryEndpoint: oidcConfig.discoveryEndpoint,
854
- clientIdLastFour: maskClientId(oidcConfig.clientId),
855
- pkce: oidcConfig.pkce,
856
- authorizationEndpoint: oidcConfig.authorizationEndpoint,
857
- tokenEndpoint: oidcConfig.tokenEndpoint,
858
- userInfoEndpoint: oidcConfig.userInfoEndpoint,
859
- jwksEndpoint: oidcConfig.jwksEndpoint,
860
- scopes: oidcConfig.scopes,
861
- tokenEndpointAuthentication: oidcConfig.tokenEndpointAuthentication
862
- } : void 0,
863
- samlConfig: samlConfig ? {
864
- entryPoint: samlConfig.entryPoint,
865
- audience: samlConfig.audience,
866
- wantAssertionsSigned: samlConfig.wantAssertionsSigned,
867
- authnRequestsSigned: samlConfig.authnRequestsSigned,
868
- identifierFormat: samlConfig.identifierFormat,
869
- signatureAlgorithm: samlConfig.signatureAlgorithm,
870
- digestAlgorithm: samlConfig.digestAlgorithm,
871
- certificate: (() => {
872
- try {
873
- return parseCertificate(samlConfig.cert);
874
- } catch {
875
- return { error: "Failed to parse certificate" };
876
- }
877
- })()
878
- } : void 0,
879
- spMetadataUrl: `${baseURL}/sso/saml2/sp/metadata?providerId=${encodeURIComponent(provider.providerId)}`
880
- };
881
931
  }
882
- const listSSOProviders = () => {
883
- return createAuthEndpoint("/sso/providers", {
884
- method: "GET",
885
- use: [sessionMiddleware],
886
- metadata: { openapi: {
887
- operationId: "listSSOProviders",
888
- summary: "List SSO providers",
889
- description: "Returns a list of SSO providers the user has access to",
890
- responses: { "200": { description: "List of SSO providers" } }
891
- } }
892
- }, async (ctx) => {
893
- const userId = ctx.context.session.user.id;
894
- const allProviders = await ctx.context.adapter.findMany({ model: "ssoProvider" });
895
- const userOwnedProviders = allProviders.filter((p) => p.userId === userId && !p.organizationId);
896
- const orgProviders = allProviders.filter((p) => p.organizationId !== null && p.organizationId !== void 0);
897
- const orgPluginEnabled = ctx.context.hasPlugin("organization");
898
- let accessibleProviders = [...userOwnedProviders];
899
- if (orgPluginEnabled && orgProviders.length > 0) {
900
- const adminOrgIds = await batchCheckOrgAdmin(ctx, userId, [...new Set(orgProviders.map((p) => p.organizationId).filter((id) => id !== null && id !== void 0))]);
901
- const orgAccessibleProviders = orgProviders.filter((provider) => provider.organizationId && adminOrgIds.has(provider.organizationId));
902
- accessibleProviders = [...accessibleProviders, ...orgAccessibleProviders];
903
- } else if (!orgPluginEnabled) {
904
- const userOwnedOrgProviders = orgProviders.filter((p) => p.userId === userId);
905
- accessibleProviders = [...accessibleProviders, ...userOwnedOrgProviders];
906
- }
907
- const providers = accessibleProviders.map((p) => sanitizeProvider(p, ctx.context.baseURL));
908
- return ctx.json({ providers });
909
- });
910
- };
911
- const getSSOProviderQuerySchema = z.object({ providerId: z.string() });
912
- async function checkProviderAccess(ctx, providerId) {
913
- const userId = ctx.context.session.user.id;
914
- const provider = await ctx.context.adapter.findOne({
915
- model: "ssoProvider",
916
- where: [{
917
- field: "providerId",
918
- value: providerId
919
- }]
920
- });
921
- if (!provider) throw new APIError("NOT_FOUND", { message: "Provider not found" });
922
- let hasAccess = false;
923
- if (provider.organizationId) if (ctx.context.hasPlugin("organization")) hasAccess = await isOrgAdmin(ctx, userId, provider.organizationId);
924
- else hasAccess = provider.userId === userId;
925
- else hasAccess = provider.userId === userId;
926
- if (!hasAccess) throw new APIError("FORBIDDEN", { message: "You don't have access to this provider" });
927
- return provider;
932
+ function handleDeprecatedAlgorithm(message, behavior, errorCode) {
933
+ switch (behavior) {
934
+ case "reject": throw new APIError("BAD_REQUEST", {
935
+ message,
936
+ code: errorCode
937
+ });
938
+ case "warn":
939
+ console.warn(`[SAML Security Warning] ${message}`);
940
+ break;
941
+ case "allow": break;
942
+ }
928
943
  }
929
- const getSSOProvider = () => {
930
- return createAuthEndpoint("/sso/get-provider", {
931
- method: "GET",
932
- use: [sessionMiddleware],
933
- query: getSSOProviderQuerySchema,
934
- metadata: { openapi: {
935
- operationId: "getSSOProvider",
936
- summary: "Get SSO provider details",
937
- description: "Returns sanitized details for a specific SSO provider",
938
- responses: {
939
- "200": { description: "SSO provider details" },
940
- "404": { description: "Provider not found" },
941
- "403": { description: "Access denied" }
942
- }
943
- } }
944
- }, async (ctx) => {
945
- const { providerId } = ctx.query;
946
- const provider = await checkProviderAccess(ctx, providerId);
947
- return ctx.json(sanitizeProvider(provider, ctx.context.baseURL));
944
+ function validateSignatureAlgorithm(algorithm, options = {}) {
945
+ if (!algorithm) return;
946
+ const { onDeprecated = "warn", allowedSignatureAlgorithms } = options;
947
+ if (allowedSignatureAlgorithms) {
948
+ if (!allowedSignatureAlgorithms.includes(algorithm)) throw new APIError("BAD_REQUEST", {
949
+ message: `SAML signature algorithm not in allow-list: ${algorithm}`,
950
+ code: "SAML_ALGORITHM_NOT_ALLOWED"
951
+ });
952
+ return;
953
+ }
954
+ if (DEPRECATED_SIGNATURE_ALGORITHMS.includes(algorithm)) {
955
+ handleDeprecatedAlgorithm(`SAML response uses deprecated signature algorithm: ${algorithm}. Please configure your IdP to use SHA-256 or stronger.`, onDeprecated, "SAML_DEPRECATED_ALGORITHM");
956
+ return;
957
+ }
958
+ if (!SECURE_SIGNATURE_ALGORITHMS.includes(algorithm)) throw new APIError("BAD_REQUEST", {
959
+ message: `SAML signature algorithm not recognized: ${algorithm}`,
960
+ code: "SAML_UNKNOWN_ALGORITHM"
948
961
  });
949
- };
950
- function parseAndValidateConfig(configString, configType) {
951
- let config = null;
962
+ }
963
+ function validateEncryptionAlgorithms(algorithms, options = {}) {
964
+ const { onDeprecated = "warn", allowedKeyEncryptionAlgorithms, allowedDataEncryptionAlgorithms } = options;
965
+ const { keyEncryption, dataEncryption } = algorithms;
966
+ if (keyEncryption) {
967
+ if (allowedKeyEncryptionAlgorithms) {
968
+ if (!allowedKeyEncryptionAlgorithms.includes(keyEncryption)) throw new APIError("BAD_REQUEST", {
969
+ message: `SAML key encryption algorithm not in allow-list: ${keyEncryption}`,
970
+ code: "SAML_ALGORITHM_NOT_ALLOWED"
971
+ });
972
+ } 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");
973
+ }
974
+ if (dataEncryption) {
975
+ if (allowedDataEncryptionAlgorithms) {
976
+ if (!allowedDataEncryptionAlgorithms.includes(dataEncryption)) throw new APIError("BAD_REQUEST", {
977
+ message: `SAML data encryption algorithm not in allow-list: ${dataEncryption}`,
978
+ code: "SAML_ALGORITHM_NOT_ALLOWED"
979
+ });
980
+ } 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");
981
+ }
982
+ }
983
+ function validateSAMLAlgorithms(response, options) {
984
+ validateSignatureAlgorithm(response.sigAlg, options);
985
+ if (hasEncryptedAssertion(response.samlContent)) validateEncryptionAlgorithms(extractEncryptionAlgorithms(response.samlContent), options);
986
+ }
987
+ function validateConfigAlgorithms(config, options = {}) {
988
+ const { onDeprecated = "warn", allowedSignatureAlgorithms, allowedDigestAlgorithms } = options;
989
+ if (config.signatureAlgorithm) {
990
+ const normalized = normalizeSignatureAlgorithm(config.signatureAlgorithm);
991
+ if (allowedSignatureAlgorithms) {
992
+ if (!allowedSignatureAlgorithms.map(normalizeSignatureAlgorithm).includes(normalized)) throw new APIError("BAD_REQUEST", {
993
+ message: `SAML signature algorithm not in allow-list: ${config.signatureAlgorithm}`,
994
+ code: "SAML_ALGORITHM_NOT_ALLOWED"
995
+ });
996
+ } 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");
997
+ else if (!SECURE_SIGNATURE_ALGORITHMS.includes(normalized)) throw new APIError("BAD_REQUEST", {
998
+ message: `SAML signature algorithm not recognized: ${config.signatureAlgorithm}`,
999
+ code: "SAML_UNKNOWN_ALGORITHM"
1000
+ });
1001
+ }
1002
+ if (config.digestAlgorithm) {
1003
+ const normalized = normalizeDigestAlgorithm(config.digestAlgorithm);
1004
+ if (allowedDigestAlgorithms) {
1005
+ if (!allowedDigestAlgorithms.map(normalizeDigestAlgorithm).includes(normalized)) throw new APIError("BAD_REQUEST", {
1006
+ message: `SAML digest algorithm not in allow-list: ${config.digestAlgorithm}`,
1007
+ code: "SAML_ALGORITHM_NOT_ALLOWED"
1008
+ });
1009
+ } 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");
1010
+ else if (!SECURE_DIGEST_ALGORITHMS.includes(normalized)) throw new APIError("BAD_REQUEST", {
1011
+ message: `SAML digest algorithm not recognized: ${config.digestAlgorithm}`,
1012
+ code: "SAML_UNKNOWN_ALGORITHM"
1013
+ });
1014
+ }
1015
+ }
1016
+ //#endregion
1017
+ //#region src/saml/assertions.ts
1018
+ function countAssertions(xml) {
1019
+ let parsed;
952
1020
  try {
953
- config = safeJsonParse(configString);
1021
+ parsed = xmlParser.parse(xml);
954
1022
  } catch {
955
- config = null;
1023
+ throw new APIError("BAD_REQUEST", {
1024
+ message: "Failed to parse SAML response XML",
1025
+ code: "SAML_INVALID_XML"
1026
+ });
956
1027
  }
957
- if (!config) throw new APIError("BAD_REQUEST", { message: `Cannot update ${configType} config for a provider that doesn't have ${configType} configured` });
958
- return config;
959
- }
960
- function mergeSAMLConfig(current, updates, issuer) {
961
- return {
962
- ...current,
963
- ...updates,
964
- issuer,
965
- entryPoint: updates.entryPoint ?? current.entryPoint,
966
- cert: updates.cert ?? current.cert,
967
- spMetadata: updates.spMetadata ?? current.spMetadata,
968
- idpMetadata: updates.idpMetadata ?? current.idpMetadata,
969
- mapping: updates.mapping ?? current.mapping,
970
- audience: updates.audience ?? current.audience,
971
- wantAssertionsSigned: updates.wantAssertionsSigned ?? current.wantAssertionsSigned,
972
- authnRequestsSigned: updates.authnRequestsSigned ?? current.authnRequestsSigned,
973
- identifierFormat: updates.identifierFormat ?? current.identifierFormat,
974
- signatureAlgorithm: updates.signatureAlgorithm ?? current.signatureAlgorithm,
975
- digestAlgorithm: updates.digestAlgorithm ?? current.digestAlgorithm
976
- };
977
- }
978
- function mergeOIDCConfig(current, updates, issuer) {
1028
+ const assertions = countAllNodes(parsed, "Assertion");
1029
+ const encryptedAssertions = countAllNodes(parsed, "EncryptedAssertion");
979
1030
  return {
980
- ...current,
981
- ...updates,
982
- issuer,
983
- pkce: updates.pkce ?? current.pkce ?? true,
984
- clientId: updates.clientId ?? current.clientId,
985
- clientSecret: updates.clientSecret ?? current.clientSecret,
986
- discoveryEndpoint: updates.discoveryEndpoint ?? current.discoveryEndpoint,
987
- mapping: updates.mapping ?? current.mapping,
988
- scopes: updates.scopes ?? current.scopes,
989
- authorizationEndpoint: updates.authorizationEndpoint ?? current.authorizationEndpoint,
990
- tokenEndpoint: updates.tokenEndpoint ?? current.tokenEndpoint,
991
- userInfoEndpoint: updates.userInfoEndpoint ?? current.userInfoEndpoint,
992
- jwksEndpoint: updates.jwksEndpoint ?? current.jwksEndpoint,
993
- tokenEndpointAuthentication: updates.tokenEndpointAuthentication ?? current.tokenEndpointAuthentication,
994
- privateKeyId: updates.privateKeyId ?? current.privateKeyId,
995
- privateKeyAlgorithm: updates.privateKeyAlgorithm ?? current.privateKeyAlgorithm
1031
+ assertions,
1032
+ encryptedAssertions,
1033
+ total: assertions + encryptedAssertions
996
1034
  };
997
1035
  }
998
- const updateSSOProvider = (options) => {
999
- return createAuthEndpoint("/sso/update-provider", {
1000
- method: "POST",
1001
- use: [sessionMiddleware],
1002
- body: updateSSOProviderBodySchema.extend({ providerId: z.string() }),
1003
- metadata: { openapi: {
1004
- operationId: "updateSSOProvider",
1005
- summary: "Update SSO provider",
1006
- description: "Partially update an SSO provider. Only provided fields are updated. If domain changes, domainVerified is reset to false.",
1007
- responses: {
1008
- "200": { description: "SSO provider updated successfully" },
1009
- "404": { description: "Provider not found" },
1010
- "403": { description: "Access denied" }
1011
- }
1012
- } }
1013
- }, async (ctx) => {
1014
- const { providerId, ...body } = ctx.body;
1015
- const { issuer, domain, samlConfig, oidcConfig } = body;
1016
- if (!issuer && !domain && !samlConfig && !oidcConfig) throw new APIError("BAD_REQUEST", { message: "No fields provided for update" });
1017
- const existingProvider = await checkProviderAccess(ctx, providerId);
1018
- const updateData = {};
1019
- if (body.issuer !== void 0) updateData.issuer = body.issuer;
1020
- if (body.domain !== void 0) {
1021
- updateData.domain = body.domain;
1022
- if (body.domain !== existingProvider.domain) updateData.domainVerified = false;
1023
- }
1024
- if (body.samlConfig) {
1025
- if (body.samlConfig.idpMetadata?.metadata) {
1026
- const maxMetadataSize = options?.saml?.maxMetadataSize ?? 102400;
1027
- if (new TextEncoder().encode(body.samlConfig.idpMetadata.metadata).length > maxMetadataSize) throw new APIError("BAD_REQUEST", { message: `IdP metadata exceeds maximum allowed size (${maxMetadataSize} bytes)` });
1028
- }
1029
- if (body.samlConfig.signatureAlgorithm !== void 0 || body.samlConfig.digestAlgorithm !== void 0) validateConfigAlgorithms({
1030
- signatureAlgorithm: body.samlConfig.signatureAlgorithm,
1031
- digestAlgorithm: body.samlConfig.digestAlgorithm
1032
- }, options?.saml?.algorithms);
1033
- const currentSamlConfig = parseAndValidateConfig(existingProvider.samlConfig, "SAML");
1034
- const updatedSamlConfig = mergeSAMLConfig(currentSamlConfig, body.samlConfig, updateData.issuer || currentSamlConfig.issuer || existingProvider.issuer);
1035
- updateData.samlConfig = JSON.stringify(updatedSamlConfig);
1036
- }
1037
- if (body.oidcConfig) {
1038
- const currentOidcConfig = parseAndValidateConfig(existingProvider.oidcConfig, "OIDC");
1039
- const updatedOidcConfig = mergeOIDCConfig(currentOidcConfig, body.oidcConfig, updateData.issuer || currentOidcConfig.issuer || existingProvider.issuer);
1040
- if (updatedOidcConfig.tokenEndpointAuthentication !== "private_key_jwt" && !updatedOidcConfig.clientSecret) throw new APIError("BAD_REQUEST", { message: "clientSecret is required when using client_secret_basic or client_secret_post authentication" });
1041
- if (updatedOidcConfig.tokenEndpointAuthentication === "private_key_jwt" && !options?.resolvePrivateKey && !options?.defaultSSO?.some((p) => p.providerId === providerId && "privateKey" in p && p.privateKey)) throw new APIError("BAD_REQUEST", { message: "private_key_jwt authentication requires either a resolvePrivateKey callback or a privateKey in defaultSSO" });
1042
- updateData.oidcConfig = JSON.stringify(updatedOidcConfig);
1043
- }
1044
- await ctx.context.adapter.update({
1045
- model: "ssoProvider",
1046
- where: [{
1047
- field: "providerId",
1048
- value: providerId
1049
- }],
1050
- update: updateData
1051
- });
1052
- const fullProvider = await ctx.context.adapter.findOne({
1053
- model: "ssoProvider",
1054
- where: [{
1055
- field: "providerId",
1056
- value: providerId
1057
- }]
1036
+ function validateSingleAssertion(samlResponse) {
1037
+ let xml;
1038
+ try {
1039
+ xml = new TextDecoder().decode(base64.decode(samlResponse.replace(/\s+/g, "")));
1040
+ if (!xml.includes("<")) throw new Error("Not XML");
1041
+ } catch {
1042
+ throw new APIError("BAD_REQUEST", {
1043
+ message: "Invalid base64-encoded SAML response",
1044
+ code: "SAML_INVALID_ENCODING"
1058
1045
  });
1059
- if (!fullProvider) throw new APIError("NOT_FOUND", { message: "Provider not found after update" });
1060
- return ctx.json(sanitizeProvider(fullProvider, ctx.context.baseURL));
1046
+ }
1047
+ const counts = countAssertions(xml);
1048
+ if (counts.total === 0) throw new APIError("BAD_REQUEST", {
1049
+ message: "SAML response contains no assertions",
1050
+ code: "SAML_NO_ASSERTION"
1061
1051
  });
1062
- };
1063
- const deleteSSOProvider = () => {
1064
- return createAuthEndpoint("/sso/delete-provider", {
1065
- method: "POST",
1066
- use: [sessionMiddleware],
1067
- body: z.object({ providerId: z.string() }),
1068
- metadata: { openapi: {
1069
- operationId: "deleteSSOProvider",
1070
- summary: "Delete SSO provider",
1071
- description: "Deletes an SSO provider",
1072
- responses: {
1073
- "200": { description: "SSO provider deleted successfully" },
1074
- "404": { description: "Provider not found" },
1075
- "403": { description: "Access denied" }
1076
- }
1077
- } }
1078
- }, async (ctx) => {
1079
- const { providerId } = ctx.body;
1080
- await checkProviderAccess(ctx, providerId);
1081
- await ctx.context.adapter.delete({
1082
- model: "ssoProvider",
1083
- where: [{
1084
- field: "providerId",
1085
- value: providerId
1086
- }]
1087
- });
1088
- return ctx.json({ success: true });
1052
+ if (counts.total > 1) throw new APIError("BAD_REQUEST", {
1053
+ message: `SAML response contains ${counts.total} assertions, expected exactly 1`,
1054
+ code: "SAML_MULTIPLE_ASSERTIONS"
1089
1055
  });
1090
- };
1056
+ }
1091
1057
  //#endregion
1092
- //#region src/oidc/types.ts
1093
- /**
1094
- * Custom error class for OIDC discovery failures.
1095
- * Can be caught and mapped to APIError at the edge.
1096
- */
1097
- var DiscoveryError = class DiscoveryError extends Error {
1098
- code;
1099
- details;
1100
- constructor(code, message, details, options) {
1101
- super(message, options);
1102
- this.name = "DiscoveryError";
1103
- this.code = code;
1104
- this.details = details;
1105
- if (Error.captureStackTrace) Error.captureStackTrace(this, DiscoveryError);
1106
- }
1107
- };
1108
- /**
1109
- * Required fields that must be present in a valid discovery document.
1110
- */
1111
- const REQUIRED_DISCOVERY_FIELDS = [
1112
- "issuer",
1113
- "authorization_endpoint",
1114
- "token_endpoint",
1115
- "jwks_uri"
1116
- ];
1058
+ //#region src/saml/error-codes.ts
1059
+ const SAML_ERROR_CODES = defineErrorCodes({
1060
+ SINGLE_LOGOUT_NOT_ENABLED: "Single Logout is not enabled",
1061
+ INVALID_LOGOUT_RESPONSE: "Invalid LogoutResponse",
1062
+ INVALID_LOGOUT_REQUEST: "Invalid LogoutRequest",
1063
+ LOGOUT_FAILED_AT_IDP: "Logout failed at IdP",
1064
+ IDP_SLO_NOT_SUPPORTED: "IdP does not support Single Logout Service",
1065
+ SAML_PROVIDER_NOT_FOUND: "SAML provider not found",
1066
+ CERT_SOURCE_MISSING: "samlConfig requires either a signing certificate (cert or idpMetadata.cert) or an idpMetadata.metadata XML document."
1067
+ });
1117
1068
  //#endregion
1118
- //#region src/oidc/discovery.ts
1119
- /**
1120
- * OIDC Discovery Pipeline
1121
- *
1122
- * Implements OIDC discovery document fetching, validation, and hydration.
1123
- * This module is used both at provider registration time (to persist validated config)
1124
- * and at runtime (to hydrate legacy providers that are missing metadata).
1125
- *
1126
- * @see https://openid.net/specs/openid-connect-discovery-1_0.html
1127
- */
1128
- /** Default timeout for discovery requests (10 seconds) */
1129
- const DEFAULT_DISCOVERY_TIMEOUT = 1e4;
1130
- /**
1131
- * Main entry point: Discover and hydrate OIDC configuration from an issuer.
1132
- *
1133
- * This function:
1134
- * 1. Computes the discovery URL from the issuer
1135
- * 2. Validates the discovery URL
1136
- * 3. Fetches the discovery document
1137
- * 4. Validates the discovery document (issuer match + required fields)
1138
- * 5. Normalizes URLs
1139
- * 6. Selects token endpoint auth method
1140
- * 7. Merges with existing config (existing values take precedence)
1141
- *
1142
- * @param params - Discovery parameters
1143
- * @param isTrustedOrigin - Origin verification tester function
1144
- * @returns Hydrated OIDC configuration ready for persistence
1145
- * @throws DiscoveryError on any failure
1146
- */
1147
- async function discoverOIDCConfig(params) {
1148
- const { issuer, existingConfig, timeout = DEFAULT_DISCOVERY_TIMEOUT } = params;
1149
- const discoveryUrl = params.discoveryEndpoint || existingConfig?.discoveryEndpoint || computeDiscoveryUrl(issuer);
1150
- validateDiscoveryUrl(discoveryUrl, params.isTrustedOrigin);
1151
- const discoveryDoc = await fetchDiscoveryDocument(discoveryUrl, timeout);
1152
- validateDiscoveryDocument(discoveryDoc, issuer);
1153
- const normalizedDoc = normalizeDiscoveryUrls(discoveryDoc, issuer, params.isTrustedOrigin);
1154
- const tokenEndpointAuth = selectTokenEndpointAuthMethod(normalizedDoc, existingConfig?.tokenEndpointAuthentication);
1155
- return {
1156
- issuer: existingConfig?.issuer ?? normalizedDoc.issuer,
1157
- discoveryEndpoint: existingConfig?.discoveryEndpoint ?? discoveryUrl,
1158
- authorizationEndpoint: existingConfig?.authorizationEndpoint ?? normalizedDoc.authorization_endpoint,
1159
- tokenEndpoint: existingConfig?.tokenEndpoint ?? normalizedDoc.token_endpoint,
1160
- jwksEndpoint: existingConfig?.jwksEndpoint ?? normalizedDoc.jwks_uri,
1161
- userInfoEndpoint: existingConfig?.userInfoEndpoint ?? normalizedDoc.userinfo_endpoint,
1162
- tokenEndpointAuthentication: existingConfig?.tokenEndpointAuthentication ?? tokenEndpointAuth,
1163
- scopesSupported: existingConfig?.scopesSupported ?? normalizedDoc.scopes_supported
1164
- };
1165
- }
1069
+ //#region src/saml/cert.ts
1166
1070
  /**
1167
- * Compute the discovery URL from an issuer URL.
1168
- *
1169
- * Per OIDC Discovery spec, the discovery document is located at:
1170
- * <issuer>/.well-known/openid-configuration
1171
- *
1172
- * Handles trailing slashes correctly.
1071
+ * IdP signing-certificate rules for SAML configs. Centralized so the runtime
1072
+ * verification path (`createIdP`), the sanitizer (`getSSOProvider` and
1073
+ * friends), and the registration validator agree on precedence and the
1074
+ * "exactly one cert source" contract.
1173
1075
  */
1174
- function computeDiscoveryUrl(issuer) {
1175
- return `${issuer.endsWith("/") ? issuer.slice(0, -1) : issuer}/.well-known/openid-configuration`;
1176
- }
1177
1076
  /**
1178
- * Validate a discovery URL before fetching.
1179
- *
1180
- * @param url - The discovery URL to validate
1181
- * @param isTrustedOrigin - Origin verification tester function
1182
- * @throws DiscoveryError if URL is invalid
1077
+ * Returns the IdP signing certificates Better Auth trusts for this provider
1078
+ * as a list. `idpMetadata.cert` wins when both are set; the top-level `cert`
1079
+ * is the fallback. Returns `undefined` when neither is set (the certs come
1080
+ * from `idpMetadata.metadata` XML instead).
1183
1081
  */
1184
- function validateDiscoveryUrl(url, isTrustedOrigin) {
1185
- const discoveryEndpoint = parseURL("discoveryEndpoint", url).toString();
1186
- if (!isTrustedOrigin(discoveryEndpoint)) throw new DiscoveryError("discovery_untrusted_origin", `The main discovery endpoint "${discoveryEndpoint}" is not trusted by your trusted origins configuration.`, { url: discoveryEndpoint });
1082
+ function resolveSigningCerts(config) {
1083
+ const cert = config.idpMetadata?.cert ?? config.cert;
1084
+ if (cert === void 0) return void 0;
1085
+ return Array.isArray(cert) ? cert : [cert];
1187
1086
  }
1188
1087
  /**
1189
- * Fetch the OIDC discovery document from the IdP.
1190
- *
1191
- * @param url - The discovery endpoint URL
1192
- * @param timeout - Request timeout in milliseconds
1193
- * @returns The parsed discovery document
1194
- * @throws DiscoveryError on network errors, timeouts, or invalid responses
1088
+ * Reject SAML configs with no signing-cert source. samlify needs either an
1089
+ * `idpMetadata.metadata` XML document (which embeds the certs) or an explicit
1090
+ * PEM under `cert` or `idpMetadata.cert`; without one of those it has nothing
1091
+ * to verify responses against.
1195
1092
  */
1196
- async function fetchDiscoveryDocument(url, timeout = DEFAULT_DISCOVERY_TIMEOUT) {
1093
+ function validateCertSources(config) {
1094
+ const hasMetadataXml = !!config.idpMetadata?.metadata;
1095
+ const hasExplicitCert = config.idpMetadata?.cert !== void 0 || config.cert !== void 0;
1096
+ if (!hasMetadataXml && !hasExplicitCert) throw APIError.from("BAD_REQUEST", SAML_ERROR_CODES.CERT_SOURCE_MISSING);
1097
+ }
1098
+ //#endregion
1099
+ //#region src/saml/response-validation.ts
1100
+ function errorRedirectUrl(base, error, description) {
1197
1101
  try {
1198
- const response = await betterFetch(url, {
1199
- method: "GET",
1200
- timeout
1201
- });
1202
- if (response.error) {
1203
- const { status } = response.error;
1204
- if (status === 404) throw new DiscoveryError("discovery_not_found", "Discovery endpoint not found", {
1205
- url,
1206
- status
1207
- });
1208
- if (status === 408) throw new DiscoveryError("discovery_timeout", "Discovery request timed out", {
1209
- url,
1210
- timeout
1211
- });
1212
- throw new DiscoveryError("discovery_unexpected_error", `Unexpected discovery error: ${response.error.statusText}`, {
1213
- url,
1214
- ...response.error
1215
- });
1216
- }
1217
- if (!response.data) throw new DiscoveryError("discovery_invalid_json", "Discovery endpoint returned an empty response", { url });
1218
- const data = response.data;
1219
- if (typeof data === "string") throw new DiscoveryError("discovery_invalid_json", "Discovery endpoint returned invalid JSON", {
1220
- url,
1221
- bodyPreview: data.slice(0, 200)
1222
- });
1223
- return data;
1224
- } catch (error) {
1225
- if (error instanceof DiscoveryError) throw error;
1226
- if (error instanceof Error && error.name === "AbortError") throw new DiscoveryError("discovery_timeout", "Discovery request timed out", {
1227
- url,
1228
- timeout
1229
- });
1230
- throw new DiscoveryError("discovery_unexpected_error", `Unexpected error during discovery: ${error instanceof Error ? error.message : String(error)}`, { url }, { cause: error });
1102
+ const url = new URL(base);
1103
+ url.searchParams.set("error", error);
1104
+ url.searchParams.set("error_description", description);
1105
+ return url.toString();
1106
+ } catch {
1107
+ const hashIdx = base.indexOf("#");
1108
+ const path = hashIdx >= 0 ? base.slice(0, hashIdx) : base;
1109
+ const hash = hashIdx >= 0 ? base.slice(hashIdx + 1) : void 0;
1110
+ return `${path}${path.includes("?") ? "&" : "?"}${`error=${encodeURIComponent(error)}&error_description=${encodeURIComponent(description)}`}${hash ? `#${hash}` : ""}`;
1231
1111
  }
1232
1112
  }
1233
1113
  /**
1234
- * Validate a discovery document.
1235
- *
1236
- * Checks:
1237
- * 1. All required fields are present
1238
- * 2. Issuer matches the configured issuer (case-sensitive, exact match)
1114
+ * Validates the InResponseTo attribute of a SAML Response.
1239
1115
  *
1240
- * Invariant: If this function returns without throwing, the document is safe
1241
- * to use for hydrating OIDC config (required fields present, issuer matches
1242
- * configured value, basic structural sanity verified).
1116
+ * This binds the IdP's Response to a specific SP-initiated AuthnRequest,
1117
+ * preventing replay attacks, unsolicited response injection, and
1118
+ * cross-provider assertion swaps.
1243
1119
  *
1244
- * @param doc - The discovery document to validate
1245
- * @param configuredIssuer - The expected issuer value
1246
- * @throws DiscoveryError if validation fails
1120
+ * The InResponseTo value lives at `extract.response.inResponseTo` in
1121
+ * samlify's parsed output (not at the top level).
1247
1122
  */
1248
- function validateDiscoveryDocument(doc, configuredIssuer) {
1249
- const missingFields = [];
1250
- for (const field of REQUIRED_DISCOVERY_FIELDS) if (!doc[field]) missingFields.push(field);
1251
- if (missingFields.length > 0) throw new DiscoveryError("discovery_incomplete", `Discovery document is missing required fields: ${missingFields.join(", ")}`, { missingFields });
1252
- if ((doc.issuer.endsWith("/") ? doc.issuer.slice(0, -1) : doc.issuer) !== (configuredIssuer.endsWith("/") ? configuredIssuer.slice(0, -1) : configuredIssuer)) throw new DiscoveryError("issuer_mismatch", `Discovered issuer "${doc.issuer}" does not match configured issuer "${configuredIssuer}"`, {
1253
- discovered: doc.issuer,
1254
- configured: configuredIssuer
1255
- });
1123
+ async function validateInResponseTo(c, ctx) {
1124
+ if (ctx.options.enableInResponseToValidation === false) return;
1125
+ const inResponseTo = ctx.extract.response?.inResponseTo;
1126
+ const allowIdpInitiated = ctx.options.allowIdpInitiated ?? false;
1127
+ if (inResponseTo) {
1128
+ let storedRequest = null;
1129
+ const verification = await c.context.internalAdapter.findVerificationValue(`${AUTHN_REQUEST_KEY_PREFIX}${inResponseTo}`);
1130
+ if (verification) try {
1131
+ storedRequest = JSON.parse(verification.value);
1132
+ if (storedRequest && storedRequest.expiresAt < Date.now()) storedRequest = null;
1133
+ } catch {
1134
+ storedRequest = null;
1135
+ }
1136
+ if (!storedRequest) {
1137
+ c.context.logger.error("SAML InResponseTo validation failed: unknown or expired request ID", {
1138
+ inResponseTo,
1139
+ providerId: ctx.providerId
1140
+ });
1141
+ throw c.redirect(errorRedirectUrl(ctx.redirectUrl, "invalid_saml_response", "Unknown or expired request ID"));
1142
+ }
1143
+ if (storedRequest.providerId !== ctx.providerId) {
1144
+ c.context.logger.error("SAML InResponseTo validation failed: provider mismatch", {
1145
+ inResponseTo,
1146
+ expectedProvider: storedRequest.providerId,
1147
+ actualProvider: ctx.providerId
1148
+ });
1149
+ await c.context.internalAdapter.deleteVerificationByIdentifier(`${AUTHN_REQUEST_KEY_PREFIX}${inResponseTo}`);
1150
+ throw c.redirect(errorRedirectUrl(ctx.redirectUrl, "invalid_saml_response", "Provider mismatch"));
1151
+ }
1152
+ await c.context.internalAdapter.deleteVerificationByIdentifier(`${AUTHN_REQUEST_KEY_PREFIX}${inResponseTo}`);
1153
+ } else if (!allowIdpInitiated) {
1154
+ c.context.logger.error("SAML IdP-initiated SSO rejected: InResponseTo missing and allowIdpInitiated is false", { providerId: ctx.providerId });
1155
+ throw c.redirect(errorRedirectUrl(ctx.redirectUrl, "unsolicited_response", "IdP-initiated SSO not allowed"));
1156
+ }
1256
1157
  }
1257
1158
  /**
1258
- * Normalize URLs in the discovery document.
1159
+ * Validates the AudienceRestriction of a SAML assertion.
1259
1160
  *
1260
- * @param document - The discovery document
1261
- * @param issuer - The base issuer URL
1262
- * @param isTrustedOrigin - Origin verification tester function
1263
- * @returns The normalized discovery document
1161
+ * Per SAML 2.0 Core §2.5.1, an assertion's Audience element specifies
1162
+ * the intended recipient SP. Without this check, an assertion issued
1163
+ * for a different SP (e.g., another application sharing the same IdP)
1164
+ * could be accepted.
1264
1165
  */
1265
- function normalizeDiscoveryUrls(document, issuer, isTrustedOrigin) {
1266
- const doc = { ...document };
1267
- doc.token_endpoint = normalizeAndValidateUrl("token_endpoint", doc.token_endpoint, issuer, isTrustedOrigin);
1268
- doc.authorization_endpoint = normalizeAndValidateUrl("authorization_endpoint", doc.authorization_endpoint, issuer, isTrustedOrigin);
1269
- doc.jwks_uri = normalizeAndValidateUrl("jwks_uri", doc.jwks_uri, issuer, isTrustedOrigin);
1270
- if (doc.userinfo_endpoint) doc.userinfo_endpoint = normalizeAndValidateUrl("userinfo_endpoint", doc.userinfo_endpoint, issuer, isTrustedOrigin);
1271
- if (doc.revocation_endpoint) doc.revocation_endpoint = normalizeAndValidateUrl("revocation_endpoint", doc.revocation_endpoint, issuer, isTrustedOrigin);
1272
- if (doc.end_session_endpoint) doc.end_session_endpoint = normalizeAndValidateUrl("end_session_endpoint", doc.end_session_endpoint, issuer, isTrustedOrigin);
1273
- if (doc.introspection_endpoint) doc.introspection_endpoint = normalizeAndValidateUrl("introspection_endpoint", doc.introspection_endpoint, issuer, isTrustedOrigin);
1274
- return doc;
1166
+ function validateAudience(c, ctx) {
1167
+ if (!ctx.expectedAudience) {
1168
+ c.context.logger.warn("Could not determine SP entity ID for audience validation; skipping", { providerId: ctx.providerId });
1169
+ return;
1170
+ }
1171
+ const audience = ctx.extract.audience;
1172
+ if (!audience) {
1173
+ c.context.logger.error("SAML assertion missing AudienceRestriction but audience is configured — rejecting", { providerId: ctx.providerId });
1174
+ throw c.redirect(errorRedirectUrl(ctx.redirectUrl, "invalid_saml_response", "Audience restriction missing"));
1175
+ }
1176
+ const audiences = Array.isArray(audience) ? audience : [audience];
1177
+ if (!audiences.includes(ctx.expectedAudience)) {
1178
+ c.context.logger.error("SAML audience mismatch: assertion was issued for a different service provider", {
1179
+ expected: ctx.expectedAudience,
1180
+ received: audiences,
1181
+ providerId: ctx.providerId
1182
+ });
1183
+ throw c.redirect(errorRedirectUrl(ctx.redirectUrl, "invalid_saml_response", "Audience mismatch"));
1184
+ }
1275
1185
  }
1276
- /**
1277
- * Normalizes and validates a single URL endpoint
1278
- * @param name The url name
1279
- * @param endpoint The url to validate
1280
- * @param issuer The issuer base url
1281
- * @param isTrustedOrigin - Origin verification tester function
1282
- * @returns
1283
- */
1284
- function normalizeAndValidateUrl(name, endpoint, issuer, isTrustedOrigin) {
1285
- const url = normalizeUrl(name, endpoint, issuer);
1286
- if (!isTrustedOrigin(url)) throw new DiscoveryError("discovery_untrusted_origin", `The ${name} "${url}" is not trusted by your trusted origins configuration.`, {
1287
- endpoint: name,
1288
- url
1186
+ //#endregion
1187
+ //#region src/routes/schemas.ts
1188
+ const oidcMappingSchema = z.object({
1189
+ id: z.string().meta({ description: "Field mapping for user ID (defaults to 'sub')" }),
1190
+ email: z.string().meta({ description: "Field mapping for email (defaults to 'email')" }),
1191
+ emailVerified: z.string().meta({ description: "Field mapping for email verification (defaults to 'email_verified')" }).optional(),
1192
+ name: z.string().meta({ description: "Field mapping for name (defaults to 'name')" }),
1193
+ image: z.string().meta({ description: "Field mapping for image (defaults to 'picture')" }).optional(),
1194
+ extraFields: z.record(z.string(), z.any()).optional()
1195
+ }).optional();
1196
+ const samlMappingSchema = z.object({
1197
+ id: z.string().meta({ description: "Field mapping for user ID (defaults to 'nameID')" }),
1198
+ email: z.string().meta({ description: "Field mapping for email (defaults to 'email')" }),
1199
+ emailVerified: z.string().meta({ description: "Field mapping for email verification" }).optional(),
1200
+ name: z.string().meta({ description: "Field mapping for name (defaults to 'displayName')" }),
1201
+ firstName: z.string().meta({ description: "Field mapping for first name (defaults to 'givenName')" }).optional(),
1202
+ lastName: z.string().meta({ description: "Field mapping for last name (defaults to 'surname')" }).optional(),
1203
+ extraFields: z.record(z.string(), z.any()).optional()
1204
+ }).optional();
1205
+ const signingCertSchema = z.union([z.string(), z.array(z.string()).nonempty()]).meta({ description: "IdP signing certificate(s). Pass a single PEM string or an array for rolling rotation." });
1206
+ const oidcConfigSchema = z.object({
1207
+ clientId: z.string().meta({ description: "The client ID" }),
1208
+ clientSecret: z.string().meta({ description: "The client secret. Required for client_secret_basic/client_secret_post. Optional for private_key_jwt." }).optional(),
1209
+ authorizationEndpoint: z.string().url().meta({ description: "The authorization endpoint" }).optional(),
1210
+ tokenEndpoint: z.string().url().meta({ description: "The token endpoint" }).optional(),
1211
+ userInfoEndpoint: z.string().url().meta({ description: "The user info endpoint" }).optional(),
1212
+ tokenEndpointAuthentication: z.enum([
1213
+ "client_secret_post",
1214
+ "client_secret_basic",
1215
+ "private_key_jwt"
1216
+ ]).optional(),
1217
+ privateKeyId: z.string().optional(),
1218
+ privateKeyAlgorithm: z.string().optional(),
1219
+ jwksEndpoint: z.string().url().meta({ description: "The JWKS endpoint" }).optional(),
1220
+ discoveryEndpoint: z.string().url().optional(),
1221
+ skipDiscovery: z.boolean().meta({ description: "Skip OIDC discovery during registration. When true, you must provide authorizationEndpoint, tokenEndpoint, and jwksEndpoint manually." }).optional(),
1222
+ scopes: z.array(z.string()).meta({ description: "The scopes to request. Defaults to ['openid', 'email', 'profile', 'offline_access']" }).optional(),
1223
+ pkce: z.boolean().meta({ description: "Whether to use PKCE for the authorization flow" }).default(true).optional(),
1224
+ overrideUserInfo: z.boolean().optional(),
1225
+ mapping: oidcMappingSchema
1226
+ });
1227
+ const samlConfigSchema = z.object({
1228
+ entryPoint: z.string().url().meta({ description: "The IdP SSO URL (entry point)" }),
1229
+ cert: signingCertSchema.meta({ description: "IdP signing certificate(s). Pass a single PEM string or an array for rolling rotation. Omit when `idpMetadata.metadata` XML carries the certs. When both this and `idpMetadata.cert` are set, `idpMetadata.cert` wins." }).optional(),
1230
+ audience: z.string().optional(),
1231
+ idpMetadata: z.object({
1232
+ metadata: z.string().optional(),
1233
+ entityID: z.string().optional(),
1234
+ cert: signingCertSchema.meta({ description: "IdP signing certificate(s). Pass a single PEM string or an array for rolling rotation. Takes precedence over the top-level `cert`." }).optional(),
1235
+ privateKey: z.string().optional(),
1236
+ privateKeyPass: z.string().optional(),
1237
+ isAssertionEncrypted: z.boolean().optional(),
1238
+ encPrivateKey: z.string().optional(),
1239
+ encPrivateKeyPass: z.string().optional(),
1240
+ singleSignOnService: z.array(z.object({
1241
+ Binding: z.string().meta({ description: "The binding type for the SSO service" }),
1242
+ Location: z.string().url().meta({ description: "The URL for the SSO service" })
1243
+ })).meta({ description: "Single Sign-On service configuration" }).optional(),
1244
+ singleLogoutService: z.array(z.object({
1245
+ Binding: z.string(),
1246
+ Location: z.string().url()
1247
+ })).optional()
1248
+ }).optional(),
1249
+ spMetadata: z.object({
1250
+ metadata: z.string().optional(),
1251
+ entityID: z.string().optional(),
1252
+ binding: z.string().optional(),
1253
+ privateKey: z.string().optional(),
1254
+ privateKeyPass: z.string().optional(),
1255
+ isAssertionEncrypted: z.boolean().optional(),
1256
+ encPrivateKey: z.string().optional(),
1257
+ encPrivateKeyPass: z.string().optional()
1258
+ }).optional(),
1259
+ wantAssertionsSigned: z.boolean().optional(),
1260
+ authnRequestsSigned: z.boolean().optional(),
1261
+ signatureAlgorithm: z.string().optional(),
1262
+ digestAlgorithm: z.string().optional(),
1263
+ identifierFormat: z.string().optional(),
1264
+ privateKey: z.string().optional(),
1265
+ mapping: samlMappingSchema
1266
+ });
1267
+ const registerSSOProviderBodySchema = z.object({
1268
+ providerId: z.string().meta({ description: "The ID of the provider. This is used to identify the provider during login and callback" }),
1269
+ issuer: z.string().url().meta({ description: "The issuer URL of the provider" }),
1270
+ 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')" }),
1271
+ oidcConfig: oidcConfigSchema.optional(),
1272
+ samlConfig: samlConfigSchema.optional(),
1273
+ organizationId: z.string().meta({ description: "If organization plugin is enabled, the organization id to link the provider to" }).optional(),
1274
+ overrideUserInfo: z.boolean().meta({ description: "Override user info with the provider info. Defaults to false" }).default(false).optional()
1275
+ });
1276
+ const updateSSOProviderBodySchema = z.object({
1277
+ issuer: z.string().url().optional(),
1278
+ domain: z.string().optional(),
1279
+ oidcConfig: oidcConfigSchema.partial().optional(),
1280
+ samlConfig: samlConfigSchema.partial().optional()
1281
+ });
1282
+ //#endregion
1283
+ //#region src/routes/providers.ts
1284
+ const ADMIN_ROLES = ["owner", "admin"];
1285
+ function hasOrgAdminRole(member) {
1286
+ return member.role.split(",").some((r) => ADMIN_ROLES.includes(r.trim()));
1287
+ }
1288
+ function parseCertOrError(cert) {
1289
+ try {
1290
+ return parseCertificate(cert);
1291
+ } catch {
1292
+ return { error: "Failed to parse certificate" };
1293
+ }
1294
+ }
1295
+ function sanitizeSigningCerts(config) {
1296
+ const certs = resolveSigningCerts(config);
1297
+ if (certs === void 0) return void 0;
1298
+ return certs.map(parseCertOrError);
1299
+ }
1300
+ async function isOrgAdmin(ctx, userId, organizationId) {
1301
+ const member = await ctx.context.adapter.findOne({
1302
+ model: "member",
1303
+ where: [{
1304
+ field: "userId",
1305
+ value: userId
1306
+ }, {
1307
+ field: "organizationId",
1308
+ value: organizationId
1309
+ }]
1310
+ });
1311
+ return member ? hasOrgAdminRole(member) : false;
1312
+ }
1313
+ async function batchCheckOrgAdmin(ctx, userId, organizationIds) {
1314
+ if (organizationIds.length === 0) return /* @__PURE__ */ new Set();
1315
+ const members = await ctx.context.adapter.findMany({
1316
+ model: "member",
1317
+ where: [{
1318
+ field: "userId",
1319
+ value: userId
1320
+ }, {
1321
+ field: "organizationId",
1322
+ value: organizationIds,
1323
+ operator: "in"
1324
+ }]
1289
1325
  });
1290
- return url;
1326
+ const adminOrgIds = /* @__PURE__ */ new Set();
1327
+ for (const member of members) if (hasOrgAdminRole(member)) adminOrgIds.add(member.organizationId);
1328
+ return adminOrgIds;
1291
1329
  }
1292
- /**
1293
- * Normalize a single URL endpoint.
1294
- *
1295
- * @param name - The endpoint name (e.g token_endpoint)
1296
- * @param endpoint - The endpoint URL to normalize
1297
- * @param issuer - The base issuer URL
1298
- * @returns The normalized endpoint URL
1299
- */
1300
- function normalizeUrl(name, endpoint, issuer) {
1330
+ function sanitizeProvider(provider, baseURL) {
1331
+ let oidcConfig = null;
1332
+ let samlConfig = null;
1301
1333
  try {
1302
- return parseURL(name, endpoint).toString();
1334
+ oidcConfig = safeJsonParse(provider.oidcConfig);
1303
1335
  } catch {
1304
- const issuerURL = parseURL(name, issuer);
1305
- const basePath = issuerURL.pathname.replace(/\/+$/, "");
1306
- const endpointPath = endpoint.replace(/^\/+/, "");
1307
- return parseURL(name, basePath + "/" + endpointPath, issuerURL.origin).toString();
1336
+ oidcConfig = null;
1308
1337
  }
1309
- }
1310
- /**
1311
- * Parses the given URL or throws in case of invalid or unsupported protocols
1312
- *
1313
- * @param name the url name
1314
- * @param endpoint the endpoint url
1315
- * @param [base] optional base path
1316
- * @returns
1317
- */
1318
- function parseURL(name, endpoint, base) {
1319
- let endpointURL;
1320
1338
  try {
1321
- endpointURL = new URL(endpoint, base);
1322
- if (endpointURL.protocol === "http:" || endpointURL.protocol === "https:") return endpointURL;
1323
- } catch (error) {
1324
- throw new DiscoveryError("discovery_invalid_url", `The url "${name}" must be valid: ${endpoint}`, { url: endpoint }, { cause: error });
1339
+ samlConfig = safeJsonParse(provider.samlConfig);
1340
+ } catch {
1341
+ samlConfig = null;
1325
1342
  }
1326
- throw new DiscoveryError("discovery_invalid_url", `The url "${name}" must use the http or https supported protocols: ${endpoint}`, {
1327
- url: endpoint,
1328
- protocol: endpointURL.protocol
1329
- });
1330
- }
1331
- /**
1332
- * Select the token endpoint authentication method.
1333
- *
1334
- * @param doc - The discovery document
1335
- * @param existing - Existing authentication method from config
1336
- * @returns The selected authentication method
1337
- */
1338
- function selectTokenEndpointAuthMethod(doc, existing) {
1339
- if (existing === "private_key_jwt") return existing;
1340
- if (existing) return existing;
1341
- const supported = doc.token_endpoint_auth_methods_supported;
1342
- if (!supported || supported.length === 0) return "client_secret_basic";
1343
- if (supported.includes("client_secret_basic")) return "client_secret_basic";
1344
- if (supported.includes("client_secret_post")) return "client_secret_post";
1345
- if (supported.includes("private_key_jwt")) return "private_key_jwt";
1346
- return "client_secret_basic";
1343
+ const type = samlConfig ? "saml" : "oidc";
1344
+ return {
1345
+ providerId: provider.providerId,
1346
+ type,
1347
+ issuer: provider.issuer,
1348
+ domain: provider.domain,
1349
+ organizationId: provider.organizationId || null,
1350
+ domainVerified: provider.domainVerified ?? false,
1351
+ oidcConfig: oidcConfig ? {
1352
+ discoveryEndpoint: oidcConfig.discoveryEndpoint,
1353
+ clientIdLastFour: maskClientId(oidcConfig.clientId),
1354
+ pkce: oidcConfig.pkce,
1355
+ authorizationEndpoint: oidcConfig.authorizationEndpoint,
1356
+ tokenEndpoint: oidcConfig.tokenEndpoint,
1357
+ userInfoEndpoint: oidcConfig.userInfoEndpoint,
1358
+ jwksEndpoint: oidcConfig.jwksEndpoint,
1359
+ scopes: oidcConfig.scopes,
1360
+ tokenEndpointAuthentication: oidcConfig.tokenEndpointAuthentication
1361
+ } : void 0,
1362
+ samlConfig: samlConfig ? {
1363
+ entryPoint: samlConfig.entryPoint,
1364
+ audience: samlConfig.audience,
1365
+ wantAssertionsSigned: samlConfig.wantAssertionsSigned,
1366
+ authnRequestsSigned: samlConfig.authnRequestsSigned,
1367
+ identifierFormat: samlConfig.identifierFormat,
1368
+ signatureAlgorithm: samlConfig.signatureAlgorithm,
1369
+ digestAlgorithm: samlConfig.digestAlgorithm,
1370
+ certificate: sanitizeSigningCerts(samlConfig)
1371
+ } : void 0,
1372
+ spMetadataUrl: `${baseURL}/sso/saml2/sp/metadata?providerId=${encodeURIComponent(provider.providerId)}`
1373
+ };
1347
1374
  }
1348
- /**
1349
- * Check if a provider configuration needs runtime discovery.
1350
- *
1351
- * Returns true if we need discovery at runtime to complete the token exchange
1352
- * and validation. Specifically checks for:
1353
- * - `tokenEndpoint` - required for exchanging authorization code for tokens
1354
- * - `jwksEndpoint` - required for validating ID token signatures
1355
- * - `authorizationEndpoint` - required for redirecting users to the IdP for login
1356
- *
1357
- * @param config - Partial OIDC config from the provider
1358
- * @returns true if runtime discovery should be performed
1359
- */
1360
- function needsRuntimeDiscovery(config) {
1361
- if (!config) return true;
1362
- return !config.tokenEndpoint || !config.jwksEndpoint || !config.authorizationEndpoint;
1375
+ const listSSOProviders = () => {
1376
+ return createAuthEndpoint("/sso/providers", {
1377
+ method: "GET",
1378
+ use: [sessionMiddleware],
1379
+ metadata: { openapi: {
1380
+ operationId: "listSSOProviders",
1381
+ summary: "List SSO providers",
1382
+ description: "Returns a list of SSO providers the user has access to",
1383
+ responses: { "200": { description: "List of SSO providers. The `certificate` field is an array of parsed certificates for SAML providers, or absent when certs live inside `idpMetadata.metadata`." } }
1384
+ } }
1385
+ }, async (ctx) => {
1386
+ const userId = ctx.context.session.user.id;
1387
+ const allProviders = await ctx.context.adapter.findMany({ model: "ssoProvider" });
1388
+ const userOwnedProviders = allProviders.filter((p) => p.userId === userId && !p.organizationId);
1389
+ const orgProviders = allProviders.filter((p) => p.organizationId !== null && p.organizationId !== void 0);
1390
+ const orgPluginEnabled = ctx.context.hasPlugin("organization");
1391
+ let accessibleProviders = [...userOwnedProviders];
1392
+ if (orgPluginEnabled && orgProviders.length > 0) {
1393
+ const adminOrgIds = await batchCheckOrgAdmin(ctx, userId, [...new Set(orgProviders.map((p) => p.organizationId).filter((id) => id !== null && id !== void 0))]);
1394
+ const orgAccessibleProviders = orgProviders.filter((provider) => provider.organizationId && adminOrgIds.has(provider.organizationId));
1395
+ accessibleProviders = [...accessibleProviders, ...orgAccessibleProviders];
1396
+ } else if (!orgPluginEnabled) {
1397
+ const userOwnedOrgProviders = orgProviders.filter((p) => p.userId === userId);
1398
+ accessibleProviders = [...accessibleProviders, ...userOwnedOrgProviders];
1399
+ }
1400
+ const providers = accessibleProviders.map((p) => sanitizeProvider(p, ctx.context.baseURL));
1401
+ return ctx.json({ providers });
1402
+ });
1403
+ };
1404
+ const getSSOProviderQuerySchema = z.object({ providerId: z.string() });
1405
+ async function checkProviderAccess(ctx, providerId) {
1406
+ const userId = ctx.context.session.user.id;
1407
+ const provider = await ctx.context.adapter.findOne({
1408
+ model: "ssoProvider",
1409
+ where: [{
1410
+ field: "providerId",
1411
+ value: providerId
1412
+ }]
1413
+ });
1414
+ if (!provider) throw new APIError("NOT_FOUND", { message: "Provider not found" });
1415
+ let hasAccess = false;
1416
+ if (provider.organizationId) if (ctx.context.hasPlugin("organization")) hasAccess = await isOrgAdmin(ctx, userId, provider.organizationId);
1417
+ else hasAccess = provider.userId === userId;
1418
+ else hasAccess = provider.userId === userId;
1419
+ if (!hasAccess) throw new APIError("FORBIDDEN", { message: "You don't have access to this provider" });
1420
+ return provider;
1363
1421
  }
1364
- /**
1365
- * Runs runtime OIDC discovery when the stored config is missing required
1366
- * endpoints, and merges the hydrated fields back into the config.
1367
- * Throws if discovery fails.
1368
- */
1369
- async function ensureRuntimeDiscovery(config, issuer, isTrustedOrigin) {
1370
- if (!needsRuntimeDiscovery(config)) return config;
1371
- const hydrated = await discoverOIDCConfig({
1372
- issuer,
1373
- existingConfig: config,
1374
- isTrustedOrigin
1422
+ const getSSOProvider = () => {
1423
+ return createAuthEndpoint("/sso/get-provider", {
1424
+ method: "GET",
1425
+ use: [sessionMiddleware],
1426
+ query: getSSOProviderQuerySchema,
1427
+ metadata: { openapi: {
1428
+ operationId: "getSSOProvider",
1429
+ summary: "Get SSO provider details",
1430
+ description: "Returns sanitized details for a specific SSO provider",
1431
+ responses: {
1432
+ "200": { description: "SSO provider details. The `certificate` field is an array of parsed certificates for SAML providers, or absent when certs live inside `idpMetadata.metadata`." },
1433
+ "404": { description: "Provider not found" },
1434
+ "403": { description: "Access denied" }
1435
+ }
1436
+ } }
1437
+ }, async (ctx) => {
1438
+ const { providerId } = ctx.query;
1439
+ const provider = await checkProviderAccess(ctx, providerId);
1440
+ return ctx.json(sanitizeProvider(provider, ctx.context.baseURL));
1375
1441
  });
1376
- return {
1377
- ...config,
1378
- authorizationEndpoint: hydrated.authorizationEndpoint,
1379
- tokenEndpoint: hydrated.tokenEndpoint,
1380
- tokenEndpointAuthentication: hydrated.tokenEndpointAuthentication,
1381
- userInfoEndpoint: hydrated.userInfoEndpoint,
1382
- jwksEndpoint: hydrated.jwksEndpoint
1383
- };
1442
+ };
1443
+ function parseAndValidateConfig(configString, configType) {
1444
+ let config = null;
1445
+ try {
1446
+ config = safeJsonParse(configString);
1447
+ } catch {
1448
+ config = null;
1449
+ }
1450
+ if (!config) throw new APIError("BAD_REQUEST", { message: `Cannot update ${configType} config for a provider that doesn't have ${configType} configured` });
1451
+ return config;
1384
1452
  }
1385
- //#endregion
1386
- //#region src/oidc/errors.ts
1387
- /**
1388
- * OIDC Discovery Error Mapping
1389
- *
1390
- * Maps DiscoveryError codes to appropriate APIError responses.
1391
- * Used at the boundary between the discovery pipeline and HTTP handlers.
1392
- */
1393
- /**
1394
- * Maps a DiscoveryError to an appropriate APIError for HTTP responses.
1395
- *
1396
- * Error code mapping:
1397
- * - discovery_invalid_url → 400 BAD_REQUEST
1398
- * - discovery_not_found → 400 BAD_REQUEST
1399
- * - discovery_invalid_json → 400 BAD_REQUEST
1400
- * - discovery_incomplete → 400 BAD_REQUEST
1401
- * - issuer_mismatch → 400 BAD_REQUEST
1402
- * - unsupported_token_auth_method → 400 BAD_REQUEST
1403
- * - discovery_timeout → 502 BAD_GATEWAY
1404
- * - discovery_unexpected_error → 502 BAD_GATEWAY
1405
- *
1406
- * @param error - The DiscoveryError to map
1407
- * @returns An APIError with appropriate status and message
1408
- */
1409
- function mapDiscoveryErrorToAPIError(error) {
1410
- switch (error.code) {
1411
- case "discovery_timeout": return new APIError("BAD_GATEWAY", {
1412
- message: `OIDC discovery timed out: ${error.message}`,
1413
- code: error.code
1414
- });
1415
- case "discovery_unexpected_error": return new APIError("BAD_GATEWAY", {
1416
- message: `OIDC discovery failed: ${error.message}`,
1417
- code: error.code
1418
- });
1419
- case "discovery_not_found": return new APIError("BAD_REQUEST", {
1420
- message: `OIDC discovery endpoint not found. The issuer may not support OIDC discovery, or the URL is incorrect. ${error.message}`,
1421
- code: error.code
1422
- });
1423
- case "discovery_invalid_url": return new APIError("BAD_REQUEST", {
1424
- message: `Invalid OIDC discovery URL: ${error.message}`,
1425
- code: error.code
1426
- });
1427
- case "discovery_untrusted_origin": return new APIError("BAD_REQUEST", {
1428
- message: `Untrusted OIDC discovery URL: ${error.message}`,
1429
- code: error.code
1430
- });
1431
- case "discovery_invalid_json": return new APIError("BAD_REQUEST", {
1432
- message: `OIDC discovery returned invalid data: ${error.message}`,
1433
- code: error.code
1434
- });
1435
- case "discovery_incomplete": return new APIError("BAD_REQUEST", {
1436
- message: `OIDC discovery document is missing required fields: ${error.message}`,
1437
- code: error.code
1453
+ function mergeSAMLConfig(current, updates, issuer) {
1454
+ return {
1455
+ ...current,
1456
+ ...updates,
1457
+ issuer,
1458
+ entryPoint: updates.entryPoint ?? current.entryPoint,
1459
+ cert: updates.cert ?? current.cert,
1460
+ spMetadata: updates.spMetadata ?? current.spMetadata,
1461
+ idpMetadata: updates.idpMetadata ?? current.idpMetadata,
1462
+ mapping: updates.mapping ?? current.mapping,
1463
+ audience: updates.audience ?? current.audience,
1464
+ wantAssertionsSigned: updates.wantAssertionsSigned ?? current.wantAssertionsSigned,
1465
+ authnRequestsSigned: updates.authnRequestsSigned ?? current.authnRequestsSigned,
1466
+ identifierFormat: updates.identifierFormat ?? current.identifierFormat,
1467
+ signatureAlgorithm: updates.signatureAlgorithm ?? current.signatureAlgorithm,
1468
+ digestAlgorithm: updates.digestAlgorithm ?? current.digestAlgorithm
1469
+ };
1470
+ }
1471
+ function mergeOIDCConfig(current, updates, issuer) {
1472
+ return {
1473
+ ...current,
1474
+ ...updates,
1475
+ issuer,
1476
+ pkce: updates.pkce ?? current.pkce ?? true,
1477
+ clientId: updates.clientId ?? current.clientId,
1478
+ clientSecret: updates.clientSecret ?? current.clientSecret,
1479
+ discoveryEndpoint: updates.discoveryEndpoint ?? current.discoveryEndpoint,
1480
+ mapping: updates.mapping ?? current.mapping,
1481
+ scopes: updates.scopes ?? current.scopes,
1482
+ authorizationEndpoint: updates.authorizationEndpoint ?? current.authorizationEndpoint,
1483
+ tokenEndpoint: updates.tokenEndpoint ?? current.tokenEndpoint,
1484
+ userInfoEndpoint: updates.userInfoEndpoint ?? current.userInfoEndpoint,
1485
+ jwksEndpoint: updates.jwksEndpoint ?? current.jwksEndpoint,
1486
+ tokenEndpointAuthentication: updates.tokenEndpointAuthentication ?? current.tokenEndpointAuthentication,
1487
+ privateKeyId: updates.privateKeyId ?? current.privateKeyId,
1488
+ privateKeyAlgorithm: updates.privateKeyAlgorithm ?? current.privateKeyAlgorithm
1489
+ };
1490
+ }
1491
+ const updateSSOProvider = (options) => {
1492
+ return createAuthEndpoint("/sso/update-provider", {
1493
+ method: "POST",
1494
+ use: [sessionMiddleware],
1495
+ body: updateSSOProviderBodySchema.extend({ providerId: z.string() }),
1496
+ metadata: { openapi: {
1497
+ operationId: "updateSSOProvider",
1498
+ summary: "Update SSO provider",
1499
+ description: "Partially update an SSO provider. Only provided fields are updated. If domain changes, domainVerified is reset to false.",
1500
+ responses: {
1501
+ "200": { description: "SSO provider updated successfully. The `certificate` field is an array of parsed certificates for SAML providers, or absent when certs live inside `idpMetadata.metadata`." },
1502
+ "404": { description: "Provider not found" },
1503
+ "403": { description: "Access denied" }
1504
+ }
1505
+ } }
1506
+ }, async (ctx) => {
1507
+ const { providerId, ...body } = ctx.body;
1508
+ const { issuer, domain, samlConfig, oidcConfig } = body;
1509
+ if (!issuer && !domain && !samlConfig && !oidcConfig) throw new APIError("BAD_REQUEST", { message: "No fields provided for update" });
1510
+ const existingProvider = await checkProviderAccess(ctx, providerId);
1511
+ const updateData = {};
1512
+ if (body.issuer !== void 0) updateData.issuer = body.issuer;
1513
+ if (body.domain !== void 0) {
1514
+ updateData.domain = body.domain;
1515
+ if (body.domain !== existingProvider.domain) updateData.domainVerified = false;
1516
+ }
1517
+ if (body.samlConfig) {
1518
+ if (body.samlConfig.idpMetadata?.metadata) {
1519
+ const maxMetadataSize = options?.saml?.maxMetadataSize ?? 102400;
1520
+ if (new TextEncoder().encode(body.samlConfig.idpMetadata.metadata).length > maxMetadataSize) throw new APIError("BAD_REQUEST", { message: `IdP metadata exceeds maximum allowed size (${maxMetadataSize} bytes)` });
1521
+ }
1522
+ if (body.samlConfig.signatureAlgorithm !== void 0 || body.samlConfig.digestAlgorithm !== void 0) validateConfigAlgorithms({
1523
+ signatureAlgorithm: body.samlConfig.signatureAlgorithm,
1524
+ digestAlgorithm: body.samlConfig.digestAlgorithm
1525
+ }, options?.saml?.algorithms);
1526
+ const currentSamlConfig = parseAndValidateConfig(existingProvider.samlConfig, "SAML");
1527
+ const updatedSamlConfig = mergeSAMLConfig(currentSamlConfig, body.samlConfig, updateData.issuer || currentSamlConfig.issuer || existingProvider.issuer);
1528
+ validateCertSources(updatedSamlConfig);
1529
+ updateData.samlConfig = JSON.stringify(updatedSamlConfig);
1530
+ }
1531
+ if (body.oidcConfig) {
1532
+ try {
1533
+ validateSkipDiscoveryEndpoints(body.oidcConfig, (url) => ctx.context.isTrustedOrigin(url));
1534
+ } catch (error) {
1535
+ if (error instanceof DiscoveryError) throw mapDiscoveryErrorToAPIError(error);
1536
+ throw error;
1537
+ }
1538
+ const currentOidcConfig = parseAndValidateConfig(existingProvider.oidcConfig, "OIDC");
1539
+ const updatedOidcConfig = mergeOIDCConfig(currentOidcConfig, body.oidcConfig, updateData.issuer || currentOidcConfig.issuer || existingProvider.issuer);
1540
+ if (updatedOidcConfig.tokenEndpointAuthentication !== "private_key_jwt" && !updatedOidcConfig.clientSecret) throw new APIError("BAD_REQUEST", { message: "clientSecret is required when using client_secret_basic or client_secret_post authentication" });
1541
+ if (updatedOidcConfig.tokenEndpointAuthentication === "private_key_jwt" && !options?.resolvePrivateKey && !options?.defaultSSO?.some((p) => p.providerId === providerId && "privateKey" in p && p.privateKey)) throw new APIError("BAD_REQUEST", { message: "private_key_jwt authentication requires either a resolvePrivateKey callback or a privateKey in defaultSSO" });
1542
+ updateData.oidcConfig = JSON.stringify(updatedOidcConfig);
1543
+ }
1544
+ await ctx.context.adapter.update({
1545
+ model: "ssoProvider",
1546
+ where: [{
1547
+ field: "providerId",
1548
+ value: providerId
1549
+ }],
1550
+ update: updateData
1438
1551
  });
1439
- case "issuer_mismatch": return new APIError("BAD_REQUEST", {
1440
- message: `OIDC issuer mismatch: ${error.message}`,
1441
- code: error.code
1552
+ const fullProvider = await ctx.context.adapter.findOne({
1553
+ model: "ssoProvider",
1554
+ where: [{
1555
+ field: "providerId",
1556
+ value: providerId
1557
+ }]
1442
1558
  });
1443
- case "unsupported_token_auth_method": return new APIError("BAD_REQUEST", {
1444
- message: `Incompatible OIDC provider: ${error.message}`,
1445
- code: error.code
1559
+ if (!fullProvider) throw new APIError("NOT_FOUND", { message: "Provider not found after update" });
1560
+ return ctx.json(sanitizeProvider(fullProvider, ctx.context.baseURL));
1561
+ });
1562
+ };
1563
+ const deleteSSOProvider = () => {
1564
+ return createAuthEndpoint("/sso/delete-provider", {
1565
+ method: "POST",
1566
+ use: [sessionMiddleware],
1567
+ body: z.object({ providerId: z.string() }),
1568
+ metadata: { openapi: {
1569
+ operationId: "deleteSSOProvider",
1570
+ summary: "Delete SSO provider",
1571
+ description: "Deletes an SSO provider",
1572
+ responses: {
1573
+ "200": { description: "SSO provider deleted successfully" },
1574
+ "404": { description: "Provider not found" },
1575
+ "403": { description: "Access denied" }
1576
+ }
1577
+ } }
1578
+ }, async (ctx) => {
1579
+ const { providerId } = ctx.body;
1580
+ await checkProviderAccess(ctx, providerId);
1581
+ await ctx.context.adapter.delete({
1582
+ model: "ssoProvider",
1583
+ where: [{
1584
+ field: "providerId",
1585
+ value: providerId
1586
+ }]
1446
1587
  });
1447
- default:
1448
- error.code;
1449
- return new APIError("INTERNAL_SERVER_ERROR", {
1450
- message: `Unexpected discovery error: ${error.message}`,
1451
- code: "discovery_unexpected_error"
1452
- });
1453
- }
1454
- }
1455
- //#endregion
1456
- //#region src/saml/error-codes.ts
1457
- const SAML_ERROR_CODES = defineErrorCodes({
1458
- SINGLE_LOGOUT_NOT_ENABLED: "Single Logout is not enabled",
1459
- INVALID_LOGOUT_RESPONSE: "Invalid LogoutResponse",
1460
- INVALID_LOGOUT_REQUEST: "Invalid LogoutRequest",
1461
- LOGOUT_FAILED_AT_IDP: "Logout failed at IdP",
1462
- IDP_SLO_NOT_SUPPORTED: "IdP does not support Single Logout Service",
1463
- SAML_PROVIDER_NOT_FOUND: "SAML provider not found"
1464
- });
1588
+ return ctx.json({ success: true });
1589
+ });
1590
+ };
1465
1591
  //#endregion
1466
1592
  //#region src/saml-state.ts
1467
1593
  async function generateRelayState(c, link, additionalData) {
@@ -1511,14 +1637,12 @@ const saml = typeof samlifyNamespace.SPMetadata === "function" && typeof samlify
1511
1637
  //#endregion
1512
1638
  //#region src/routes/helpers.ts
1513
1639
  /**
1514
- * Normalizes a PEM string by trimming leading/trailing whitespace from each
1515
- * line. Native `crypto.createPrivateKey` (used by samlify 2.12+) rejects PEM
1516
- * blocks with leading whitespace, which is common when keys are stored in
1517
- * indented config files, environment variables, or JSON.
1640
+ * Same as `normalizePem`, but applied across the resolved list of IdP signing
1641
+ * certificates so multi-cert rotation configs survive the line-trim step.
1518
1642
  */
1519
- function normalizePem(pem) {
1520
- if (!pem) return pem;
1521
- return pem.split("\n").map((line) => line.trim()).join("\n");
1643
+ function normalizePemList(certs) {
1644
+ if (!certs) return certs;
1645
+ return certs.map((pem) => normalizePem(pem) ?? pem);
1522
1646
  }
1523
1647
  async function findSAMLProvider(providerId, options, adapter) {
1524
1648
  if (options?.defaultSSO?.length) {
@@ -1595,7 +1719,7 @@ function createIdP(config) {
1595
1719
  Location: config.entryPoint
1596
1720
  }],
1597
1721
  singleLogoutService: idpData?.singleLogoutService,
1598
- signingCert: normalizePem(idpData?.cert || config.cert),
1722
+ signingCert: normalizePemList(resolveSigningCerts(config)),
1599
1723
  wantAuthnRequestsSigned: config.authnRequestsSigned || false,
1600
1724
  isAssertionEncrypted: idpData?.isAssertionEncrypted || false,
1601
1725
  encPrivateKey: normalizePem(idpData?.encPrivateKey),
@@ -1813,12 +1937,16 @@ async function processSAMLResponse(ctx, params, options) {
1813
1937
  } else ctx.context.logger.warn("Could not extract assertion ID for replay protection", { providerId });
1814
1938
  const attributes = extract.attributes || {};
1815
1939
  const mapping = parsedSamlConfig.mapping ?? {};
1940
+ const attr = (key) => {
1941
+ const value = attributes[key];
1942
+ return Array.isArray(value) ? value[0] : value;
1943
+ };
1816
1944
  const userInfo = {
1817
1945
  ...Object.fromEntries(Object.entries(mapping.extraFields || {}).map(([key, value]) => [key, attributes[value]])),
1818
- id: attributes[mapping.id || "nameID"] || extract.nameID,
1819
- email: (attributes[mapping.email || "email"] || extract.nameID || "").toLowerCase(),
1820
- name: [attributes[mapping.firstName || "givenName"], attributes[mapping.lastName || "surname"]].filter(Boolean).join(" ") || attributes[mapping.name || "displayName"] || extract.nameID,
1821
- emailVerified: options?.trustEmailVerified && mapping.emailVerified ? attributes[mapping.emailVerified] || false : false
1946
+ id: attr(mapping.id || "nameID") || extract.nameID,
1947
+ email: (attr(mapping.email || "email") || extract.nameID || "").toLowerCase(),
1948
+ name: [attr(mapping.firstName || "givenName"), attr(mapping.lastName || "surname")].filter(Boolean).join(" ") || attr(mapping.name || "displayName") || extract.nameID,
1949
+ emailVerified: options?.trustEmailVerified && mapping.emailVerified ? attr(mapping.emailVerified) || false : false
1822
1950
  };
1823
1951
  if (!userInfo.id || !userInfo.email) {
1824
1952
  ctx.context.logger.error("Missing essential user info from SAML response", {
@@ -1831,23 +1959,35 @@ async function processSAMLResponse(ctx, params, options) {
1831
1959
  }
1832
1960
  const isTrustedProvider = ctx.context.trustedProviders.includes(providerId) || "domainVerified" in provider && !!provider.domainVerified && validateEmailDomain(userInfo.email, provider.domain);
1833
1961
  const postAuthRedirect = relayState?.callbackURL || ctx.context.baseURL;
1834
- const result = await handleOAuthUserInfo(ctx, {
1835
- userInfo: {
1836
- email: userInfo.email,
1837
- name: userInfo.name || userInfo.email,
1838
- id: userInfo.id,
1839
- emailVerified: Boolean(userInfo.emailVerified)
1840
- },
1841
- account: {
1842
- providerId,
1843
- accountId: userInfo.id,
1844
- accessToken: "",
1845
- refreshToken: ""
1846
- },
1847
- callbackURL: postAuthRedirect,
1848
- disableSignUp: options?.disableImplicitSignUp,
1849
- isTrustedProvider
1850
- });
1962
+ const errorUrl = relayState?.errorURL || samlRedirectUrl;
1963
+ let result;
1964
+ try {
1965
+ result = await handleOAuthUserInfo(ctx, {
1966
+ userInfo: {
1967
+ email: userInfo.email,
1968
+ name: userInfo.name || userInfo.email,
1969
+ id: userInfo.id,
1970
+ emailVerified: Boolean(userInfo.emailVerified)
1971
+ },
1972
+ account: {
1973
+ providerId,
1974
+ accountId: userInfo.id,
1975
+ accessToken: "",
1976
+ refreshToken: ""
1977
+ },
1978
+ callbackURL: postAuthRedirect,
1979
+ disableSignUp: options?.disableImplicitSignUp,
1980
+ isTrustedProvider
1981
+ });
1982
+ } catch (e) {
1983
+ if (isAPIError(e) && e.body?.code) {
1984
+ const params = new URLSearchParams({ error: e.body.code });
1985
+ if (e.body.message) params.set("error_description", e.body.message);
1986
+ const sep = errorUrl.includes("?") ? "&" : "?";
1987
+ throw ctx.redirect(`${errorUrl}${sep}${params.toString()}`);
1988
+ }
1989
+ throw e;
1990
+ }
1851
1991
  if (result.error) throw ctx.redirect(`${samlRedirectUrl}?error=${result.error.split(" ").join("_")}`);
1852
1992
  const { session, user } = result.data;
1853
1993
  if (options?.provisionUser && (result.isRegister || options.provisionUserOnEveryLogin)) await options.provisionUser({
@@ -1876,6 +2016,7 @@ async function processSAMLResponse(ctx, params, options) {
1876
2016
  const samlSessionKey = `${SAML_SESSION_KEY_PREFIX}${providerId}:${extract.nameID}`;
1877
2017
  const samlSessionData = {
1878
2018
  sessionId: session.id,
2019
+ sessionToken: session.token,
1879
2020
  providerId,
1880
2021
  nameID: extract.nameID,
1881
2022
  sessionIndex: extract.sessionIndex?.sessionIndex
@@ -1931,88 +2072,10 @@ const spMetadata = (options) => {
1931
2072
  return new Response(sp.getMetadata(), { headers: { "Content-Type": "application/xml" } });
1932
2073
  });
1933
2074
  };
1934
- const ssoProviderBodySchema = z.object({
1935
- providerId: z.string({}).meta({ description: "The ID of the provider. This is used to identify the provider during login and callback" }),
1936
- issuer: z.string({}).meta({ description: "The issuer of the provider" }),
1937
- 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')" }),
1938
- oidcConfig: z.object({
1939
- clientId: z.string({}).meta({ description: "The client ID" }),
1940
- clientSecret: z.string({}).optional().meta({ description: "The client secret. Required for client_secret_basic/client_secret_post. Optional for private_key_jwt." }),
1941
- authorizationEndpoint: z.string({}).meta({ description: "The authorization endpoint" }).optional(),
1942
- tokenEndpoint: z.string({}).meta({ description: "The token endpoint" }).optional(),
1943
- userInfoEndpoint: z.string({}).meta({ description: "The user info endpoint" }).optional(),
1944
- tokenEndpointAuthentication: z.enum([
1945
- "client_secret_post",
1946
- "client_secret_basic",
1947
- "private_key_jwt"
1948
- ]).optional(),
1949
- privateKeyId: z.string().optional(),
1950
- privateKeyAlgorithm: z.string().optional(),
1951
- jwksEndpoint: z.string({}).meta({ description: "The JWKS endpoint" }).optional(),
1952
- discoveryEndpoint: z.string().optional(),
1953
- skipDiscovery: z.boolean().meta({ description: "Skip OIDC discovery during registration. When true, you must provide authorizationEndpoint, tokenEndpoint, and jwksEndpoint manually." }).optional(),
1954
- scopes: z.array(z.string(), {}).meta({ description: "The scopes to request. Defaults to ['openid', 'email', 'profile', 'offline_access']" }).optional(),
1955
- pkce: z.boolean({}).meta({ description: "Whether to use PKCE for the authorization flow" }).default(true).optional(),
1956
- mapping: z.object({
1957
- id: z.string({}).meta({ description: "Field mapping for user ID (defaults to 'sub')" }),
1958
- email: z.string({}).meta({ description: "Field mapping for email (defaults to 'email')" }),
1959
- emailVerified: z.string({}).meta({ description: "Field mapping for email verification (defaults to 'email_verified')" }).optional(),
1960
- name: z.string({}).meta({ description: "Field mapping for name (defaults to 'name')" }),
1961
- image: z.string({}).meta({ description: "Field mapping for image (defaults to 'picture')" }).optional(),
1962
- extraFields: z.record(z.string(), z.any()).optional()
1963
- }).optional()
1964
- }).optional(),
1965
- samlConfig: z.object({
1966
- entryPoint: z.string({}).meta({ description: "The entry point of the provider" }),
1967
- cert: z.string({}).meta({ description: "The certificate of the provider" }),
1968
- audience: z.string().optional(),
1969
- idpMetadata: z.object({
1970
- metadata: z.string().optional(),
1971
- entityID: z.string().optional(),
1972
- cert: z.string().optional(),
1973
- privateKey: z.string().optional(),
1974
- privateKeyPass: z.string().optional(),
1975
- isAssertionEncrypted: z.boolean().optional(),
1976
- encPrivateKey: z.string().optional(),
1977
- encPrivateKeyPass: z.string().optional(),
1978
- singleSignOnService: z.array(z.object({
1979
- Binding: z.string().meta({ description: "The binding type for the SSO service" }),
1980
- Location: z.string().meta({ description: "The URL for the SSO service" })
1981
- })).optional().meta({ description: "Single Sign-On service configuration" })
1982
- }).optional(),
1983
- spMetadata: z.object({
1984
- metadata: z.string().optional(),
1985
- entityID: z.string().optional(),
1986
- binding: z.string().optional(),
1987
- privateKey: z.string().optional(),
1988
- privateKeyPass: z.string().optional(),
1989
- isAssertionEncrypted: z.boolean().optional(),
1990
- encPrivateKey: z.string().optional(),
1991
- encPrivateKeyPass: z.string().optional()
1992
- }).optional(),
1993
- wantAssertionsSigned: z.boolean().optional(),
1994
- authnRequestsSigned: z.boolean().optional(),
1995
- signatureAlgorithm: z.string().optional(),
1996
- digestAlgorithm: z.string().optional(),
1997
- identifierFormat: z.string().optional(),
1998
- privateKey: z.string().optional(),
1999
- mapping: z.object({
2000
- id: z.string({}).meta({ description: "Field mapping for user ID (defaults to 'nameID')" }),
2001
- email: z.string({}).meta({ description: "Field mapping for email (defaults to 'email')" }),
2002
- emailVerified: z.string({}).meta({ description: "Field mapping for email verification" }).optional(),
2003
- name: z.string({}).meta({ description: "Field mapping for name (defaults to 'displayName')" }),
2004
- firstName: z.string({}).meta({ description: "Field mapping for first name (defaults to 'givenName')" }).optional(),
2005
- lastName: z.string({}).meta({ description: "Field mapping for last name (defaults to 'surname')" }).optional(),
2006
- extraFields: z.record(z.string(), z.any()).optional()
2007
- }).optional()
2008
- }).optional(),
2009
- organizationId: z.string({}).meta({ description: "If organization plugin is enabled, the organization id to link the provider to" }).optional(),
2010
- overrideUserInfo: z.boolean({}).meta({ description: "Override user info with the provider info. Defaults to false" }).default(false).optional()
2011
- });
2012
2075
  const registerSSOProvider = (options) => {
2013
2076
  return createAuthEndpoint("/sso/register", {
2014
2077
  method: "POST",
2015
- body: ssoProviderBodySchema,
2078
+ body: registerSSOProviderBodySchema,
2016
2079
  use: [sessionMiddleware],
2017
2080
  metadata: { openapi: {
2018
2081
  operationId: "registerSSOProvider",
@@ -2193,13 +2256,12 @@ const registerSSOProvider = (options) => {
2193
2256
  }]
2194
2257
  })).length >= limit) throw new APIError("FORBIDDEN", { message: "You have reached the maximum number of SSO providers" });
2195
2258
  const body = ctx.body;
2196
- if (z.string().url().safeParse(body.issuer).error) throw new APIError("BAD_REQUEST", { message: "Invalid issuer. Must be a valid URL" });
2197
2259
  if (body.samlConfig?.idpMetadata?.metadata) {
2198
2260
  const maxMetadataSize = options?.saml?.maxMetadataSize ?? 102400;
2199
2261
  if (new TextEncoder().encode(body.samlConfig.idpMetadata.metadata).length > maxMetadataSize) throw new APIError("BAD_REQUEST", { message: `IdP metadata exceeds maximum allowed size (${maxMetadataSize} bytes)` });
2200
2262
  }
2201
2263
  if (ctx.body.organizationId) {
2202
- if (!await ctx.context.adapter.findOne({
2264
+ const member = await ctx.context.adapter.findOne({
2203
2265
  model: "member",
2204
2266
  where: [{
2205
2267
  field: "userId",
@@ -2208,7 +2270,9 @@ const registerSSOProvider = (options) => {
2208
2270
  field: "organizationId",
2209
2271
  value: ctx.body.organizationId
2210
2272
  }]
2211
- })) throw new APIError("BAD_REQUEST", { message: "You are not a member of the organization" });
2273
+ });
2274
+ if (!member) throw new APIError("BAD_REQUEST", { message: "You are not a member of the organization" });
2275
+ if (ctx.context.hasPlugin("organization") && !hasOrgAdminRole(member)) throw new APIError("FORBIDDEN", { message: "You must be an organization owner or admin to register SSO providers" });
2212
2276
  }
2213
2277
  if (await ctx.context.adapter.findOne({
2214
2278
  model: "ssoProvider",
@@ -2220,6 +2284,12 @@ const registerSSOProvider = (options) => {
2220
2284
  ctx.context.logger.info(`SSO provider creation attempt with existing providerId: ${body.providerId}`);
2221
2285
  throw new APIError("UNPROCESSABLE_ENTITY", { message: "SSO provider with this providerId already exists" });
2222
2286
  }
2287
+ if (body.oidcConfig) try {
2288
+ validateSkipDiscoveryEndpoints(body.oidcConfig, (url) => ctx.context.isTrustedOrigin(url));
2289
+ } catch (error) {
2290
+ if (error instanceof DiscoveryError) throw mapDiscoveryErrorToAPIError(error);
2291
+ throw error;
2292
+ }
2223
2293
  let hydratedOIDCConfig = null;
2224
2294
  if (body.oidcConfig && !body.oidcConfig.skipDiscovery) try {
2225
2295
  hydratedOIDCConfig = await discoverOIDCConfig({
@@ -2281,6 +2351,7 @@ const registerSSOProvider = (options) => {
2281
2351
  signatureAlgorithm: body.samlConfig.signatureAlgorithm,
2282
2352
  digestAlgorithm: body.samlConfig.digestAlgorithm
2283
2353
  }, options?.saml?.algorithms);
2354
+ validateCertSources(body.samlConfig);
2284
2355
  const hasIdpMetadata = body.samlConfig.idpMetadata?.metadata;
2285
2356
  let hasEntryPoint = false;
2286
2357
  if (body.samlConfig.entryPoint) try {
@@ -2348,17 +2419,18 @@ const registerSSOProvider = (options) => {
2348
2419
  });
2349
2420
  };
2350
2421
  const signInSSOBodySchema = z.object({
2351
- email: z.string({}).meta({ description: "The email address to sign in with. This is used to identify the issuer to sign in with. It's optional if the issuer is provided" }).optional(),
2352
- organizationSlug: z.string({}).meta({ description: "The slug of the organization to sign in with" }).optional(),
2353
- providerId: z.string({}).meta({ description: "The ID of the provider to sign in with. This can be provided instead of email or issuer" }).optional(),
2354
- domain: z.string({}).meta({ description: "The domain of the provider." }).optional(),
2355
- callbackURL: z.string({}).meta({ description: "The URL to redirect to after login" }),
2356
- errorCallbackURL: z.string({}).meta({ description: "The URL to redirect to after login" }).optional(),
2357
- newUserCallbackURL: z.string({}).meta({ description: "The URL to redirect to after login if the user is new" }).optional(),
2422
+ email: z.string({}).meta({ description: "The email address to sign in with. Used to resolve the provider via the email domain; optional if providerId, domain, or organizationSlug is provided." }).optional(),
2423
+ organizationSlug: z.string({}).meta({ description: "The slug of the organization to sign in with." }).optional(),
2424
+ providerId: z.string({}).meta({ description: "The ID of the provider to sign in with. Can be provided instead of email." }).optional(),
2425
+ domain: z.string({}).meta({ description: "The email domain of the provider. Can be provided instead of email." }).optional(),
2426
+ callbackURL: z.string({}).meta({ description: "The URL to redirect to after successful sign-in." }),
2427
+ errorCallbackURL: z.string({}).meta({ description: "The URL to redirect to if the sign-in flow fails." }).optional(),
2428
+ newUserCallbackURL: z.string({}).meta({ description: "The URL to redirect to after sign-in if the user is newly registered." }).optional(),
2358
2429
  scopes: z.array(z.string(), {}).meta({ description: "Scopes to request from the provider." }).optional(),
2359
- loginHint: z.string({}).meta({ description: "Login hint to send to the identity provider (e.g., email or identifier). If supported, will be sent as 'login_hint'." }).optional(),
2360
- requestSignUp: z.boolean({}).meta({ description: "Explicitly request sign-up. Useful when disableImplicitSignUp is true for this provider" }).optional(),
2361
- providerType: z.enum(["oidc", "saml"]).optional()
2430
+ loginHint: z.string({}).meta({ description: "Login hint to send to the identity provider (e.g., email or identifier). If supported, sent as 'login_hint'." }).optional(),
2431
+ additionalParams: additionalAuthorizationParamsSchema,
2432
+ requestSignUp: z.boolean({}).meta({ description: "Explicitly request sign-up. Useful when disableImplicitSignUp is true for this provider." }).optional(),
2433
+ providerType: z.enum(["oidc", "saml"]).meta({ description: "The provider protocol to sign in with." }).optional()
2362
2434
  });
2363
2435
  const signInSSO = (options) => {
2364
2436
  return createAuthEndpoint("/sign-in/sso", {
@@ -2373,31 +2445,54 @@ const signInSSO = (options) => {
2373
2445
  properties: {
2374
2446
  email: {
2375
2447
  type: "string",
2376
- description: "The email address to sign in with. This is used to identify the issuer to sign in with. It's optional if the issuer is provided"
2448
+ description: "The email address to sign in with. Used to resolve the provider via the email domain; optional if providerId, domain, or organizationSlug is provided."
2377
2449
  },
2378
- issuer: {
2450
+ organizationSlug: {
2379
2451
  type: "string",
2380
- description: "The issuer identifier, this is the URL of the provider and can be used to verify the provider and identify the provider during login. It's optional if the email is provided"
2452
+ description: "The slug of the organization to sign in with."
2381
2453
  },
2382
2454
  providerId: {
2383
2455
  type: "string",
2384
- description: "The ID of the provider to sign in with. This can be provided instead of email or issuer"
2456
+ description: "The ID of the provider to sign in with. Can be provided instead of email."
2457
+ },
2458
+ domain: {
2459
+ type: "string",
2460
+ description: "The email domain of the provider. Can be provided instead of email."
2385
2461
  },
2386
2462
  callbackURL: {
2387
2463
  type: "string",
2388
- description: "The URL to redirect to after login"
2464
+ description: "The URL to redirect to after successful sign-in."
2389
2465
  },
2390
2466
  errorCallbackURL: {
2391
2467
  type: "string",
2392
- description: "The URL to redirect to after login"
2468
+ description: "The URL to redirect to if the sign-in flow fails."
2393
2469
  },
2394
2470
  newUserCallbackURL: {
2395
2471
  type: "string",
2396
- description: "The URL to redirect to after login if the user is new"
2472
+ description: "The URL to redirect to after sign-in if the user is newly registered."
2473
+ },
2474
+ scopes: {
2475
+ type: "array",
2476
+ items: { type: "string" },
2477
+ description: "Scopes to request from the provider."
2397
2478
  },
2398
2479
  loginHint: {
2399
2480
  type: "string",
2400
2481
  description: "Login hint to send to the identity provider (e.g., email or identifier). If supported, sent as 'login_hint'."
2482
+ },
2483
+ additionalParams: {
2484
+ type: "object",
2485
+ additionalProperties: { type: "string" },
2486
+ description: "Extra query parameters to append to the OIDC provider authorization URL. RFC 6749 reserved keys (state, client_id, redirect_uri, response_type, code_challenge, code_challenge_method, scope) are rejected. Not supported for SAML providers."
2487
+ },
2488
+ requestSignUp: {
2489
+ type: "boolean",
2490
+ description: "Explicitly request sign-up. Useful when disableImplicitSignUp is true for this provider."
2491
+ },
2492
+ providerType: {
2493
+ type: "string",
2494
+ enum: ["oidc", "saml"],
2495
+ description: "The provider protocol to sign in with."
2401
2496
  }
2402
2497
  },
2403
2498
  required: ["callbackURL"]
@@ -2512,7 +2607,8 @@ const signInSSO = (options) => {
2512
2607
  "offline_access"
2513
2608
  ],
2514
2609
  loginHint: ctx.body.loginHint || email,
2515
- authorizationEndpoint: config.authorizationEndpoint
2610
+ authorizationEndpoint: config.authorizationEndpoint,
2611
+ additionalParams: ctx.body.additionalParams
2516
2612
  });
2517
2613
  return ctx.json({
2518
2614
  url: authorizationURL.toString(),
@@ -2520,6 +2616,7 @@ const signInSSO = (options) => {
2520
2616
  });
2521
2617
  }
2522
2618
  if (provider.samlConfig) {
2619
+ if (ctx.body.additionalParams) throw new APIError("BAD_REQUEST", { message: "additionalParams is not supported for SAML providers; the SAML AuthnRequest is signed and cannot carry caller-supplied query parameters." });
2523
2620
  const parsedSamlConfig = typeof provider.samlConfig === "object" ? provider.samlConfig : safeJsonParse(provider.samlConfig);
2524
2621
  if (!parsedSamlConfig) throw new APIError("BAD_REQUEST", { message: "Invalid SAML configuration" });
2525
2622
  if (parsedSamlConfig.authnRequestsSigned && !parsedSamlConfig.spMetadata?.privateKey && !parsedSamlConfig.privateKey) throw new APIError("BAD_REQUEST", { message: "authnRequestsSigned is enabled but no privateKey provided in spMetadata or samlConfig" });
@@ -2552,7 +2649,7 @@ const signInSSO = (options) => {
2552
2649
  };
2553
2650
  const callbackSSOQuerySchema = z.object({
2554
2651
  code: z.string().optional(),
2555
- state: z.string(),
2652
+ state: z.string().optional(),
2556
2653
  error: z.string().optional(),
2557
2654
  error_description: z.string().optional()
2558
2655
  });
@@ -2573,29 +2670,7 @@ async function handleOIDCCallback(ctx, options, providerId, stateData) {
2573
2670
  }
2574
2671
  const { callbackURL, errorURL, newUserURL, requestSignUp } = stateData;
2575
2672
  if (!code || error) throw ctx.redirect(`${errorURL || callbackURL}?error=${error}&error_description=${error_description}`);
2576
- let provider = null;
2577
- if (options?.defaultSSO?.length) {
2578
- const matchingDefault = options.defaultSSO.find((defaultProvider) => defaultProvider.providerId === providerId);
2579
- if (matchingDefault) provider = {
2580
- ...matchingDefault,
2581
- issuer: matchingDefault.oidcConfig?.issuer || "",
2582
- userId: "default",
2583
- ...options.domainVerification?.enabled ? { domainVerified: true } : {}
2584
- };
2585
- }
2586
- if (!provider) provider = await ctx.context.adapter.findOne({
2587
- model: "ssoProvider",
2588
- where: [{
2589
- field: "providerId",
2590
- value: providerId
2591
- }]
2592
- }).then((res) => {
2593
- if (!res) return null;
2594
- return {
2595
- ...res,
2596
- oidcConfig: safeJsonParse(res.oidcConfig) || void 0
2597
- };
2598
- });
2673
+ const provider = await resolveOIDCProvider(ctx, options, providerId);
2599
2674
  if (!provider) throw ctx.redirect(`${errorURL || callbackURL}?error=invalid_provider&error_description=provider not found`);
2600
2675
  if (options?.domainVerification?.enabled && !("domainVerified" in provider && provider.domainVerified)) throw new APIError("UNAUTHORIZED", { message: "Provider domain has not been verified" });
2601
2676
  let config = provider.oidcConfig;
@@ -2616,11 +2691,8 @@ async function handleOIDCCallback(ctx, options, providerId, stateData) {
2616
2691
  ]
2617
2692
  };
2618
2693
  if (!config.tokenEndpoint) throw ctx.redirect(`${errorURL || callbackURL}?error=invalid_provider&error_description=token_endpoint_not_found`);
2619
- let authMethod = "basic";
2620
- if (config.tokenEndpointAuthentication === "client_secret_post") authMethod = "post";
2621
- else if (config.tokenEndpointAuthentication === "private_key_jwt") authMethod = "private_key_jwt";
2622
- let clientAssertionConfig;
2623
- if (authMethod === "private_key_jwt") {
2694
+ let tokenEndpointAuth = config.tokenEndpointAuthentication === "client_secret_post" ? { method: "client_secret_post" } : { method: "client_secret_basic" };
2695
+ if (config.tokenEndpointAuthentication === "private_key_jwt") {
2624
2696
  let resolved;
2625
2697
  const matchingDefault = options?.defaultSSO?.find((p) => p.providerId === provider.providerId && "privateKey" in p && p.privateKey);
2626
2698
  if (matchingDefault && "privateKey" in matchingDefault) resolved = matchingDefault.privateKey;
@@ -2629,28 +2701,28 @@ async function handleOIDCCallback(ctx, options, providerId, stateData) {
2629
2701
  keyId: config.privateKeyId,
2630
2702
  issuer: config.issuer
2631
2703
  });
2632
- if (!resolved) throw ctx.redirect(`${errorURL || callbackURL}?error=invalid_provider&error_description=no_private_key_available`);
2704
+ if (!resolved || !resolved.privateKeyJwk && !resolved.privateKeyPem) throw ctx.redirect(`${errorURL || callbackURL}?error=invalid_provider&error_description=no_private_key_available`);
2633
2705
  const rawAlg = config.privateKeyAlgorithm ?? resolved.algorithm;
2634
- const algorithm = rawAlg && ASSERTION_SIGNING_ALGORITHMS.includes(rawAlg) ? rawAlg : void 0;
2635
- clientAssertionConfig = {
2636
- privateKeyJwk: resolved.privateKeyJwk,
2637
- privateKeyPem: resolved.privateKeyPem,
2638
- kid: config.privateKeyId ?? resolved.kid,
2639
- algorithm,
2640
- tokenEndpoint: config.tokenEndpoint
2706
+ const algorithm = rawAlg && PRIVATE_KEY_JWT_SIGNING_ALGORITHMS.includes(rawAlg) ? rawAlg : void 0;
2707
+ tokenEndpointAuth = {
2708
+ method: "private_key_jwt",
2709
+ getClientAssertion: createPrivateKeyJwtClientAssertionGetter({
2710
+ privateKeyJwk: resolved.privateKeyJwk,
2711
+ privateKeyPem: resolved.privateKeyPem,
2712
+ kid: config.privateKeyId ?? resolved.kid,
2713
+ algorithm
2714
+ })
2641
2715
  };
2642
2716
  }
2717
+ const tokenRequestOptions = { clientId: config.clientId };
2718
+ if (tokenEndpointAuth.method !== "private_key_jwt") tokenRequestOptions.clientSecret = config.clientSecret;
2643
2719
  const tokenResponse = await validateAuthorizationCode({
2644
2720
  code,
2645
2721
  codeVerifier: config.pkce ? stateData.codeVerifier : void 0,
2646
2722
  redirectURI: getOIDCRedirectURI(ctx.context.baseURL, provider.providerId, options),
2647
- options: {
2648
- clientId: config.clientId,
2649
- clientSecret: config.clientSecret
2650
- },
2723
+ options: tokenRequestOptions,
2651
2724
  tokenEndpoint: config.tokenEndpoint,
2652
- authentication: authMethod,
2653
- clientAssertion: clientAssertionConfig
2725
+ tokenEndpointAuth
2654
2726
  }).catch((e) => {
2655
2727
  ctx.context.logger.error("Error validating authorization code", e);
2656
2728
  if (e instanceof BetterFetchError) throw ctx.redirect(`${errorURL || callbackURL}?error=invalid_provider&error_description=${e.message}`);
@@ -2693,30 +2765,47 @@ async function handleOIDCCallback(ctx, options, providerId, stateData) {
2693
2765
  } else throw ctx.redirect(`${errorURL || callbackURL}?error=invalid_provider&error_description=user_info_endpoint_not_found`);
2694
2766
  if (!userInfo.email || !userInfo.id) throw ctx.redirect(`${errorURL || callbackURL}?error=invalid_provider&error_description=missing_user_info`);
2695
2767
  const isTrustedProvider = "domainVerified" in provider && provider.domainVerified === true && validateEmailDomain(userInfo.email, provider.domain);
2696
- const linked = await handleOAuthUserInfo(ctx, {
2697
- userInfo: {
2698
- email: userInfo.email,
2699
- name: userInfo.name || "",
2700
- id: userInfo.id,
2701
- image: userInfo.image,
2702
- emailVerified: options?.trustEmailVerified ? userInfo.emailVerified || false : false
2703
- },
2704
- account: {
2705
- idToken: tokenResponse.idToken,
2706
- accessToken: tokenResponse.accessToken,
2707
- refreshToken: tokenResponse.refreshToken,
2708
- accountId: userInfo.id,
2709
- providerId: provider.providerId,
2710
- accessTokenExpiresAt: tokenResponse.accessTokenExpiresAt,
2711
- refreshTokenExpiresAt: tokenResponse.refreshTokenExpiresAt,
2712
- scope: tokenResponse.scopes?.join(",")
2713
- },
2714
- callbackURL,
2715
- disableSignUp: options?.disableImplicitSignUp && !requestSignUp,
2716
- overrideUserInfo: config.overrideUserInfo,
2717
- isTrustedProvider
2718
- });
2719
- if (linked.error) throw ctx.redirect(`${errorURL || callbackURL}?error=${linked.error}`);
2768
+ let linked;
2769
+ try {
2770
+ linked = await handleOAuthUserInfo(ctx, {
2771
+ userInfo: {
2772
+ email: userInfo.email,
2773
+ name: userInfo.name || "",
2774
+ id: userInfo.id,
2775
+ image: userInfo.image,
2776
+ emailVerified: options?.trustEmailVerified ? userInfo.emailVerified || false : false
2777
+ },
2778
+ account: {
2779
+ idToken: tokenResponse.idToken,
2780
+ accessToken: tokenResponse.accessToken,
2781
+ refreshToken: tokenResponse.refreshToken,
2782
+ accountId: userInfo.id,
2783
+ providerId: provider.providerId,
2784
+ accessTokenExpiresAt: tokenResponse.accessTokenExpiresAt,
2785
+ refreshTokenExpiresAt: tokenResponse.refreshTokenExpiresAt,
2786
+ scope: tokenResponse.scopes?.join(",")
2787
+ },
2788
+ callbackURL,
2789
+ disableSignUp: options?.disableImplicitSignUp && !requestSignUp,
2790
+ overrideUserInfo: config.overrideUserInfo,
2791
+ isTrustedProvider
2792
+ });
2793
+ } catch (e) {
2794
+ if (isAPIError(e) && e.body?.code) {
2795
+ const baseURL = errorURL || callbackURL;
2796
+ const params = new URLSearchParams({ error: e.body.code });
2797
+ if (e.body.message) params.set("error_description", e.body.message);
2798
+ const sep = baseURL.includes("?") ? "&" : "?";
2799
+ throw ctx.redirect(`${baseURL}${sep}${params.toString()}`);
2800
+ }
2801
+ throw e;
2802
+ }
2803
+ if (linked.error) {
2804
+ const baseURL = errorURL || callbackURL;
2805
+ const params = new URLSearchParams({ error: linked.error });
2806
+ const sep = baseURL.includes("?") ? "&" : "?";
2807
+ throw ctx.redirect(`${baseURL}${sep}${params.toString()}`);
2808
+ }
2720
2809
  const { session, user } = linked.data;
2721
2810
  if (options?.provisionUser && (linked.isRegister || options.provisionUserOnEveryLogin)) await options.provisionUser({
2722
2811
  user,
@@ -2764,9 +2853,85 @@ const callbackSSOEndpointConfig = {
2764
2853
  }
2765
2854
  }
2766
2855
  };
2856
+ /**
2857
+ * Resolves an SSO provider by `providerId`, first checking `options.defaultSSO`
2858
+ * and falling back to the `ssoProvider` table. Returns `null` when no match is
2859
+ * found so the caller can decide how to react (redirect, silently skip, etc.).
2860
+ */
2861
+ async function resolveOIDCProvider(ctx, options, providerId) {
2862
+ const matchingDefault = options?.defaultSSO?.find((defaultProvider) => defaultProvider.providerId === providerId);
2863
+ if (matchingDefault) return {
2864
+ ...matchingDefault,
2865
+ issuer: matchingDefault.oidcConfig?.issuer || "",
2866
+ userId: "default",
2867
+ ...options?.domainVerification?.enabled ? { domainVerified: true } : {}
2868
+ };
2869
+ return ctx.context.adapter.findOne({
2870
+ model: "ssoProvider",
2871
+ where: [{
2872
+ field: "providerId",
2873
+ value: providerId
2874
+ }]
2875
+ }).then((res) => {
2876
+ if (!res) return null;
2877
+ return {
2878
+ ...res,
2879
+ oidcConfig: safeJsonParse(res.oidcConfig) || void 0
2880
+ };
2881
+ });
2882
+ }
2883
+ /**
2884
+ * Restarts the OAuth flow server-side when a stateless callback arrives for
2885
+ * an OIDC provider that opted into IDP-initiated flows. Silently returns
2886
+ * otherwise, letting the normal handler produce its error redirect.
2887
+ */
2888
+ async function bounceIfIdpInitiated(ctx, options, providerId) {
2889
+ const provider = await resolveOIDCProvider(ctx, options, providerId);
2890
+ if (!provider?.oidcConfig?.allowIdpInitiated) return;
2891
+ let config = provider.oidcConfig;
2892
+ try {
2893
+ config = await ensureRuntimeDiscovery(config, provider.issuer, (url) => ctx.context.isTrustedOrigin(url));
2894
+ } catch (error) {
2895
+ ctx.context.logger.error("IDP-initiated bounce skipped: OIDC discovery failed", {
2896
+ providerId: provider.providerId,
2897
+ issuer: provider.issuer,
2898
+ error
2899
+ });
2900
+ return;
2901
+ }
2902
+ if (!config.authorizationEndpoint) {
2903
+ ctx.context.logger.error("IDP-initiated bounce skipped: authorizationEndpoint missing after discovery", {
2904
+ providerId: provider.providerId,
2905
+ issuer: provider.issuer
2906
+ });
2907
+ return;
2908
+ }
2909
+ const state = await generateState(ctx, void 0, options?.redirectURI?.trim() ? { ssoProviderId: provider.providerId } : false);
2910
+ const redirectURI = getOIDCRedirectURI(ctx.context.baseURL, provider.providerId, options);
2911
+ const authorizationURL = await createAuthorizationURL({
2912
+ id: provider.issuer,
2913
+ options: {
2914
+ clientId: config.clientId,
2915
+ clientSecret: config.clientSecret
2916
+ },
2917
+ redirectURI,
2918
+ state: state.state,
2919
+ codeVerifier: config.pkce ? state.codeVerifier : void 0,
2920
+ scopes: config.scopes || [
2921
+ "openid",
2922
+ "email",
2923
+ "profile",
2924
+ "offline_access"
2925
+ ],
2926
+ authorizationEndpoint: config.authorizationEndpoint
2927
+ });
2928
+ throw ctx.redirect(authorizationURL.toString());
2929
+ }
2767
2930
  const callbackSSO = (options) => {
2768
2931
  return createAuthEndpoint("/sso/callback/:providerId", callbackSSOEndpointConfig, async (ctx) => {
2769
- return handleOIDCCallback(ctx, options, ctx.params.providerId);
2932
+ const providerId = ctx.params.providerId;
2933
+ if (ctx.query.state === void 0 && ctx.query.code) await bounceIfIdpInitiated(ctx, options, providerId);
2934
+ return handleOIDCCallback(ctx, options, providerId);
2770
2935
  });
2771
2936
  };
2772
2937
  /**
@@ -2941,7 +3106,7 @@ async function handleLogoutRequest(ctx, sp, idp, relayState, providerId) {
2941
3106
  if (stored) {
2942
3107
  const data = safeJsonParse(stored.value);
2943
3108
  if (data) if (!sessionIndex || !data.sessionIndex || sessionIndex === data.sessionIndex) {
2944
- await ctx.context.internalAdapter.deleteSession(data.sessionId).catch((e) => ctx.context.logger.warn("Failed to delete session during SLO", { error: e }));
3109
+ await ctx.context.internalAdapter.deleteSession(data.sessionToken).catch((e) => ctx.context.logger.warn("Failed to delete session during SLO", { error: e }));
2945
3110
  await ctx.context.internalAdapter.deleteVerificationByIdentifier(`${SAML_SESSION_BY_ID_PREFIX}${data.sessionId}`).catch((e) => ctx.context.logger.warn("Failed to delete SAML session lookup during SLO", e));
2946
3111
  } else ctx.context.logger.warn("SessionIndex mismatch in LogoutRequest - skipping session deletion", {
2947
3112
  providerId,
@@ -2951,10 +3116,9 @@ async function handleLogoutRequest(ctx, sp, idp, relayState, providerId) {
2951
3116
  await ctx.context.internalAdapter.deleteVerificationByIdentifier(key).catch((e) => ctx.context.logger.warn("Failed to delete SAML session key during SLO", e));
2952
3117
  }
2953
3118
  const currentSession = await getSessionFromCtx(ctx);
2954
- if (currentSession?.session) await ctx.context.internalAdapter.deleteSession(currentSession.session.id);
3119
+ if (currentSession?.session) await ctx.context.internalAdapter.deleteSession(currentSession.session.token);
2955
3120
  deleteSessionCookie(ctx);
2956
- const requestId = parsed.extract.request?.id || "";
2957
- const res = sp.createLogoutResponse(idp, null, binding, relayState || "", (template) => template.replace("{InResponseTo}", requestId).replace("{StatusCode}", SAML_STATUS_SUCCESS));
3121
+ const res = sp.createLogoutResponse(idp, parsed, binding, relayState || "");
2958
3122
  if (binding === "post" && res.entityEndpoint) return createSAMLPostForm(res.entityEndpoint, "SAMLResponse", res.context, relayState);
2959
3123
  throw ctx.redirect(res.context);
2960
3124
  }
@@ -3007,7 +3171,7 @@ const initiateSLO = (options) => {
3007
3171
  });
3008
3172
  if (samlSessionKey) await ctx.context.internalAdapter.deleteVerificationByIdentifier(samlSessionKey).catch((e) => ctx.context.logger.warn("Failed to delete SAML session key during logout", e));
3009
3173
  await ctx.context.internalAdapter.deleteVerificationByIdentifier(sessionLookupKey).catch((e) => ctx.context.logger.warn("Failed to delete session lookup key during logout", e));
3010
- await ctx.context.internalAdapter.deleteSession(session.session.id);
3174
+ await ctx.context.internalAdapter.deleteSession(session.session.token);
3011
3175
  deleteSessionCookie(ctx);
3012
3176
  throw ctx.redirect(logoutRequest.context);
3013
3177
  });