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

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";
2
- import { APIError, createAuthEndpoint, createAuthMiddleware, getSessionFromCtx, sessionMiddleware } from "better-auth/api";
1
+ import { t as PACKAGE_VERSION } from "./version-DzWb5tB_.mjs";
2
+ import { APIError, addOAuthServerContext, 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 { classifyHost, 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, signInWithOAuthIdentity } 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,1107 +376,1297 @@ 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;
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
+ };
476
452
  }
477
- function normalizeDigestAlgorithm(alg) {
478
- return SHORT_FORM_DIGEST_TO_URI[alg.toLowerCase()] ?? alg;
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`;
479
463
  }
480
- function extractEncryptionAlgorithms(xml) {
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 });
474
+ }
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
507
+ });
508
+ }
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
+ * Re-validate an endpoint by resolving its hostname and rejecting any resolved
531
+ * address that is not publicly routable.
532
+ *
533
+ * {@link validateSkipDiscoveryEndpoint} only classifies the literal hostname, so
534
+ * a host like `idp.example` whose DNS record points at `127.0.0.1`,
535
+ * `169.254.169.254`, or an RFC 1918 address passes that check unchanged. This
536
+ * function closes that gap by performing the same RFC 6890 classification on the
537
+ * addresses the host actually resolves to, right before the server-side fetch.
538
+ *
539
+ * Best-effort by design:
540
+ * - Operator-allowlisted origins (trustedOrigins) are skipped — this is the
541
+ * documented escape hatch for internal IdPs.
542
+ * - IP-literal hosts are already fully covered by the synchronous check.
543
+ * - On runtimes without `node:dns` (e.g. Cloudflare Workers / edge), DNS
544
+ * resolution is unavailable; we fall back to the synchronous host check and
545
+ * the platform's own egress controls.
546
+ *
547
+ * Note: this resolves once and validates the result; it does not pin the address
548
+ * for the subsequent connection, so a change in the resolved address between
549
+ * this lookup and the fetch remains theoretically possible. It nonetheless
550
+ * rejects the common case of a DNS record that statically points at an internal
551
+ * address.
552
+ *
553
+ * @throws DiscoveryError(discovery_private_host) if any resolved address is not public
554
+ */
555
+ async function assertEndpointResolvesPublic(name, endpoint, isTrustedOrigin) {
556
+ const parsed = parseURL(name, endpoint);
557
+ if (isTrustedOrigin(parsed.toString())) return;
558
+ const host = parsed.hostname;
559
+ if (classifyHost(host).literal !== "fqdn") return;
560
+ let dns;
481
561
  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
- };
562
+ dns = await import("node:dns/promises");
489
563
  } catch {
490
- return {
491
- keyEncryption: null,
492
- dataEncryption: null
493
- };
564
+ return;
494
565
  }
495
- }
496
- function hasEncryptedAssertion(xml) {
566
+ let resolved;
497
567
  try {
498
- return findNode(xmlParser.parse(xml), "EncryptedAssertion") !== null;
568
+ resolved = await dns.lookup(host, { all: true });
499
569
  } catch {
500
- return false;
570
+ return;
501
571
  }
572
+ for (const { address } of resolved) if (!isPublicRoutableHost(address)) throw new DiscoveryError("discovery_private_host", `The ${name} host "${host}" resolves to a non-publicly-routable address (${address}). If this is an internal IdP, add its origin to trustedOrigins.`, {
573
+ endpoint: name,
574
+ url: endpoint,
575
+ hostname: host,
576
+ resolved: address
577
+ });
502
578
  }
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;
579
+ /**
580
+ * Re-validate, at fetch time, every OIDC endpoint that is fetched server-side
581
+ * (token, userinfo, jwks). Runs the synchronous host classification plus the
582
+ * best-effort DNS resolution check. `authorizationEndpoint` is intentionally
583
+ * excluded — it is a browser redirect target, not a server-side fetch, so these
584
+ * checks don't apply to it.
585
+ */
586
+ async function assertOIDCEndpointsResolvePublic(config, isTrustedOrigin) {
587
+ const fields = [
588
+ ["tokenEndpoint", config.tokenEndpoint],
589
+ ["userInfoEndpoint", config.userInfoEndpoint],
590
+ ["jwksEndpoint", config.jwksEndpoint]
591
+ ];
592
+ for (const [name, url] of fields) {
593
+ if (!url) continue;
594
+ validateSkipDiscoveryEndpoint(name, url, isTrustedOrigin);
595
+ await assertEndpointResolvesPublic(name, url, isTrustedOrigin);
513
596
  }
514
597
  }
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"
598
+ /**
599
+ * Fetch the OIDC discovery document from the IdP.
600
+ *
601
+ * @param url - The discovery endpoint URL
602
+ * @param timeout - Request timeout in milliseconds
603
+ * @returns The parsed discovery document
604
+ * @throws DiscoveryError on network errors, timeouts, or invalid responses
605
+ */
606
+ async function fetchDiscoveryDocument(url, timeout = DEFAULT_DISCOVERY_TIMEOUT) {
607
+ try {
608
+ const response = await betterFetch(url, {
609
+ method: "GET",
610
+ timeout,
611
+ redirect: "error"
522
612
  });
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"
532
- });
533
- }
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"
542
- });
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"
613
+ if (response.error) {
614
+ const { status } = response.error;
615
+ if (status === 404) throw new DiscoveryError("discovery_not_found", "Discovery endpoint not found", {
616
+ url,
617
+ status
550
618
  });
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"
619
+ if (status === 408) throw new DiscoveryError("discovery_timeout", "Discovery request timed out", {
620
+ url,
621
+ timeout
566
622
  });
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"
571
- });
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"
623
+ throw new DiscoveryError("discovery_unexpected_error", `Unexpected discovery error: ${response.error.statusText}`, {
624
+ url,
625
+ ...response.error
579
626
  });
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"
627
+ }
628
+ if (!response.data) throw new DiscoveryError("discovery_invalid_json", "Discovery endpoint returned an empty response", { url });
629
+ const data = response.data;
630
+ if (typeof data === "string") throw new DiscoveryError("discovery_invalid_json", "Discovery endpoint returned invalid JSON", {
631
+ url,
632
+ bodyPreview: data.slice(0, 200)
584
633
  });
585
- }
586
- }
587
- //#endregion
588
- //#region src/saml/assertions.ts
589
- function countAssertions(xml) {
590
- let parsed;
591
- try {
592
- parsed = xmlParser.parse(xml);
593
- } catch {
594
- throw new APIError("BAD_REQUEST", {
595
- message: "Failed to parse SAML response XML",
596
- code: "SAML_INVALID_XML"
634
+ return data;
635
+ } catch (error) {
636
+ if (error instanceof DiscoveryError) throw error;
637
+ if (error instanceof Error && error.name === "AbortError") throw new DiscoveryError("discovery_timeout", "Discovery request timed out", {
638
+ url,
639
+ timeout
597
640
  });
641
+ throw new DiscoveryError("discovery_unexpected_error", `Unexpected error during discovery: ${error instanceof Error ? error.message : String(error)}`, { url }, { cause: error });
598
642
  }
599
- const assertions = countAllNodes(parsed, "Assertion");
600
- const encryptedAssertions = countAllNodes(parsed, "EncryptedAssertion");
601
- return {
602
- assertions,
603
- encryptedAssertions,
604
- total: assertions + encryptedAssertions
605
- };
606
643
  }
607
- function validateSingleAssertion(samlResponse) {
608
- let xml;
609
- 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
- });
617
- }
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"
644
+ /**
645
+ * Validate a discovery document.
646
+ *
647
+ * Checks:
648
+ * 1. All required fields are present
649
+ * 2. Issuer matches the configured issuer (case-sensitive, exact match)
650
+ *
651
+ * Invariant: If this function returns without throwing, the document is safe
652
+ * to use for hydrating OIDC config (required fields present, issuer matches
653
+ * configured value, basic structural sanity verified).
654
+ *
655
+ * @param doc - The discovery document to validate
656
+ * @param configuredIssuer - The expected issuer value
657
+ * @throws DiscoveryError if validation fails
658
+ */
659
+ function validateDiscoveryDocument(doc, configuredIssuer) {
660
+ const missingFields = [];
661
+ for (const field of REQUIRED_DISCOVERY_FIELDS) if (!doc[field]) missingFields.push(field);
662
+ if (missingFields.length > 0) throw new DiscoveryError("discovery_incomplete", `Discovery document is missing required fields: ${missingFields.join(", ")}`, { missingFields });
663
+ 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}"`, {
664
+ discovered: doc.issuer,
665
+ configured: configuredIssuer
622
666
  });
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
+ }
668
+ /**
669
+ * Normalize URLs in the discovery document.
670
+ *
671
+ * @param document - The discovery document
672
+ * @param issuer - The base issuer URL
673
+ * @param isTrustedOrigin - Origin verification tester function
674
+ * @returns The normalized discovery document
675
+ */
676
+ function normalizeDiscoveryUrls(document, issuer, isTrustedOrigin) {
677
+ const doc = { ...document };
678
+ doc.token_endpoint = normalizeAndValidateUrl("token_endpoint", doc.token_endpoint, issuer, isTrustedOrigin);
679
+ doc.authorization_endpoint = normalizeAndValidateUrl("authorization_endpoint", doc.authorization_endpoint, issuer, isTrustedOrigin);
680
+ doc.jwks_uri = normalizeAndValidateUrl("jwks_uri", doc.jwks_uri, issuer, isTrustedOrigin);
681
+ if (doc.userinfo_endpoint) doc.userinfo_endpoint = normalizeAndValidateUrl("userinfo_endpoint", doc.userinfo_endpoint, issuer, isTrustedOrigin);
682
+ if (doc.revocation_endpoint) doc.revocation_endpoint = normalizeAndValidateUrl("revocation_endpoint", doc.revocation_endpoint, issuer, isTrustedOrigin);
683
+ if (doc.end_session_endpoint) doc.end_session_endpoint = normalizeAndValidateUrl("end_session_endpoint", doc.end_session_endpoint, issuer, isTrustedOrigin);
684
+ if (doc.introspection_endpoint) doc.introspection_endpoint = normalizeAndValidateUrl("introspection_endpoint", doc.introspection_endpoint, issuer, isTrustedOrigin);
685
+ return doc;
686
+ }
687
+ /**
688
+ * Normalizes and validates a single URL endpoint
689
+ * @param name The url name
690
+ * @param endpoint The url to validate
691
+ * @param issuer The issuer base url
692
+ * @param isTrustedOrigin - Origin verification tester function
693
+ * @returns
694
+ */
695
+ function normalizeAndValidateUrl(name, endpoint, issuer, isTrustedOrigin) {
696
+ const url = normalizeUrl(name, endpoint, issuer);
697
+ if (!isTrustedOrigin(url)) throw new DiscoveryError("discovery_untrusted_origin", `The ${name} "${url}" is not trusted by your trusted origins configuration.`, {
698
+ endpoint: name,
699
+ url
626
700
  });
701
+ return url;
627
702
  }
628
- //#endregion
629
- //#region src/saml/response-validation.ts
630
- function errorRedirectUrl(base, error, description) {
703
+ /**
704
+ * Normalize a single URL endpoint.
705
+ *
706
+ * @param name - The endpoint name (e.g token_endpoint)
707
+ * @param endpoint - The endpoint URL to normalize
708
+ * @param issuer - The base issuer URL
709
+ * @returns The normalized endpoint URL
710
+ */
711
+ function normalizeUrl(name, endpoint, issuer) {
631
712
  try {
632
- const url = new URL(base);
633
- url.searchParams.set("error", error);
634
- url.searchParams.set("error_description", description);
635
- return url.toString();
713
+ return parseURL(name, endpoint).toString();
636
714
  } 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}` : ""}`;
715
+ const issuerURL = parseURL(name, issuer);
716
+ const basePath = issuerURL.pathname.replace(/\/+$/, "");
717
+ const endpointPath = endpoint.replace(/^\/+/, "");
718
+ return parseURL(name, basePath + "/" + endpointPath, issuerURL.origin).toString();
641
719
  }
642
720
  }
