@better-auth/sso 1.6.10 → 1.6.12

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,12 +1,14 @@
1
- import { t as PACKAGE_VERSION } from "./version-Bwp02oze.mjs";
1
+ import { t as PACKAGE_VERSION } from "./version-dnGn0OgM.mjs";
2
2
  import { APIError, createAuthEndpoint, createAuthMiddleware, getSessionFromCtx, sessionMiddleware } from "better-auth/api";
3
3
  import { XMLParser, XMLValidator } from "fast-xml-parser";
4
4
  import { X509Certificate } from "node:crypto";
5
5
  import { getHostname } from "tldts";
6
6
  import { generateRandomString } from "better-auth/crypto";
7
7
  import * as z from "zod";
8
- import { base64 } from "@better-auth/utils/base64";
8
+ import { isPublicRoutableHost } from "@better-auth/core/utils/host";
9
9
  import { BetterFetchError, betterFetch } from "@better-fetch/fetch";
10
+ import { base64 } from "@better-auth/utils/base64";
11
+ import { isAPIError } from "@better-auth/core/utils/is-api-error";
10
12
  import { HIDE_METADATA, createAuthorizationURL, generateGenericState, generateState, parseGenericState, parseState, validateAuthorizationCode, validateToken } from "better-auth";
11
13
  import { deleteSessionCookie, setSessionCookie } from "better-auth/cookies";
12
14
  import { handleOAuthUserInfo } from "better-auth/oauth2";
@@ -371,992 +373,1060 @@ const verifyDomain = (options) => {
371
373
  });
372
374
  };
373
375
  //#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;
376
+ //#region src/oidc/types.ts
377
+ /**
378
+ * Custom error class for OIDC discovery failures.
379
+ * Can be caught and mapped to APIError at the edge.
380
+ */
381
+ var DiscoveryError = class DiscoveryError extends Error {
382
+ code;
383
+ details;
384
+ constructor(code, message, details, options) {
385
+ super(message, options);
386
+ this.name = "DiscoveryError";
387
+ this.code = code;
388
+ this.details = details;
389
+ if (Error.captureStackTrace) Error.captureStackTrace(this, DiscoveryError);
402
390
  }
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
391
  };
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
- };
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
392
+ /**
393
+ * Required fields that must be present in a valid discovery document.
394
+ */
395
+ const REQUIRED_DISCOVERY_FIELDS = [
396
+ "issuer",
397
+ "authorization_endpoint",
398
+ "token_endpoint",
399
+ "jwks_uri"
454
400
  ];
