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

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