643
721
  /**
644
- * Validates the InResponseTo attribute of a SAML Response.
722
+ * Parses the given URL or throws in case of invalid or unsupported protocols
645
723
  *
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.
724
+ * @param name the url name
725
+ * @param endpoint the endpoint url
726
+ * @param [base] optional base path
727
+ * @returns
728
+ */
729
+ function parseURL(name, endpoint, base) {
730
+ let endpointURL;
731
+ try {
732
+ endpointURL = new URL(endpoint, base);
733
+ if (endpointURL.protocol === "http:" || endpointURL.protocol === "https:") return endpointURL;
734
+ } catch (error) {
735
+ throw new DiscoveryError("discovery_invalid_url", `The url "${name}" must be valid: ${endpoint}`, { url: endpoint }, { cause: error });
736
+ }
737
+ throw new DiscoveryError("discovery_invalid_url", `The url "${name}" must use the http or https supported protocols: ${endpoint}`, {
738
+ url: endpoint,
739
+ protocol: endpointURL.protocol
740
+ });
741
+ }
742
+ /**
743
+ * Select the token endpoint authentication method.
649
744
  *
650
- * The InResponseTo value lives at `extract.response.inResponseTo` in
651
- * samlify's parsed output (not at the top level).
745
+ * @param doc - The discovery document
746
+ * @param existing - Existing authentication method from config
747
+ * @returns The selected authentication method
652
748
  */
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
- }
749
+ function selectTokenEndpointAuthMethod(doc, existing) {
750
+ if (existing === "private_key_jwt") return existing;
751
+ if (existing) return existing;
752
+ const supported = doc.token_endpoint_auth_methods_supported;
753
+ if (!supported || supported.length === 0) return "client_secret_basic";
754
+ if (supported.includes("client_secret_basic")) return "client_secret_basic";
755
+ if (supported.includes("client_secret_post")) return "client_secret_post";
756
+ if (supported.includes("private_key_jwt")) return "private_key_jwt";
757
+ return "client_secret_basic";
687
758
  }
688
759
  /**
689
- * Validates the AudienceRestriction of a SAML assertion.
760
+ * Check if a provider configuration needs runtime discovery.
690
761
  *
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.
762
+ * Returns true if we need discovery at runtime to complete the token exchange
763
+ * and validation. Specifically checks for:
764
+ * - `tokenEndpoint` - required for exchanging authorization code for tokens
765
+ * - `jwksEndpoint` - required for validating ID token signatures
766
+ * - `authorizationEndpoint` - required for redirecting users to the IdP for login
767
+ *
768
+ * @param config - Partial OIDC config from the provider
769
+ * @returns true if runtime discovery should be performed
695
770
  */
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"));
771
+ function needsRuntimeDiscovery(config) {
772
+ if (!config) return true;
773
+ return !config.tokenEndpoint || !config.jwksEndpoint || !config.authorizationEndpoint;
774
+ }
775
+ /**
776
+ * Runs runtime OIDC discovery when the stored config is missing required
777
+ * endpoints, and merges the hydrated fields back into the config.
778
+ * Throws if discovery fails.
779
+ */
780
+ async function ensureRuntimeDiscovery(config, issuer, isTrustedOrigin) {
781
+ let resolved = config;
782
+ if (needsRuntimeDiscovery(config)) {
783
+ const hydrated = await discoverOIDCConfig({
784
+ issuer,
785
+ existingConfig: config,
786
+ isTrustedOrigin
787
+ });
788
+ resolved = {
789
+ ...config,
790
+ authorizationEndpoint: hydrated.authorizationEndpoint,
791
+ tokenEndpoint: hydrated.tokenEndpoint,
792
+ tokenEndpointAuthentication: hydrated.tokenEndpointAuthentication,
793
+ userInfoEndpoint: hydrated.userInfoEndpoint,
794
+ jwksEndpoint: hydrated.jwksEndpoint
795
+ };
705
796
  }
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
797
+ await assertOIDCEndpointsResolvePublic(resolved, isTrustedOrigin);
798
+ return resolved;
799
+ }
800
+ //#endregion
801
+ //#region src/oidc/errors.ts
802
+ /**
803
+ * OIDC Discovery Error Mapping
804
+ *
805
+ * Maps DiscoveryError codes to appropriate APIError responses.
806
+ * Used at the boundary between the discovery pipeline and HTTP handlers.
807
+ */
808
+ /**
809
+ * Maps a DiscoveryError to an appropriate APIError for HTTP responses.
810
+ *
811
+ * Error code mapping:
812
+ * - discovery_invalid_url → 400 BAD_REQUEST
813
+ * - discovery_not_found → 400 BAD_REQUEST
814
+ * - discovery_untrusted_origin → 400 BAD_REQUEST
815
+ * - discovery_private_host → 400 BAD_REQUEST
816
+ * - discovery_invalid_json → 400 BAD_REQUEST
817
+ * - discovery_incomplete → 400 BAD_REQUEST
818
+ * - issuer_mismatch → 400 BAD_REQUEST
819
+ * - unsupported_token_auth_method → 400 BAD_REQUEST
820
+ * - discovery_timeout → 502 BAD_GATEWAY
821
+ * - discovery_unexpected_error → 502 BAD_GATEWAY
822
+ *
823
+ * @param error - The DiscoveryError to map
824
+ * @returns An APIError with appropriate status and message
825
+ */
826
+ function mapDiscoveryErrorToAPIError(error) {
827
+ switch (error.code) {
828
+ case "discovery_timeout": return new APIError("BAD_GATEWAY", {
829
+ message: `OIDC discovery timed out: ${error.message}`,
830
+ code: error.code
712
831
  });
713
- throw c.redirect(errorRedirectUrl(ctx.redirectUrl, "invalid_saml_response", "Audience mismatch"));
832
+ case "discovery_unexpected_error": return new APIError("BAD_GATEWAY", {
833
+ message: `OIDC discovery failed: ${error.message}`,
834
+ code: error.code
835
+ });
836
+ case "discovery_not_found": return new APIError("BAD_REQUEST", {
837
+ message: `OIDC discovery endpoint not found. The issuer may not support OIDC discovery, or the URL is incorrect. ${error.message}`,
838
+ code: error.code
839
+ });
840
+ case "discovery_invalid_url": return new APIError("BAD_REQUEST", {
841
+ message: `Invalid OIDC endpoint URL: ${error.message}`,
842
+ code: error.code
843
+ });
844
+ case "discovery_untrusted_origin": return new APIError("BAD_REQUEST", {
845
+ message: `Untrusted OIDC discovery URL: ${error.message}`,
846
+ code: error.code
847
+ });
848
+ case "discovery_private_host": return new APIError("BAD_REQUEST", {
849
+ message: error.message,
850
+ code: error.code
851
+ });
852
+ case "discovery_invalid_json": return new APIError("BAD_REQUEST", {
853
+ message: `OIDC discovery returned invalid data: ${error.message}`,
854
+ code: error.code
855
+ });
856
+ case "discovery_incomplete": return new APIError("BAD_REQUEST", {
857
+ message: `OIDC discovery document is missing required fields: ${error.message}`,
858
+ code: error.code
859
+ });
860
+ case "issuer_mismatch": return new APIError("BAD_REQUEST", {
861
+ message: `OIDC issuer mismatch: ${error.message}`,
862
+ code: error.code
863
+ });
864
+ case "unsupported_token_auth_method": return new APIError("BAD_REQUEST", {
865
+ message: `Incompatible OIDC provider: ${error.message}`,
866
+ code: error.code
867
+ });
868
+ default:
869
+ error.code;
870
+ return new APIError("INTERNAL_SERVER_ERROR", {
871
+ message: `Unexpected discovery error: ${error.message}`,
872
+ code: "discovery_unexpected_error"
873
+ });
714
874
  }
715
875
  }
716
876
  //#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()
877
+ //#region src/saml/parser.ts
878
+ const xmlParser = new XMLParser({
879
+ ignoreAttributes: false,
880
+ attributeNamePrefix: "@_",
881
+ removeNSPrefix: true,
882
+ processEntities: false
796
883
  });
797
- //#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()));
813
- }
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;
830
- }
831
- function sanitizeProvider(provider, baseURL) {
832
- let oidcConfig = null;
833
- let samlConfig = null;
834
- try {
835
- oidcConfig = safeJsonParse(provider.oidcConfig);
836
- } catch {
837
- oidcConfig = null;
884
+ function findNode(obj, nodeName) {
885
+ if (!obj || typeof obj !== "object") return null;
886
+ const record = obj;
887
+ if (nodeName in record) return record[nodeName];
888
+ for (const value of Object.values(record)) if (Array.isArray(value)) for (const item of value) {
889
+ const found = findNode(item, nodeName);
890
+ if (found) return found;
838
891
  }
839
- try {
840
- samlConfig = safeJsonParse(provider.samlConfig);
841
- } catch {
842
- samlConfig = null;
892
+ else if (typeof value === "object" && value !== null) {
893
+ const found = findNode(value, nodeName);
894
+ if (found) return found;
843
895
  }
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
- };
896
+ return null;
881
897
  }
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;
898
+ function countAllNodes(obj, nodeName) {
899
+ if (!obj || typeof obj !== "object") return 0;
900
+ let count = 0;
901
+ const record = obj;
902
+ if (nodeName in record) {
903
+ const node = record[nodeName];
904
+ count += Array.isArray(node) ? node.length : 1;
905
+ }
906
+ for (const value of Object.values(record)) if (Array.isArray(value)) for (const item of value) count += countAllNodes(item, nodeName);
907
+ else if (typeof value === "object" && value !== null) count += countAllNodes(value, nodeName);
908
+ return count;
928
909
  }
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));
948
- });
910
+ //#endregion
911
+ //#region src/saml/algorithms.ts
912
+ const SignatureAlgorithm = {
913
+ RSA_SHA1: "http://www.w3.org/2000/09/xmldsig#rsa-sha1",
914
+ RSA_SHA256: "http://www.w3.org/2001/04/xmldsig-more#rsa-sha256",
915
+ RSA_SHA384: "http://www.w3.org/2001/04/xmldsig-more#rsa-sha384",
916
+ RSA_SHA512: "http://www.w3.org/2001/04/xmldsig-more#rsa-sha512",
917
+ ECDSA_SHA256: "http://www.w3.org/2001/04/xmldsig-more#ecdsa-sha256",
918
+ ECDSA_SHA384: "http://www.w3.org/2001/04/xmldsig-more#ecdsa-sha384",
919
+ ECDSA_SHA512: "http://www.w3.org/2001/04/xmldsig-more#ecdsa-sha512"
949
920
  };