455
- const SHORT_FORM_SIGNATURE_TO_URI = {
456
- sha1: SignatureAlgorithm.RSA_SHA1,
457
- sha256: SignatureAlgorithm.RSA_SHA256,
458
- sha384: SignatureAlgorithm.RSA_SHA384,
459
- sha512: SignatureAlgorithm.RSA_SHA512,
460
- "rsa-sha1": SignatureAlgorithm.RSA_SHA1,
461
- "rsa-sha256": SignatureAlgorithm.RSA_SHA256,
462
- "rsa-sha384": SignatureAlgorithm.RSA_SHA384,
463
- "rsa-sha512": SignatureAlgorithm.RSA_SHA512,
464
- "ecdsa-sha256": SignatureAlgorithm.ECDSA_SHA256,
465
- "ecdsa-sha384": SignatureAlgorithm.ECDSA_SHA384,
466
- "ecdsa-sha512": SignatureAlgorithm.ECDSA_SHA512
467
- };
468
- const SHORT_FORM_DIGEST_TO_URI = {
469
- sha1: DigestAlgorithm.SHA1,
470
- sha256: DigestAlgorithm.SHA256,
471
- sha384: DigestAlgorithm.SHA384,
472
- sha512: DigestAlgorithm.SHA512
473
- };
474
- function normalizeSignatureAlgorithm(alg) {
475
- return SHORT_FORM_SIGNATURE_TO_URI[alg.toLowerCase()] ?? alg;
476
- }
477
- function normalizeDigestAlgorithm(alg) {
478
- return SHORT_FORM_DIGEST_TO_URI[alg.toLowerCase()] ?? alg;
479
- }
480
- function extractEncryptionAlgorithms(xml) {
481
- try {
482
- const parsed = xmlParser.parse(xml);
483
- const keyAlg = (findNode(parsed, "EncryptedKey")?.EncryptionMethod)?.["@_Algorithm"];
484
- const dataAlg = (findNode(parsed, "EncryptedData")?.EncryptionMethod)?.["@_Algorithm"];
485
- return {
486
- keyEncryption: keyAlg || null,
487
- dataEncryption: dataAlg || null
488
- };
489
- } catch {
490
- return {
491
- keyEncryption: null,
492
- dataEncryption: null
493
- };
494
- }
401
+ //#endregion
402
+ //#region src/oidc/discovery.ts
403
+ /**
404
+ * OIDC Discovery Pipeline
405
+ *
406
+ * Implements OIDC discovery document fetching, validation, and hydration.
407
+ * This module is used both at provider registration time (to persist validated config)
408
+ * and at runtime (to hydrate legacy providers that are missing metadata).
409
+ *
410
+ * @see https://openid.net/specs/openid-connect-discovery-1_0.html
411
+ */
412
+ /** Default timeout for discovery requests (10 seconds) */
413
+ const DEFAULT_DISCOVERY_TIMEOUT = 1e4;
414
+ /**
415
+ * Main entry point: Discover and hydrate OIDC configuration from an issuer.
416
+ *
417
+ * This function:
418
+ * 1. Computes the discovery URL from the issuer
419
+ * 2. Validates the discovery URL
420
+ * 3. Fetches the discovery document
421
+ * 4. Validates the discovery document (issuer match + required fields)
422
+ * 5. Normalizes URLs
423
+ * 6. Selects token endpoint auth method
424
+ * 7. Merges with existing config (existing values take precedence)
425
+ *
426
+ * @param params - Discovery parameters
427
+ * @param isTrustedOrigin - Origin verification tester function
428
+ * @returns Hydrated OIDC configuration ready for persistence
429
+ * @throws DiscoveryError on any failure
430
+ */
431
+ async function discoverOIDCConfig(params) {
432
+ const { issuer, existingConfig, timeout = DEFAULT_DISCOVERY_TIMEOUT } = params;
433
+ const discoveryUrl = params.discoveryEndpoint || existingConfig?.discoveryEndpoint || computeDiscoveryUrl(issuer);
434
+ validateDiscoveryUrl(discoveryUrl, params.isTrustedOrigin);
435
+ const discoveryDoc = await fetchDiscoveryDocument(discoveryUrl, timeout);
436
+ validateDiscoveryDocument(discoveryDoc, issuer);
437
+ const normalizedDoc = normalizeDiscoveryUrls(discoveryDoc, issuer, params.isTrustedOrigin);
438
+ const tokenEndpointAuth = selectTokenEndpointAuthMethod(normalizedDoc, existingConfig?.tokenEndpointAuthentication);
439
+ return {
440
+ issuer: existingConfig?.issuer ?? normalizedDoc.issuer,
441
+ discoveryEndpoint: existingConfig?.discoveryEndpoint ?? discoveryUrl,
442
+ authorizationEndpoint: existingConfig?.authorizationEndpoint ?? normalizedDoc.authorization_endpoint,
443
+ tokenEndpoint: existingConfig?.tokenEndpoint ?? normalizedDoc.token_endpoint,
444
+ jwksEndpoint: existingConfig?.jwksEndpoint ?? normalizedDoc.jwks_uri,
445
+ userInfoEndpoint: existingConfig?.userInfoEndpoint ?? normalizedDoc.userinfo_endpoint,
446
+ tokenEndpointAuthentication: existingConfig?.tokenEndpointAuthentication ?? tokenEndpointAuth,
447
+ scopesSupported: existingConfig?.scopesSupported ?? normalizedDoc.scopes_supported
448
+ };
495
449
  }
496
- function hasEncryptedAssertion(xml) {
497
- try {
498
- return findNode(xmlParser.parse(xml), "EncryptedAssertion") !== null;
499
- } catch {
500
- return false;
501
- }
450
+ /**
451
+ * Compute the discovery URL from an issuer URL.
452
+ *
453
+ * Per OIDC Discovery spec, the discovery document is located at:
454
+ * <issuer>/.well-known/openid-configuration
455
+ *
456
+ * Handles trailing slashes correctly.
457
+ */
458
+ function computeDiscoveryUrl(issuer) {
459
+ return `${issuer.endsWith("/") ? issuer.slice(0, -1) : issuer}/.well-known/openid-configuration`;
502
460
  }
503
- function handleDeprecatedAlgorithm(message, behavior, errorCode) {
504
- switch (behavior) {
505
- case "reject": throw new APIError("BAD_REQUEST", {
506
- message,
507
- code: errorCode
508
- });
509
- case "warn":
510
- console.warn(`[SAML Security Warning] ${message}`);
511
- break;
512
- case "allow": break;
513
- }
461
+ /**
462
+ * Validate a discovery URL before fetching.
463
+ *
464
+ * @param url - The discovery URL to validate
465
+ * @param isTrustedOrigin - Origin verification tester function
466
+ * @throws DiscoveryError if URL is invalid
467
+ */
468
+ function validateDiscoveryUrl(url, isTrustedOrigin) {
469
+ const discoveryEndpoint = parseURL("discoveryEndpoint", url).toString();
470
+ if (!isTrustedOrigin(discoveryEndpoint)) throw new DiscoveryError("discovery_untrusted_origin", `The main discovery endpoint "${discoveryEndpoint}" is not trusted by your trusted origins configuration.`, { url: discoveryEndpoint });
514
471
  }
515
- function validateSignatureAlgorithm(algorithm, options = {}) {
516
- if (!algorithm) return;
517
- const { onDeprecated = "warn", allowedSignatureAlgorithms } = options;
518
- if (allowedSignatureAlgorithms) {
519
- if (!allowedSignatureAlgorithms.includes(algorithm)) throw new APIError("BAD_REQUEST", {
520
- message: `SAML signature algorithm not in allow-list: ${algorithm}`,
521
- code: "SAML_ALGORITHM_NOT_ALLOWED"
522
- });
523
- return;
524
- }
525
- if (DEPRECATED_SIGNATURE_ALGORITHMS.includes(algorithm)) {
526
- handleDeprecatedAlgorithm(`SAML response uses deprecated signature algorithm: ${algorithm}. Please configure your IdP to use SHA-256 or stronger.`, onDeprecated, "SAML_DEPRECATED_ALGORITHM");
527
- return;
528
- }
529
- if (!SECURE_SIGNATURE_ALGORITHMS.includes(algorithm)) throw new APIError("BAD_REQUEST", {
530
- message: `SAML signature algorithm not recognized: ${algorithm}`,
531
- code: "SAML_UNKNOWN_ALGORITHM"
472
+ /**
473
+ * Validate that a user-supplied OIDC endpoint URL is safe to fetch.
474
+ *
475
+ * Layered checks (in order):
476
+ * 1. URL parsing + http(s) scheme → discovery_invalid_url
477
+ * 2. Public-routable host (RFC 6890) → allowed
478
+ * 3. Operator-allowlisted via trustedOrigins → allowed (opt-in for internal IdPs)
479
+ * 4. Otherwise → discovery_private_host
480
+ *
481
+ * Step 2 rejects loopback, RFC 1918, link-local, ULA, shared-address,
482
+ * cloud-metadata FQDNs (e.g. `169.254.169.254`, `metadata.google.internal`),
483
+ * multicast, and reserved ranges. See `isPublicRoutableHost` in
484
+ * `@better-auth/core/utils/host`.
485
+ *
486
+ * Step 3 is the documented escape hatch for customers whose IdP runs on a
487
+ * private network or behind a corporate VPN: they add the IdP origin to their
488
+ * `trustedOrigins` configuration.
489
+ *
490
+ * @param name - The endpoint field name, used in error messages
491
+ * @param endpoint - The URL to validate
492
+ * @param isTrustedOrigin - Predicate matching the configured `trustedOrigins`
493
+ * @throws DiscoveryError(discovery_invalid_url) — malformed URL or non-http(s) scheme
494
+ * @throws DiscoveryError(discovery_private_host) — host is not publicly routable and not allowlisted
495
+ */
496
+ function validateSkipDiscoveryEndpoint(name, endpoint, isTrustedOrigin) {
497
+ const parsed = parseURL(name, endpoint);
498
+ if (isPublicRoutableHost(parsed.hostname)) return;
499
+ if (isTrustedOrigin(parsed.toString())) return;
500
+ 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.`, {
501
+ endpoint: name,
502
+ url: endpoint,
503
+ hostname: parsed.hostname
532
504
  });
533
505
  }
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"
506
+ /**
507
+ * Validate every present OIDC endpoint URL in a registration or update body.
508
+ *
509
+ * Each provided URL is checked with {@link validateSkipDiscoveryEndpoint}.
510
+ * Omitted (undefined / null / empty) fields are skipped.
511
+ *
512
+ * @param config - OIDC endpoint URLs from the request body
513
+ * @param isTrustedOrigin - Predicate matching the configured `trustedOrigins`
514
+ * @throws DiscoveryError on the first invalid endpoint
515
+ */
516
+ function validateSkipDiscoveryEndpoints(config, isTrustedOrigin) {
517
+ const fields = [
518
+ ["authorizationEndpoint", config.authorizationEndpoint],
519
+ ["tokenEndpoint", config.tokenEndpoint],
520
+ ["userInfoEndpoint", config.userInfoEndpoint],
521
+ ["jwksEndpoint", config.jwksEndpoint],
522
+ ["discoveryEndpoint", config.discoveryEndpoint]
523
+ ];
524
+ for (const [name, url] of fields) if (url) validateSkipDiscoveryEndpoint(name, url, isTrustedOrigin);
525
+ }
526
+ /**
527
+ * Fetch the OIDC discovery document from the IdP.
528
+ *
529
+ * @param url - The discovery endpoint URL
530
+ * @param timeout - Request timeout in milliseconds
531
+ * @returns The parsed discovery document
532
+ * @throws DiscoveryError on network errors, timeouts, or invalid responses
533
+ */
534
+ async function fetchDiscoveryDocument(url, timeout = DEFAULT_DISCOVERY_TIMEOUT) {
535
+ try {
536
+ const response = await betterFetch(url, {
537
+ method: "GET",
538
+ timeout
539
+ });
540
+ if (response.error) {
541
+ const { status } = response.error;
542
+ if (status === 404) throw new DiscoveryError("discovery_not_found", "Discovery endpoint not found", {
543
+ url,
544
+ status
542
545
  });
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"
546
+ if (status === 408) throw new DiscoveryError("discovery_timeout", "Discovery request timed out", {
547
+ url,
548
+ timeout
550
549
  });
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"
550
+ throw new DiscoveryError("discovery_unexpected_error", `Unexpected discovery error: ${response.error.statusText}`, {
551
+ url,
552
+ ...response.error
566
553
  });
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"
554
+ }
555
+ if (!response.data) throw new DiscoveryError("discovery_invalid_json", "Discovery endpoint returned an empty response", { url });
556
+ const data = response.data;
557
+ if (typeof data === "string") throw new DiscoveryError("discovery_invalid_json", "Discovery endpoint returned invalid JSON", {
558
+ url,
559
+ bodyPreview: data.slice(0, 200)
571
560
  });
572
- }
573
- if (config.digestAlgorithm) {
574
- const normalized = normalizeDigestAlgorithm(config.digestAlgorithm);
575
- if (allowedDigestAlgorithms) {
576
- if (!allowedDigestAlgorithms.map(normalizeDigestAlgorithm).includes(normalized)) throw new APIError("BAD_REQUEST", {
577
- message: `SAML digest algorithm not in allow-list: ${config.digestAlgorithm}`,
578
- code: "SAML_ALGORITHM_NOT_ALLOWED"
579
- });
580
- } else if (DEPRECATED_DIGEST_ALGORITHMS.includes(normalized)) handleDeprecatedAlgorithm(`SAML config uses deprecated digest algorithm: ${config.digestAlgorithm}. Consider using SHA-256 or stronger.`, onDeprecated, "SAML_DEPRECATED_CONFIG_ALGORITHM");
581
- else if (!SECURE_DIGEST_ALGORITHMS.includes(normalized)) throw new APIError("BAD_REQUEST", {
582
- message: `SAML digest algorithm not recognized: ${config.digestAlgorithm}`,
583
- code: "SAML_UNKNOWN_ALGORITHM"
561
+ return data;
562
+ } catch (error) {
563
+ if (error instanceof DiscoveryError) throw error;
564
+ if (error instanceof Error && error.name === "AbortError") throw new DiscoveryError("discovery_timeout", "Discovery request timed out", {
565
+ url,
566
+ timeout
584
567
  });
568
+ throw new DiscoveryError("discovery_unexpected_error", `Unexpected error during discovery: ${error instanceof Error ? error.message : String(error)}`, { url }, { cause: error });
585
569
  }
586
570
  }
587
- //#endregion
588
- //#region src/saml/assertions.ts
589
- function countAssertions(xml) {
590
- let parsed;
571
+ /**
572
+ * Validate a discovery document.
573
+ *
574
+ * Checks:
575
+ * 1. All required fields are present
576
+ * 2. Issuer matches the configured issuer (case-sensitive, exact match)
577
+ *
578
+ * Invariant: If this function returns without throwing, the document is safe
579
+ * to use for hydrating OIDC config (required fields present, issuer matches
580
+ * configured value, basic structural sanity verified).
581
+ *
582
+ * @param doc - The discovery document to validate
583
+ * @param configuredIssuer - The expected issuer value
584
+ * @throws DiscoveryError if validation fails
585
+ */
586
+ function validateDiscoveryDocument(doc, configuredIssuer) {
587
+ const missingFields = [];
588
+ for (const field of REQUIRED_DISCOVERY_FIELDS) if (!doc[field]) missingFields.push(field);
589
+ if (missingFields.length > 0) throw new DiscoveryError("discovery_incomplete", `Discovery document is missing required fields: ${missingFields.join(", ")}`, { missingFields });
590
+ 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}"`, {
591
+ discovered: doc.issuer,
592
+ configured: configuredIssuer
593
+ });
594
+ }
595
+ /**
596
+ * Normalize URLs in the discovery document.
597
+ *
598
+ * @param document - The discovery document
599
+ * @param issuer - The base issuer URL
600
+ * @param isTrustedOrigin - Origin verification tester function
601
+ * @returns The normalized discovery document
602
+ */
603
+ function normalizeDiscoveryUrls(document, issuer, isTrustedOrigin) {
604
+ const doc = { ...document };
605
+ doc.token_endpoint = normalizeAndValidateUrl("token_endpoint", doc.token_endpoint, issuer, isTrustedOrigin);
606
+ doc.authorization_endpoint = normalizeAndValidateUrl("authorization_endpoint", doc.authorization_endpoint, issuer, isTrustedOrigin);
607
+ doc.jwks_uri = normalizeAndValidateUrl("jwks_uri", doc.jwks_uri, issuer, isTrustedOrigin);
608
+ if (doc.userinfo_endpoint) doc.userinfo_endpoint = normalizeAndValidateUrl("userinfo_endpoint", doc.userinfo_endpoint, issuer, isTrustedOrigin);
609
+ if (doc.revocation_endpoint) doc.revocation_endpoint = normalizeAndValidateUrl("revocation_endpoint", doc.revocation_endpoint, issuer, isTrustedOrigin);
610
+ if (doc.end_session_endpoint) doc.end_session_endpoint = normalizeAndValidateUrl("end_session_endpoint", doc.end_session_endpoint, issuer, isTrustedOrigin);
611
+ if (doc.introspection_endpoint) doc.introspection_endpoint = normalizeAndValidateUrl("introspection_endpoint", doc.introspection_endpoint, issuer, isTrustedOrigin);
612
+ return doc;
613
+ }
614
+ /**
615
+ * Normalizes and validates a single URL endpoint
616
+ * @param name The url name
617
+ * @param endpoint The url to validate
618
+ * @param issuer The issuer base url
619
+ * @param isTrustedOrigin - Origin verification tester function
620
+ * @returns
621
+ */
622
+ function normalizeAndValidateUrl(name, endpoint, issuer, isTrustedOrigin) {
623
+ const url = normalizeUrl(name, endpoint, issuer);
624
+ if (!isTrustedOrigin(url)) throw new DiscoveryError("discovery_untrusted_origin", `The ${name} "${url}" is not trusted by your trusted origins configuration.`, {
625
+ endpoint: name,
626
+ url
627
+ });
628
+ return url;
629
+ }
630
+ /**
631
+ * Normalize a single URL endpoint.
632
+ *
633
+ * @param name - The endpoint name (e.g token_endpoint)
634
+ * @param endpoint - The endpoint URL to normalize
635
+ * @param issuer - The base issuer URL
636
+ * @returns The normalized endpoint URL
637
+ */
638
+ function normalizeUrl(name, endpoint, issuer) {
591
639
  try {
592
- parsed = xmlParser.parse(xml);
640
+ return parseURL(name, endpoint).toString();
593
641
  } catch {
594
- throw new APIError("BAD_REQUEST", {
595
- message: "Failed to parse SAML response XML",
596
- code: "SAML_INVALID_XML"
597
- });
642
+ const issuerURL = parseURL(name, issuer);
643
+ const basePath = issuerURL.pathname.replace(/\/+$/, "");
644
+ const endpointPath = endpoint.replace(/^\/+/, "");
645
+ return parseURL(name, basePath + "/" + endpointPath, issuerURL.origin).toString();
598
646
  }
599
- const assertions = countAllNodes(parsed, "Assertion");
600
- const encryptedAssertions = countAllNodes(parsed, "EncryptedAssertion");
601
- return {
602
- assertions,
603
- encryptedAssertions,
604
- total: assertions + encryptedAssertions
605
- };
606
647
  }
607
- function validateSingleAssertion(samlResponse) {
608
- let xml;
648
+ /**
649
+ * Parses the given URL or throws in case of invalid or unsupported protocols
650
+ *
651
+ * @param name the url name
652
+ * @param endpoint the endpoint url
653
+ * @param [base] optional base path
654
+ * @returns
655
+ */
656
+ function parseURL(name, endpoint, base) {
657
+ let endpointURL;
609
658
  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
- });
659
+ endpointURL = new URL(endpoint, base);
660
+ if (endpointURL.protocol === "http:" || endpointURL.protocol === "https:") return endpointURL;
661
+ } catch (error) {
662
+ throw new DiscoveryError("discovery_invalid_url", `The url "${name}" must be valid: ${endpoint}`, { url: endpoint }, { cause: error });
617
663
  }
618
- const counts = countAssertions(xml);
619
- if (counts.total === 0) throw new APIError("BAD_REQUEST", {
620
- message: "SAML response contains no assertions",
621
- code: "SAML_NO_ASSERTION"
622
- });
623
- if (counts.total > 1) throw new APIError("BAD_REQUEST", {
624
- message: `SAML response contains ${counts.total} assertions, expected exactly 1`,
625
- code: "SAML_MULTIPLE_ASSERTIONS"
664
+ throw new DiscoveryError("discovery_invalid_url", `The url "${name}" must use the http or https supported protocols: ${endpoint}`, {
665
+ url: endpoint,
666
+ protocol: endpointURL.protocol
626
667
  });
627
668
  }
628
- //#endregion
629
- //#region src/routes/schemas.ts
630
- const oidcMappingSchema = z.object({
631
- id: z.string().optional(),
632
- email: z.string().optional(),
633
- emailVerified: z.string().optional(),
634
- name: z.string().optional(),
635
- image: z.string().optional(),
636
- extraFields: z.record(z.string(), z.any()).optional()
637
- }).optional();
638
- const samlMappingSchema = z.object({
639
- id: z.string().optional(),
640
- email: z.string().optional(),
641
- emailVerified: z.string().optional(),
642
- name: z.string().optional(),
643
- firstName: z.string().optional(),
644
- lastName: z.string().optional(),
645
- extraFields: z.record(z.string(), z.any()).optional()
646
- }).optional();
647
- const oidcConfigSchema = z.object({
648
- clientId: z.string().optional(),
649
- clientSecret: z.string().optional(),
650
- authorizationEndpoint: z.string().url().optional(),
651
- tokenEndpoint: z.string().url().optional(),
652
- userInfoEndpoint: z.string().url().optional(),
653
- tokenEndpointAuthentication: z.enum(["client_secret_post", "client_secret_basic"]).optional(),
654
- jwksEndpoint: z.string().url().optional(),
655
- discoveryEndpoint: z.string().url().optional(),
656
- scopes: z.array(z.string()).optional(),
657
- pkce: z.boolean().optional(),
658
- overrideUserInfo: z.boolean().optional(),
659
- mapping: oidcMappingSchema
660
- });
661
- const samlConfigSchema = z.object({
662
- entryPoint: z.string().url().optional(),
663
- cert: z.string().optional(),
664
- callbackUrl: z.string().url().optional(),
665
- audience: z.string().optional(),
666
- idpMetadata: z.object({
667
- metadata: z.string().optional(),
668
- entityID: z.string().optional(),
669
- cert: z.string().optional(),
670
- privateKey: z.string().optional(),
671
- privateKeyPass: z.string().optional(),
672
- isAssertionEncrypted: z.boolean().optional(),
673
- encPrivateKey: z.string().optional(),
674
- encPrivateKeyPass: z.string().optional(),
675
- singleSignOnService: z.array(z.object({
676
- Binding: z.string(),
677
- Location: z.string().url()
678
- })).optional()
679
- }).optional(),
680
- spMetadata: z.object({
681
- metadata: z.string().optional(),
682
- entityID: z.string().optional(),
683
- binding: z.string().optional(),
684
- privateKey: z.string().optional(),
685
- privateKeyPass: z.string().optional(),
686
- isAssertionEncrypted: z.boolean().optional(),
687
- encPrivateKey: z.string().optional(),
688
- encPrivateKeyPass: z.string().optional()
689
- }).optional(),
690
- wantAssertionsSigned: z.boolean().optional(),
691
- authnRequestsSigned: z.boolean().optional(),
692
- signatureAlgorithm: z.string().optional(),
693
- digestAlgorithm: z.string().optional(),
694
- identifierFormat: z.string().optional(),
695
- privateKey: z.string().optional(),
696
- decryptionPvk: z.string().optional(),
697
- additionalParams: z.record(z.string(), z.any()).optional(),
698
- mapping: samlMappingSchema
699
- });
700
- const updateSSOProviderBodySchema = z.object({
701
- issuer: z.string().url().optional(),
702
- domain: z.string().optional(),
703
- oidcConfig: oidcConfigSchema.optional(),
704
- samlConfig: samlConfigSchema.optional()
705
- });
706
- //#endregion
707
- //#region src/routes/providers.ts
708
- const ADMIN_ROLES = ["owner", "admin"];
709
- async function isOrgAdmin(ctx, userId, organizationId) {
710
- const member = await ctx.context.adapter.findOne({
711
- model: "member",
712
- where: [{
713
- field: "userId",
714
- value: userId
715
- }, {
716
- field: "organizationId",
717
- value: organizationId
718
- }]
719
- });
720
- if (!member) return false;
721
- return member.role.split(",").some((r) => ADMIN_ROLES.includes(r.trim()));
722
- }
723
- async function batchCheckOrgAdmin(ctx, userId, organizationIds) {
724
- if (organizationIds.length === 0) return /* @__PURE__ */ new Set();
725
- const members = await ctx.context.adapter.findMany({
726
- model: "member",
727
- where: [{
728
- field: "userId",
729
- value: userId
730
- }, {
731
- field: "organizationId",
732
- value: organizationIds,
733
- operator: "in"
734
- }]
735
- });
736
- const adminOrgIds = /* @__PURE__ */ new Set();
737
- for (const member of members) if (member.role.split(",").some((r) => ADMIN_ROLES.includes(r.trim()))) adminOrgIds.add(member.organizationId);
738
- return adminOrgIds;
739
- }
740
- function sanitizeProvider(provider, baseURL) {
741
- let oidcConfig = null;
742
- let samlConfig = null;
743
- try {
744
- oidcConfig = safeJsonParse(provider.oidcConfig);
745
- } catch {
746
- oidcConfig = null;
747
- }
748
- try {
749
- samlConfig = safeJsonParse(provider.samlConfig);
750
- } catch {
751
- samlConfig = null;
752
- }
753
- const type = samlConfig ? "saml" : "oidc";
754
- return {
755
- providerId: provider.providerId,
756
- type,
757
- issuer: provider.issuer,
758
- domain: provider.domain,
759
- organizationId: provider.organizationId || null,
760
- domainVerified: provider.domainVerified ?? false,
761
- oidcConfig: oidcConfig ? {
762
- discoveryEndpoint: oidcConfig.discoveryEndpoint,
763
- clientIdLastFour: maskClientId(oidcConfig.clientId),
764
- pkce: oidcConfig.pkce,
765
- authorizationEndpoint: oidcConfig.authorizationEndpoint,
766
- tokenEndpoint: oidcConfig.tokenEndpoint,
767
- userInfoEndpoint: oidcConfig.userInfoEndpoint,
768
- jwksEndpoint: oidcConfig.jwksEndpoint,
769
- scopes: oidcConfig.scopes,
770
- tokenEndpointAuthentication: oidcConfig.tokenEndpointAuthentication
771
- } : void 0,
772
- samlConfig: samlConfig ? {
773
- entryPoint: samlConfig.entryPoint,
774
- callbackUrl: samlConfig.callbackUrl,
775
- audience: samlConfig.audience,
776
- wantAssertionsSigned: samlConfig.wantAssertionsSigned,
777
- authnRequestsSigned: samlConfig.authnRequestsSigned,
778
- identifierFormat: samlConfig.identifierFormat,
779
- signatureAlgorithm: samlConfig.signatureAlgorithm,
780
- digestAlgorithm: samlConfig.digestAlgorithm,
781
- certificate: (() => {
782
- try {
783
- return parseCertificate(samlConfig.cert);
784
- } catch {
785
- return { error: "Failed to parse certificate" };
786
- }
787
- })()
788
- } : void 0,
789
- spMetadataUrl: `${baseURL}/sso/saml2/sp/metadata?providerId=${encodeURIComponent(provider.providerId)}`
790
- };
791
- }
792
- const listSSOProviders = () => {
793
- return createAuthEndpoint("/sso/providers", {
794
- method: "GET",
795
- use: [sessionMiddleware],
796
- metadata: { openapi: {
797
- operationId: "listSSOProviders",
798
- summary: "List SSO providers",
799
- description: "Returns a list of SSO providers the user has access to",
800
- responses: { "200": { description: "List of SSO providers" } }
801
- } }
802
- }, async (ctx) => {
803
- const userId = ctx.context.session.user.id;
804
- const allProviders = await ctx.context.adapter.findMany({ model: "ssoProvider" });
805
- const userOwnedProviders = allProviders.filter((p) => p.userId === userId && !p.organizationId);
806
- const orgProviders = allProviders.filter((p) => p.organizationId !== null && p.organizationId !== void 0);
807
- const orgPluginEnabled = ctx.context.hasPlugin("organization");
808
- let accessibleProviders = [...userOwnedProviders];
809
- if (orgPluginEnabled && orgProviders.length > 0) {
810
- const adminOrgIds = await batchCheckOrgAdmin(ctx, userId, [...new Set(orgProviders.map((p) => p.organizationId).filter((id) => id !== null && id !== void 0))]);
811
- const orgAccessibleProviders = orgProviders.filter((provider) => provider.organizationId && adminOrgIds.has(provider.organizationId));
812
- accessibleProviders = [...accessibleProviders, ...orgAccessibleProviders];
813
- } else if (!orgPluginEnabled) {
814
- const userOwnedOrgProviders = orgProviders.filter((p) => p.userId === userId);
815
- accessibleProviders = [...accessibleProviders, ...userOwnedOrgProviders];
816
- }
817
- const providers = accessibleProviders.map((p) => sanitizeProvider(p, ctx.context.baseURL));
818
- return ctx.json({ providers });
819
- });
820
- };
821
- const getSSOProviderQuerySchema = z.object({ providerId: z.string() });
822
- async function checkProviderAccess(ctx, providerId) {
823
- const userId = ctx.context.session.user.id;
824
- const provider = await ctx.context.adapter.findOne({
825
- model: "ssoProvider",
826
- where: [{
827
- field: "providerId",
828
- value: providerId
829
- }]
830
- });
831
- if (!provider) throw new APIError("NOT_FOUND", { message: "Provider not found" });
832
- let hasAccess = false;
833
- if (provider.organizationId) if (ctx.context.hasPlugin("organization")) hasAccess = await isOrgAdmin(ctx, userId, provider.organizationId);
834
- else hasAccess = provider.userId === userId;
835
- else hasAccess = provider.userId === userId;
836
- if (!hasAccess) throw new APIError("FORBIDDEN", { message: "You don't have access to this provider" });
837
- return provider;
838
- }
839
- const getSSOProvider = () => {
840
- return createAuthEndpoint("/sso/get-provider", {
841
- method: "GET",
842
- use: [sessionMiddleware],
843
- query: getSSOProviderQuerySchema,
844
- metadata: { openapi: {
845
- operationId: "getSSOProvider",
846
- summary: "Get SSO provider details",
847
- description: "Returns sanitized details for a specific SSO provider",
848
- responses: {
849
- "200": { description: "SSO provider details" },
850
- "404": { description: "Provider not found" },
851
- "403": { description: "Access denied" }
852
- }
853
- } }
854
- }, async (ctx) => {
855
- const { providerId } = ctx.query;
856
- const provider = await checkProviderAccess(ctx, providerId);
857
- return ctx.json(sanitizeProvider(provider, ctx.context.baseURL));
858
- });
859
- };
860
- function parseAndValidateConfig(configString, configType) {
861
- let config = null;
862
- try {
863
- config = safeJsonParse(configString);
864
- } catch {
865
- config = null;
866
- }
867
- if (!config) throw new APIError("BAD_REQUEST", { message: `Cannot update ${configType} config for a provider that doesn't have ${configType} configured` });
868
- return config;
869
- }
870
- function mergeSAMLConfig(current, updates, issuer) {
871
- return {
872
- ...current,
873
- ...updates,
874
- issuer,
875
- entryPoint: updates.entryPoint ?? current.entryPoint,
876
- cert: updates.cert ?? current.cert,
877
- callbackUrl: updates.callbackUrl ?? current.callbackUrl,
878
- spMetadata: updates.spMetadata ?? current.spMetadata,
879
- idpMetadata: updates.idpMetadata ?? current.idpMetadata,
880
- mapping: updates.mapping ?? current.mapping,
881
- audience: updates.audience ?? current.audience,
882
- wantAssertionsSigned: updates.wantAssertionsSigned ?? current.wantAssertionsSigned,
883
- authnRequestsSigned: updates.authnRequestsSigned ?? current.authnRequestsSigned,
884
- identifierFormat: updates.identifierFormat ?? current.identifierFormat,
885
- signatureAlgorithm: updates.signatureAlgorithm ?? current.signatureAlgorithm,
886
- digestAlgorithm: updates.digestAlgorithm ?? current.digestAlgorithm
887
- };
888
- }
889
- function mergeOIDCConfig(current, updates, issuer) {
890
- return {
891
- ...current,
892
- ...updates,
893
- issuer,
894
- pkce: updates.pkce ?? current.pkce ?? true,
895
- clientId: updates.clientId ?? current.clientId,
896
- clientSecret: updates.clientSecret ?? current.clientSecret,
897
- discoveryEndpoint: updates.discoveryEndpoint ?? current.discoveryEndpoint,
898
- mapping: updates.mapping ?? current.mapping,
899
- scopes: updates.scopes ?? current.scopes,
900
- authorizationEndpoint: updates.authorizationEndpoint ?? current.authorizationEndpoint,
901
- tokenEndpoint: updates.tokenEndpoint ?? current.tokenEndpoint,
902
- userInfoEndpoint: updates.userInfoEndpoint ?? current.userInfoEndpoint,
903
- jwksEndpoint: updates.jwksEndpoint ?? current.jwksEndpoint,
904
- tokenEndpointAuthentication: updates.tokenEndpointAuthentication ?? current.tokenEndpointAuthentication
905
- };
906
- }
907
- const updateSSOProvider = (options) => {
908
- return createAuthEndpoint("/sso/update-provider", {
909
- method: "POST",
910
- use: [sessionMiddleware],
911
- body: updateSSOProviderBodySchema.extend({ providerId: z.string() }),
912
- metadata: { openapi: {
913
- operationId: "updateSSOProvider",
914
- summary: "Update SSO provider",
915
- description: "Partially update an SSO provider. Only provided fields are updated. If domain changes, domainVerified is reset to false.",
916
- responses: {
917
- "200": { description: "SSO provider updated successfully" },
918
- "404": { description: "Provider not found" },
919
- "403": { description: "Access denied" }
920
- }
921
- } }
922
- }, async (ctx) => {
923
- const { providerId, ...body } = ctx.body;
924
- const { issuer, domain, samlConfig, oidcConfig } = body;
925
- if (!issuer && !domain && !samlConfig && !oidcConfig) throw new APIError("BAD_REQUEST", { message: "No fields provided for update" });
926
- const existingProvider = await checkProviderAccess(ctx, providerId);
927
- const updateData = {};
928
- if (body.issuer !== void 0) updateData.issuer = body.issuer;
929
- if (body.domain !== void 0) {
930
- updateData.domain = body.domain;
931
- if (body.domain !== existingProvider.domain) updateData.domainVerified = false;
932
- }
933
- if (body.samlConfig) {
934
- if (body.samlConfig.idpMetadata?.metadata) {
935
- const maxMetadataSize = options?.saml?.maxMetadataSize ?? 102400;
936
- if (new TextEncoder().encode(body.samlConfig.idpMetadata.metadata).length > maxMetadataSize) throw new APIError("BAD_REQUEST", { message: `IdP metadata exceeds maximum allowed size (${maxMetadataSize} bytes)` });
937
- }
938
- if (body.samlConfig.signatureAlgorithm !== void 0 || body.samlConfig.digestAlgorithm !== void 0) validateConfigAlgorithms({
939
- signatureAlgorithm: body.samlConfig.signatureAlgorithm,
940
- digestAlgorithm: body.samlConfig.digestAlgorithm
941
- }, options?.saml?.algorithms);
942
- const currentSamlConfig = parseAndValidateConfig(existingProvider.samlConfig, "SAML");
943
- const updatedSamlConfig = mergeSAMLConfig(currentSamlConfig, body.samlConfig, updateData.issuer || currentSamlConfig.issuer || existingProvider.issuer);
944
- updateData.samlConfig = JSON.stringify(updatedSamlConfig);
945
- }
946
- if (body.oidcConfig) {
947
- const currentOidcConfig = parseAndValidateConfig(existingProvider.oidcConfig, "OIDC");
948
- const updatedOidcConfig = mergeOIDCConfig(currentOidcConfig, body.oidcConfig, updateData.issuer || currentOidcConfig.issuer || existingProvider.issuer);
949
- updateData.oidcConfig = JSON.stringify(updatedOidcConfig);
950
- }
951
- await ctx.context.adapter.update({
952
- model: "ssoProvider",
953
- where: [{
954
- field: "providerId",
955
- value: providerId
956
- }],
957
- update: updateData
958
- });
959
- const fullProvider = await ctx.context.adapter.findOne({
960
- model: "ssoProvider",
961
- where: [{
962
- field: "providerId",
963
- value: providerId
964
- }]
965
- });
966
- if (!fullProvider) throw new APIError("NOT_FOUND", { message: "Provider not found after update" });
967
- return ctx.json(sanitizeProvider(fullProvider, ctx.context.baseURL));
968
- });
969
- };
970
- const deleteSSOProvider = () => {
971
- return createAuthEndpoint("/sso/delete-provider", {
972
- method: "POST",
973
- use: [sessionMiddleware],
974
- body: z.object({ providerId: z.string() }),
975
- metadata: { openapi: {
976
- operationId: "deleteSSOProvider",
977
- summary: "Delete SSO provider",
978
- description: "Deletes an SSO provider",
979
- responses: {
980
- "200": { description: "SSO provider deleted successfully" },
981
- "404": { description: "Provider not found" },
982
- "403": { description: "Access denied" }
983
- }
984
- } }
985
- }, async (ctx) => {
986
- const { providerId } = ctx.body;
987
- await checkProviderAccess(ctx, providerId);
988
- await ctx.context.adapter.delete({
989
- model: "ssoProvider",
990
- where: [{
991
- field: "providerId",
992
- value: providerId
993
- }]
994
- });
995
- return ctx.json({ success: true });
996
- });
997
- };
998
- //#endregion
999
- //#region src/oidc/types.ts
1000
669
  /**
1001
- * Custom error class for OIDC discovery failures.
1002
- * Can be caught and mapped to APIError at the edge.
670
+ * Select the token endpoint authentication method.
671
+ *
672
+ * @param doc - The discovery document
673
+ * @param existing - Existing authentication method from config
674
+ * @returns The selected authentication method
675
+ */
676
+ function selectTokenEndpointAuthMethod(doc, existing) {
677
+ if (existing) return existing;
678
+ const supported = doc.token_endpoint_auth_methods_supported;
679
+ if (!supported || supported.length === 0) return "client_secret_basic";
680
+ if (supported.includes("client_secret_basic")) return "client_secret_basic";
681
+ if (supported.includes("client_secret_post")) return "client_secret_post";
682
+ return "client_secret_basic";
683
+ }
684
+ /**
685
+ * Check if a provider configuration needs runtime discovery.
686
+ *
687
+ * Returns true if we need discovery at runtime to complete the token exchange
688
+ * and validation. Specifically checks for:
689
+ * - `tokenEndpoint` - required for exchanging authorization code for tokens
690
+ * - `jwksEndpoint` - required for validating ID token signatures
691
+ * - `authorizationEndpoint` - required for redirecting users to the IdP for login
692
+ *
693
+ * @param config - Partial OIDC config from the provider
694
+ * @returns true if runtime discovery should be performed
1003
695
  */
1004
- var DiscoveryError = class DiscoveryError extends Error {
1005
- code;
1006
- details;
1007
- constructor(code, message, details, options) {
1008
- super(message, options);
1009
- this.name = "DiscoveryError";
1010
- this.code = code;
1011
- this.details = details;
1012
- if (Error.captureStackTrace) Error.captureStackTrace(this, DiscoveryError);
1013
- }
1014
- };
696
+ function needsRuntimeDiscovery(config) {
697
+ if (!config) return true;
698
+ return !config.tokenEndpoint || !config.jwksEndpoint || !config.authorizationEndpoint;
699
+ }
1015
700
  /**
1016
- * Required fields that must be present in a valid discovery document.
701
+ * Runs runtime OIDC discovery when the stored config is missing required
702
+ * endpoints, and merges the hydrated fields back into the config.
703
+ * Throws if discovery fails.
1017
704
  */
1018
- const REQUIRED_DISCOVERY_FIELDS = [
1019
- "issuer",
1020
- "authorization_endpoint",
1021
- "token_endpoint",
1022
- "jwks_uri"
1023
- ];
705
+ async function ensureRuntimeDiscovery(config, issuer, isTrustedOrigin) {
706
+ if (!needsRuntimeDiscovery(config)) return config;
707
+ const hydrated = await discoverOIDCConfig({
708
+ issuer,
709
+ existingConfig: config,
710
+ isTrustedOrigin
711
+ });
712
+ return {
713
+ ...config,
714
+ authorizationEndpoint: hydrated.authorizationEndpoint,
715
+ tokenEndpoint: hydrated.tokenEndpoint,
716
+ tokenEndpointAuthentication: hydrated.tokenEndpointAuthentication,
717
+ userInfoEndpoint: hydrated.userInfoEndpoint,
718
+ jwksEndpoint: hydrated.jwksEndpoint
719
+ };
720
+ }
1024
721
  //#endregion
1025
- //#region src/oidc/discovery.ts
722
+ //#region src/oidc/errors.ts
1026
723
  /**
1027
- * OIDC Discovery Pipeline
1028
- *
1029
- * Implements OIDC discovery document fetching, validation, and hydration.
1030
- * This module is used both at provider registration time (to persist validated config)
1031
- * and at runtime (to hydrate legacy providers that are missing metadata).
724
+ * OIDC Discovery Error Mapping
1032
725
  *
1033
- * @see https://openid.net/specs/openid-connect-discovery-1_0.html
726
+ * Maps DiscoveryError codes to appropriate APIError responses.
727
+ * Used at the boundary between the discovery pipeline and HTTP handlers.
1034
728
  */
1035
- /** Default timeout for discovery requests (10 seconds) */
1036
- const DEFAULT_DISCOVERY_TIMEOUT = 1e4;
1037
729
  /**
1038
- * Main entry point: Discover and hydrate OIDC configuration from an issuer.
730
+ * Maps a DiscoveryError to an appropriate APIError for HTTP responses.
1039
731
  *
1040
- * This function:
1041
- * 1. Computes the discovery URL from the issuer
1042
- * 2. Validates the discovery URL
1043
- * 3. Fetches the discovery document
1044
- * 4. Validates the discovery document (issuer match + required fields)
1045
- * 5. Normalizes URLs
1046
- * 6. Selects token endpoint auth method
1047
- * 7. Merges with existing config (existing values take precedence)
732
+ * Error code mapping:
733
+ * - discovery_invalid_url → 400 BAD_REQUEST
734
+ * - discovery_not_found → 400 BAD_REQUEST
735
+ * - discovery_untrusted_origin → 400 BAD_REQUEST
736
+ * - discovery_private_host → 400 BAD_REQUEST
737
+ * - discovery_invalid_json → 400 BAD_REQUEST
738
+ * - discovery_incomplete → 400 BAD_REQUEST
739
+ * - issuer_mismatch → 400 BAD_REQUEST
740
+ * - unsupported_token_auth_method → 400 BAD_REQUEST
741
+ * - discovery_timeout → 502 BAD_GATEWAY
742
+ * - discovery_unexpected_error → 502 BAD_GATEWAY
1048
743
  *
1049
- * @param params - Discovery parameters
1050
- * @param isTrustedOrigin - Origin verification tester function
1051
- * @returns Hydrated OIDC configuration ready for persistence
1052
- * @throws DiscoveryError on any failure
744
+ * @param error - The DiscoveryError to map
745
+ * @returns An APIError with appropriate status and message
1053
746
  */
1054
- async function discoverOIDCConfig(params) {
1055
- const { issuer, existingConfig, timeout = DEFAULT_DISCOVERY_TIMEOUT } = params;
1056
- const discoveryUrl = params.discoveryEndpoint || existingConfig?.discoveryEndpoint || computeDiscoveryUrl(issuer);
1057
- validateDiscoveryUrl(discoveryUrl, params.isTrustedOrigin);
1058
- const discoveryDoc = await fetchDiscoveryDocument(discoveryUrl, timeout);
1059
- validateDiscoveryDocument(discoveryDoc, issuer);
1060
- const normalizedDoc = normalizeDiscoveryUrls(discoveryDoc, issuer, params.isTrustedOrigin);
1061
- const tokenEndpointAuth = selectTokenEndpointAuthMethod(normalizedDoc, existingConfig?.tokenEndpointAuthentication);
1062
- return {
1063
- issuer: existingConfig?.issuer ?? normalizedDoc.issuer,
1064
- discoveryEndpoint: existingConfig?.discoveryEndpoint ?? discoveryUrl,
1065
- authorizationEndpoint: existingConfig?.authorizationEndpoint ?? normalizedDoc.authorization_endpoint,
1066
- tokenEndpoint: existingConfig?.tokenEndpoint ?? normalizedDoc.token_endpoint,
1067
- jwksEndpoint: existingConfig?.jwksEndpoint ?? normalizedDoc.jwks_uri,
1068
- userInfoEndpoint: existingConfig?.userInfoEndpoint ?? normalizedDoc.userinfo_endpoint,
1069
- tokenEndpointAuthentication: existingConfig?.tokenEndpointAuthentication ?? tokenEndpointAuth,
1070
- scopesSupported: existingConfig?.scopesSupported ?? normalizedDoc.scopes_supported
1071
- };
747
+ function mapDiscoveryErrorToAPIError(error) {
748
+ switch (error.code) {
749
+ case "discovery_timeout": return new APIError("BAD_GATEWAY", {
750
+ message: `OIDC discovery timed out: ${error.message}`,
751
+ code: error.code
752
+ });
753
+ case "discovery_unexpected_error": return new APIError("BAD_GATEWAY", {
754
+ message: `OIDC discovery failed: ${error.message}`,
755
+ code: error.code
756
+ });
757
+ case "discovery_not_found": return new APIError("BAD_REQUEST", {
758
+ message: `OIDC discovery endpoint not found. The issuer may not support OIDC discovery, or the URL is incorrect. ${error.message}`,
759
+ code: error.code
760
+ });
761
+ case "discovery_invalid_url": return new APIError("BAD_REQUEST", {
762
+ message: `Invalid OIDC endpoint URL: ${error.message}`,
763
+ code: error.code
764
+ });
765
+ case "discovery_untrusted_origin": return new APIError("BAD_REQUEST", {
766
+ message: `Untrusted OIDC discovery URL: ${error.message}`,
767
+ code: error.code
768
+ });
769
+ case "discovery_private_host": return new APIError("BAD_REQUEST", {
770
+ message: error.message,
771
+ code: error.code
772
+ });
773
+ case "discovery_invalid_json": return new APIError("BAD_REQUEST", {
774
+ message: `OIDC discovery returned invalid data: ${error.message}`,
775
+ code: error.code
776
+ });
777
+ case "discovery_incomplete": return new APIError("BAD_REQUEST", {
778
+ message: `OIDC discovery document is missing required fields: ${error.message}`,
779
+ code: error.code
780
+ });
781
+ case "issuer_mismatch": return new APIError("BAD_REQUEST", {
782
+ message: `OIDC issuer mismatch: ${error.message}`,
783
+ code: error.code
784
+ });
785
+ case "unsupported_token_auth_method": return new APIError("BAD_REQUEST", {
786
+ message: `Incompatible OIDC provider: ${error.message}`,
787
+ code: error.code
788
+ });
789
+ default:
790
+ error.code;
791
+ return new APIError("INTERNAL_SERVER_ERROR", {
792
+ message: `Unexpected discovery error: ${error.message}`,
793
+ code: "discovery_unexpected_error"
794
+ });
795
+ }
796
+ }
797
+ //#endregion
798
+ //#region src/saml/parser.ts
799
+ const xmlParser = new XMLParser({
800
+ ignoreAttributes: false,
801
+ attributeNamePrefix: "@_",
802
+ removeNSPrefix: true,
803
+ processEntities: false
804
+ });
805
+ function findNode(obj, nodeName) {
806
+ if (!obj || typeof obj !== "object") return null;
807
+ const record = obj;
808
+ if (nodeName in record) return record[nodeName];
809
+ for (const value of Object.values(record)) if (Array.isArray(value)) for (const item of value) {
810
+ const found = findNode(item, nodeName);
811
+ if (found) return found;
812
+ }
813
+ else if (typeof value === "object" && value !== null) {
814
+ const found = findNode(value, nodeName);
815
+ if (found) return found;
816
+ }
817
+ return null;
818
+ }
819
+ function countAllNodes(obj, nodeName) {
820
+ if (!obj || typeof obj !== "object") return 0;
821
+ let count = 0;
822
+ const record = obj;
823
+ if (nodeName in record) {
824
+ const node = record[nodeName];
825
+ count += Array.isArray(node) ? node.length : 1;
826
+ }
827
+ for (const value of Object.values(record)) if (Array.isArray(value)) for (const item of value) count += countAllNodes(item, nodeName);
828
+ else if (typeof value === "object" && value !== null) count += countAllNodes(value, nodeName);
829
+ return count;
830
+ }
831
+ //#endregion
832
+ //#region src/saml/algorithms.ts
833
+ const SignatureAlgorithm = {
834
+ RSA_SHA1: "http://www.w3.org/2000/09/xmldsig#rsa-sha1",
835
+ RSA_SHA256: "http://www.w3.org/2001/04/xmldsig-more#rsa-sha256",
836
+ RSA_SHA384: "http://www.w3.org/2001/04/xmldsig-more#rsa-sha384",
837
+ RSA_SHA512: "http://www.w3.org/2001/04/xmldsig-more#rsa-sha512",
838
+ ECDSA_SHA256: "http://www.w3.org/2001/04/xmldsig-more#ecdsa-sha256",
839
+ ECDSA_SHA384: "http://www.w3.org/2001/04/xmldsig-more#ecdsa-sha384",
840
+ ECDSA_SHA512: "http://www.w3.org/2001/04/xmldsig-more#ecdsa-sha512"
841
+ };
842
+ const DigestAlgorithm = {
843
+ SHA1: "http://www.w3.org/2000/09/xmldsig#sha1",
844
+ SHA256: "http://www.w3.org/2001/04/xmlenc#sha256",
845
+ SHA384: "http://www.w3.org/2001/04/xmldsig-more#sha384",
846
+ SHA512: "http://www.w3.org/2001/04/xmlenc#sha512"
847
+ };
848
+ const KeyEncryptionAlgorithm = {
849
+ RSA_1_5: "http://www.w3.org/2001/04/xmlenc#rsa-1_5",
850
+ RSA_OAEP: "http://www.w3.org/2001/04/xmlenc#rsa-oaep-mgf1p",
851
+ RSA_OAEP_SHA256: "http://www.w3.org/2009/xmlenc11#rsa-oaep"
852
+ };
853
+ const DataEncryptionAlgorithm = {
854
+ TRIPLEDES_CBC: "http://www.w3.org/2001/04/xmlenc#tripledes-cbc",
855
+ AES_128_CBC: "http://www.w3.org/2001/04/xmlenc#aes128-cbc",
856
+ AES_192_CBC: "http://www.w3.org/2001/04/xmlenc#aes192-cbc",
857
+ AES_256_CBC: "http://www.w3.org/2001/04/xmlenc#aes256-cbc",
858
+ AES_128_GCM: "http://www.w3.org/2009/xmlenc11#aes128-gcm",
859
+ AES_192_GCM: "http://www.w3.org/2009/xmlenc11#aes192-gcm",
860
+ AES_256_GCM: "http://www.w3.org/2009/xmlenc11#aes256-gcm"
861
+ };
862
+ const DEPRECATED_SIGNATURE_ALGORITHMS = [SignatureAlgorithm.RSA_SHA1];
863
+ const DEPRECATED_KEY_ENCRYPTION_ALGORITHMS = [KeyEncryptionAlgorithm.RSA_1_5];
864
+ const DEPRECATED_DATA_ENCRYPTION_ALGORITHMS = [DataEncryptionAlgorithm.TRIPLEDES_CBC];
865
+ const DEPRECATED_DIGEST_ALGORITHMS = [DigestAlgorithm.SHA1];
866
+ const SECURE_SIGNATURE_ALGORITHMS = [
867
+ SignatureAlgorithm.RSA_SHA256,
868
+ SignatureAlgorithm.RSA_SHA384,
869
+ SignatureAlgorithm.RSA_SHA512,
870
+ SignatureAlgorithm.ECDSA_SHA256,
871
+ SignatureAlgorithm.ECDSA_SHA384,
872
+ SignatureAlgorithm.ECDSA_SHA512
873
+ ];
874
+ const SECURE_DIGEST_ALGORITHMS = [
875
+ DigestAlgorithm.SHA256,
876
+ DigestAlgorithm.SHA384,
877
+ DigestAlgorithm.SHA512
878
+ ];
879
+ const SHORT_FORM_SIGNATURE_TO_URI = {
880
+ sha1: SignatureAlgorithm.RSA_SHA1,
881
+ sha256: SignatureAlgorithm.RSA_SHA256,
882
+ sha384: SignatureAlgorithm.RSA_SHA384,
883
+ sha512: SignatureAlgorithm.RSA_SHA512,
884
+ "rsa-sha1": SignatureAlgorithm.RSA_SHA1,
885
+ "rsa-sha256": SignatureAlgorithm.RSA_SHA256,
886
+ "rsa-sha384": SignatureAlgorithm.RSA_SHA384,
887
+ "rsa-sha512": SignatureAlgorithm.RSA_SHA512,
888
+ "ecdsa-sha256": SignatureAlgorithm.ECDSA_SHA256,
889
+ "ecdsa-sha384": SignatureAlgorithm.ECDSA_SHA384,
890
+ "ecdsa-sha512": SignatureAlgorithm.ECDSA_SHA512
891
+ };
892
+ const SHORT_FORM_DIGEST_TO_URI = {
893
+ sha1: DigestAlgorithm.SHA1,
894
+ sha256: DigestAlgorithm.SHA256,
895
+ sha384: DigestAlgorithm.SHA384,
896
+ sha512: DigestAlgorithm.SHA512
897
+ };
898
+ function normalizeSignatureAlgorithm(alg) {
899
+ return SHORT_FORM_SIGNATURE_TO_URI[alg.toLowerCase()] ?? alg;
1072
900
  }
1073
- /**
1074
- * Compute the discovery URL from an issuer URL.
1075
- *
1076
- * Per OIDC Discovery spec, the discovery document is located at:
1077
- * <issuer>/.well-known/openid-configuration
1078
- *
1079
- * Handles trailing slashes correctly.
1080
- */
1081
- function computeDiscoveryUrl(issuer) {
1082
- return `${issuer.endsWith("/") ? issuer.slice(0, -1) : issuer}/.well-known/openid-configuration`;
901
+ function normalizeDigestAlgorithm(alg) {
902
+ return SHORT_FORM_DIGEST_TO_URI[alg.toLowerCase()] ?? alg;
1083
903
  }
1084
- /**
1085
- * Validate a discovery URL before fetching.
1086
- *
1087
- * @param url - The discovery URL to validate
1088
- * @param isTrustedOrigin - Origin verification tester function
1089
- * @throws DiscoveryError if URL is invalid
1090
- */
1091
- function validateDiscoveryUrl(url, isTrustedOrigin) {
1092
- const discoveryEndpoint = parseURL("discoveryEndpoint", url).toString();
1093
- if (!isTrustedOrigin(discoveryEndpoint)) throw new DiscoveryError("discovery_untrusted_origin", `The main discovery endpoint "${discoveryEndpoint}" is not trusted by your trusted origins configuration.`, { url: discoveryEndpoint });
904
+ function extractEncryptionAlgorithms(xml) {
905
+ try {
906
+ const parsed = xmlParser.parse(xml);
907
+ const keyAlg = (findNode(parsed, "EncryptedKey")?.EncryptionMethod)?.["@_Algorithm"];
908
+ const dataAlg = (findNode(parsed, "EncryptedData")?.EncryptionMethod)?.["@_Algorithm"];
909
+ return {
910
+ keyEncryption: keyAlg || null,
911
+ dataEncryption: dataAlg || null
912
+ };
913
+ } catch {
914
+ return {
915
+ keyEncryption: null,
916
+ dataEncryption: null
917
+ };
918
+ }
1094
919
  }
1095
- /**
1096
- * Fetch the OIDC discovery document from the IdP.
1097
- *
1098
- * @param url - The discovery endpoint URL
1099
- * @param timeout - Request timeout in milliseconds
1100
- * @returns The parsed discovery document
1101
- * @throws DiscoveryError on network errors, timeouts, or invalid responses
1102
- */
1103
- async function fetchDiscoveryDocument(url, timeout = DEFAULT_DISCOVERY_TIMEOUT) {
920
+ function hasEncryptedAssertion(xml) {
1104
921
  try {
1105
- const response = await betterFetch(url, {
1106
- method: "GET",
1107
- timeout
922
+ return findNode(xmlParser.parse(xml), "EncryptedAssertion") !== null;
923
+ } catch {
924
+ return false;
925
+ }
926
+ }
927
+ function handleDeprecatedAlgorithm(message, behavior, errorCode) {
928
+ switch (behavior) {
929
+ case "reject": throw new APIError("BAD_REQUEST", {
930
+ message,
931
+ code: errorCode
1108
932
  });
1109
- if (response.error) {
1110
- const { status } = response.error;
1111
- if (status === 404) throw new DiscoveryError("discovery_not_found", "Discovery endpoint not found", {
1112
- url,
1113
- status
933
+ case "warn":
934
+ console.warn(`[SAML Security Warning] ${message}`);
935
+ break;
936
+ case "allow": break;
937
+ }
938
+ }
939
+ function validateSignatureAlgorithm(algorithm, options = {}) {
940
+ if (!algorithm) return;
941
+ const { onDeprecated = "warn", allowedSignatureAlgorithms } = options;
942
+ if (allowedSignatureAlgorithms) {
943
+ if (!allowedSignatureAlgorithms.includes(algorithm)) throw new APIError("BAD_REQUEST", {
944
+ message: `SAML signature algorithm not in allow-list: ${algorithm}`,
945
+ code: "SAML_ALGORITHM_NOT_ALLOWED"
946
+ });
947
+ return;
948
+ }
949
+ if (DEPRECATED_SIGNATURE_ALGORITHMS.includes(algorithm)) {
950
+ handleDeprecatedAlgorithm(`SAML response uses deprecated signature algorithm: ${algorithm}. Please configure your IdP to use SHA-256 or stronger.`, onDeprecated, "SAML_DEPRECATED_ALGORITHM");
951
+ return;
952
+ }
953
+ if (!SECURE_SIGNATURE_ALGORITHMS.includes(algorithm)) throw new APIError("BAD_REQUEST", {
954
+ message: `SAML signature algorithm not recognized: ${algorithm}`,
955
+ code: "SAML_UNKNOWN_ALGORITHM"
956
+ });
957
+ }
958
+ function validateEncryptionAlgorithms(algorithms, options = {}) {
959
+ const { onDeprecated = "warn", allowedKeyEncryptionAlgorithms, allowedDataEncryptionAlgorithms } = options;
960
+ const { keyEncryption, dataEncryption } = algorithms;
961
+ if (keyEncryption) {
962
+ if (allowedKeyEncryptionAlgorithms) {
963
+ if (!allowedKeyEncryptionAlgorithms.includes(keyEncryption)) throw new APIError("BAD_REQUEST", {
964
+ message: `SAML key encryption algorithm not in allow-list: ${keyEncryption}`,
965
+ code: "SAML_ALGORITHM_NOT_ALLOWED"
1114
966
  });
1115
- if (status === 408) throw new DiscoveryError("discovery_timeout", "Discovery request timed out", {
1116
- url,
1117
- timeout
967
+ } 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");
968
+ }
969
+ if (dataEncryption) {
970
+ if (allowedDataEncryptionAlgorithms) {
971
+ if (!allowedDataEncryptionAlgorithms.includes(dataEncryption)) throw new APIError("BAD_REQUEST", {
972
+ message: `SAML data encryption algorithm not in allow-list: ${dataEncryption}`,
973
+ code: "SAML_ALGORITHM_NOT_ALLOWED"
1118
974
  });
1119
- throw new DiscoveryError("discovery_unexpected_error", `Unexpected discovery error: ${response.error.statusText}`, {
1120
- url,
1121
- ...response.error
975
+ } 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");
976
+ }
977
+ }
978
+ function validateSAMLAlgorithms(response, options) {
979
+ validateSignatureAlgorithm(response.sigAlg, options);
980
+ if (hasEncryptedAssertion(response.samlContent)) validateEncryptionAlgorithms(extractEncryptionAlgorithms(response.samlContent), options);
981
+ }
982
+ function validateConfigAlgorithms(config, options = {}) {
983
+ const { onDeprecated = "warn", allowedSignatureAlgorithms, allowedDigestAlgorithms } = options;
984
+ if (config.signatureAlgorithm) {
985
+ const normalized = normalizeSignatureAlgorithm(config.signatureAlgorithm);
986
+ if (allowedSignatureAlgorithms) {
987
+ if (!allowedSignatureAlgorithms.map(normalizeSignatureAlgorithm).includes(normalized)) throw new APIError("BAD_REQUEST", {
988
+ message: `SAML signature algorithm not in allow-list: ${config.signatureAlgorithm}`,
989
+ code: "SAML_ALGORITHM_NOT_ALLOWED"
1122
990
  });
1123
- }
1124
- if (!response.data) throw new DiscoveryError("discovery_invalid_json", "Discovery endpoint returned an empty response", { url });
1125
- const data = response.data;
1126
- if (typeof data === "string") throw new DiscoveryError("discovery_invalid_json", "Discovery endpoint returned invalid JSON", {
1127
- url,
1128
- bodyPreview: data.slice(0, 200)
991
+ } 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");
992
+ else if (!SECURE_SIGNATURE_ALGORITHMS.includes(normalized)) throw new APIError("BAD_REQUEST", {
993
+ message: `SAML signature algorithm not recognized: ${config.signatureAlgorithm}`,
994
+ code: "SAML_UNKNOWN_ALGORITHM"
1129
995
  });
1130
- return data;
1131
- } catch (error) {
1132
- if (error instanceof DiscoveryError) throw error;
1133
- if (error instanceof Error && error.name === "AbortError") throw new DiscoveryError("discovery_timeout", "Discovery request timed out", {
1134
- url,
1135
- timeout
996
+ }
997
+ if (config.digestAlgorithm) {
998
+ const normalized = normalizeDigestAlgorithm(config.digestAlgorithm);
999
+ if (allowedDigestAlgorithms) {
1000
+ if (!allowedDigestAlgorithms.map(normalizeDigestAlgorithm).includes(normalized)) throw new APIError("BAD_REQUEST", {
1001
+ message: `SAML digest algorithm not in allow-list: ${config.digestAlgorithm}`,
1002
+ code: "SAML_ALGORITHM_NOT_ALLOWED"
1003
+ });
1004
+ } 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");
1005
+ else if (!SECURE_DIGEST_ALGORITHMS.includes(normalized)) throw new APIError("BAD_REQUEST", {
1006
+ message: `SAML digest algorithm not recognized: ${config.digestAlgorithm}`,
1007
+ code: "SAML_UNKNOWN_ALGORITHM"
1008
+ });
1009
+ }
1010
+ }
1011
+ //#endregion
1012
+ //#region src/saml/assertions.ts
1013
+ function countAssertions(xml) {
1014
+ let parsed;
1015
+ try {
1016
+ parsed = xmlParser.parse(xml);
1017
+ } catch {
1018
+ throw new APIError("BAD_REQUEST", {
1019
+ message: "Failed to parse SAML response XML",
1020
+ code: "SAML_INVALID_XML"
1021
+ });
1022
+ }
1023
+ const assertions = countAllNodes(parsed, "Assertion");
1024
+ const encryptedAssertions = countAllNodes(parsed, "EncryptedAssertion");
1025
+ return {
1026
+ assertions,
1027
+ encryptedAssertions,
1028
+ total: assertions + encryptedAssertions
1029
+ };
1030
+ }
1031
+ function validateSingleAssertion(samlResponse) {
1032
+ let xml;
1033
+ try {
1034
+ xml = new TextDecoder().decode(base64.decode(samlResponse.replace(/\s+/g, "")));
1035
+ if (!xml.includes("<")) throw new Error("Not XML");
1036
+ } catch {
1037
+ throw new APIError("BAD_REQUEST", {
1038
+ message: "Invalid base64-encoded SAML response",
1039
+ code: "SAML_INVALID_ENCODING"
1136
1040
  });
1137
- throw new DiscoveryError("discovery_unexpected_error", `Unexpected error during discovery: ${error instanceof Error ? error.message : String(error)}`, { url }, { cause: error });
1138
1041
  }
1042
+ const counts = countAssertions(xml);
1043
+ if (counts.total === 0) throw new APIError("BAD_REQUEST", {
1044
+ message: "SAML response contains no assertions",
1045
+ code: "SAML_NO_ASSERTION"
1046
+ });
1047
+ if (counts.total > 1) throw new APIError("BAD_REQUEST", {
1048
+ message: `SAML response contains ${counts.total} assertions, expected exactly 1`,
1049
+ code: "SAML_MULTIPLE_ASSERTIONS"
1050
+ });
1051
+ }
1052
+ //#endregion
1053
+ //#region src/routes/schemas.ts
1054
+ const oidcMappingSchema = z.object({
1055
+ id: z.string().optional(),
1056
+ email: z.string().optional(),
1057
+ emailVerified: z.string().optional(),
1058
+ name: z.string().optional(),
1059
+ image: z.string().optional(),
1060
+ extraFields: z.record(z.string(), z.any()).optional()
1061
+ }).optional();
1062
+ const samlMappingSchema = z.object({
1063
+ id: z.string().optional(),
1064
+ email: z.string().optional(),
1065
+ emailVerified: z.string().optional(),
1066
+ name: z.string().optional(),
1067
+ firstName: z.string().optional(),
1068
+ lastName: z.string().optional(),
1069
+ extraFields: z.record(z.string(), z.any()).optional()
1070
+ }).optional();
1071
+ const oidcConfigSchema = z.object({
1072
+ clientId: z.string().optional(),
1073
+ clientSecret: z.string().optional(),
1074
+ authorizationEndpoint: z.url().optional(),
1075
+ tokenEndpoint: z.url().optional(),
1076
+ userInfoEndpoint: z.url().optional(),
1077
+ tokenEndpointAuthentication: z.enum(["client_secret_post", "client_secret_basic"]).optional(),
1078
+ jwksEndpoint: z.url().optional(),
1079
+ discoveryEndpoint: z.url().optional(),
1080
+ scopes: z.array(z.string()).optional(),
1081
+ pkce: z.boolean().optional(),
1082
+ overrideUserInfo: z.boolean().optional(),
1083
+ mapping: oidcMappingSchema
1084
+ });
1085
+ const samlConfigSchema = z.object({
1086
+ entryPoint: z.string().url().optional(),
1087
+ cert: z.string().optional(),
1088
+ callbackUrl: z.string().url().optional(),
1089
+ audience: z.string().optional(),
1090
+ idpMetadata: z.object({
1091
+ metadata: z.string().optional(),
1092
+ entityID: z.string().optional(),
1093
+ cert: z.string().optional(),
1094
+ privateKey: z.string().optional(),
1095
+ privateKeyPass: z.string().optional(),
1096
+ isAssertionEncrypted: z.boolean().optional(),
1097
+ encPrivateKey: z.string().optional(),
1098
+ encPrivateKeyPass: z.string().optional(),
1099
+ singleSignOnService: z.array(z.object({
1100
+ Binding: z.string(),
1101
+ Location: z.string().url()
1102
+ })).optional()
1103
+ }).optional(),
1104
+ spMetadata: z.object({
1105
+ metadata: z.string().optional(),
1106
+ entityID: z.string().optional(),
1107
+ binding: z.string().optional(),
1108
+ privateKey: z.string().optional(),
1109
+ privateKeyPass: z.string().optional(),
1110
+ isAssertionEncrypted: z.boolean().optional(),
1111
+ encPrivateKey: z.string().optional(),
1112
+ encPrivateKeyPass: z.string().optional()
1113
+ }).optional(),
1114
+ wantAssertionsSigned: z.boolean().optional(),
1115
+ authnRequestsSigned: z.boolean().optional(),
1116
+ signatureAlgorithm: z.string().optional(),
1117
+ digestAlgorithm: z.string().optional(),
1118
+ identifierFormat: z.string().optional(),
1119
+ privateKey: z.string().optional(),
1120
+ decryptionPvk: z.string().optional(),
1121
+ additionalParams: z.record(z.string(), z.any()).optional(),
1122
+ mapping: samlMappingSchema
1123
+ });
1124
+ const updateSSOProviderBodySchema = z.object({
1125
+ issuer: z.string().url().optional(),
1126
+ domain: z.string().optional(),
1127
+ oidcConfig: oidcConfigSchema.optional(),
1128
+ samlConfig: samlConfigSchema.optional()
1129
+ });
1130
+ //#endregion
1131
+ //#region src/routes/providers.ts
1132
+ const ADMIN_ROLES = ["owner", "admin"];
1133
+ function hasOrgAdminRole(member) {
1134
+ return member.role.split(",").some((r) => ADMIN_ROLES.includes(r.trim()));
1139
1135
  }
1140
- /**
1141
- * Validate a discovery document.
1142
- *
1143
- * Checks:
1144
- * 1. All required fields are present
1145
- * 2. Issuer matches the configured issuer (case-sensitive, exact match)
1146
- *
1147
- * Invariant: If this function returns without throwing, the document is safe
1148
- * to use for hydrating OIDC config (required fields present, issuer matches
1149
- * configured value, basic structural sanity verified).
1150
- *
1151
- * @param doc - The discovery document to validate
1152
- * @param configuredIssuer - The expected issuer value
1153
- * @throws DiscoveryError if validation fails
1154
- */
1155
- function validateDiscoveryDocument(doc, configuredIssuer) {
1156
- const missingFields = [];
1157
- for (const field of REQUIRED_DISCOVERY_FIELDS) if (!doc[field]) missingFields.push(field);
1158
- if (missingFields.length > 0) throw new DiscoveryError("discovery_incomplete", `Discovery document is missing required fields: ${missingFields.join(", ")}`, { missingFields });
1159
- 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}"`, {
1160
- discovered: doc.issuer,
1161
- configured: configuredIssuer
1136
+ async function isOrgAdmin(ctx, userId, organizationId) {
1137
+ const member = await ctx.context.adapter.findOne({
1138
+ model: "member",
1139
+ where: [{
1140
+ field: "userId",
1141
+ value: userId
1142
+ }, {
1143
+ field: "organizationId",
1144
+ value: organizationId
1145
+ }]
1162
1146
  });
1147
+ return member ? hasOrgAdminRole(member) : false;
1163
1148
  }
1164
- /**
1165
- * Normalize URLs in the discovery document.
1166
- *
1167
- * @param document - The discovery document
1168
- * @param issuer - The base issuer URL
1169
- * @param isTrustedOrigin - Origin verification tester function
1170
- * @returns The normalized discovery document
1171
- */
1172
- function normalizeDiscoveryUrls(document, issuer, isTrustedOrigin) {
1173
- const doc = { ...document };
1174
- doc.token_endpoint = normalizeAndValidateUrl("token_endpoint", doc.token_endpoint, issuer, isTrustedOrigin);
1175
- doc.authorization_endpoint = normalizeAndValidateUrl("authorization_endpoint", doc.authorization_endpoint, issuer, isTrustedOrigin);
1176
- doc.jwks_uri = normalizeAndValidateUrl("jwks_uri", doc.jwks_uri, issuer, isTrustedOrigin);
1177
- if (doc.userinfo_endpoint) doc.userinfo_endpoint = normalizeAndValidateUrl("userinfo_endpoint", doc.userinfo_endpoint, issuer, isTrustedOrigin);
1178
- if (doc.revocation_endpoint) doc.revocation_endpoint = normalizeAndValidateUrl("revocation_endpoint", doc.revocation_endpoint, issuer, isTrustedOrigin);
1179
- if (doc.end_session_endpoint) doc.end_session_endpoint = normalizeAndValidateUrl("end_session_endpoint", doc.end_session_endpoint, issuer, isTrustedOrigin);
1180
- if (doc.introspection_endpoint) doc.introspection_endpoint = normalizeAndValidateUrl("introspection_endpoint", doc.introspection_endpoint, issuer, isTrustedOrigin);
1181
- return doc;
1182
- }
1183
- /**
1184
- * Normalizes and validates a single URL endpoint
1185
- * @param name The url name
1186
- * @param endpoint The url to validate
1187
- * @param issuer The issuer base url
1188
- * @param isTrustedOrigin - Origin verification tester function
1189
- * @returns
1190
- */
1191
- function normalizeAndValidateUrl(name, endpoint, issuer, isTrustedOrigin) {
1192
- const url = normalizeUrl(name, endpoint, issuer);
1193
- if (!isTrustedOrigin(url)) throw new DiscoveryError("discovery_untrusted_origin", `The ${name} "${url}" is not trusted by your trusted origins configuration.`, {
1194
- endpoint: name,
1195
- url
1149
+ async function batchCheckOrgAdmin(ctx, userId, organizationIds) {
1150
+ if (organizationIds.length === 0) return /* @__PURE__ */ new Set();
1151
+ const members = await ctx.context.adapter.findMany({
1152
+ model: "member",
1153
+ where: [{
1154
+ field: "userId",
1155
+ value: userId
1156
+ }, {
1157
+ field: "organizationId",
1158
+ value: organizationIds,
1159
+ operator: "in"
1160
+ }]
1196
1161
  });
1197
- return url;
1162
+ const adminOrgIds = /* @__PURE__ */ new Set();
1163
+ for (const member of members) if (hasOrgAdminRole(member)) adminOrgIds.add(member.organizationId);
1164
+ return adminOrgIds;
1198
1165
  }
1199
- /**
1200
- * Normalize a single URL endpoint.
1201
- *
1202
- * @param name - The endpoint name (e.g token_endpoint)
1203
- * @param endpoint - The endpoint URL to normalize
1204
- * @param issuer - The base issuer URL
1205
- * @returns The normalized endpoint URL
1206
- */
1207
- function normalizeUrl(name, endpoint, issuer) {
1166
+ function sanitizeProvider(provider, baseURL) {
1167
+ let oidcConfig = null;
1168
+ let samlConfig = null;
1208
1169
  try {
1209
- return parseURL(name, endpoint).toString();
1170
+ oidcConfig = safeJsonParse(provider.oidcConfig);
1210
1171
  } catch {
1211
- const issuerURL = parseURL(name, issuer);
1212
- const basePath = issuerURL.pathname.replace(/\/+$/, "");
1213
- const endpointPath = endpoint.replace(/^\/+/, "");
1214
- return parseURL(name, basePath + "/" + endpointPath, issuerURL.origin).toString();
1172
+ oidcConfig = null;
1215
1173
  }
1216
- }
1217
- /**
1218
- * Parses the given URL or throws in case of invalid or unsupported protocols
1219
- *
1220
- * @param name the url name
1221
- * @param endpoint the endpoint url
1222
- * @param [base] optional base path
1223
- * @returns
1224
- */
1225
- function parseURL(name, endpoint, base) {
1226
- let endpointURL;
1227
1174
  try {
1228
- endpointURL = new URL(endpoint, base);
1229
- if (endpointURL.protocol === "http:" || endpointURL.protocol === "https:") return endpointURL;
1230
- } catch (error) {
1231
- throw new DiscoveryError("discovery_invalid_url", `The url "${name}" must be valid: ${endpoint}`, { url: endpoint }, { cause: error });
1175
+ samlConfig = safeJsonParse(provider.samlConfig);
1176
+ } catch {
1177
+ samlConfig = null;
1232
1178
  }
1233
- throw new DiscoveryError("discovery_invalid_url", `The url "${name}" must use the http or https supported protocols: ${endpoint}`, {
1234
- url: endpoint,
1235
- protocol: endpointURL.protocol
1236
- });
1179
+ const type = samlConfig ? "saml" : "oidc";
1180
+ return {
1181
+ providerId: provider.providerId,
1182
+ type,
1183
+ issuer: provider.issuer,
1184
+ domain: provider.domain,
1185
+ organizationId: provider.organizationId || null,
1186
+ domainVerified: provider.domainVerified ?? false,
1187
+ oidcConfig: oidcConfig ? {
1188
+ discoveryEndpoint: oidcConfig.discoveryEndpoint,
1189
+ clientIdLastFour: maskClientId(oidcConfig.clientId),
1190
+ pkce: oidcConfig.pkce,
1191
+ authorizationEndpoint: oidcConfig.authorizationEndpoint,
1192
+ tokenEndpoint: oidcConfig.tokenEndpoint,
1193
+ userInfoEndpoint: oidcConfig.userInfoEndpoint,
1194
+ jwksEndpoint: oidcConfig.jwksEndpoint,
1195
+ scopes: oidcConfig.scopes,
1196
+ tokenEndpointAuthentication: oidcConfig.tokenEndpointAuthentication
1197
+ } : void 0,
1198
+ samlConfig: samlConfig ? {
1199
+ entryPoint: samlConfig.entryPoint,
1200
+ callbackUrl: samlConfig.callbackUrl,
1201
+ audience: samlConfig.audience,
1202
+ wantAssertionsSigned: samlConfig.wantAssertionsSigned,
1203
+ authnRequestsSigned: samlConfig.authnRequestsSigned,
1204
+ identifierFormat: samlConfig.identifierFormat,
1205
+ signatureAlgorithm: samlConfig.signatureAlgorithm,
1206
+ digestAlgorithm: samlConfig.digestAlgorithm,
1207
+ certificate: (() => {
1208
+ try {
1209
+ return parseCertificate(samlConfig.cert);
1210
+ } catch {
1211
+ return { error: "Failed to parse certificate" };
1212
+ }
1213
+ })()
1214
+ } : void 0,
1215
+ spMetadataUrl: `${baseURL}/sso/saml2/sp/metadata?providerId=${encodeURIComponent(provider.providerId)}`
1216
+ };
1237
1217
  }
1238
- /**
1239
- * Select the token endpoint authentication method.
1240
- *
1241
- * @param doc - The discovery document
1242
- * @param existing - Existing authentication method from config
1243
- * @returns The selected authentication method
1244
- */
1245
- function selectTokenEndpointAuthMethod(doc, existing) {
1246
- if (existing) return existing;
1247
- const supported = doc.token_endpoint_auth_methods_supported;
1248
- if (!supported || supported.length === 0) return "client_secret_basic";
1249
- if (supported.includes("client_secret_basic")) return "client_secret_basic";
1250
- if (supported.includes("client_secret_post")) return "client_secret_post";
1251
- return "client_secret_basic";
1218
+ const listSSOProviders = () => {
1219
+ return createAuthEndpoint("/sso/providers", {
1220
+ method: "GET",
1221
+ use: [sessionMiddleware],
1222
+ metadata: { openapi: {
1223
+ operationId: "listSSOProviders",
1224
+ summary: "List SSO providers",
1225
+ description: "Returns a list of SSO providers the user has access to",
1226
+ responses: { "200": { description: "List of SSO providers" } }
1227
+ } }
1228
+ }, async (ctx) => {
1229
+ const userId = ctx.context.session.user.id;
1230
+ const allProviders = await ctx.context.adapter.findMany({ model: "ssoProvider" });
1231
+ const userOwnedProviders = allProviders.filter((p) => p.userId === userId && !p.organizationId);
1232
+ const orgProviders = allProviders.filter((p) => p.organizationId !== null && p.organizationId !== void 0);
1233
+ const orgPluginEnabled = ctx.context.hasPlugin("organization");
1234
+ let accessibleProviders = [...userOwnedProviders];
1235
+ if (orgPluginEnabled && orgProviders.length > 0) {
1236
+ const adminOrgIds = await batchCheckOrgAdmin(ctx, userId, [...new Set(orgProviders.map((p) => p.organizationId).filter((id) => id !== null && id !== void 0))]);
1237
+ const orgAccessibleProviders = orgProviders.filter((provider) => provider.organizationId && adminOrgIds.has(provider.organizationId));
1238
+ accessibleProviders = [...accessibleProviders, ...orgAccessibleProviders];
1239
+ } else if (!orgPluginEnabled) {
1240
+ const userOwnedOrgProviders = orgProviders.filter((p) => p.userId === userId);
1241
+ accessibleProviders = [...accessibleProviders, ...userOwnedOrgProviders];
1242
+ }
1243
+ const providers = accessibleProviders.map((p) => sanitizeProvider(p, ctx.context.baseURL));
1244
+ return ctx.json({ providers });
1245
+ });
1246
+ };
1247
+ const getSSOProviderQuerySchema = z.object({ providerId: z.string() });
1248
+ async function checkProviderAccess(ctx, providerId) {
1249
+ const userId = ctx.context.session.user.id;
1250
+ const provider = await ctx.context.adapter.findOne({
1251
+ model: "ssoProvider",
1252
+ where: [{
1253
+ field: "providerId",
1254
+ value: providerId
1255
+ }]
1256
+ });
1257
+ if (!provider) throw new APIError("NOT_FOUND", { message: "Provider not found" });
1258
+ let hasAccess = false;
1259
+ if (provider.organizationId) if (ctx.context.hasPlugin("organization")) hasAccess = await isOrgAdmin(ctx, userId, provider.organizationId);
1260
+ else hasAccess = provider.userId === userId;
1261
+ else hasAccess = provider.userId === userId;
1262
+ if (!hasAccess) throw new APIError("FORBIDDEN", { message: "You don't have access to this provider" });
1263
+ return provider;
1252
1264
  }
1253
- /**
1254
- * Check if a provider configuration needs runtime discovery.
1255
- *
1256
- * Returns true if we need discovery at runtime to complete the token exchange
1257
- * and validation. Specifically checks for:
1258
- * - `tokenEndpoint` - required for exchanging authorization code for tokens
1259
- * - `jwksEndpoint` - required for validating ID token signatures
1260
- * - `authorizationEndpoint` - required for redirecting users to the IdP for login
1261
- *
1262
- * @param config - Partial OIDC config from the provider
1263
- * @returns true if runtime discovery should be performed
1264
- */
1265
- function needsRuntimeDiscovery(config) {
1266
- if (!config) return true;
1267
- return !config.tokenEndpoint || !config.jwksEndpoint || !config.authorizationEndpoint;
1265
+ const getSSOProvider = () => {
1266
+ return createAuthEndpoint("/sso/get-provider", {
1267
+ method: "GET",
1268
+ use: [sessionMiddleware],
1269
+ query: getSSOProviderQuerySchema,
1270
+ metadata: { openapi: {
1271
+ operationId: "getSSOProvider",
1272
+ summary: "Get SSO provider details",
1273
+ description: "Returns sanitized details for a specific SSO provider",
1274
+ responses: {
1275
+ "200": { description: "SSO provider details" },
1276
+ "404": { description: "Provider not found" },
1277
+ "403": { description: "Access denied" }
1278
+ }
1279
+ } }
1280
+ }, async (ctx) => {
1281
+ const { providerId } = ctx.query;
1282
+ const provider = await checkProviderAccess(ctx, providerId);
1283
+ return ctx.json(sanitizeProvider(provider, ctx.context.baseURL));
1284
+ });
1285
+ };
1286
+ function parseAndValidateConfig(configString, configType) {
1287
+ let config = null;
1288
+ try {
1289
+ config = safeJsonParse(configString);
1290
+ } catch {
1291
+ config = null;
1292
+ }
1293
+ if (!config) throw new APIError("BAD_REQUEST", { message: `Cannot update ${configType} config for a provider that doesn't have ${configType} configured` });
1294
+ return config;
1268
1295
  }
1269
- /**
1270
- * Runs runtime OIDC discovery when the stored config is missing required
1271
- * endpoints, and merges the hydrated fields back into the config.
1272
- * Throws if discovery fails.
1273
- */
1274
- async function ensureRuntimeDiscovery(config, issuer, isTrustedOrigin) {
1275
- if (!needsRuntimeDiscovery(config)) return config;
1276
- const hydrated = await discoverOIDCConfig({
1296
+ function mergeSAMLConfig(current, updates, issuer) {
1297
+ return {
1298
+ ...current,
1299
+ ...updates,
1277
1300
  issuer,
1278
- existingConfig: config,
1279
- isTrustedOrigin
1280
- });
1301
+ entryPoint: updates.entryPoint ?? current.entryPoint,
1302
+ cert: updates.cert ?? current.cert,
1303
+ callbackUrl: updates.callbackUrl ?? current.callbackUrl,
1304
+ spMetadata: updates.spMetadata ?? current.spMetadata,
1305
+ idpMetadata: updates.idpMetadata ?? current.idpMetadata,
1306
+ mapping: updates.mapping ?? current.mapping,
1307
+ audience: updates.audience ?? current.audience,
1308
+ wantAssertionsSigned: updates.wantAssertionsSigned ?? current.wantAssertionsSigned,
1309
+ authnRequestsSigned: updates.authnRequestsSigned ?? current.authnRequestsSigned,
1310
+ identifierFormat: updates.identifierFormat ?? current.identifierFormat,
1311
+ signatureAlgorithm: updates.signatureAlgorithm ?? current.signatureAlgorithm,
1312
+ digestAlgorithm: updates.digestAlgorithm ?? current.digestAlgorithm
1313
+ };
1314
+ }
1315
+ function mergeOIDCConfig(current, updates, issuer) {
1281
1316
  return {
1282
- ...config,
1283
- authorizationEndpoint: hydrated.authorizationEndpoint,
1284
- tokenEndpoint: hydrated.tokenEndpoint,
1285
- tokenEndpointAuthentication: hydrated.tokenEndpointAuthentication,
1286
- userInfoEndpoint: hydrated.userInfoEndpoint,
1287
- jwksEndpoint: hydrated.jwksEndpoint
1317
+ ...current,
1318
+ ...updates,
1319
+ issuer,
1320
+ pkce: updates.pkce ?? current.pkce ?? true,
1321
+ clientId: updates.clientId ?? current.clientId,
1322
+ clientSecret: updates.clientSecret ?? current.clientSecret,
1323
+ discoveryEndpoint: updates.discoveryEndpoint ?? current.discoveryEndpoint,
1324
+ mapping: updates.mapping ?? current.mapping,
1325
+ scopes: updates.scopes ?? current.scopes,
1326
+ authorizationEndpoint: updates.authorizationEndpoint ?? current.authorizationEndpoint,
1327
+ tokenEndpoint: updates.tokenEndpoint ?? current.tokenEndpoint,
1328
+ userInfoEndpoint: updates.userInfoEndpoint ?? current.userInfoEndpoint,
1329
+ jwksEndpoint: updates.jwksEndpoint ?? current.jwksEndpoint,
1330
+ tokenEndpointAuthentication: updates.tokenEndpointAuthentication ?? current.tokenEndpointAuthentication
1288
1331
  };
1289
1332
  }
1290
- //#endregion
1291
- //#region src/oidc/errors.ts
1292
- /**
1293
- * OIDC Discovery Error Mapping
1294
- *
1295
- * Maps DiscoveryError codes to appropriate APIError responses.
1296
- * Used at the boundary between the discovery pipeline and HTTP handlers.
1297
- */
1298
- /**
1299
- * Maps a DiscoveryError to an appropriate APIError for HTTP responses.
1300
- *
1301
- * Error code mapping:
1302
- * - discovery_invalid_url → 400 BAD_REQUEST
1303
- * - discovery_not_found → 400 BAD_REQUEST
1304
- * - discovery_invalid_json → 400 BAD_REQUEST
1305
- * - discovery_incomplete → 400 BAD_REQUEST
1306
- * - issuer_mismatch → 400 BAD_REQUEST
1307
- * - unsupported_token_auth_method 400 BAD_REQUEST
1308
- * - discovery_timeout → 502 BAD_GATEWAY
1309
- * - discovery_unexpected_error → 502 BAD_GATEWAY
1310
- *
1311
- * @param error - The DiscoveryError to map
1312
- * @returns An APIError with appropriate status and message
1313
- */
1314
- function mapDiscoveryErrorToAPIError(error) {
1315
- switch (error.code) {
1316
- case "discovery_timeout": return new APIError("BAD_GATEWAY", {
1317
- message: `OIDC discovery timed out: ${error.message}`,
1318
- code: error.code
1319
- });
1320
- case "discovery_unexpected_error": return new APIError("BAD_GATEWAY", {
1321
- message: `OIDC discovery failed: ${error.message}`,
1322
- code: error.code
1323
- });
1324
- case "discovery_not_found": return new APIError("BAD_REQUEST", {
1325
- message: `OIDC discovery endpoint not found. The issuer may not support OIDC discovery, or the URL is incorrect. ${error.message}`,
1326
- code: error.code
1327
- });
1328
- case "discovery_invalid_url": return new APIError("BAD_REQUEST", {
1329
- message: `Invalid OIDC discovery URL: ${error.message}`,
1330
- code: error.code
1331
- });
1332
- case "discovery_untrusted_origin": return new APIError("BAD_REQUEST", {
1333
- message: `Untrusted OIDC discovery URL: ${error.message}`,
1334
- code: error.code
1335
- });
1336
- case "discovery_invalid_json": return new APIError("BAD_REQUEST", {
1337
- message: `OIDC discovery returned invalid data: ${error.message}`,
1338
- code: error.code
1339
- });
1340
- case "discovery_incomplete": return new APIError("BAD_REQUEST", {
1341
- message: `OIDC discovery document is missing required fields: ${error.message}`,
1342
- code: error.code
1333
+ const updateSSOProvider = (options) => {
1334
+ return createAuthEndpoint("/sso/update-provider", {
1335
+ method: "POST",
1336
+ use: [sessionMiddleware],
1337
+ body: updateSSOProviderBodySchema.extend({ providerId: z.string() }),
1338
+ metadata: { openapi: {
1339
+ operationId: "updateSSOProvider",
1340
+ summary: "Update SSO provider",
1341
+ description: "Partially update an SSO provider. Only provided fields are updated. If domain changes, domainVerified is reset to false.",
1342
+ responses: {
1343
+ "200": { description: "SSO provider updated successfully" },
1344
+ "404": { description: "Provider not found" },
1345
+ "403": { description: "Access denied" }
1346
+ }
1347
+ } }
1348
+ }, async (ctx) => {
1349
+ const { providerId, ...body } = ctx.body;
1350
+ const { issuer, domain, samlConfig, oidcConfig } = body;
1351
+ if (!issuer && !domain && !samlConfig && !oidcConfig) throw new APIError("BAD_REQUEST", { message: "No fields provided for update" });
1352
+ const existingProvider = await checkProviderAccess(ctx, providerId);
1353
+ const updateData = {};
1354
+ if (body.issuer !== void 0) updateData.issuer = body.issuer;
1355
+ if (body.domain !== void 0) {
1356
+ updateData.domain = body.domain;
1357
+ if (body.domain !== existingProvider.domain) updateData.domainVerified = false;
1358
+ }
1359
+ if (body.samlConfig) {
1360
+ if (body.samlConfig.idpMetadata?.metadata) {
1361
+ const maxMetadataSize = options?.saml?.maxMetadataSize ?? 102400;
1362
+ if (new TextEncoder().encode(body.samlConfig.idpMetadata.metadata).length > maxMetadataSize) throw new APIError("BAD_REQUEST", { message: `IdP metadata exceeds maximum allowed size (${maxMetadataSize} bytes)` });
1363
+ }
1364
+ if (body.samlConfig.signatureAlgorithm !== void 0 || body.samlConfig.digestAlgorithm !== void 0) validateConfigAlgorithms({
1365
+ signatureAlgorithm: body.samlConfig.signatureAlgorithm,
1366
+ digestAlgorithm: body.samlConfig.digestAlgorithm
1367
+ }, options?.saml?.algorithms);
1368
+ const currentSamlConfig = parseAndValidateConfig(existingProvider.samlConfig, "SAML");
1369
+ const updatedSamlConfig = mergeSAMLConfig(currentSamlConfig, body.samlConfig, updateData.issuer || currentSamlConfig.issuer || existingProvider.issuer);
1370
+ updateData.samlConfig = JSON.stringify(updatedSamlConfig);
1371
+ }
1372
+ if (body.oidcConfig) {
1373
+ try {
1374
+ validateSkipDiscoveryEndpoints(body.oidcConfig, (url) => ctx.context.isTrustedOrigin(url));
1375
+ } catch (error) {
1376
+ if (error instanceof DiscoveryError) throw mapDiscoveryErrorToAPIError(error);
1377
+ throw error;
1378
+ }
1379
+ const currentOidcConfig = parseAndValidateConfig(existingProvider.oidcConfig, "OIDC");
1380
+ const updatedOidcConfig = mergeOIDCConfig(currentOidcConfig, body.oidcConfig, updateData.issuer || currentOidcConfig.issuer || existingProvider.issuer);
1381
+ updateData.oidcConfig = JSON.stringify(updatedOidcConfig);
1382
+ }
1383
+ await ctx.context.adapter.update({
1384
+ model: "ssoProvider",
1385
+ where: [{
1386
+ field: "providerId",
1387
+ value: providerId
1388
+ }],
1389
+ update: updateData
1343
1390
  });
1344
- case "issuer_mismatch": return new APIError("BAD_REQUEST", {
1345
- message: `OIDC issuer mismatch: ${error.message}`,
1346
- code: error.code
1391
+ const fullProvider = await ctx.context.adapter.findOne({
1392
+ model: "ssoProvider",
1393
+ where: [{
1394
+ field: "providerId",
1395
+ value: providerId
1396
+ }]
1347
1397
  });
1348
- case "unsupported_token_auth_method": return new APIError("BAD_REQUEST", {
1349
- message: `Incompatible OIDC provider: ${error.message}`,
1350
- code: error.code
1398
+ if (!fullProvider) throw new APIError("NOT_FOUND", { message: "Provider not found after update" });
1399
+ return ctx.json(sanitizeProvider(fullProvider, ctx.context.baseURL));
1400
+ });
1401
+ };
1402
+ const deleteSSOProvider = () => {
1403
+ return createAuthEndpoint("/sso/delete-provider", {
1404
+ method: "POST",
1405
+ use: [sessionMiddleware],
1406
+ body: z.object({ providerId: z.string() }),
1407
+ metadata: { openapi: {
1408
+ operationId: "deleteSSOProvider",
1409
+ summary: "Delete SSO provider",
1410
+ description: "Deletes an SSO provider",
1411
+ responses: {
1412
+ "200": { description: "SSO provider deleted successfully" },
1413
+ "404": { description: "Provider not found" },
1414
+ "403": { description: "Access denied" }
1415
+ }
1416
+ } }
1417
+ }, async (ctx) => {
1418
+ const { providerId } = ctx.body;
1419
+ await checkProviderAccess(ctx, providerId);
1420
+ await ctx.context.adapter.delete({
1421
+ model: "ssoProvider",
1422
+ where: [{
1423
+ field: "providerId",
1424
+ value: providerId
1425
+ }]
1351
1426
  });
1352
- default:
1353
- error.code;
1354
- return new APIError("INTERNAL_SERVER_ERROR", {
1355
- message: `Unexpected discovery error: ${error.message}`,
1356
- code: "discovery_unexpected_error"
1357
- });
1358
- }
1359
- }
1427
+ return ctx.json({ success: true });
1428
+ });
1429
+ };
1360
1430
  //#endregion
1361
1431
  //#region src/saml/error-codes.ts
1362
1432
  const SAML_ERROR_CODES = defineErrorCodes({
@@ -1740,23 +1810,35 @@ async function processSAMLResponse(ctx, params, options) {
1740
1810
  }
1741
1811
  const isTrustedProvider = ctx.context.trustedProviders.includes(providerId) || "domainVerified" in provider && !!provider.domainVerified && validateEmailDomain(userInfo.email, provider.domain);
1742
1812
  const callbackUrl = relayState?.callbackURL || parsedSamlConfig.callbackUrl || ctx.context.baseURL;
1743
- const result = await handleOAuthUserInfo(ctx, {
1744
- userInfo: {
1745
- email: userInfo.email,
1746
- name: userInfo.name || userInfo.email,
1747
- id: userInfo.id,
1748
- emailVerified: Boolean(userInfo.emailVerified)
1749
- },
1750
- account: {
1751
- providerId,
1752
- accountId: userInfo.id,
1753
- accessToken: "",
1754
- refreshToken: ""
1755
- },
1756
- callbackURL: callbackUrl,
1757
- disableSignUp: options?.disableImplicitSignUp,
1758
- isTrustedProvider
1759
- });
1813
+ const errorUrl = relayState?.errorURL || samlRedirectUrl;
1814
+ let result;
1815
+ try {
1816
+ result = await handleOAuthUserInfo(ctx, {
1817
+ userInfo: {
1818
+ email: userInfo.email,
1819
+ name: userInfo.name || userInfo.email,
1820
+ id: userInfo.id,
1821
+ emailVerified: Boolean(userInfo.emailVerified)
1822
+ },
1823
+ account: {
1824
+ providerId,
1825
+ accountId: userInfo.id,
1826
+ accessToken: "",
1827
+ refreshToken: ""
1828
+ },
1829
+ callbackURL: callbackUrl,
1830
+ disableSignUp: options?.disableImplicitSignUp,
1831
+ isTrustedProvider
1832
+ });
1833
+ } catch (e) {
1834
+ if (isAPIError(e) && e.body?.code) {
1835
+ const params = new URLSearchParams({ error: e.body.code });
1836
+ if (e.body.message) params.set("error_description", e.body.message);
1837
+ const sep = errorUrl.includes("?") ? "&" : "?";
1838
+ throw ctx.redirect(`${errorUrl}${sep}${params.toString()}`);
1839
+ }
1840
+ throw e;
1841
+ }
1760
1842
  if (result.error) throw ctx.redirect(`${callbackUrl}?error=${result.error.split(" ").join("_")}`);
1761
1843
  const { session, user } = result.data;
1762
1844
  if (options?.provisionUser && (result.isRegister || options.provisionUserOnEveryLogin)) await options.provisionUser({
@@ -1865,12 +1947,12 @@ const ssoProviderBodySchema = z.object({
1865
1947
  oidcConfig: z.object({
1866
1948
  clientId: z.string({}).meta({ description: "The client ID" }),
1867
1949
  clientSecret: z.string({}).meta({ description: "The client secret" }),
1868
- authorizationEndpoint: z.string({}).meta({ description: "The authorization endpoint" }).optional(),
1869
- tokenEndpoint: z.string({}).meta({ description: "The token endpoint" }).optional(),
1870
- userInfoEndpoint: z.string({}).meta({ description: "The user info endpoint" }).optional(),
1950
+ authorizationEndpoint: z.url().meta({ description: "The authorization endpoint" }).optional(),
1951
+ tokenEndpoint: z.url().meta({ description: "The token endpoint" }).optional(),
1952
+ userInfoEndpoint: z.url().meta({ description: "The user info endpoint" }).optional(),
1871
1953
  tokenEndpointAuthentication: z.enum(["client_secret_post", "client_secret_basic"]).optional(),
1872
- jwksEndpoint: z.string({}).meta({ description: "The JWKS endpoint" }).optional(),
1873
- discoveryEndpoint: z.string().optional(),
1954
+ jwksEndpoint: z.url().meta({ description: "The JWKS endpoint" }).optional(),
1955
+ discoveryEndpoint: z.url().optional(),
1874
1956
  skipDiscovery: z.boolean().meta({ description: "Skip OIDC discovery during registration. When true, you must provide authorizationEndpoint, tokenEndpoint, and jwksEndpoint manually." }).optional(),
1875
1957
  scopes: z.array(z.string(), {}).meta({ description: "The scopes to request. Defaults to ['openid', 'email', 'profile', 'offline_access']" }).optional(),
1876
1958
  pkce: z.boolean({}).meta({ description: "Whether to use PKCE for the authorization flow" }).default(true).optional(),
@@ -2123,7 +2205,7 @@ const registerSSOProvider = (options) => {
2123
2205
  if (new TextEncoder().encode(body.samlConfig.idpMetadata.metadata).length > maxMetadataSize) throw new APIError("BAD_REQUEST", { message: `IdP metadata exceeds maximum allowed size (${maxMetadataSize} bytes)` });
2124
2206
  }
2125
2207
  if (ctx.body.organizationId) {
2126
- if (!await ctx.context.adapter.findOne({
2208
+ const member = await ctx.context.adapter.findOne({
2127
2209
  model: "member",
2128
2210
  where: [{
2129
2211
  field: "userId",
@@ -2132,7 +2214,9 @@ const registerSSOProvider = (options) => {
2132
2214
  field: "organizationId",
2133
2215
  value: ctx.body.organizationId
2134
2216
  }]
2135
- })) throw new APIError("BAD_REQUEST", { message: "You are not a member of the organization" });
2217
+ });
2218
+ if (!member) throw new APIError("BAD_REQUEST", { message: "You are not a member of the organization" });
2219
+ if (ctx.context.hasPlugin("organization") && !hasOrgAdminRole(member)) throw new APIError("FORBIDDEN", { message: "You must be an organization owner or admin to register SSO providers" });
2136
2220
  }
2137
2221
  if (await ctx.context.adapter.findOne({
2138
2222
  model: "ssoProvider",
@@ -2144,6 +2228,12 @@ const registerSSOProvider = (options) => {
2144
2228
  ctx.context.logger.info(`SSO provider creation attempt with existing providerId: ${body.providerId}`);
2145
2229
  throw new APIError("UNPROCESSABLE_ENTITY", { message: "SSO provider with this providerId already exists" });
2146
2230
  }
2231
+ if (body.oidcConfig) try {
2232
+ validateSkipDiscoveryEndpoints(body.oidcConfig, (url) => ctx.context.isTrustedOrigin(url));
2233
+ } catch (error) {
2234
+ if (error instanceof DiscoveryError) throw mapDiscoveryErrorToAPIError(error);
2235
+ throw error;
2236
+ }
2147
2237
  let hydratedOIDCConfig = null;
2148
2238
  if (body.oidcConfig && !body.oidcConfig.skipDiscovery) try {
2149
2239
  hydratedOIDCConfig = await discoverOIDCConfig({
@@ -2621,30 +2711,47 @@ async function handleOIDCCallback(ctx, options, providerId, stateData) {
2621
2711
  } else throw ctx.redirect(`${errorURL || callbackURL}?error=invalid_provider&error_description=user_info_endpoint_not_found`);
2622
2712
  if (!userInfo.email || !userInfo.id) throw ctx.redirect(`${errorURL || callbackURL}?error=invalid_provider&error_description=missing_user_info`);
2623
2713
  const isTrustedProvider = "domainVerified" in provider && provider.domainVerified === true && validateEmailDomain(userInfo.email, provider.domain);
2624
- const linked = await handleOAuthUserInfo(ctx, {
2625
- userInfo: {
2626
- email: userInfo.email,
2627
- name: userInfo.name || "",
2628
- id: userInfo.id,
2629
- image: userInfo.image,
2630
- emailVerified: options?.trustEmailVerified ? userInfo.emailVerified || false : false
2631
- },
2632
- account: {
2633
- idToken: tokenResponse.idToken,
2634
- accessToken: tokenResponse.accessToken,
2635
- refreshToken: tokenResponse.refreshToken,
2636
- accountId: userInfo.id,
2637
- providerId: provider.providerId,
2638
- accessTokenExpiresAt: tokenResponse.accessTokenExpiresAt,
2639
- refreshTokenExpiresAt: tokenResponse.refreshTokenExpiresAt,
2640
- scope: tokenResponse.scopes?.join(",")
2641
- },
2642
- callbackURL,
2643
- disableSignUp: options?.disableImplicitSignUp && !requestSignUp,
2644
- overrideUserInfo: config.overrideUserInfo,
2645
- isTrustedProvider
2646
- });
2647
- if (linked.error) throw ctx.redirect(`${errorURL || callbackURL}?error=${linked.error}`);
2714
+ let linked;
2715
+ try {
2716
+ linked = await handleOAuthUserInfo(ctx, {
2717
+ userInfo: {
2718
+ email: userInfo.email,
2719
+ name: userInfo.name || "",
2720
+ id: userInfo.id,
2721
+ image: userInfo.image,
2722
+ emailVerified: options?.trustEmailVerified ? userInfo.emailVerified || false : false
2723
+ },
2724
+ account: {
2725
+ idToken: tokenResponse.idToken,
2726
+ accessToken: tokenResponse.accessToken,
2727
+ refreshToken: tokenResponse.refreshToken,
2728
+ accountId: userInfo.id,
2729
+ providerId: provider.providerId,
2730
+ accessTokenExpiresAt: tokenResponse.accessTokenExpiresAt,
2731
+ refreshTokenExpiresAt: tokenResponse.refreshTokenExpiresAt,
2732
+ scope: tokenResponse.scopes?.join(",")
2733
+ },
2734
+ callbackURL,
2735
+ disableSignUp: options?.disableImplicitSignUp && !requestSignUp,
2736
+ overrideUserInfo: config.overrideUserInfo,
2737
+ isTrustedProvider
2738
+ });
2739
+ } catch (e) {
2740
+ if (isAPIError(e) && e.body?.code) {
2741
+ const baseURL = errorURL || callbackURL;
2742
+ const params = new URLSearchParams({ error: e.body.code });
2743
+ if (e.body.message) params.set("error_description", e.body.message);
2744
+ const sep = baseURL.includes("?") ? "&" : "?";
2745
+ throw ctx.redirect(`${baseURL}${sep}${params.toString()}`);
2746
+ }
2747
+ throw e;
2748
+ }
2749
+ if (linked.error) {
2750
+ const baseURL = errorURL || callbackURL;
2751
+ const params = new URLSearchParams({ error: linked.error });
2752
+ const sep = baseURL.includes("?") ? "&" : "?";
2753
+ throw ctx.redirect(`${baseURL}${sep}${params.toString()}`);
2754
+ }
2648
2755
  const { session, user } = linked.data;
2649
2756
  if (options?.provisionUser && (linked.isRegister || options.provisionUserOnEveryLogin)) await options.provisionUser({
2650
2757
  user,