@better-auth/sso 1.6.10 → 1.6.11

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