950
- function parseAndValidateConfig(configString, configType) {
951
- let config = null;
921
+ const DigestAlgorithm = {
922
+ SHA1: "http://www.w3.org/2000/09/xmldsig#sha1",
923
+ SHA256: "http://www.w3.org/2001/04/xmlenc#sha256",
924
+ SHA384: "http://www.w3.org/2001/04/xmldsig-more#sha384",
925
+ SHA512: "http://www.w3.org/2001/04/xmlenc#sha512"
926
+ };
927
+ const KeyEncryptionAlgorithm = {
928
+ RSA_1_5: "http://www.w3.org/2001/04/xmlenc#rsa-1_5",
929
+ RSA_OAEP: "http://www.w3.org/2001/04/xmlenc#rsa-oaep-mgf1p",
930
+ RSA_OAEP_SHA256: "http://www.w3.org/2009/xmlenc11#rsa-oaep"
931
+ };
932
+ const DataEncryptionAlgorithm = {
933
+ TRIPLEDES_CBC: "http://www.w3.org/2001/04/xmlenc#tripledes-cbc",
934
+ AES_128_CBC: "http://www.w3.org/2001/04/xmlenc#aes128-cbc",
935
+ AES_192_CBC: "http://www.w3.org/2001/04/xmlenc#aes192-cbc",
936
+ AES_256_CBC: "http://www.w3.org/2001/04/xmlenc#aes256-cbc",
937
+ AES_128_GCM: "http://www.w3.org/2009/xmlenc11#aes128-gcm",
938
+ AES_192_GCM: "http://www.w3.org/2009/xmlenc11#aes192-gcm",
939
+ AES_256_GCM: "http://www.w3.org/2009/xmlenc11#aes256-gcm"
940
+ };
941
+ const DEPRECATED_SIGNATURE_ALGORITHMS = [SignatureAlgorithm.RSA_SHA1];
942
+ const DEPRECATED_KEY_ENCRYPTION_ALGORITHMS = [KeyEncryptionAlgorithm.RSA_1_5];
943
+ const DEPRECATED_DATA_ENCRYPTION_ALGORITHMS = [DataEncryptionAlgorithm.TRIPLEDES_CBC];
944
+ const DEPRECATED_DIGEST_ALGORITHMS = [DigestAlgorithm.SHA1];
945
+ const SECURE_SIGNATURE_ALGORITHMS = [
946
+ SignatureAlgorithm.RSA_SHA256,
947
+ SignatureAlgorithm.RSA_SHA384,
948
+ SignatureAlgorithm.RSA_SHA512,
949
+ SignatureAlgorithm.ECDSA_SHA256,
950
+ SignatureAlgorithm.ECDSA_SHA384,
951
+ SignatureAlgorithm.ECDSA_SHA512
952
+ ];
953
+ const SECURE_DIGEST_ALGORITHMS = [
954
+ DigestAlgorithm.SHA256,
955
+ DigestAlgorithm.SHA384,
956
+ DigestAlgorithm.SHA512
957
+ ];
958
+ const SHORT_FORM_SIGNATURE_TO_URI = {
959
+ sha1: SignatureAlgorithm.RSA_SHA1,
960
+ sha256: SignatureAlgorithm.RSA_SHA256,
961
+ sha384: SignatureAlgorithm.RSA_SHA384,
962
+ sha512: SignatureAlgorithm.RSA_SHA512,
963
+ "rsa-sha1": SignatureAlgorithm.RSA_SHA1,
964
+ "rsa-sha256": SignatureAlgorithm.RSA_SHA256,
965
+ "rsa-sha384": SignatureAlgorithm.RSA_SHA384,
966
+ "rsa-sha512": SignatureAlgorithm.RSA_SHA512,
967
+ "ecdsa-sha256": SignatureAlgorithm.ECDSA_SHA256,
968
+ "ecdsa-sha384": SignatureAlgorithm.ECDSA_SHA384,
969
+ "ecdsa-sha512": SignatureAlgorithm.ECDSA_SHA512
970
+ };
971
+ const SHORT_FORM_DIGEST_TO_URI = {
972
+ sha1: DigestAlgorithm.SHA1,
973
+ sha256: DigestAlgorithm.SHA256,
974
+ sha384: DigestAlgorithm.SHA384,
975
+ sha512: DigestAlgorithm.SHA512
976
+ };
977
+ function normalizeSignatureAlgorithm(alg) {
978
+ return SHORT_FORM_SIGNATURE_TO_URI[alg.toLowerCase()] ?? alg;
979
+ }
980
+ function normalizeDigestAlgorithm(alg) {
981
+ return SHORT_FORM_DIGEST_TO_URI[alg.toLowerCase()] ?? alg;
982
+ }
983
+ function extractEncryptionAlgorithms(xml) {
952
984
  try {
953
- config = safeJsonParse(configString);
985
+ const parsed = xmlParser.parse(xml);
986
+ const keyAlg = (findNode(parsed, "EncryptedKey")?.EncryptionMethod)?.["@_Algorithm"];
987
+ const dataAlg = (findNode(parsed, "EncryptedData")?.EncryptionMethod)?.["@_Algorithm"];
988
+ return {
989
+ keyEncryption: keyAlg || null,
990
+ dataEncryption: dataAlg || null
991
+ };
954
992
  } catch {
955
- config = null;
993
+ return {
994
+ keyEncryption: null,
995
+ dataEncryption: null
996
+ };
956
997
  }
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
998
  }
978
- function mergeOIDCConfig(current, updates, issuer) {
979
- 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
996
- };
999
+ function hasEncryptedAssertion(xml) {
1000
+ try {
1001
+ return findNode(xmlParser.parse(xml), "EncryptedAssertion") !== null;
1002
+ } catch {
1003
+ return false;
1004
+ }
997
1005
  }
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
1006
+ function handleDeprecatedAlgorithm(message, behavior, errorCode) {
1007
+ switch (behavior) {
1008
+ case "reject": throw new APIError("BAD_REQUEST", {
1009
+ message,
1010
+ code: errorCode
1051
1011
  });
1052
- const fullProvider = await ctx.context.adapter.findOne({
1053
- model: "ssoProvider",
1054
- where: [{
1055
- field: "providerId",
1056
- value: providerId
1057
- }]
1012
+ case "warn":
1013
+ console.warn(`[SAML Security Warning] ${message}`);
1014
+ break;
1015
+ case "allow": break;
1016
+ }
1017
+ }
1018
+ function validateSignatureAlgorithm(algorithm, options = {}) {
1019
+ if (!algorithm) return;
1020
+ const { onDeprecated = "warn", allowedSignatureAlgorithms } = options;
1021
+ if (allowedSignatureAlgorithms) {
1022
+ if (!allowedSignatureAlgorithms.includes(algorithm)) throw new APIError("BAD_REQUEST", {
1023
+ message: `SAML signature algorithm not in allow-list: ${algorithm}`,
1024
+ code: "SAML_ALGORITHM_NOT_ALLOWED"
1058
1025
  });
1059
- if (!fullProvider) throw new APIError("NOT_FOUND", { message: "Provider not found after update" });
1060
- return ctx.json(sanitizeProvider(fullProvider, ctx.context.baseURL));
1026
+ return;
1027
+ }
1028
+ if (DEPRECATED_SIGNATURE_ALGORITHMS.includes(algorithm)) {
1029
+ handleDeprecatedAlgorithm(`SAML response uses deprecated signature algorithm: ${algorithm}. Please configure your IdP to use SHA-256 or stronger.`, onDeprecated, "SAML_DEPRECATED_ALGORITHM");
1030
+ return;
1031
+ }
1032
+ if (!SECURE_SIGNATURE_ALGORITHMS.includes(algorithm)) throw new APIError("BAD_REQUEST", {
1033
+ message: `SAML signature algorithm not recognized: ${algorithm}`,
1034
+ code: "SAML_UNKNOWN_ALGORITHM"
1061
1035
  });
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
- }]
1036
+ }
1037
+ function validateEncryptionAlgorithms(algorithms, options = {}) {
1038
+ const { onDeprecated = "warn", allowedKeyEncryptionAlgorithms, allowedDataEncryptionAlgorithms } = options;
1039
+ const { keyEncryption, dataEncryption } = algorithms;
1040
+ if (keyEncryption) {
1041
+ if (allowedKeyEncryptionAlgorithms) {
1042
+ if (!allowedKeyEncryptionAlgorithms.includes(keyEncryption)) throw new APIError("BAD_REQUEST", {
1043
+ message: `SAML key encryption algorithm not in allow-list: ${keyEncryption}`,
1044
+ code: "SAML_ALGORITHM_NOT_ALLOWED"
1045
+ });
1046
+ } 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");
1047
+ }
1048
+ if (dataEncryption) {
1049
+ if (allowedDataEncryptionAlgorithms) {
1050
+ if (!allowedDataEncryptionAlgorithms.includes(dataEncryption)) throw new APIError("BAD_REQUEST", {
1051
+ message: `SAML data encryption algorithm not in allow-list: ${dataEncryption}`,
1052
+ code: "SAML_ALGORITHM_NOT_ALLOWED"
1053
+ });
1054
+ } 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");
1055
+ }
1056
+ }
1057
+ function validateSAMLAlgorithms(response, options) {
1058
+ validateSignatureAlgorithm(response.sigAlg, options);
1059
+ if (hasEncryptedAssertion(response.samlContent)) validateEncryptionAlgorithms(extractEncryptionAlgorithms(response.samlContent), options);
1060
+ }
1061
+ function validateConfigAlgorithms(config, options = {}) {
1062
+ const { onDeprecated = "warn", allowedSignatureAlgorithms, allowedDigestAlgorithms } = options;
1063
+ if (config.signatureAlgorithm) {
1064
+ const normalized = normalizeSignatureAlgorithm(config.signatureAlgorithm);
1065
+ if (allowedSignatureAlgorithms) {
1066
+ if (!allowedSignatureAlgorithms.map(normalizeSignatureAlgorithm).includes(normalized)) throw new APIError("BAD_REQUEST", {
1067
+ message: `SAML signature algorithm not in allow-list: ${config.signatureAlgorithm}`,
1068
+ code: "SAML_ALGORITHM_NOT_ALLOWED"
1069
+ });
1070
+ } 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");
1071
+ else if (!SECURE_SIGNATURE_ALGORITHMS.includes(normalized)) throw new APIError("BAD_REQUEST", {
1072
+ message: `SAML signature algorithm not recognized: ${config.signatureAlgorithm}`,
1073
+ code: "SAML_UNKNOWN_ALGORITHM"
1087
1074
  });
1088
- return ctx.json({ success: true });
1089
- });
1090
- };
1091
- //#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
1075
  }
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
- ];
1076
+ if (config.digestAlgorithm) {
1077
+ const normalized = normalizeDigestAlgorithm(config.digestAlgorithm);
1078
+ if (allowedDigestAlgorithms) {
1079
+ if (!allowedDigestAlgorithms.map(normalizeDigestAlgorithm).includes(normalized)) throw new APIError("BAD_REQUEST", {
1080
+ message: `SAML digest algorithm not in allow-list: ${config.digestAlgorithm}`,
1081
+ code: "SAML_ALGORITHM_NOT_ALLOWED"
1082
+ });
1083
+ } 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");
1084
+ else if (!SECURE_DIGEST_ALGORITHMS.includes(normalized)) throw new APIError("BAD_REQUEST", {
1085
+ message: `SAML digest algorithm not recognized: ${config.digestAlgorithm}`,
1086
+ code: "SAML_UNKNOWN_ALGORITHM"
1087
+ });
1088
+ }
1089
+ }
1117
1090
  //#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);
1091
+ //#region src/saml/assertions.ts
1092
+ function countAssertions(xml) {
1093
+ let parsed;
1094
+ try {
1095
+ parsed = xmlParser.parse(xml);
1096
+ } catch {
1097
+ throw new APIError("BAD_REQUEST", {
1098
+ message: "Failed to parse SAML response XML",
1099
+ code: "SAML_INVALID_XML"
1100
+ });
1101
+ }
1102
+ const assertions = countAllNodes(parsed, "Assertion");
1103
+ const encryptedAssertions = countAllNodes(parsed, "EncryptedAssertion");
1155
1104
  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
1105
+ assertions,
1106
+ encryptedAssertions,
1107
+ total: assertions + encryptedAssertions
1164
1108
  };
1165
1109
  }
1110
+ function validateSingleAssertion(samlResponse) {
1111
+ let xml;
1112
+ try {
1113
+ xml = new TextDecoder().decode(base64.decode(samlResponse.replace(/\s+/g, "")));
1114
+ if (!xml.includes("<")) throw new Error("Not XML");
1115
+ } catch {
1116
+ throw new APIError("BAD_REQUEST", {
1117
+ message: "Invalid base64-encoded SAML response",
1118
+ code: "SAML_INVALID_ENCODING"
1119
+ });
1120
+ }
1121
+ const counts = countAssertions(xml);
1122
+ if (counts.total === 0) throw new APIError("BAD_REQUEST", {
1123
+ message: "SAML response contains no assertions",
1124
+ code: "SAML_NO_ASSERTION"
1125
+ });
1126
+ if (counts.total > 1) throw new APIError("BAD_REQUEST", {
1127
+ message: `SAML response contains ${counts.total} assertions, expected exactly 1`,
1128
+ code: "SAML_MULTIPLE_ASSERTIONS"
1129
+ });
1130
+ }
1131
+ //#endregion
1132
+ //#region src/saml/error-codes.ts
1133
+ const SAML_ERROR_CODES = defineErrorCodes({
1134
+ SINGLE_LOGOUT_NOT_ENABLED: "Single Logout is not enabled",
1135
+ INVALID_LOGOUT_RESPONSE: "Invalid LogoutResponse",
1136
+ INVALID_LOGOUT_REQUEST: "Invalid LogoutRequest",
1137
+ LOGOUT_FAILED_AT_IDP: "Logout failed at IdP",
1138
+ IDP_SLO_NOT_SUPPORTED: "IdP does not support Single Logout Service",
1139
+ SAML_PROVIDER_NOT_FOUND: "SAML provider not found",
1140
+ CERT_SOURCE_MISSING: "samlConfig requires either a signing certificate (cert or idpMetadata.cert) or an idpMetadata.metadata XML document."
1141
+ });
1142
+ //#endregion
1143
+ //#region src/saml/cert.ts
1166
1144
  /**
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.
1145
+ * IdP signing-certificate rules for SAML configs. Centralized so the runtime
1146
+ * verification path (`createIdP`), the sanitizer (`getSSOProvider` and
1147
+ * friends), and the registration validator agree on precedence and the
1148
+ * "exactly one cert source" contract.
1173
1149
  */
1174
- function computeDiscoveryUrl(issuer) {
1175
- return `${issuer.endsWith("/") ? issuer.slice(0, -1) : issuer}/.well-known/openid-configuration`;
1176
- }
1177
1150
  /**
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
1151
+ * Returns the IdP signing certificates Better Auth trusts for this provider
1152
+ * as a list. `idpMetadata.cert` wins when both are set; the top-level `cert`
1153
+ * is the fallback. Returns `undefined` when neither is set (the certs come
1154
+ * from `idpMetadata.metadata` XML instead).
1183
1155
  */
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 });
1156
+ function resolveSigningCerts(config) {
1157
+ const cert = config.idpMetadata?.cert ?? config.cert;
1158
+ if (cert === void 0) return void 0;
1159
+ return Array.isArray(cert) ? cert : [cert];
1187
1160
  }
1188
1161
  /**
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
1162
+ * Reject SAML configs with no signing-cert source. samlify needs either an
1163
+ * `idpMetadata.metadata` XML document (which embeds the certs) or an explicit
1164
+ * PEM under `cert` or `idpMetadata.cert`; without one of those it has nothing
1165
+ * to verify responses against.
1195
1166
  */
1196
- async function fetchDiscoveryDocument(url, timeout = DEFAULT_DISCOVERY_TIMEOUT) {
1167
+ function validateCertSources(config) {
1168
+ const hasMetadataXml = !!config.idpMetadata?.metadata;
1169
+ const hasExplicitCert = config.idpMetadata?.cert !== void 0 || config.cert !== void 0;
1170
+ if (!hasMetadataXml && !hasExplicitCert) throw APIError.from("BAD_REQUEST", SAML_ERROR_CODES.CERT_SOURCE_MISSING);
1171
+ }
1172
+ //#endregion
1173
+ //#region src/saml/response-validation.ts
1174
+ function errorRedirectUrl(base, error, description) {
1197
1175
  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 });
1176
+ const url = new URL(base);
1177
+ url.searchParams.set("error", error);
1178
+ url.searchParams.set("error_description", description);
1179
+ return url.toString();
1180
+ } catch {
1181
+ const hashIdx = base.indexOf("#");
1182
+ const path = hashIdx >= 0 ? base.slice(0, hashIdx) : base;
1183
+ const hash = hashIdx >= 0 ? base.slice(hashIdx + 1) : void 0;
1184
+ return `${path}${path.includes("?") ? "&" : "?"}${`error=${encodeURIComponent(error)}&error_description=${encodeURIComponent(description)}`}${hash ? `#${hash}` : ""}`;
1231
1185
  }
1232
1186
  }
1233
1187
  /**
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)
1188
+ * Validates the InResponseTo attribute of a SAML Response.
1239
1189
  *
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).
1190
+ * This binds the IdP's Response to a specific SP-initiated AuthnRequest,
1191
+ * preventing replay attacks, unsolicited response injection, and
1192
+ * cross-provider assertion swaps.
1243
1193
  *
1244
- * @param doc - The discovery document to validate
1245
- * @param configuredIssuer - The expected issuer value
1246
- * @throws DiscoveryError if validation fails
1194
+ * The InResponseTo value lives at `extract.response.inResponseTo` in
1195
+ * samlify's parsed output (not at the top level).
1247
1196
  */
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
- });
1197
+ async function validateInResponseTo(c, ctx) {
1198
+ if (ctx.options.enableInResponseToValidation === false) return;
1199
+ const inResponseTo = ctx.extract.response?.inResponseTo;
1200
+ const allowIdpInitiated = ctx.options.allowIdpInitiated ?? false;
1201
+ if (inResponseTo) {
1202
+ const consumed = await c.context.internalAdapter.consumeVerificationValue(`${AUTHN_REQUEST_KEY_PREFIX}${inResponseTo}`);
1203
+ let storedRequest = null;
1204
+ if (consumed) try {
1205
+ storedRequest = JSON.parse(consumed.value);
1206
+ } catch {
1207
+ storedRequest = null;
1208
+ }
1209
+ if (!storedRequest) {
1210
+ c.context.logger.error("SAML InResponseTo validation failed: unknown or expired request ID", {
1211
+ inResponseTo,
1212
+ providerId: ctx.providerId
1213
+ });
1214
+ throw c.redirect(errorRedirectUrl(ctx.redirectUrl, "invalid_saml_response", "Unknown or expired request ID"));
1215
+ }
1216
+ if (storedRequest.providerId !== ctx.providerId) {
1217
+ c.context.logger.error("SAML InResponseTo validation failed: provider mismatch", {
1218
+ inResponseTo,
1219
+ expectedProvider: storedRequest.providerId,
1220
+ actualProvider: ctx.providerId
1221
+ });
1222
+ throw c.redirect(errorRedirectUrl(ctx.redirectUrl, "invalid_saml_response", "Provider mismatch"));
1223
+ }
1224
+ } else if (!allowIdpInitiated) {
1225
+ c.context.logger.error("SAML IdP-initiated SSO rejected: InResponseTo missing and allowIdpInitiated is false", { providerId: ctx.providerId });
1226
+ throw c.redirect(errorRedirectUrl(ctx.redirectUrl, "unsolicited_response", "IdP-initiated SSO not allowed"));
1227
+ }
1256
1228
  }
1257
1229
  /**
1258
- * Normalize URLs in the discovery document.
1230
+ * Validates the AudienceRestriction of a SAML assertion.
1259
1231
  *
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
1232
+ * Per SAML 2.0 Core §2.5.1, an assertion's Audience element specifies
1233
+ * the intended recipient SP. Without this check, an assertion issued
1234
+ * for a different SP (e.g., another application sharing the same IdP)
1235
+ * could be accepted.
1264
1236
  */
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;
1237
+ function validateAudience(c, ctx) {
1238
+ if (!ctx.expectedAudience) {
1239
+ c.context.logger.warn("Could not determine SP entity ID for audience validation; skipping", { providerId: ctx.providerId });
1240
+ return;
1241
+ }
1242
+ const audience = ctx.extract.audience;
1243
+ if (!audience) {
1244
+ c.context.logger.error("SAML assertion missing AudienceRestriction but audience is configured — rejecting", { providerId: ctx.providerId });
1245
+ throw c.redirect(errorRedirectUrl(ctx.redirectUrl, "invalid_saml_response", "Audience restriction missing"));
1246
+ }
1247
+ const audiences = Array.isArray(audience) ? audience : [audience];
1248
+ if (!audiences.includes(ctx.expectedAudience)) {
1249
+ c.context.logger.error("SAML audience mismatch: assertion was issued for a different service provider", {
1250
+ expected: ctx.expectedAudience,
1251
+ received: audiences,
1252
+ providerId: ctx.providerId
1253
+ });
1254
+ throw c.redirect(errorRedirectUrl(ctx.redirectUrl, "invalid_saml_response", "Audience mismatch"));
1255
+ }
1275
1256
  }
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
1257
+ //#endregion
1258
+ //#region src/routes/schemas.ts
1259
+ const oidcMappingSchema = z.object({
1260
+ id: z.string().meta({ description: "Field mapping for user ID (defaults to 'sub')" }),
1261
+ email: z.string().meta({ description: "Field mapping for email (defaults to 'email')" }),
1262
+ emailVerified: z.string().meta({ description: "Field mapping for email verification (defaults to 'email_verified')" }).optional(),
1263
+ name: z.string().meta({ description: "Field mapping for name (defaults to 'name')" }),
1264
+ image: z.string().meta({ description: "Field mapping for image (defaults to 'picture')" }).optional(),
1265
+ extraFields: z.record(z.string(), z.any()).optional()
1266
+ }).optional();
1267
+ const samlMappingSchema = z.object({
1268
+ id: z.string().meta({ description: "Field mapping for user ID (defaults to 'nameID')" }),
1269
+ email: z.string().meta({ description: "Field mapping for email (defaults to 'email')" }),
1270
+ emailVerified: z.string().meta({ description: "Field mapping for email verification" }).optional(),
1271
+ name: z.string().meta({ description: "Field mapping for name (defaults to 'displayName')" }),
1272
+ firstName: z.string().meta({ description: "Field mapping for first name (defaults to 'givenName')" }).optional(),
1273
+ lastName: z.string().meta({ description: "Field mapping for last name (defaults to 'surname')" }).optional(),
1274
+ extraFields: z.record(z.string(), z.any()).optional()
1275
+ }).optional();
1276
+ 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." });
1277
+ const oidcConfigSchema = z.object({
1278
+ clientId: z.string().meta({ description: "The client ID" }),
1279
+ clientSecret: z.string().meta({ description: "The client secret. Required for client_secret_basic/client_secret_post. Optional for private_key_jwt." }).optional(),
1280
+ authorizationEndpoint: z.string().url().meta({ description: "The authorization endpoint" }).optional(),
1281
+ tokenEndpoint: z.string().url().meta({ description: "The token endpoint" }).optional(),
1282
+ userInfoEndpoint: z.string().url().meta({ description: "The user info endpoint" }).optional(),
1283
+ tokenEndpointAuthentication: z.enum([
1284
+ "client_secret_post",
1285
+ "client_secret_basic",
1286
+ "private_key_jwt"
1287
+ ]).optional(),
1288
+ privateKeyId: z.string().optional(),
1289
+ privateKeyAlgorithm: z.string().optional(),
1290
+ jwksEndpoint: z.string().url().meta({ description: "The JWKS endpoint" }).optional(),
1291
+ discoveryEndpoint: z.string().url().optional(),
1292
+ skipDiscovery: z.boolean().meta({ description: "Skip OIDC discovery during registration. When true, you must provide authorizationEndpoint, tokenEndpoint, and jwksEndpoint manually." }).optional(),
1293
+ scopes: z.array(z.string()).meta({ description: "The scopes to request. Defaults to ['openid', 'email', 'profile', 'offline_access']" }).optional(),
1294
+ pkce: z.boolean().meta({ description: "Whether to use PKCE for the authorization flow" }).default(true).optional(),
1295
+ overrideUserInfo: z.boolean().optional(),
1296
+ mapping: oidcMappingSchema
1297
+ });
1298
+ const samlConfigSchema = z.object({
1299
+ entryPoint: z.string().url().meta({ description: "The IdP SSO URL (entry point)" }),
1300
+ 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(),
1301
+ audience: z.string().optional(),
1302
+ idpMetadata: z.object({
1303
+ metadata: z.string().optional(),
1304
+ entityID: z.string().optional(),
1305
+ 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(),
1306
+ privateKey: z.string().optional(),
1307
+ privateKeyPass: z.string().optional(),
1308
+ isAssertionEncrypted: z.boolean().optional(),
1309
+ encPrivateKey: z.string().optional(),
1310
+ encPrivateKeyPass: z.string().optional(),
1311
+ singleSignOnService: z.array(z.object({
1312
+ Binding: z.string().meta({ description: "The binding type for the SSO service" }),
1313
+ Location: z.string().url().meta({ description: "The URL for the SSO service" })
1314
+ })).meta({ description: "Single Sign-On service configuration" }).optional(),
1315
+ singleLogoutService: z.array(z.object({
1316
+ Binding: z.string(),
1317
+ Location: z.string().url()
1318
+ })).optional()
1319
+ }).optional(),
1320
+ spMetadata: z.object({
1321
+ metadata: z.string().optional(),
1322
+ entityID: z.string().optional(),
1323
+ binding: z.string().optional(),
1324
+ privateKey: z.string().optional(),
1325
+ privateKeyPass: z.string().optional(),
1326
+ isAssertionEncrypted: z.boolean().optional(),
1327
+ encPrivateKey: z.string().optional(),
1328
+ encPrivateKeyPass: z.string().optional()
1329
+ }).optional(),
1330
+ wantAssertionsSigned: z.boolean().optional(),
1331
+ authnRequestsSigned: z.boolean().optional(),
1332
+ signatureAlgorithm: z.string().optional(),
1333
+ digestAlgorithm: z.string().optional(),
1334
+ identifierFormat: z.string().optional(),
1335
+ privateKey: z.string().optional(),
1336
+ mapping: samlMappingSchema
1337
+ });
1338
+ const registerSSOProviderBodySchema = z.object({
1339
+ providerId: z.string().meta({ description: "The ID of the provider. This is used to identify the provider during login and callback" }),
1340
+ issuer: z.string().url().meta({ description: "The issuer URL of the provider" }),
1341
+ 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')" }),
1342
+ oidcConfig: oidcConfigSchema.optional(),
1343
+ samlConfig: samlConfigSchema.optional(),
1344
+ organizationId: z.string().meta({ description: "If organization plugin is enabled, the organization id to link the provider to" }).optional(),
1345
+ overrideUserInfo: z.boolean().meta({ description: "Override user info with the provider info. Defaults to false" }).default(false).optional()
1346
+ });
1347
+ const updateSSOProviderBodySchema = z.object({
1348
+ issuer: z.string().url().optional(),
1349
+ domain: z.string().optional(),
1350
+ oidcConfig: oidcConfigSchema.partial().optional(),
1351
+ samlConfig: samlConfigSchema.partial().optional()
1352
+ });
1353
+ //#endregion
1354
+ //#region src/routes/providers.ts
1355
+ const ADMIN_ROLES = ["owner", "admin"];
1356
+ function hasOrgAdminRole(member) {
1357
+ return member.role.split(",").some((r) => ADMIN_ROLES.includes(r.trim()));
1358
+ }
1359
+ function parseCertOrError(cert) {
1360
+ try {
1361
+ return parseCertificate(cert);
1362
+ } catch {
1363
+ return { error: "Failed to parse certificate" };
1364
+ }
1365
+ }
1366
+ function sanitizeSigningCerts(config) {
1367
+ const certs = resolveSigningCerts(config);
1368
+ if (certs === void 0) return void 0;
1369
+ return certs.map(parseCertOrError);
1370
+ }
1371
+ async function isOrgAdmin(ctx, userId, organizationId) {
1372
+ const member = await ctx.context.adapter.findOne({
1373
+ model: "member",
1374
+ where: [{
1375
+ field: "userId",
1376
+ value: userId
1377
+ }, {
1378
+ field: "organizationId",
1379
+ value: organizationId
1380
+ }]
1289
1381
  });
1290
- return url;
1382
+ return member ? hasOrgAdminRole(member) : false;
1291
1383
  }
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) {
1384
+ async function batchCheckOrgAdmin(ctx, userId, organizationIds) {
1385
+ if (organizationIds.length === 0) return /* @__PURE__ */ new Set();
1386
+ const members = await ctx.context.adapter.findMany({
1387
+ model: "member",
1388
+ where: [{
1389
+ field: "userId",
1390
+ value: userId
1391
+ }, {
1392
+ field: "organizationId",
1393
+ value: organizationIds,
1394
+ operator: "in"
1395
+ }]
1396
+ });
1397
+ const adminOrgIds = /* @__PURE__ */ new Set();
1398
+ for (const member of members) if (hasOrgAdminRole(member)) adminOrgIds.add(member.organizationId);
1399
+ return adminOrgIds;
1400
+ }
1401
+ function sanitizeProvider(provider, baseURL) {
1402
+ let oidcConfig = null;
1403
+ let samlConfig = null;
1301
1404
  try {
1302
- return parseURL(name, endpoint).toString();
1405
+ oidcConfig = safeJsonParse(provider.oidcConfig);
1303
1406
  } 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();
1407
+ oidcConfig = null;
1308
1408
  }
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
1409
  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 });
1410
+ samlConfig = safeJsonParse(provider.samlConfig);
1411
+ } catch {
1412
+ samlConfig = null;
1325
1413
  }
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";
1414
+ const type = samlConfig ? "saml" : "oidc";
1415
+ return {
1416
+ providerId: provider.providerId,
1417
+ type,
1418
+ issuer: provider.issuer,
1419
+ domain: provider.domain,
1420
+ organizationId: provider.organizationId || null,
1421
+ domainVerified: provider.domainVerified ?? false,
1422
+ oidcConfig: oidcConfig ? {
1423
+ discoveryEndpoint: oidcConfig.discoveryEndpoint,
1424
+ clientIdLastFour: maskClientId(oidcConfig.clientId),
1425
+ pkce: oidcConfig.pkce,
1426
+ authorizationEndpoint: oidcConfig.authorizationEndpoint,
1427
+ tokenEndpoint: oidcConfig.tokenEndpoint,
1428
+ userInfoEndpoint: oidcConfig.userInfoEndpoint,
1429
+ jwksEndpoint: oidcConfig.jwksEndpoint,
1430
+ scopes: oidcConfig.scopes,
1431
+ tokenEndpointAuthentication: oidcConfig.tokenEndpointAuthentication
1432
+ } : void 0,
1433
+ samlConfig: samlConfig ? {
1434
+ entryPoint: samlConfig.entryPoint,
1435
+ audience: samlConfig.audience,
1436
+ wantAssertionsSigned: samlConfig.wantAssertionsSigned,
1437
+ authnRequestsSigned: samlConfig.authnRequestsSigned,
1438
+ identifierFormat: samlConfig.identifierFormat,
1439
+ signatureAlgorithm: samlConfig.signatureAlgorithm,
1440
+ digestAlgorithm: samlConfig.digestAlgorithm,
1441
+ certificate: sanitizeSigningCerts(samlConfig)
1442
+ } : void 0,
1443
+ spMetadataUrl: `${baseURL}/sso/saml2/sp/metadata?providerId=${encodeURIComponent(provider.providerId)}`
1444
+ };
1347
1445
  }
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;
1446
+ const listSSOProviders = () => {
1447
+ return createAuthEndpoint("/sso/providers", {
1448
+ method: "GET",
1449
+ use: [sessionMiddleware],
1450
+ metadata: { openapi: {
1451
+ operationId: "listSSOProviders",
1452
+ summary: "List SSO providers",
1453
+ description: "Returns a list of SSO providers the user has access to",
1454
+ 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`." } }
1455
+ } }
1456
+ }, async (ctx) => {
1457
+ const userId = ctx.context.session.user.id;
1458
+ const allProviders = await ctx.context.adapter.findMany({ model: "ssoProvider" });
1459
+ const userOwnedProviders = allProviders.filter((p) => p.userId === userId && !p.organizationId);
1460
+ const orgProviders = allProviders.filter((p) => p.organizationId !== null && p.organizationId !== void 0);
1461
+ const orgPluginEnabled = ctx.context.hasPlugin("organization");
1462
+ let accessibleProviders = [...userOwnedProviders];
1463
+ if (orgPluginEnabled && orgProviders.length > 0) {
1464
+ const adminOrgIds = await batchCheckOrgAdmin(ctx, userId, [...new Set(orgProviders.map((p) => p.organizationId).filter((id) => id !== null && id !== void 0))]);
1465
+ const orgAccessibleProviders = orgProviders.filter((provider) => provider.organizationId && adminOrgIds.has(provider.organizationId));
1466
+ accessibleProviders = [...accessibleProviders, ...orgAccessibleProviders];
1467
+ } else if (!orgPluginEnabled) {
1468
+ const userOwnedOrgProviders = orgProviders.filter((p) => p.userId === userId);
1469
+ accessibleProviders = [...accessibleProviders, ...userOwnedOrgProviders];
1470
+ }
1471
+ const providers = accessibleProviders.map((p) => sanitizeProvider(p, ctx.context.baseURL));
1472
+ return ctx.json({ providers });
1473
+ });
1474
+ };
1475
+ const getSSOProviderQuerySchema = z.object({ providerId: z.string() });
1476
+ async function checkProviderAccess(ctx, providerId) {
1477
+ const userId = ctx.context.session.user.id;
1478
+ const provider = await ctx.context.adapter.findOne({
1479
+ model: "ssoProvider",
1480
+ where: [{
1481
+ field: "providerId",
1482
+ value: providerId
1483
+ }]
1484
+ });
1485
+ if (!provider) throw new APIError("NOT_FOUND", { message: "Provider not found" });
1486
+ let hasAccess = false;
1487
+ if (provider.organizationId) if (ctx.context.hasPlugin("organization")) hasAccess = await isOrgAdmin(ctx, userId, provider.organizationId);
1488
+ else hasAccess = provider.userId === userId;
1489
+ else hasAccess = provider.userId === userId;
1490
+ if (!hasAccess) throw new APIError("FORBIDDEN", { message: "You don't have access to this provider" });
1491
+ return provider;
1363
1492
  }
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
1493
+ const getSSOProvider = () => {
1494
+ return createAuthEndpoint("/sso/get-provider", {
1495
+ method: "GET",
1496
+ use: [sessionMiddleware],
1497
+ query: getSSOProviderQuerySchema,
1498
+ metadata: { openapi: {
1499
+ operationId: "getSSOProvider",
1500
+ summary: "Get SSO provider details",
1501
+ description: "Returns sanitized details for a specific SSO provider",
1502
+ responses: {
1503
+ "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`." },
1504
+ "404": { description: "Provider not found" },
1505
+ "403": { description: "Access denied" }
1506
+ }
1507
+ } }
1508
+ }, async (ctx) => {
1509
+ const { providerId } = ctx.query;
1510
+ const provider = await checkProviderAccess(ctx, providerId);
1511
+ return ctx.json(sanitizeProvider(provider, ctx.context.baseURL));
1375
1512
  });
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
- };
1513
+ };
1514
+ function parseAndValidateConfig(configString, configType) {
1515
+ let config = null;
1516
+ try {
1517
+ config = safeJsonParse(configString);
1518
+ } catch {
1519
+ config = null;
1520
+ }
1521
+ if (!config) throw new APIError("BAD_REQUEST", { message: `Cannot update ${configType} config for a provider that doesn't have ${configType} configured` });
1522
+ return config;
1384
1523
  }
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
1524
+ function mergeSAMLConfig(current, updates, issuer) {
1525
+ return {
1526
+ ...current,
1527
+ ...updates,
1528
+ issuer,
1529
+ entryPoint: updates.entryPoint ?? current.entryPoint,
1530
+ cert: updates.cert ?? current.cert,
1531
+ spMetadata: updates.spMetadata ?? current.spMetadata,
1532
+ idpMetadata: updates.idpMetadata ?? current.idpMetadata,
1533
+ mapping: updates.mapping ?? current.mapping,
1534
+ audience: updates.audience ?? current.audience,
1535
+ wantAssertionsSigned: updates.wantAssertionsSigned ?? current.wantAssertionsSigned,
1536
+ authnRequestsSigned: updates.authnRequestsSigned ?? current.authnRequestsSigned,
1537
+ identifierFormat: updates.identifierFormat ?? current.identifierFormat,
1538
+ signatureAlgorithm: updates.signatureAlgorithm ?? current.signatureAlgorithm,
1539
+ digestAlgorithm: updates.digestAlgorithm ?? current.digestAlgorithm
1540
+ };
1541
+ }
1542
+ function mergeOIDCConfig(current, updates, issuer) {
1543
+ return {
1544
+ ...current,
1545
+ ...updates,
1546
+ issuer,
1547
+ pkce: updates.pkce ?? current.pkce ?? true,
1548
+ clientId: updates.clientId ?? current.clientId,
1549
+ clientSecret: updates.clientSecret ?? current.clientSecret,
1550
+ discoveryEndpoint: updates.discoveryEndpoint ?? current.discoveryEndpoint,
1551
+ mapping: updates.mapping ?? current.mapping,
1552
+ scopes: updates.scopes ?? current.scopes,
1553
+ authorizationEndpoint: updates.authorizationEndpoint ?? current.authorizationEndpoint,
1554
+ tokenEndpoint: updates.tokenEndpoint ?? current.tokenEndpoint,
1555
+ userInfoEndpoint: updates.userInfoEndpoint ?? current.userInfoEndpoint,
1556
+ jwksEndpoint: updates.jwksEndpoint ?? current.jwksEndpoint,
1557
+ tokenEndpointAuthentication: updates.tokenEndpointAuthentication ?? current.tokenEndpointAuthentication,
1558
+ privateKeyId: updates.privateKeyId ?? current.privateKeyId,
1559
+ privateKeyAlgorithm: updates.privateKeyAlgorithm ?? current.privateKeyAlgorithm
1560
+ };
1561
+ }
1562
+ const updateSSOProvider = (options) => {
1563
+ return createAuthEndpoint("/sso/update-provider", {
1564
+ method: "POST",
1565
+ use: [sessionMiddleware],
1566
+ body: updateSSOProviderBodySchema.extend({ providerId: z.string() }),
1567
+ metadata: { openapi: {
1568
+ operationId: "updateSSOProvider",
1569
+ summary: "Update SSO provider",
1570
+ description: "Partially update an SSO provider. Only provided fields are updated. If domain changes, domainVerified is reset to false.",
1571
+ responses: {
1572
+ "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`." },
1573
+ "404": { description: "Provider not found" },
1574
+ "403": { description: "Access denied" }
1575
+ }
1576
+ } }
1577
+ }, async (ctx) => {
1578
+ const { providerId, ...body } = ctx.body;
1579
+ const { issuer, domain, samlConfig, oidcConfig } = body;
1580
+ if (!issuer && !domain && !samlConfig && !oidcConfig) throw new APIError("BAD_REQUEST", { message: "No fields provided for update" });
1581
+ const existingProvider = await checkProviderAccess(ctx, providerId);
1582
+ const updateData = {};
1583
+ if (body.issuer !== void 0) updateData.issuer = body.issuer;
1584
+ if (body.domain !== void 0) {
1585
+ updateData.domain = body.domain;
1586
+ if (body.domain !== existingProvider.domain) updateData.domainVerified = false;
1587
+ }
1588
+ if (body.samlConfig) {
1589
+ if (body.samlConfig.idpMetadata?.metadata) {
1590
+ const maxMetadataSize = options?.saml?.maxMetadataSize ?? 102400;
1591
+ if (new TextEncoder().encode(body.samlConfig.idpMetadata.metadata).length > maxMetadataSize) throw new APIError("BAD_REQUEST", { message: `IdP metadata exceeds maximum allowed size (${maxMetadataSize} bytes)` });
1592
+ }
1593
+ if (body.samlConfig.signatureAlgorithm !== void 0 || body.samlConfig.digestAlgorithm !== void 0) validateConfigAlgorithms({
1594
+ signatureAlgorithm: body.samlConfig.signatureAlgorithm,
1595
+ digestAlgorithm: body.samlConfig.digestAlgorithm
1596
+ }, options?.saml?.algorithms);
1597
+ const currentSamlConfig = parseAndValidateConfig(existingProvider.samlConfig, "SAML");
1598
+ const updatedSamlConfig = mergeSAMLConfig(currentSamlConfig, body.samlConfig, updateData.issuer || currentSamlConfig.issuer || existingProvider.issuer);
1599
+ validateCertSources(updatedSamlConfig);
1600
+ updateData.samlConfig = JSON.stringify(updatedSamlConfig);
1601
+ }
1602
+ if (body.oidcConfig) {
1603
+ try {
1604
+ validateSkipDiscoveryEndpoints(body.oidcConfig, (url) => ctx.context.isTrustedOrigin(url));
1605
+ } catch (error) {
1606
+ if (error instanceof DiscoveryError) throw mapDiscoveryErrorToAPIError(error);
1607
+ throw error;
1608
+ }
1609
+ const currentOidcConfig = parseAndValidateConfig(existingProvider.oidcConfig, "OIDC");
1610
+ const updatedOidcConfig = mergeOIDCConfig(currentOidcConfig, body.oidcConfig, updateData.issuer || currentOidcConfig.issuer || existingProvider.issuer);
1611
+ 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" });
1612
+ 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" });
1613
+ updateData.oidcConfig = JSON.stringify(updatedOidcConfig);
1614
+ }
1615
+ await ctx.context.adapter.update({
1616
+ model: "ssoProvider",
1617
+ where: [{
1618
+ field: "providerId",
1619
+ value: providerId
1620
+ }],
1621
+ update: updateData
1438
1622
  });
1439
- case "issuer_mismatch": return new APIError("BAD_REQUEST", {
1440
- message: `OIDC issuer mismatch: ${error.message}`,
1441
- code: error.code
1623
+ const fullProvider = await ctx.context.adapter.findOne({
1624
+ model: "ssoProvider",
1625
+ where: [{
1626
+ field: "providerId",
1627
+ value: providerId
1628
+ }]
1442
1629
  });
1443
- case "unsupported_token_auth_method": return new APIError("BAD_REQUEST", {
1444
- message: `Incompatible OIDC provider: ${error.message}`,
1445
- code: error.code
1630
+ if (!fullProvider) throw new APIError("NOT_FOUND", { message: "Provider not found after update" });
1631
+ return ctx.json(sanitizeProvider(fullProvider, ctx.context.baseURL));
1632
+ });
1633
+ };
1634
+ const deleteSSOProvider = () => {
1635
+ return createAuthEndpoint("/sso/delete-provider", {
1636
+ method: "POST",
1637
+ use: [sessionMiddleware],
1638
+ body: z.object({ providerId: z.string() }),
1639
+ metadata: { openapi: {
1640
+ operationId: "deleteSSOProvider",
1641
+ summary: "Delete SSO provider",
1642
+ description: "Deletes an SSO provider",
1643
+ responses: {
1644
+ "200": { description: "SSO provider deleted successfully" },
1645
+ "404": { description: "Provider not found" },
1646
+ "403": { description: "Access denied" }
1647
+ }
1648
+ } }
1649
+ }, async (ctx) => {
1650
+ const { providerId } = ctx.body;
1651
+ await checkProviderAccess(ctx, providerId);
1652
+ await ctx.context.adapter.delete({
1653
+ model: "ssoProvider",
1654
+ where: [{
1655
+ field: "providerId",
1656
+ value: providerId
1657
+ }]
1446
1658
  });
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
- });
1659
+ return ctx.json({ success: true });
1660
+ });
1661
+ };
1465
1662
  //#endregion
1466
1663
  //#region src/saml-state.ts
1467
- async function generateRelayState(c, link, additionalData) {
1664
+ async function generateRelayState(c, link) {
1468
1665
  const callbackURL = c.body.callbackURL;
1469
1666
  if (!callbackURL) throw new APIError("BAD_REQUEST", { message: "callbackURL is required" });
1470
- const codeVerifier = generateRandomString(128);
1471
1667
  const stateData = {
1472
- ...additionalData ? additionalData : {},
1473
1668
  callbackURL,
1474
- codeVerifier,
1669
+ codeVerifier: generateRandomString(128),
1475
1670
  errorURL: c.body.errorCallbackURL,
1476
1671
  newUserURL: c.body.newUserCallbackURL,
1477
1672
  link,
@@ -1511,14 +1706,12 @@ const saml = typeof samlifyNamespace.SPMetadata === "function" && typeof samlify
1511
1706
  //#endregion
1512
1707
  //#region src/routes/helpers.ts
1513
1708
  /**
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.
1709
+ * Same as `normalizePem`, but applied across the resolved list of IdP signing
1710
+ * certificates so multi-cert rotation configs survive the line-trim step.
1518
1711
  */
1519
- function normalizePem(pem) {
1520
- if (!pem) return pem;
1521
- return pem.split("\n").map((line) => line.trim()).join("\n");
1712
+ function normalizePemList(certs) {
1713
+ if (!certs) return certs;
1714
+ return certs.map((pem) => normalizePem(pem) ?? pem);
1522
1715
  }
1523
1716
  async function findSAMLProvider(providerId, options, adapter) {
1524
1717
  if (options?.defaultSSO?.length) {
@@ -1575,7 +1768,8 @@ function createSP(config, baseURL, providerId, opts) {
1575
1768
  isAssertionEncrypted: spData?.isAssertionEncrypted || false,
1576
1769
  encPrivateKey: normalizePem(spData?.encPrivateKey),
1577
1770
  encPrivateKeyPass: spData?.encPrivateKeyPass,
1578
- relayState: opts?.relayState
1771
+ relayState: opts?.relayState,
1772
+ clockDrifts: opts?.clockSkew && opts?.clockSkew !== 0 ? [-opts.clockSkew, opts.clockSkew] : void 0
1579
1773
  });
1580
1774
  }
1581
1775
  function createIdP(config) {
@@ -1595,7 +1789,7 @@ function createIdP(config) {
1595
1789
  Location: config.entryPoint
1596
1790
  }],
1597
1791
  singleLogoutService: idpData?.singleLogoutService,
1598
- signingCert: normalizePem(idpData?.cert || config.cert),
1792
+ signingCert: normalizePemList(resolveSigningCerts(config)),
1599
1793
  wantAuthnRequestsSigned: config.authnRequestsSigned || false,
1600
1794
  isAssertionEncrypted: idpData?.isAssertionEncrypted || false,
1601
1795
  encPrivateKey: normalizePem(idpData?.encPrivateKey),
@@ -1731,7 +1925,7 @@ async function processSAMLResponse(ctx, params, options) {
1731
1925
  if (options?.domainVerification?.enabled && !("domainVerified" in provider && provider.domainVerified)) throw new APIError("UNAUTHORIZED", { message: "Provider domain has not been verified" });
1732
1926
  const parsedSamlConfig = typeof provider.samlConfig === "object" ? provider.samlConfig : safeJsonParse(provider.samlConfig);
1733
1927
  if (!parsedSamlConfig) throw new APIError("BAD_REQUEST", { message: "Invalid SAML configuration" });
1734
- const sp = createSP(parsedSamlConfig, ctx.context.baseURL, providerId);
1928
+ const sp = createSP(parsedSamlConfig, ctx.context.baseURL, providerId, { clockSkew: options?.saml?.clockSkew });
1735
1929
  const idp = createIdP(parsedSamlConfig);
1736
1930
  const samlRedirectUrl = getSafeRedirectUrl(relayState?.callbackURL, params.currentCallbackPath, appOrigin, (url, settings) => ctx.context.isTrustedOrigin(url, settings));
1737
1931
  validateSingleAssertion(SAMLResponse);
@@ -1813,12 +2007,16 @@ async function processSAMLResponse(ctx, params, options) {
1813
2007
  } else ctx.context.logger.warn("Could not extract assertion ID for replay protection", { providerId });
1814
2008
  const attributes = extract.attributes || {};
1815
2009
  const mapping = parsedSamlConfig.mapping ?? {};
2010
+ const attr = (key) => {
2011
+ const value = attributes[key];
2012
+ return Array.isArray(value) ? value[0] : value;
2013
+ };
1816
2014
  const userInfo = {
1817
2015
  ...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
2016
+ id: attr(mapping.id || "nameID") || extract.nameID,
2017
+ email: (attr(mapping.email || "email") || extract.nameID || "").toLowerCase(),
2018
+ name: [attr(mapping.firstName || "givenName"), attr(mapping.lastName || "surname")].filter(Boolean).join(" ") || attr(mapping.name || "displayName") || extract.nameID,
2019
+ emailVerified: options?.trustEmailVerified && mapping.emailVerified ? attr(mapping.emailVerified) || false : false
1822
2020
  };
1823
2021
  if (!userInfo.id || !userInfo.email) {
1824
2022
  ctx.context.logger.error("Missing essential user info from SAML response", {
@@ -1829,25 +2027,42 @@ async function processSAMLResponse(ctx, params, options) {
1829
2027
  });
1830
2028
  throw new APIError("BAD_REQUEST", { message: "Unable to extract user ID or email from SAML response" });
1831
2029
  }
1832
- const isTrustedProvider = ctx.context.trustedProviders.includes(providerId) || "domainVerified" in provider && !!provider.domainVerified && validateEmailDomain(userInfo.email, provider.domain);
2030
+ const isTrustedProvider = "domainVerified" in provider && !!provider.domainVerified && validateEmailDomain(userInfo.email, provider.domain);
1833
2031
  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: {
2032
+ const errorUrl = relayState?.errorURL || samlRedirectUrl;
2033
+ let result;
2034
+ try {
2035
+ result = await signInWithOAuthIdentity(ctx, {
2036
+ userInfo: {
2037
+ email: userInfo.email,
2038
+ name: userInfo.name || userInfo.email,
2039
+ id: userInfo.id,
2040
+ emailVerified: Boolean(userInfo.emailVerified)
2041
+ },
1842
2042
  providerId,
1843
2043
  accountId: userInfo.id,
1844
- accessToken: "",
1845
- refreshToken: ""
1846
- },
1847
- callbackURL: postAuthRedirect,
1848
- disableSignUp: options?.disableImplicitSignUp,
1849
- isTrustedProvider
1850
- });
2044
+ tokens: {},
2045
+ callbackURL: postAuthRedirect,
2046
+ disableSignUp: options?.disableImplicitSignUp,
2047
+ source: {
2048
+ method: "sso-saml",
2049
+ sso: {
2050
+ providerId,
2051
+ profile: attributes
2052
+ }
2053
+ },
2054
+ isTrustedProvider,
2055
+ trustProviderByName: false
2056
+ });
2057
+ } catch (e) {
2058
+ if (isAPIError(e) && e.body?.code) {
2059
+ const params = new URLSearchParams({ error: e.body.code });
2060
+ if (e.body.message) params.set("error_description", e.body.message);
2061
+ const sep = errorUrl.includes("?") ? "&" : "?";
2062
+ throw ctx.redirect(`${errorUrl}${sep}${params.toString()}`);
2063
+ }
2064
+ throw e;
2065
+ }
1851
2066
  if (result.error) throw ctx.redirect(`${samlRedirectUrl}?error=${result.error.split(" ").join("_")}`);
1852
2067
  const { session, user } = result.data;
1853
2068
  if (options?.provisionUser && (result.isRegister || options.provisionUserOnEveryLogin)) await options.provisionUser({
@@ -1876,6 +2091,7 @@ async function processSAMLResponse(ctx, params, options) {
1876
2091
  const samlSessionKey = `${SAML_SESSION_KEY_PREFIX}${providerId}:${extract.nameID}`;
1877
2092
  const samlSessionData = {
1878
2093
  sessionId: session.id,
2094
+ sessionToken: session.token,
1879
2095
  providerId,
1880
2096
  nameID: extract.nameID,
1881
2097
  sessionIndex: extract.sessionIndex?.sessionIndex
@@ -1931,88 +2147,10 @@ const spMetadata = (options) => {
1931
2147
  return new Response(sp.getMetadata(), { headers: { "Content-Type": "application/xml" } });
1932
2148
  });
1933
2149
  };
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
2150
  const registerSSOProvider = (options) => {
2013
2151
  return createAuthEndpoint("/sso/register", {
2014
2152
  method: "POST",
2015
- body: ssoProviderBodySchema,
2153
+ body: registerSSOProviderBodySchema,
2016
2154
  use: [sessionMiddleware],
2017
2155
  metadata: { openapi: {
2018
2156
  operationId: "registerSSOProvider",
@@ -2193,13 +2331,12 @@ const registerSSOProvider = (options) => {
2193
2331
  }]
2194
2332
  })).length >= limit) throw new APIError("FORBIDDEN", { message: "You have reached the maximum number of SSO providers" });
2195
2333
  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
2334
  if (body.samlConfig?.idpMetadata?.metadata) {
2198
2335
  const maxMetadataSize = options?.saml?.maxMetadataSize ?? 102400;
2199
2336
  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
2337
  }
2201
2338
  if (ctx.body.organizationId) {
2202
- if (!await ctx.context.adapter.findOne({
2339
+ const member = await ctx.context.adapter.findOne({
2203
2340
  model: "member",
2204
2341
  where: [{
2205
2342
  field: "userId",
@@ -2208,7 +2345,17 @@ const registerSSOProvider = (options) => {
2208
2345
  field: "organizationId",
2209
2346
  value: ctx.body.organizationId
2210
2347
  }]
2211
- })) throw new APIError("BAD_REQUEST", { message: "You are not a member of the organization" });
2348
+ });
2349
+ if (!member) throw new APIError("BAD_REQUEST", { message: "You are not a member of the organization" });
2350
+ if (ctx.context.hasPlugin("organization") && !hasOrgAdminRole(member)) throw new APIError("FORBIDDEN", { message: "You must be an organization owner or admin to register SSO providers" });
2351
+ }
2352
+ if (new Set([
2353
+ "credential",
2354
+ ...ctx.context.socialProviders.map((p) => p.id),
2355
+ ...ctx.context.trustedProviders
2356
+ ]).has(body.providerId)) {
2357
+ ctx.context.logger.warn(`SSO provider registration rejected for reserved providerId: ${body.providerId}`);
2358
+ throw new APIError("UNPROCESSABLE_ENTITY", { message: "This providerId is reserved and cannot be used for an SSO provider" });
2212
2359
  }
2213
2360
  if (await ctx.context.adapter.findOne({
2214
2361
  model: "ssoProvider",
@@ -2220,6 +2367,12 @@ const registerSSOProvider = (options) => {
2220
2367
  ctx.context.logger.info(`SSO provider creation attempt with existing providerId: ${body.providerId}`);
2221
2368
  throw new APIError("UNPROCESSABLE_ENTITY", { message: "SSO provider with this providerId already exists" });
2222
2369
  }
2370
+ if (body.oidcConfig) try {
2371
+ validateSkipDiscoveryEndpoints(body.oidcConfig, (url) => ctx.context.isTrustedOrigin(url));
2372
+ } catch (error) {
2373
+ if (error instanceof DiscoveryError) throw mapDiscoveryErrorToAPIError(error);
2374
+ throw error;
2375
+ }
2223
2376
  let hydratedOIDCConfig = null;
2224
2377
  if (body.oidcConfig && !body.oidcConfig.skipDiscovery) try {
2225
2378
  hydratedOIDCConfig = await discoverOIDCConfig({
@@ -2281,6 +2434,7 @@ const registerSSOProvider = (options) => {
2281
2434
  signatureAlgorithm: body.samlConfig.signatureAlgorithm,
2282
2435
  digestAlgorithm: body.samlConfig.digestAlgorithm
2283
2436
  }, options?.saml?.algorithms);
2437
+ validateCertSources(body.samlConfig);
2284
2438
  const hasIdpMetadata = body.samlConfig.idpMetadata?.metadata;
2285
2439
  let hasEntryPoint = false;
2286
2440
  if (body.samlConfig.entryPoint) try {
@@ -2348,17 +2502,18 @@ const registerSSOProvider = (options) => {
2348
2502
  });
2349
2503
  };
2350
2504
  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(),
2505
+ 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(),
2506
+ organizationSlug: z.string({}).meta({ description: "The slug of the organization to sign in with." }).optional(),
2507
+ providerId: z.string({}).meta({ description: "The ID of the provider to sign in with. Can be provided instead of email." }).optional(),
2508
+ domain: z.string({}).meta({ description: "The email domain of the provider. Can be provided instead of email." }).optional(),
2509
+ callbackURL: z.string({}).meta({ description: "The URL to redirect to after successful sign-in." }),
2510
+ errorCallbackURL: z.string({}).meta({ description: "The URL to redirect to if the sign-in flow fails." }).optional(),
2511
+ newUserCallbackURL: z.string({}).meta({ description: "The URL to redirect to after sign-in if the user is newly registered." }).optional(),
2358
2512
  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()
2513
+ 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(),
2514
+ additionalParams: additionalAuthorizationParamsSchema,
2515
+ requestSignUp: z.boolean({}).meta({ description: "Explicitly request sign-up. Useful when disableImplicitSignUp is true for this provider." }).optional(),
2516
+ providerType: z.enum(["oidc", "saml"]).meta({ description: "The provider protocol to sign in with." }).optional()
2362
2517
  });
2363
2518
  const signInSSO = (options) => {
2364
2519
  return createAuthEndpoint("/sign-in/sso", {
@@ -2373,31 +2528,54 @@ const signInSSO = (options) => {
2373
2528
  properties: {
2374
2529
  email: {
2375
2530
  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"
2531
+ 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
2532
  },
2378
- issuer: {
2533
+ organizationSlug: {
2379
2534
  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"
2535
+ description: "The slug of the organization to sign in with."
2381
2536
  },
2382
2537
  providerId: {
2383
2538
  type: "string",
2384
- description: "The ID of the provider to sign in with. This can be provided instead of email or issuer"
2539
+ description: "The ID of the provider to sign in with. Can be provided instead of email."
2540
+ },
2541
+ domain: {
2542
+ type: "string",
2543
+ description: "The email domain of the provider. Can be provided instead of email."
2385
2544
  },
2386
2545
  callbackURL: {
2387
2546
  type: "string",
2388
- description: "The URL to redirect to after login"
2547
+ description: "The URL to redirect to after successful sign-in."
2389
2548
  },
2390
2549
  errorCallbackURL: {
2391
2550
  type: "string",
2392
- description: "The URL to redirect to after login"
2551
+ description: "The URL to redirect to if the sign-in flow fails."
2393
2552
  },
2394
2553
  newUserCallbackURL: {
2395
2554
  type: "string",
2396
- description: "The URL to redirect to after login if the user is new"
2555
+ description: "The URL to redirect to after sign-in if the user is newly registered."
2556
+ },
2557
+ scopes: {
2558
+ type: "array",
2559
+ items: { type: "string" },
2560
+ description: "Scopes to request from the provider."
2397
2561
  },
2398
2562
  loginHint: {
2399
2563
  type: "string",
2400
2564
  description: "Login hint to send to the identity provider (e.g., email or identifier). If supported, sent as 'login_hint'."
2565
+ },
2566
+ additionalParams: {
2567
+ type: "object",
2568
+ additionalProperties: { type: "string" },
2569
+ 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."
2570
+ },
2571
+ requestSignUp: {
2572
+ type: "boolean",
2573
+ description: "Explicitly request sign-up. Useful when disableImplicitSignUp is true for this provider."
2574
+ },
2575
+ providerType: {
2576
+ type: "string",
2577
+ enum: ["oidc", "saml"],
2578
+ description: "The provider protocol to sign in with."
2401
2579
  }
2402
2580
  },
2403
2581
  required: ["callbackURL"]
@@ -2494,9 +2672,16 @@ const signInSSO = (options) => {
2494
2672
  throw error;
2495
2673
  }
2496
2674
  if (!config.authorizationEndpoint) throw new APIError("BAD_REQUEST", { message: "Invalid OIDC configuration. Authorization URL not found." });
2497
- const state = await generateState(ctx, void 0, options?.redirectURI?.trim() ? { ssoProviderId: provider.providerId } : false);
2675
+ const requestedScopes = ctx.body.scopes || config.scopes || [
2676
+ "openid",
2677
+ "email",
2678
+ "profile",
2679
+ "offline_access"
2680
+ ];
2681
+ if (options?.redirectURI?.trim()) await addOAuthServerContext({ ssoProviderId: provider.providerId });
2682
+ const state = await generateState(ctx, { requestedScopes });
2498
2683
  const redirectURI = getOIDCRedirectURI(ctx.context.baseURL, provider.providerId, options);
2499
- const authorizationURL = await createAuthorizationURL({
2684
+ const { url: authorizationURL } = await createAuthorizationURL({
2500
2685
  id: provider.issuer,
2501
2686
  options: {
2502
2687
  clientId: config.clientId,
@@ -2505,14 +2690,10 @@ const signInSSO = (options) => {
2505
2690
  redirectURI,
2506
2691
  state: state.state,
2507
2692
  codeVerifier: config.pkce ? state.codeVerifier : void 0,
2508
- scopes: ctx.body.scopes || config.scopes || [
2509
- "openid",
2510
- "email",
2511
- "profile",
2512
- "offline_access"
2513
- ],
2693
+ scopes: requestedScopes,
2514
2694
  loginHint: ctx.body.loginHint || email,
2515
- authorizationEndpoint: config.authorizationEndpoint
2695
+ authorizationEndpoint: config.authorizationEndpoint,
2696
+ additionalParams: ctx.body.additionalParams
2516
2697
  });
2517
2698
  return ctx.json({
2518
2699
  url: authorizationURL.toString(),
@@ -2520,10 +2701,11 @@ const signInSSO = (options) => {
2520
2701
  });
2521
2702
  }
2522
2703
  if (provider.samlConfig) {
2704
+ 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
2705
  const parsedSamlConfig = typeof provider.samlConfig === "object" ? provider.samlConfig : safeJsonParse(provider.samlConfig);
2524
2706
  if (!parsedSamlConfig) throw new APIError("BAD_REQUEST", { message: "Invalid SAML configuration" });
2525
2707
  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" });
2526
- const { state: relayState } = await generateRelayState(ctx, void 0, false);
2708
+ const { state: relayState } = await generateRelayState(ctx, void 0);
2527
2709
  const sp = createSP(parsedSamlConfig, ctx.context.baseURL, provider.providerId, { relayState });
2528
2710
  const idp = createIdP(parsedSamlConfig);
2529
2711
  const loginRequest = sp.createLoginRequest(idp, "redirect");
@@ -2552,7 +2734,7 @@ const signInSSO = (options) => {
2552
2734
  };
2553
2735
  const callbackSSOQuerySchema = z.object({
2554
2736
  code: z.string().optional(),
2555
- state: z.string(),
2737
+ state: z.string().optional(),
2556
2738
  error: z.string().optional(),
2557
2739
  error_description: z.string().optional()
2558
2740
  });
@@ -2571,31 +2753,9 @@ async function handleOIDCCallback(ctx, options, providerId, stateData) {
2571
2753
  const errorURL = ctx.context.options.onAPIError?.errorURL || `${ctx.context.baseURL}/error`;
2572
2754
  throw ctx.redirect(`${errorURL}?error=invalid_state`);
2573
2755
  }
2574
- const { callbackURL, errorURL, newUserURL, requestSignUp } = stateData;
2756
+ const { callbackURL, errorURL, newUserURL, requestSignUp, requestedScopes } = stateData;
2575
2757
  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
- });
2758
+ const provider = await resolveOIDCProvider(ctx, options, providerId);
2599
2759
  if (!provider) throw ctx.redirect(`${errorURL || callbackURL}?error=invalid_provider&error_description=provider not found`);
2600
2760
  if (options?.domainVerification?.enabled && !("domainVerified" in provider && provider.domainVerified)) throw new APIError("UNAUTHORIZED", { message: "Provider domain has not been verified" });
2601
2761
  let config = provider.oidcConfig;
@@ -2616,11 +2776,8 @@ async function handleOIDCCallback(ctx, options, providerId, stateData) {
2616
2776
  ]
2617
2777
  };
2618
2778
  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") {
2779
+ let tokenEndpointAuth = config.tokenEndpointAuthentication === "client_secret_post" ? { method: "client_secret_post" } : { method: "client_secret_basic" };
2780
+ if (config.tokenEndpointAuthentication === "private_key_jwt") {
2624
2781
  let resolved;
2625
2782
  const matchingDefault = options?.defaultSSO?.find((p) => p.providerId === provider.providerId && "privateKey" in p && p.privateKey);
2626
2783
  if (matchingDefault && "privateKey" in matchingDefault) resolved = matchingDefault.privateKey;
@@ -2629,28 +2786,28 @@ async function handleOIDCCallback(ctx, options, providerId, stateData) {
2629
2786
  keyId: config.privateKeyId,
2630
2787
  issuer: config.issuer
2631
2788
  });
2632
- if (!resolved) throw ctx.redirect(`${errorURL || callbackURL}?error=invalid_provider&error_description=no_private_key_available`);
2789
+ if (!resolved || !resolved.privateKeyJwk && !resolved.privateKeyPem) throw ctx.redirect(`${errorURL || callbackURL}?error=invalid_provider&error_description=no_private_key_available`);
2633
2790
  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
2791
+ const algorithm = rawAlg && PRIVATE_KEY_JWT_SIGNING_ALGORITHMS.includes(rawAlg) ? rawAlg : void 0;
2792
+ tokenEndpointAuth = {
2793
+ method: "private_key_jwt",
2794
+ getClientAssertion: createPrivateKeyJwtClientAssertionGetter({
2795
+ privateKeyJwk: resolved.privateKeyJwk,
2796
+ privateKeyPem: resolved.privateKeyPem,
2797
+ kid: config.privateKeyId ?? resolved.kid,
2798
+ algorithm
2799
+ })
2641
2800
  };
2642
2801
  }
2802
+ const tokenRequestOptions = { clientId: config.clientId };
2803
+ if (tokenEndpointAuth.method !== "private_key_jwt") tokenRequestOptions.clientSecret = config.clientSecret;
2643
2804
  const tokenResponse = await validateAuthorizationCode({
2644
2805
  code,
2645
2806
  codeVerifier: config.pkce ? stateData.codeVerifier : void 0,
2646
2807
  redirectURI: getOIDCRedirectURI(ctx.context.baseURL, provider.providerId, options),
2647
- options: {
2648
- clientId: config.clientId,
2649
- clientSecret: config.clientSecret
2650
- },
2808
+ options: tokenRequestOptions,
2651
2809
  tokenEndpoint: config.tokenEndpoint,
2652
- authentication: authMethod,
2653
- clientAssertion: clientAssertionConfig
2810
+ tokenEndpointAuth
2654
2811
  }).catch((e) => {
2655
2812
  ctx.context.logger.error("Error validating authorization code", e);
2656
2813
  if (e instanceof BetterFetchError) throw ctx.redirect(`${errorURL || callbackURL}?error=invalid_provider&error_description=${e.message}`);
@@ -2659,10 +2816,15 @@ async function handleOIDCCallback(ctx, options, providerId, stateData) {
2659
2816
  if (!tokenResponse) throw ctx.redirect(`${errorURL || callbackURL}?error=invalid_provider&error_description=token_response_not_found`);
2660
2817
  let userInfo = null;
2661
2818
  const mapping = config.mapping || {};
2819
+ let rawProfile;
2662
2820
  if (config.userInfoEndpoint) {
2663
- const userInfoResponse = await betterFetch(config.userInfoEndpoint, { headers: { Authorization: `Bearer ${tokenResponse.accessToken}` } });
2821
+ const userInfoResponse = await betterFetch(config.userInfoEndpoint, {
2822
+ headers: { Authorization: `Bearer ${tokenResponse.accessToken}` },
2823
+ redirect: "error"
2824
+ });
2664
2825
  if (userInfoResponse.error) throw ctx.redirect(`${errorURL || callbackURL}?error=invalid_provider&error_description=${userInfoResponse.error.message}`);
2665
2826
  const rawUserInfo = userInfoResponse.data;
2827
+ rawProfile = rawUserInfo;
2666
2828
  userInfo = {
2667
2829
  ...Object.fromEntries(Object.entries(mapping.extraFields || {}).map(([key, value]) => [key, rawUserInfo[value]])),
2668
2830
  id: rawUserInfo[mapping.id || "sub"],
@@ -2673,6 +2835,7 @@ async function handleOIDCCallback(ctx, options, providerId, stateData) {
2673
2835
  };
2674
2836
  } else if (tokenResponse.idToken) {
2675
2837
  const idToken = decodeJwt(tokenResponse.idToken);
2838
+ rawProfile = idToken;
2676
2839
  if (!config.jwksEndpoint) throw ctx.redirect(`${errorURL || callbackURL}?error=invalid_provider&error_description=jwks_endpoint_not_found`);
2677
2840
  const verified = await validateToken(tokenResponse.idToken, config.jwksEndpoint, {
2678
2841
  audience: config.clientId,
@@ -2693,30 +2856,49 @@ async function handleOIDCCallback(ctx, options, providerId, stateData) {
2693
2856
  } else throw ctx.redirect(`${errorURL || callbackURL}?error=invalid_provider&error_description=user_info_endpoint_not_found`);
2694
2857
  if (!userInfo.email || !userInfo.id) throw ctx.redirect(`${errorURL || callbackURL}?error=invalid_provider&error_description=missing_user_info`);
2695
2858
  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,
2859
+ let linked;
2860
+ try {
2861
+ linked = await signInWithOAuthIdentity(ctx, {
2862
+ userInfo: {
2863
+ email: userInfo.email,
2864
+ name: userInfo.name || "",
2865
+ id: userInfo.id,
2866
+ image: userInfo.image,
2867
+ emailVerified: options?.trustEmailVerified ? userInfo.emailVerified || false : false
2868
+ },
2709
2869
  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}`);
2870
+ accountId: userInfo.id,
2871
+ tokens: tokenResponse,
2872
+ requestedScopes,
2873
+ callbackURL,
2874
+ disableSignUp: options?.disableImplicitSignUp && !requestSignUp,
2875
+ overrideUserInfo: config.overrideUserInfo,
2876
+ source: {
2877
+ method: "sso-oidc",
2878
+ sso: {
2879
+ providerId: provider.providerId,
2880
+ profile: rawProfile
2881
+ }
2882
+ },
2883
+ isTrustedProvider,
2884
+ trustProviderByName: false
2885
+ });
2886
+ } catch (e) {
2887
+ if (isAPIError(e) && e.body?.code) {
2888
+ const baseURL = errorURL || callbackURL;
2889
+ const params = new URLSearchParams({ error: e.body.code });
2890
+ if (e.body.message) params.set("error_description", e.body.message);
2891
+ const sep = baseURL.includes("?") ? "&" : "?";
2892
+ throw ctx.redirect(`${baseURL}${sep}${params.toString()}`);
2893
+ }
2894
+ throw e;
2895
+ }
2896
+ if (linked.error) {
2897
+ const baseURL = errorURL || callbackURL;
2898
+ const params = new URLSearchParams({ error: linked.error });
2899
+ const sep = baseURL.includes("?") ? "&" : "?";
2900
+ throw ctx.redirect(`${baseURL}${sep}${params.toString()}`);
2901
+ }
2720
2902
  const { session, user } = linked.data;
2721
2903
  if (options?.provisionUser && (linked.isRegister || options.provisionUserOnEveryLogin)) await options.provisionUser({
2722
2904
  user,
@@ -2764,9 +2946,91 @@ const callbackSSOEndpointConfig = {
2764
2946
  }
2765
2947
  }
2766
2948
  };
2949
+ /**
2950
+ * Resolves an SSO provider by `providerId`, first checking `options.defaultSSO`
2951
+ * and falling back to the `ssoProvider` table. Returns `null` when no match is
2952
+ * found so the caller can decide how to react (redirect, silently skip, etc.).
2953
+ */
2954
+ async function resolveOIDCProvider(ctx, options, providerId) {
2955
+ const matchingDefault = options?.defaultSSO?.find((defaultProvider) => defaultProvider.providerId === providerId);
2956
+ if (matchingDefault) return {
2957
+ ...matchingDefault,
2958
+ issuer: matchingDefault.oidcConfig?.issuer || "",
2959
+ userId: "default",
2960
+ ...options?.domainVerification?.enabled ? { domainVerified: true } : {}
2961
+ };
2962
+ return ctx.context.adapter.findOne({
2963
+ model: "ssoProvider",
2964
+ where: [{
2965
+ field: "providerId",
2966
+ value: providerId
2967
+ }]
2968
+ }).then((res) => {
2969
+ if (!res) return null;
2970
+ return {
2971
+ ...res,
2972
+ oidcConfig: safeJsonParse(res.oidcConfig) || void 0
2973
+ };
2974
+ });
2975
+ }
2976
+ /**
2977
+ * Restarts the OAuth flow server-side when a stateless callback arrives for
2978
+ * an OIDC provider that opted into IDP-initiated flows. Silently returns
2979
+ * otherwise, letting the normal handler produce its error redirect.
2980
+ */
2981
+ async function bounceIfIdpInitiated(ctx, options, providerId) {
2982
+ const provider = await resolveOIDCProvider(ctx, options, providerId);
2983
+ if (!provider?.oidcConfig?.allowIdpInitiated) return;
2984
+ let config = provider.oidcConfig;
2985
+ try {
2986
+ config = await ensureRuntimeDiscovery(config, provider.issuer, (url) => ctx.context.isTrustedOrigin(url));
2987
+ } catch (error) {
2988
+ ctx.context.logger.error("IDP-initiated bounce skipped: OIDC discovery failed", {
2989
+ providerId: provider.providerId,
2990
+ issuer: provider.issuer,
2991
+ error
2992
+ });
2993
+ return;
2994
+ }
2995
+ if (!config.authorizationEndpoint) {
2996
+ ctx.context.logger.error("IDP-initiated bounce skipped: authorizationEndpoint missing after discovery", {
2997
+ providerId: provider.providerId,
2998
+ issuer: provider.issuer
2999
+ });
3000
+ return;
3001
+ }
3002
+ if (options?.redirectURI?.trim()) await addOAuthServerContext({ ssoProviderId: provider.providerId });
3003
+ const state = await generateState(ctx, { requestedScopes: config.scopes || [
3004
+ "openid",
3005
+ "email",
3006
+ "profile",
3007
+ "offline_access"
3008
+ ] });
3009
+ const redirectURI = getOIDCRedirectURI(ctx.context.baseURL, provider.providerId, options);
3010
+ const { url: authorizationURL } = await createAuthorizationURL({
3011
+ id: provider.issuer,
3012
+ options: {
3013
+ clientId: config.clientId,
3014
+ clientSecret: config.clientSecret
3015
+ },
3016
+ redirectURI,
3017
+ state: state.state,
3018
+ codeVerifier: config.pkce ? state.codeVerifier : void 0,
3019
+ scopes: config.scopes || [
3020
+ "openid",
3021
+ "email",
3022
+ "profile",
3023
+ "offline_access"
3024
+ ],
3025
+ authorizationEndpoint: config.authorizationEndpoint
3026
+ });
3027
+ throw ctx.redirect(authorizationURL.toString());
3028
+ }
2767
3029
  const callbackSSO = (options) => {
2768
3030
  return createAuthEndpoint("/sso/callback/:providerId", callbackSSOEndpointConfig, async (ctx) => {
2769
- return handleOIDCCallback(ctx, options, ctx.params.providerId);
3031
+ const providerId = ctx.params.providerId;
3032
+ if (ctx.query.state === void 0 && ctx.query.code) await bounceIfIdpInitiated(ctx, options, providerId);
3033
+ return handleOIDCCallback(ctx, options, providerId);
2770
3034
  });
2771
3035
  };
2772
3036
  /**
@@ -2792,7 +3056,7 @@ const callbackSSOShared = (options) => {
2792
3056
  const errorURL = ctx.context.options.onAPIError?.errorURL || `${ctx.context.baseURL}/error`;
2793
3057
  throw ctx.redirect(`${errorURL}?error=invalid_state`);
2794
3058
  }
2795
- const providerId = stateData.ssoProviderId;
3059
+ const providerId = stateData.serverContext?.ssoProviderId;
2796
3060
  if (!providerId) {
2797
3061
  const errorURL = stateData.errorURL || stateData.callbackURL;
2798
3062
  throw ctx.redirect(`${errorURL}?error=invalid_state&error_description=missing_provider_id`);
@@ -2941,7 +3205,7 @@ async function handleLogoutRequest(ctx, sp, idp, relayState, providerId) {
2941
3205
  if (stored) {
2942
3206
  const data = safeJsonParse(stored.value);
2943
3207
  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 }));
3208
+ await ctx.context.internalAdapter.deleteSession(data.sessionToken).catch((e) => ctx.context.logger.warn("Failed to delete session during SLO", { error: e }));
2945
3209
  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
3210
  } else ctx.context.logger.warn("SessionIndex mismatch in LogoutRequest - skipping session deletion", {
2947
3211
  providerId,
@@ -2951,10 +3215,9 @@ async function handleLogoutRequest(ctx, sp, idp, relayState, providerId) {
2951
3215
  await ctx.context.internalAdapter.deleteVerificationByIdentifier(key).catch((e) => ctx.context.logger.warn("Failed to delete SAML session key during SLO", e));
2952
3216
  }
2953
3217
  const currentSession = await getSessionFromCtx(ctx);
2954
- if (currentSession?.session) await ctx.context.internalAdapter.deleteSession(currentSession.session.id);
3218
+ if (currentSession?.session) await ctx.context.internalAdapter.deleteSession(currentSession.session.token);
2955
3219
  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));
3220
+ const res = sp.createLogoutResponse(idp, parsed, binding, relayState || "");
2958
3221
  if (binding === "post" && res.entityEndpoint) return createSAMLPostForm(res.entityEndpoint, "SAMLResponse", res.context, relayState);
2959
3222
  throw ctx.redirect(res.context);
2960
3223
  }
@@ -3007,7 +3270,7 @@ const initiateSLO = (options) => {
3007
3270
  });
3008
3271
  if (samlSessionKey) await ctx.context.internalAdapter.deleteVerificationByIdentifier(samlSessionKey).catch((e) => ctx.context.logger.warn("Failed to delete SAML session key during logout", e));
3009
3272
  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);
3273
+ await ctx.context.internalAdapter.deleteSession(session.session.token);
3011
3274
  deleteSessionCookie(ctx);
3012
3275
  throw ctx.redirect(logoutRequest.context);
3013
3276
  });