@better-auth/sso 1.5.0-beta.8 → 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.mjs CHANGED
@@ -1,17 +1,123 @@
1
1
  import { APIError, createAuthEndpoint, createAuthMiddleware, getSessionFromCtx, sessionMiddleware } from "better-auth/api";
2
2
  import { XMLParser, XMLValidator } from "fast-xml-parser";
3
- import * as saml from "samlify";
3
+ import 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
10
  import { HIDE_METADATA, createAuthorizationURL, generateGenericState, generateState, parseGenericState, parseState, validateAuthorizationCode, validateToken } from "better-auth";
9
- import { setSessionCookie } from "better-auth/cookies";
11
+ import { deleteSessionCookie, 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";
13
- import { APIError as APIError$1 } from "better-call";
14
+ import { defineErrorCodes } from "@better-auth/core/utils/error-codes";
15
+
16
+ //#region src/constants.ts
17
+ /**
18
+ * SAML Constants
19
+ *
20
+ * Centralized constants for SAML SSO functionality.
21
+ */
22
+ /** Prefix for AuthnRequest IDs used in InResponseTo validation */
23
+ const AUTHN_REQUEST_KEY_PREFIX = "saml-authn-request:";
24
+ /** Prefix for used Assertion IDs used in replay protection */
25
+ const USED_ASSERTION_KEY_PREFIX = "saml-used-assertion:";
26
+ /** Prefix for SAML session data (NameID + SessionIndex) for SLO */
27
+ const SAML_SESSION_KEY_PREFIX = "saml-session:";
28
+ /** Prefix for reverse lookup of SAML session by Better Auth session ID */
29
+ const SAML_SESSION_BY_ID_PREFIX = "saml-session-by-id:";
30
+ /** Prefix for LogoutRequest IDs used in SP-initiated SLO validation */
31
+ const LOGOUT_REQUEST_KEY_PREFIX = "saml-logout-request:";
32
+ /**
33
+ * Default TTL for AuthnRequest records (5 minutes).
34
+ * This should be sufficient for most IdPs while protecting against stale requests.
35
+ */
36
+ const DEFAULT_AUTHN_REQUEST_TTL_MS = 300 * 1e3;
37
+ /**
38
+ * Default TTL for used assertion records (15 minutes).
39
+ * This should match the maximum expected NotOnOrAfter window plus clock skew.
40
+ */
41
+ const DEFAULT_ASSERTION_TTL_MS = 900 * 1e3;
42
+ /**
43
+ * Default TTL for LogoutRequest records (5 minutes).
44
+ * Should be sufficient for IdP to process and respond.
45
+ */
46
+ const DEFAULT_LOGOUT_REQUEST_TTL_MS = 300 * 1e3;
47
+ /**
48
+ * Default clock skew tolerance (5 minutes).
49
+ * Allows for minor time differences between IdP and SP servers.
50
+ *
51
+ * Accommodates:
52
+ * - Network latency and processing time
53
+ * - Clock synchronization differences (NTP drift)
54
+ * - Distributed systems across timezones
55
+ */
56
+ const DEFAULT_CLOCK_SKEW_MS = 300 * 1e3;
57
+ /**
58
+ * Default maximum size for SAML responses (256 KB).
59
+ * Protects against memory exhaustion from oversized SAML payloads.
60
+ */
61
+ const DEFAULT_MAX_SAML_RESPONSE_SIZE = 256 * 1024;
62
+ /**
63
+ * Default maximum size for IdP metadata (100 KB).
64
+ * Protects against oversized metadata documents.
65
+ */
66
+ const DEFAULT_MAX_SAML_METADATA_SIZE = 100 * 1024;
67
+ const SAML_STATUS_SUCCESS = "urn:oasis:names:tc:SAML:2.0:status:Success";
68
+
69
+ //#endregion
70
+ //#region src/utils.ts
71
+ /**
72
+ * Safely parses a value that might be a JSON string or already a parsed object.
73
+ * This handles cases where ORMs like Drizzle might return already parsed objects
74
+ * instead of JSON strings from TEXT/JSON columns.
75
+ *
76
+ * @param value - The value to parse (string, object, null, or undefined)
77
+ * @returns The parsed object or null
78
+ * @throws Error if string parsing fails
79
+ */
80
+ function safeJsonParse(value) {
81
+ if (!value) return null;
82
+ if (typeof value === "object") return value;
83
+ if (typeof value === "string") try {
84
+ return JSON.parse(value);
85
+ } catch (error) {
86
+ throw new Error(`Failed to parse JSON: ${error instanceof Error ? error.message : "Unknown error"}`);
87
+ }
88
+ return null;
89
+ }
90
+ /**
91
+ * Checks if a domain matches any domain in a comma-separated list.
92
+ */
93
+ const domainMatches = (searchDomain, domainList) => {
94
+ const search = searchDomain.toLowerCase();
95
+ return domainList.split(",").map((d) => d.trim().toLowerCase()).filter(Boolean).some((d) => search === d || search.endsWith(`.${d}`));
96
+ };
97
+ /**
98
+ * Validates email domain against allowed domain(s).
99
+ * Supports comma-separated domains for multi-domain SSO.
100
+ */
101
+ const validateEmailDomain = (email, domain) => {
102
+ const emailDomain = email.split("@")[1]?.toLowerCase();
103
+ if (!emailDomain || !domain) return false;
104
+ return domainMatches(emailDomain, domain);
105
+ };
106
+ function parseCertificate(certPem) {
107
+ const cert = new X509Certificate(certPem.includes("-----BEGIN") ? certPem : `-----BEGIN CERTIFICATE-----\n${certPem}\n-----END CERTIFICATE-----`);
108
+ return {
109
+ fingerprintSha256: cert.fingerprint256,
110
+ notBefore: cert.validFrom,
111
+ notAfter: cert.validTo,
112
+ publicKeyAlgorithm: cert.publicKey.asymmetricKeyType?.toUpperCase() || "UNKNOWN"
113
+ };
114
+ }
115
+ function maskClientId(clientId) {
116
+ if (clientId.length <= 4) return "****";
117
+ return `****${clientId.slice(-4)}`;
118
+ }
14
119
 
120
+ //#endregion
15
121
  //#region src/linking/org-assignment.ts
16
122
  /**
17
123
  * Assigns a user to an organization based on the SSO provider's organizationId.
@@ -21,7 +127,7 @@ async function assignOrganizationFromProvider(ctx, options) {
21
127
  const { user, profile, provider, token, provisioningOptions } = options;
22
128
  if (!provider.organizationId) return;
23
129
  if (provisioningOptions?.disabled) return;
24
- if (!ctx.context.options.plugins?.find((plugin) => plugin.id === "organization")) return;
130
+ if (!ctx.context.hasPlugin("organization")) return;
25
131
  if (await ctx.context.adapter.findOne({
26
132
  model: "member",
27
133
  where: [{
@@ -59,7 +165,7 @@ async function assignOrganizationFromProvider(ctx, options) {
59
165
  async function assignOrganizationByDomain(ctx, options) {
60
166
  const { user, provisioningOptions, domainVerification } = options;
61
167
  if (provisioningOptions?.disabled) return;
62
- if (!ctx.context.options.plugins?.find((plugin) => plugin.id === "organization")) return;
168
+ if (!ctx.context.hasPlugin("organization")) return;
63
169
  const domain = user.email.split("@")[1];
64
170
  if (!domain) return;
65
171
  const whereClause = [{
@@ -70,10 +176,17 @@ async function assignOrganizationByDomain(ctx, options) {
70
176
  field: "domainVerified",
71
177
  value: true
72
178
  });
73
- const ssoProvider = await ctx.context.adapter.findOne({
179
+ let ssoProvider = await ctx.context.adapter.findOne({
74
180
  model: "ssoProvider",
75
181
  where: whereClause
76
182
  });
183
+ if (!ssoProvider) ssoProvider = (await ctx.context.adapter.findMany({
184
+ model: "ssoProvider",
185
+ where: domainVerification?.enabled ? [{
186
+ field: "domainVerified",
187
+ value: true
188
+ }] : []
189
+ })).find((p) => domainMatches(domain, p.domain)) ?? null;
77
190
  if (!ssoProvider || !ssoProvider.organizationId) return;
78
191
  if (await ctx.context.adapter.findOne({
79
192
  model: "member",
@@ -103,7 +216,12 @@ async function assignOrganizationByDomain(ctx, options) {
103
216
 
104
217
  //#endregion
105
218
  //#region src/routes/domain-verification.ts
219
+ const DNS_LABEL_MAX_LENGTH = 63;
220
+ const DEFAULT_TOKEN_PREFIX = "better-auth-token";
106
221
  const domainVerificationBodySchema = z$1.object({ providerId: z$1.string() });
222
+ function getVerificationIdentifier(options, providerId) {
223
+ return `_${options.domainVerification?.tokenPrefix || DEFAULT_TOKEN_PREFIX}-${providerId}`;
224
+ }
107
225
  const requestDomainVerification = (options) => {
108
226
  return createAuthEndpoint("/sso/request-domain-verification", {
109
227
  method: "POST",
@@ -151,11 +269,12 @@ const requestDomainVerification = (options) => {
151
269
  message: "Domain has already been verified",
152
270
  code: "DOMAIN_VERIFIED"
153
271
  });
272
+ const identifier = getVerificationIdentifier(options, provider.providerId);
154
273
  const activeVerification = await ctx.context.adapter.findOne({
155
274
  model: "verification",
156
275
  where: [{
157
276
  field: "identifier",
158
- value: options.domainVerification?.tokenPrefix ? `${options.domainVerification?.tokenPrefix}-${provider.providerId}` : `better-auth-token-${provider.providerId}`
277
+ value: identifier
159
278
  }, {
160
279
  field: "expiresAt",
161
280
  value: /* @__PURE__ */ new Date(),
@@ -170,7 +289,7 @@ const requestDomainVerification = (options) => {
170
289
  await ctx.context.adapter.create({
171
290
  model: "verification",
172
291
  data: {
173
- identifier: options.domainVerification?.tokenPrefix ? `${options.domainVerification?.tokenPrefix}-${provider.providerId}` : `better-auth-token-${provider.providerId}`,
292
+ identifier,
174
293
  createdAt: /* @__PURE__ */ new Date(),
175
294
  updatedAt: /* @__PURE__ */ new Date(),
176
295
  value: domainVerificationToken,
@@ -229,11 +348,16 @@ const verifyDomain = (options) => {
229
348
  message: "Domain has already been verified",
230
349
  code: "DOMAIN_VERIFIED"
231
350
  });
351
+ const identifier = getVerificationIdentifier(options, provider.providerId);
352
+ if (identifier.length > DNS_LABEL_MAX_LENGTH) throw new APIError("BAD_REQUEST", {
353
+ message: `Verification identifier exceeds the DNS label limit of ${DNS_LABEL_MAX_LENGTH} characters`,
354
+ code: "IDENTIFIER_TOO_LONG"
355
+ });
232
356
  const activeVerification = await ctx.context.adapter.findOne({
233
357
  model: "verification",
234
358
  where: [{
235
359
  field: "identifier",
236
- value: options.domainVerification?.tokenPrefix ? `${options.domainVerification?.tokenPrefix}-${provider.providerId}` : `better-auth-token-${provider.providerId}`
360
+ value: identifier
237
361
  }, {
238
362
  field: "expiresAt",
239
363
  value: /* @__PURE__ */ new Date(),
@@ -256,7 +380,8 @@ const verifyDomain = (options) => {
256
380
  });
257
381
  }
258
382
  try {
259
- records = (await dns.resolveTxt(new URL(provider.domain).hostname)).flat();
383
+ const hostname = new URL(provider.domain).hostname;
384
+ records = (await dns.resolveTxt(`${identifier}.${hostname}`)).flat();
260
385
  } catch (error) {
261
386
  ctx.context.logger.warn("DNS resolution failure while validating domain ownership", error);
262
387
  }
@@ -277,110 +402,699 @@ const verifyDomain = (options) => {
277
402
  };
278
403
 
279
404
  //#endregion
280
- //#region src/constants.ts
281
- /**
282
- * SAML Constants
283
- *
284
- * Centralized constants for SAML SSO functionality.
285
- */
286
- /** Prefix for AuthnRequest IDs used in InResponseTo validation */
287
- const AUTHN_REQUEST_KEY_PREFIX = "saml-authn-request:";
288
- /** Prefix for used Assertion IDs used in replay protection */
289
- const USED_ASSERTION_KEY_PREFIX = "saml-used-assertion:";
290
- /**
291
- * Default TTL for AuthnRequest records (5 minutes).
292
- * This should be sufficient for most IdPs while protecting against stale requests.
293
- */
294
- const DEFAULT_AUTHN_REQUEST_TTL_MS = 300 * 1e3;
295
- /**
296
- * Default TTL for used assertion records (15 minutes).
297
- * This should match the maximum expected NotOnOrAfter window plus clock skew.
298
- */
299
- const DEFAULT_ASSERTION_TTL_MS = 900 * 1e3;
300
- /**
301
- * Default clock skew tolerance (5 minutes).
302
- * Allows for minor time differences between IdP and SP servers.
303
- *
304
- * Accommodates:
305
- * - Network latency and processing time
306
- * - Clock synchronization differences (NTP drift)
307
- * - Distributed systems across timezones
308
- */
309
- const DEFAULT_CLOCK_SKEW_MS = 300 * 1e3;
310
- /**
311
- * Default maximum size for SAML responses (256 KB).
312
- * Protects against memory exhaustion from oversized SAML payloads.
313
- */
314
- const DEFAULT_MAX_SAML_RESPONSE_SIZE = 256 * 1024;
315
- /**
316
- * Default maximum size for IdP metadata (100 KB).
317
- * Protects against oversized metadata documents.
318
- */
319
- const DEFAULT_MAX_SAML_METADATA_SIZE = 100 * 1024;
405
+ //#region src/saml/parser.ts
406
+ const xmlParser = new XMLParser({
407
+ ignoreAttributes: false,
408
+ attributeNamePrefix: "@_",
409
+ removeNSPrefix: true,
410
+ processEntities: false
411
+ });
412
+ function findNode(obj, nodeName) {
413
+ if (!obj || typeof obj !== "object") return null;
414
+ const record = obj;
415
+ if (nodeName in record) return record[nodeName];
416
+ for (const value of Object.values(record)) if (Array.isArray(value)) for (const item of value) {
417
+ const found = findNode(item, nodeName);
418
+ if (found) return found;
419
+ }
420
+ else if (typeof value === "object" && value !== null) {
421
+ const found = findNode(value, nodeName);
422
+ if (found) return found;
423
+ }
424
+ return null;
425
+ }
426
+ function countAllNodes(obj, nodeName) {
427
+ if (!obj || typeof obj !== "object") return 0;
428
+ let count = 0;
429
+ const record = obj;
430
+ if (nodeName in record) {
431
+ const node = record[nodeName];
432
+ count += Array.isArray(node) ? node.length : 1;
433
+ }
434
+ for (const value of Object.values(record)) if (Array.isArray(value)) for (const item of value) count += countAllNodes(item, nodeName);
435
+ else if (typeof value === "object" && value !== null) count += countAllNodes(value, nodeName);
436
+ return count;
437
+ }
320
438
 
321
439
  //#endregion
322
- //#region src/oidc/types.ts
323
- /**
324
- * Custom error class for OIDC discovery failures.
325
- * Can be caught and mapped to APIError at the edge.
326
- */
327
- var DiscoveryError = class DiscoveryError extends Error {
328
- code;
329
- details;
330
- constructor(code, message, details, options) {
331
- super(message, options);
332
- this.name = "DiscoveryError";
333
- this.code = code;
334
- this.details = details;
335
- if (Error.captureStackTrace) Error.captureStackTrace(this, DiscoveryError);
336
- }
440
+ //#region src/saml/algorithms.ts
441
+ const SignatureAlgorithm = {
442
+ RSA_SHA1: "http://www.w3.org/2000/09/xmldsig#rsa-sha1",
443
+ RSA_SHA256: "http://www.w3.org/2001/04/xmldsig-more#rsa-sha256",
444
+ RSA_SHA384: "http://www.w3.org/2001/04/xmldsig-more#rsa-sha384",
445
+ RSA_SHA512: "http://www.w3.org/2001/04/xmldsig-more#rsa-sha512",
446
+ ECDSA_SHA256: "http://www.w3.org/2001/04/xmldsig-more#ecdsa-sha256",
447
+ ECDSA_SHA384: "http://www.w3.org/2001/04/xmldsig-more#ecdsa-sha384",
448
+ ECDSA_SHA512: "http://www.w3.org/2001/04/xmldsig-more#ecdsa-sha512"
337
449
  };
338
- /**
339
- * Required fields that must be present in a valid discovery document.
340
- */
341
- const REQUIRED_DISCOVERY_FIELDS = [
342
- "issuer",
343
- "authorization_endpoint",
344
- "token_endpoint",
345
- "jwks_uri"
450
+ const DigestAlgorithm = {
451
+ SHA1: "http://www.w3.org/2000/09/xmldsig#sha1",
452
+ SHA256: "http://www.w3.org/2001/04/xmlenc#sha256",
453
+ SHA384: "http://www.w3.org/2001/04/xmldsig-more#sha384",
454
+ SHA512: "http://www.w3.org/2001/04/xmlenc#sha512"
455
+ };
456
+ const KeyEncryptionAlgorithm = {
457
+ RSA_1_5: "http://www.w3.org/2001/04/xmlenc#rsa-1_5",
458
+ RSA_OAEP: "http://www.w3.org/2001/04/xmlenc#rsa-oaep-mgf1p",
459
+ RSA_OAEP_SHA256: "http://www.w3.org/2009/xmlenc11#rsa-oaep"
460
+ };
461
+ const DataEncryptionAlgorithm = {
462
+ TRIPLEDES_CBC: "http://www.w3.org/2001/04/xmlenc#tripledes-cbc",
463
+ AES_128_CBC: "http://www.w3.org/2001/04/xmlenc#aes128-cbc",
464
+ AES_192_CBC: "http://www.w3.org/2001/04/xmlenc#aes192-cbc",
465
+ AES_256_CBC: "http://www.w3.org/2001/04/xmlenc#aes256-cbc",
466
+ AES_128_GCM: "http://www.w3.org/2009/xmlenc11#aes128-gcm",
467
+ AES_192_GCM: "http://www.w3.org/2009/xmlenc11#aes192-gcm",
468
+ AES_256_GCM: "http://www.w3.org/2009/xmlenc11#aes256-gcm"
469
+ };
470
+ const DEPRECATED_SIGNATURE_ALGORITHMS = [SignatureAlgorithm.RSA_SHA1];
471
+ const DEPRECATED_KEY_ENCRYPTION_ALGORITHMS = [KeyEncryptionAlgorithm.RSA_1_5];
472
+ const DEPRECATED_DATA_ENCRYPTION_ALGORITHMS = [DataEncryptionAlgorithm.TRIPLEDES_CBC];
473
+ const DEPRECATED_DIGEST_ALGORITHMS = [DigestAlgorithm.SHA1];
474
+ const SECURE_SIGNATURE_ALGORITHMS = [
475
+ SignatureAlgorithm.RSA_SHA256,
476
+ SignatureAlgorithm.RSA_SHA384,
477
+ SignatureAlgorithm.RSA_SHA512,
478
+ SignatureAlgorithm.ECDSA_SHA256,
479
+ SignatureAlgorithm.ECDSA_SHA384,
480
+ SignatureAlgorithm.ECDSA_SHA512
346
481
  ];
347
-
348
- //#endregion
349
- //#region src/oidc/discovery.ts
350
- /**
351
- * OIDC Discovery Pipeline
352
- *
353
- * Implements OIDC discovery document fetching, validation, and hydration.
354
- * This module is used both at provider registration time (to persist validated config)
355
- * and at runtime (to hydrate legacy providers that are missing metadata).
356
- *
357
- * @see https://openid.net/specs/openid-connect-discovery-1_0.html
358
- */
359
- /** Default timeout for discovery requests (10 seconds) */
360
- const DEFAULT_DISCOVERY_TIMEOUT = 1e4;
361
- /**
362
- * Main entry point: Discover and hydrate OIDC configuration from an issuer.
363
- *
364
- * This function:
365
- * 1. Computes the discovery URL from the issuer
366
- * 2. Validates the discovery URL
367
- * 3. Fetches the discovery document
368
- * 4. Validates the discovery document (issuer match + required fields)
369
- * 5. Normalizes URLs
370
- * 6. Selects token endpoint auth method
371
- * 7. Merges with existing config (existing values take precedence)
372
- *
373
- * @param params - Discovery parameters
374
- * @param isTrustedOrigin - Origin verification tester function
375
- * @returns Hydrated OIDC configuration ready for persistence
376
- * @throws DiscoveryError on any failure
377
- */
378
- async function discoverOIDCConfig(params) {
379
- const { issuer, existingConfig, timeout = DEFAULT_DISCOVERY_TIMEOUT } = params;
380
- const discoveryUrl = params.discoveryEndpoint || existingConfig?.discoveryEndpoint || computeDiscoveryUrl(issuer);
381
- validateDiscoveryUrl(discoveryUrl, params.isTrustedOrigin);
382
- const discoveryDoc = await fetchDiscoveryDocument(discoveryUrl, timeout);
383
- validateDiscoveryDocument(discoveryDoc, issuer);
482
+ const SECURE_DIGEST_ALGORITHMS = [
483
+ DigestAlgorithm.SHA256,
484
+ DigestAlgorithm.SHA384,
485
+ DigestAlgorithm.SHA512
486
+ ];
487
+ const SHORT_FORM_SIGNATURE_TO_URI = {
488
+ sha1: SignatureAlgorithm.RSA_SHA1,
489
+ sha256: SignatureAlgorithm.RSA_SHA256,
490
+ sha384: SignatureAlgorithm.RSA_SHA384,
491
+ sha512: SignatureAlgorithm.RSA_SHA512,
492
+ "rsa-sha1": SignatureAlgorithm.RSA_SHA1,
493
+ "rsa-sha256": SignatureAlgorithm.RSA_SHA256,
494
+ "rsa-sha384": SignatureAlgorithm.RSA_SHA384,
495
+ "rsa-sha512": SignatureAlgorithm.RSA_SHA512,
496
+ "ecdsa-sha256": SignatureAlgorithm.ECDSA_SHA256,
497
+ "ecdsa-sha384": SignatureAlgorithm.ECDSA_SHA384,
498
+ "ecdsa-sha512": SignatureAlgorithm.ECDSA_SHA512
499
+ };
500
+ const SHORT_FORM_DIGEST_TO_URI = {
501
+ sha1: DigestAlgorithm.SHA1,
502
+ sha256: DigestAlgorithm.SHA256,
503
+ sha384: DigestAlgorithm.SHA384,
504
+ sha512: DigestAlgorithm.SHA512
505
+ };
506
+ function normalizeSignatureAlgorithm(alg) {
507
+ return SHORT_FORM_SIGNATURE_TO_URI[alg.toLowerCase()] ?? alg;
508
+ }
509
+ function normalizeDigestAlgorithm(alg) {
510
+ return SHORT_FORM_DIGEST_TO_URI[alg.toLowerCase()] ?? alg;
511
+ }
512
+ function extractEncryptionAlgorithms(xml) {
513
+ try {
514
+ const parsed = xmlParser.parse(xml);
515
+ const keyAlg = (findNode(parsed, "EncryptedKey")?.EncryptionMethod)?.["@_Algorithm"];
516
+ const dataAlg = (findNode(parsed, "EncryptedData")?.EncryptionMethod)?.["@_Algorithm"];
517
+ return {
518
+ keyEncryption: keyAlg || null,
519
+ dataEncryption: dataAlg || null
520
+ };
521
+ } catch {
522
+ return {
523
+ keyEncryption: null,
524
+ dataEncryption: null
525
+ };
526
+ }
527
+ }
528
+ function hasEncryptedAssertion(xml) {
529
+ try {
530
+ return findNode(xmlParser.parse(xml), "EncryptedAssertion") !== null;
531
+ } catch {
532
+ return false;
533
+ }
534
+ }
535
+ function handleDeprecatedAlgorithm(message, behavior, errorCode) {
536
+ switch (behavior) {
537
+ case "reject": throw new APIError("BAD_REQUEST", {
538
+ message,
539
+ code: errorCode
540
+ });
541
+ case "warn":
542
+ console.warn(`[SAML Security Warning] ${message}`);
543
+ break;
544
+ case "allow": break;
545
+ }
546
+ }
547
+ function validateSignatureAlgorithm(algorithm, options = {}) {
548
+ if (!algorithm) return;
549
+ const { onDeprecated = "warn", allowedSignatureAlgorithms } = options;
550
+ if (allowedSignatureAlgorithms) {
551
+ if (!allowedSignatureAlgorithms.includes(algorithm)) throw new APIError("BAD_REQUEST", {
552
+ message: `SAML signature algorithm not in allow-list: ${algorithm}`,
553
+ code: "SAML_ALGORITHM_NOT_ALLOWED"
554
+ });
555
+ return;
556
+ }
557
+ if (DEPRECATED_SIGNATURE_ALGORITHMS.includes(algorithm)) {
558
+ handleDeprecatedAlgorithm(`SAML response uses deprecated signature algorithm: ${algorithm}. Please configure your IdP to use SHA-256 or stronger.`, onDeprecated, "SAML_DEPRECATED_ALGORITHM");
559
+ return;
560
+ }
561
+ if (!SECURE_SIGNATURE_ALGORITHMS.includes(algorithm)) throw new APIError("BAD_REQUEST", {
562
+ message: `SAML signature algorithm not recognized: ${algorithm}`,
563
+ code: "SAML_UNKNOWN_ALGORITHM"
564
+ });
565
+ }
566
+ function validateEncryptionAlgorithms(algorithms, options = {}) {
567
+ const { onDeprecated = "warn", allowedKeyEncryptionAlgorithms, allowedDataEncryptionAlgorithms } = options;
568
+ const { keyEncryption, dataEncryption } = algorithms;
569
+ if (keyEncryption) {
570
+ if (allowedKeyEncryptionAlgorithms) {
571
+ if (!allowedKeyEncryptionAlgorithms.includes(keyEncryption)) throw new APIError("BAD_REQUEST", {
572
+ message: `SAML key encryption algorithm not in allow-list: ${keyEncryption}`,
573
+ code: "SAML_ALGORITHM_NOT_ALLOWED"
574
+ });
575
+ } 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");
576
+ }
577
+ if (dataEncryption) {
578
+ if (allowedDataEncryptionAlgorithms) {
579
+ if (!allowedDataEncryptionAlgorithms.includes(dataEncryption)) throw new APIError("BAD_REQUEST", {
580
+ message: `SAML data encryption algorithm not in allow-list: ${dataEncryption}`,
581
+ code: "SAML_ALGORITHM_NOT_ALLOWED"
582
+ });
583
+ } 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");
584
+ }
585
+ }
586
+ function validateSAMLAlgorithms(response, options) {
587
+ validateSignatureAlgorithm(response.sigAlg, options);
588
+ if (hasEncryptedAssertion(response.samlContent)) validateEncryptionAlgorithms(extractEncryptionAlgorithms(response.samlContent), options);
589
+ }
590
+ function validateConfigAlgorithms(config, options = {}) {
591
+ const { onDeprecated = "warn", allowedSignatureAlgorithms, allowedDigestAlgorithms } = options;
592
+ if (config.signatureAlgorithm) {
593
+ const normalized = normalizeSignatureAlgorithm(config.signatureAlgorithm);
594
+ if (allowedSignatureAlgorithms) {
595
+ if (!allowedSignatureAlgorithms.map(normalizeSignatureAlgorithm).includes(normalized)) throw new APIError("BAD_REQUEST", {
596
+ message: `SAML signature algorithm not in allow-list: ${config.signatureAlgorithm}`,
597
+ code: "SAML_ALGORITHM_NOT_ALLOWED"
598
+ });
599
+ } 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");
600
+ else if (!SECURE_SIGNATURE_ALGORITHMS.includes(normalized)) throw new APIError("BAD_REQUEST", {
601
+ message: `SAML signature algorithm not recognized: ${config.signatureAlgorithm}`,
602
+ code: "SAML_UNKNOWN_ALGORITHM"
603
+ });
604
+ }
605
+ if (config.digestAlgorithm) {
606
+ const normalized = normalizeDigestAlgorithm(config.digestAlgorithm);
607
+ if (allowedDigestAlgorithms) {
608
+ if (!allowedDigestAlgorithms.map(normalizeDigestAlgorithm).includes(normalized)) throw new APIError("BAD_REQUEST", {
609
+ message: `SAML digest algorithm not in allow-list: ${config.digestAlgorithm}`,
610
+ code: "SAML_ALGORITHM_NOT_ALLOWED"
611
+ });
612
+ } 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");
613
+ else if (!SECURE_DIGEST_ALGORITHMS.includes(normalized)) throw new APIError("BAD_REQUEST", {
614
+ message: `SAML digest algorithm not recognized: ${config.digestAlgorithm}`,
615
+ code: "SAML_UNKNOWN_ALGORITHM"
616
+ });
617
+ }
618
+ }
619
+
620
+ //#endregion
621
+ //#region src/saml/assertions.ts
622
+ /** @lintignore used in tests */
623
+ function countAssertions(xml) {
624
+ let parsed;
625
+ try {
626
+ parsed = xmlParser.parse(xml);
627
+ } catch {
628
+ throw new APIError("BAD_REQUEST", {
629
+ message: "Failed to parse SAML response XML",
630
+ code: "SAML_INVALID_XML"
631
+ });
632
+ }
633
+ const assertions = countAllNodes(parsed, "Assertion");
634
+ const encryptedAssertions = countAllNodes(parsed, "EncryptedAssertion");
635
+ return {
636
+ assertions,
637
+ encryptedAssertions,
638
+ total: assertions + encryptedAssertions
639
+ };
640
+ }
641
+ function validateSingleAssertion(samlResponse) {
642
+ let xml;
643
+ try {
644
+ xml = new TextDecoder().decode(base64.decode(samlResponse));
645
+ if (!xml.includes("<")) throw new Error("Not XML");
646
+ } catch {
647
+ throw new APIError("BAD_REQUEST", {
648
+ message: "Invalid base64-encoded SAML response",
649
+ code: "SAML_INVALID_ENCODING"
650
+ });
651
+ }
652
+ const counts = countAssertions(xml);
653
+ if (counts.total === 0) throw new APIError("BAD_REQUEST", {
654
+ message: "SAML response contains no assertions",
655
+ code: "SAML_NO_ASSERTION"
656
+ });
657
+ if (counts.total > 1) throw new APIError("BAD_REQUEST", {
658
+ message: `SAML response contains ${counts.total} assertions, expected exactly 1`,
659
+ code: "SAML_MULTIPLE_ASSERTIONS"
660
+ });
661
+ }
662
+
663
+ //#endregion
664
+ //#region src/routes/schemas.ts
665
+ const oidcMappingSchema = z.object({
666
+ id: z.string().optional(),
667
+ email: z.string().optional(),
668
+ emailVerified: z.string().optional(),
669
+ name: z.string().optional(),
670
+ image: z.string().optional(),
671
+ extraFields: z.record(z.string(), z.any()).optional()
672
+ }).optional();
673
+ const samlMappingSchema = z.object({
674
+ id: z.string().optional(),
675
+ email: z.string().optional(),
676
+ emailVerified: z.string().optional(),
677
+ name: z.string().optional(),
678
+ firstName: z.string().optional(),
679
+ lastName: z.string().optional(),
680
+ extraFields: z.record(z.string(), z.any()).optional()
681
+ }).optional();
682
+ const oidcConfigSchema = z.object({
683
+ clientId: z.string().optional(),
684
+ clientSecret: z.string().optional(),
685
+ authorizationEndpoint: z.string().url().optional(),
686
+ tokenEndpoint: z.string().url().optional(),
687
+ userInfoEndpoint: z.string().url().optional(),
688
+ tokenEndpointAuthentication: z.enum(["client_secret_post", "client_secret_basic"]).optional(),
689
+ jwksEndpoint: z.string().url().optional(),
690
+ discoveryEndpoint: z.string().url().optional(),
691
+ scopes: z.array(z.string()).optional(),
692
+ pkce: z.boolean().optional(),
693
+ overrideUserInfo: z.boolean().optional(),
694
+ mapping: oidcMappingSchema
695
+ });
696
+ const samlConfigSchema = z.object({
697
+ entryPoint: z.string().url().optional(),
698
+ cert: z.string().optional(),
699
+ callbackUrl: z.string().url().optional(),
700
+ audience: z.string().optional(),
701
+ idpMetadata: z.object({
702
+ metadata: z.string().optional(),
703
+ entityID: z.string().optional(),
704
+ cert: z.string().optional(),
705
+ privateKey: z.string().optional(),
706
+ privateKeyPass: z.string().optional(),
707
+ isAssertionEncrypted: z.boolean().optional(),
708
+ encPrivateKey: z.string().optional(),
709
+ encPrivateKeyPass: z.string().optional(),
710
+ singleSignOnService: z.array(z.object({
711
+ Binding: z.string(),
712
+ Location: z.string().url()
713
+ })).optional()
714
+ }).optional(),
715
+ spMetadata: z.object({
716
+ metadata: z.string().optional(),
717
+ entityID: z.string().optional(),
718
+ binding: z.string().optional(),
719
+ privateKey: z.string().optional(),
720
+ privateKeyPass: z.string().optional(),
721
+ isAssertionEncrypted: z.boolean().optional(),
722
+ encPrivateKey: z.string().optional(),
723
+ encPrivateKeyPass: z.string().optional()
724
+ }).optional(),
725
+ wantAssertionsSigned: z.boolean().optional(),
726
+ authnRequestsSigned: z.boolean().optional(),
727
+ signatureAlgorithm: z.string().optional(),
728
+ digestAlgorithm: z.string().optional(),
729
+ identifierFormat: z.string().optional(),
730
+ privateKey: z.string().optional(),
731
+ decryptionPvk: z.string().optional(),
732
+ additionalParams: z.record(z.string(), z.any()).optional(),
733
+ mapping: samlMappingSchema
734
+ });
735
+ const updateSSOProviderBodySchema = z.object({
736
+ issuer: z.string().url().optional(),
737
+ domain: z.string().optional(),
738
+ oidcConfig: oidcConfigSchema.optional(),
739
+ samlConfig: samlConfigSchema.optional()
740
+ });
741
+
742
+ //#endregion
743
+ //#region src/routes/providers.ts
744
+ const ADMIN_ROLES = ["owner", "admin"];
745
+ async function isOrgAdmin(ctx, userId, organizationId) {
746
+ const member = await ctx.context.adapter.findOne({
747
+ model: "member",
748
+ where: [{
749
+ field: "userId",
750
+ value: userId
751
+ }, {
752
+ field: "organizationId",
753
+ value: organizationId
754
+ }]
755
+ });
756
+ if (!member) return false;
757
+ return member.role.split(",").some((r) => ADMIN_ROLES.includes(r.trim()));
758
+ }
759
+ async function batchCheckOrgAdmin(ctx, userId, organizationIds) {
760
+ if (organizationIds.length === 0) return /* @__PURE__ */ new Set();
761
+ const members = await ctx.context.adapter.findMany({
762
+ model: "member",
763
+ where: [{
764
+ field: "userId",
765
+ value: userId
766
+ }, {
767
+ field: "organizationId",
768
+ value: organizationIds,
769
+ operator: "in"
770
+ }]
771
+ });
772
+ const adminOrgIds = /* @__PURE__ */ new Set();
773
+ for (const member of members) if (member.role.split(",").some((r) => ADMIN_ROLES.includes(r.trim()))) adminOrgIds.add(member.organizationId);
774
+ return adminOrgIds;
775
+ }
776
+ function sanitizeProvider(provider, baseURL) {
777
+ let oidcConfig = null;
778
+ let samlConfig = null;
779
+ try {
780
+ oidcConfig = safeJsonParse(provider.oidcConfig);
781
+ } catch {
782
+ oidcConfig = null;
783
+ }
784
+ try {
785
+ samlConfig = safeJsonParse(provider.samlConfig);
786
+ } catch {
787
+ samlConfig = null;
788
+ }
789
+ const type = samlConfig ? "saml" : "oidc";
790
+ return {
791
+ providerId: provider.providerId,
792
+ type,
793
+ issuer: provider.issuer,
794
+ domain: provider.domain,
795
+ organizationId: provider.organizationId || null,
796
+ domainVerified: provider.domainVerified ?? false,
797
+ oidcConfig: oidcConfig ? {
798
+ discoveryEndpoint: oidcConfig.discoveryEndpoint,
799
+ clientIdLastFour: maskClientId(oidcConfig.clientId),
800
+ pkce: oidcConfig.pkce,
801
+ authorizationEndpoint: oidcConfig.authorizationEndpoint,
802
+ tokenEndpoint: oidcConfig.tokenEndpoint,
803
+ userInfoEndpoint: oidcConfig.userInfoEndpoint,
804
+ jwksEndpoint: oidcConfig.jwksEndpoint,
805
+ scopes: oidcConfig.scopes,
806
+ tokenEndpointAuthentication: oidcConfig.tokenEndpointAuthentication
807
+ } : void 0,
808
+ samlConfig: samlConfig ? {
809
+ entryPoint: samlConfig.entryPoint,
810
+ callbackUrl: samlConfig.callbackUrl,
811
+ audience: samlConfig.audience,
812
+ wantAssertionsSigned: samlConfig.wantAssertionsSigned,
813
+ authnRequestsSigned: samlConfig.authnRequestsSigned,
814
+ identifierFormat: samlConfig.identifierFormat,
815
+ signatureAlgorithm: samlConfig.signatureAlgorithm,
816
+ digestAlgorithm: samlConfig.digestAlgorithm,
817
+ certificate: (() => {
818
+ try {
819
+ return parseCertificate(samlConfig.cert);
820
+ } catch {
821
+ return { error: "Failed to parse certificate" };
822
+ }
823
+ })()
824
+ } : void 0,
825
+ spMetadataUrl: `${baseURL}/sso/saml2/sp/metadata?providerId=${encodeURIComponent(provider.providerId)}`
826
+ };
827
+ }
828
+ const listSSOProviders = () => {
829
+ return createAuthEndpoint("/sso/providers", {
830
+ method: "GET",
831
+ use: [sessionMiddleware],
832
+ metadata: { openapi: {
833
+ operationId: "listSSOProviders",
834
+ summary: "List SSO providers",
835
+ description: "Returns a list of SSO providers the user has access to",
836
+ responses: { "200": { description: "List of SSO providers" } }
837
+ } }
838
+ }, async (ctx) => {
839
+ const userId = ctx.context.session.user.id;
840
+ const allProviders = await ctx.context.adapter.findMany({ model: "ssoProvider" });
841
+ const userOwnedProviders = allProviders.filter((p) => p.userId === userId && !p.organizationId);
842
+ const orgProviders = allProviders.filter((p) => p.organizationId !== null && p.organizationId !== void 0);
843
+ const orgPluginEnabled = ctx.context.hasPlugin("organization");
844
+ let accessibleProviders = [...userOwnedProviders];
845
+ if (orgPluginEnabled && orgProviders.length > 0) {
846
+ const adminOrgIds = await batchCheckOrgAdmin(ctx, userId, [...new Set(orgProviders.map((p) => p.organizationId).filter((id) => id !== null && id !== void 0))]);
847
+ const orgAccessibleProviders = orgProviders.filter((provider) => provider.organizationId && adminOrgIds.has(provider.organizationId));
848
+ accessibleProviders = [...accessibleProviders, ...orgAccessibleProviders];
849
+ } else if (!orgPluginEnabled) {
850
+ const userOwnedOrgProviders = orgProviders.filter((p) => p.userId === userId);
851
+ accessibleProviders = [...accessibleProviders, ...userOwnedOrgProviders];
852
+ }
853
+ const providers = accessibleProviders.map((p) => sanitizeProvider(p, ctx.context.baseURL));
854
+ return ctx.json({ providers });
855
+ });
856
+ };
857
+ const getSSOProviderQuerySchema = z.object({ providerId: z.string() });
858
+ async function checkProviderAccess(ctx, providerId) {
859
+ const userId = ctx.context.session.user.id;
860
+ const provider = await ctx.context.adapter.findOne({
861
+ model: "ssoProvider",
862
+ where: [{
863
+ field: "providerId",
864
+ value: providerId
865
+ }]
866
+ });
867
+ if (!provider) throw new APIError("NOT_FOUND", { message: "Provider not found" });
868
+ let hasAccess = false;
869
+ if (provider.organizationId) if (ctx.context.hasPlugin("organization")) hasAccess = await isOrgAdmin(ctx, userId, provider.organizationId);
870
+ else hasAccess = provider.userId === userId;
871
+ else hasAccess = provider.userId === userId;
872
+ if (!hasAccess) throw new APIError("FORBIDDEN", { message: "You don't have access to this provider" });
873
+ return provider;
874
+ }
875
+ const getSSOProvider = () => {
876
+ return createAuthEndpoint("/sso/get-provider", {
877
+ method: "GET",
878
+ use: [sessionMiddleware],
879
+ query: getSSOProviderQuerySchema,
880
+ metadata: { openapi: {
881
+ operationId: "getSSOProvider",
882
+ summary: "Get SSO provider details",
883
+ description: "Returns sanitized details for a specific SSO provider",
884
+ responses: {
885
+ "200": { description: "SSO provider details" },
886
+ "404": { description: "Provider not found" },
887
+ "403": { description: "Access denied" }
888
+ }
889
+ } }
890
+ }, async (ctx) => {
891
+ const { providerId } = ctx.query;
892
+ const provider = await checkProviderAccess(ctx, providerId);
893
+ return ctx.json(sanitizeProvider(provider, ctx.context.baseURL));
894
+ });
895
+ };
896
+ function parseAndValidateConfig(configString, configType) {
897
+ let config = null;
898
+ try {
899
+ config = safeJsonParse(configString);
900
+ } catch {
901
+ config = null;
902
+ }
903
+ if (!config) throw new APIError("BAD_REQUEST", { message: `Cannot update ${configType} config for a provider that doesn't have ${configType} configured` });
904
+ return config;
905
+ }
906
+ function mergeSAMLConfig(current, updates, issuer) {
907
+ return {
908
+ ...current,
909
+ ...updates,
910
+ issuer,
911
+ entryPoint: updates.entryPoint ?? current.entryPoint,
912
+ cert: updates.cert ?? current.cert,
913
+ callbackUrl: updates.callbackUrl ?? current.callbackUrl,
914
+ spMetadata: updates.spMetadata ?? current.spMetadata,
915
+ idpMetadata: updates.idpMetadata ?? current.idpMetadata,
916
+ mapping: updates.mapping ?? current.mapping,
917
+ audience: updates.audience ?? current.audience,
918
+ wantAssertionsSigned: updates.wantAssertionsSigned ?? current.wantAssertionsSigned,
919
+ authnRequestsSigned: updates.authnRequestsSigned ?? current.authnRequestsSigned,
920
+ identifierFormat: updates.identifierFormat ?? current.identifierFormat,
921
+ signatureAlgorithm: updates.signatureAlgorithm ?? current.signatureAlgorithm,
922
+ digestAlgorithm: updates.digestAlgorithm ?? current.digestAlgorithm
923
+ };
924
+ }
925
+ function mergeOIDCConfig(current, updates, issuer) {
926
+ return {
927
+ ...current,
928
+ ...updates,
929
+ issuer,
930
+ pkce: updates.pkce ?? current.pkce ?? true,
931
+ clientId: updates.clientId ?? current.clientId,
932
+ clientSecret: updates.clientSecret ?? current.clientSecret,
933
+ discoveryEndpoint: updates.discoveryEndpoint ?? current.discoveryEndpoint,
934
+ mapping: updates.mapping ?? current.mapping,
935
+ scopes: updates.scopes ?? current.scopes,
936
+ authorizationEndpoint: updates.authorizationEndpoint ?? current.authorizationEndpoint,
937
+ tokenEndpoint: updates.tokenEndpoint ?? current.tokenEndpoint,
938
+ userInfoEndpoint: updates.userInfoEndpoint ?? current.userInfoEndpoint,
939
+ jwksEndpoint: updates.jwksEndpoint ?? current.jwksEndpoint,
940
+ tokenEndpointAuthentication: updates.tokenEndpointAuthentication ?? current.tokenEndpointAuthentication
941
+ };
942
+ }
943
+ const updateSSOProvider = (options) => {
944
+ return createAuthEndpoint("/sso/update-provider", {
945
+ method: "POST",
946
+ use: [sessionMiddleware],
947
+ body: updateSSOProviderBodySchema.extend({ providerId: z.string() }),
948
+ metadata: { openapi: {
949
+ operationId: "updateSSOProvider",
950
+ summary: "Update SSO provider",
951
+ description: "Partially update an SSO provider. Only provided fields are updated. If domain changes, domainVerified is reset to false.",
952
+ responses: {
953
+ "200": { description: "SSO provider updated successfully" },
954
+ "404": { description: "Provider not found" },
955
+ "403": { description: "Access denied" }
956
+ }
957
+ } }
958
+ }, async (ctx) => {
959
+ const { providerId, ...body } = ctx.body;
960
+ const { issuer, domain, samlConfig, oidcConfig } = body;
961
+ if (!issuer && !domain && !samlConfig && !oidcConfig) throw new APIError("BAD_REQUEST", { message: "No fields provided for update" });
962
+ const existingProvider = await checkProviderAccess(ctx, providerId);
963
+ const updateData = {};
964
+ if (body.issuer !== void 0) updateData.issuer = body.issuer;
965
+ if (body.domain !== void 0) {
966
+ updateData.domain = body.domain;
967
+ if (body.domain !== existingProvider.domain) updateData.domainVerified = false;
968
+ }
969
+ if (body.samlConfig) {
970
+ if (body.samlConfig.idpMetadata?.metadata) {
971
+ const maxMetadataSize = options?.saml?.maxMetadataSize ?? DEFAULT_MAX_SAML_METADATA_SIZE;
972
+ if (new TextEncoder().encode(body.samlConfig.idpMetadata.metadata).length > maxMetadataSize) throw new APIError("BAD_REQUEST", { message: `IdP metadata exceeds maximum allowed size (${maxMetadataSize} bytes)` });
973
+ }
974
+ if (body.samlConfig.signatureAlgorithm !== void 0 || body.samlConfig.digestAlgorithm !== void 0) validateConfigAlgorithms({
975
+ signatureAlgorithm: body.samlConfig.signatureAlgorithm,
976
+ digestAlgorithm: body.samlConfig.digestAlgorithm
977
+ }, options?.saml?.algorithms);
978
+ const currentSamlConfig = parseAndValidateConfig(existingProvider.samlConfig, "SAML");
979
+ const updatedSamlConfig = mergeSAMLConfig(currentSamlConfig, body.samlConfig, updateData.issuer || currentSamlConfig.issuer || existingProvider.issuer);
980
+ updateData.samlConfig = JSON.stringify(updatedSamlConfig);
981
+ }
982
+ if (body.oidcConfig) {
983
+ const currentOidcConfig = parseAndValidateConfig(existingProvider.oidcConfig, "OIDC");
984
+ const updatedOidcConfig = mergeOIDCConfig(currentOidcConfig, body.oidcConfig, updateData.issuer || currentOidcConfig.issuer || existingProvider.issuer);
985
+ updateData.oidcConfig = JSON.stringify(updatedOidcConfig);
986
+ }
987
+ await ctx.context.adapter.update({
988
+ model: "ssoProvider",
989
+ where: [{
990
+ field: "providerId",
991
+ value: providerId
992
+ }],
993
+ update: updateData
994
+ });
995
+ const fullProvider = await ctx.context.adapter.findOne({
996
+ model: "ssoProvider",
997
+ where: [{
998
+ field: "providerId",
999
+ value: providerId
1000
+ }]
1001
+ });
1002
+ if (!fullProvider) throw new APIError("NOT_FOUND", { message: "Provider not found after update" });
1003
+ return ctx.json(sanitizeProvider(fullProvider, ctx.context.baseURL));
1004
+ });
1005
+ };
1006
+ const deleteSSOProvider = () => {
1007
+ return createAuthEndpoint("/sso/delete-provider", {
1008
+ method: "POST",
1009
+ use: [sessionMiddleware],
1010
+ body: z.object({ providerId: z.string() }),
1011
+ metadata: { openapi: {
1012
+ operationId: "deleteSSOProvider",
1013
+ summary: "Delete SSO provider",
1014
+ description: "Deletes an SSO provider",
1015
+ responses: {
1016
+ "200": { description: "SSO provider deleted successfully" },
1017
+ "404": { description: "Provider not found" },
1018
+ "403": { description: "Access denied" }
1019
+ }
1020
+ } }
1021
+ }, async (ctx) => {
1022
+ const { providerId } = ctx.body;
1023
+ await checkProviderAccess(ctx, providerId);
1024
+ await ctx.context.adapter.delete({
1025
+ model: "ssoProvider",
1026
+ where: [{
1027
+ field: "providerId",
1028
+ value: providerId
1029
+ }]
1030
+ });
1031
+ return ctx.json({ success: true });
1032
+ });
1033
+ };
1034
+
1035
+ //#endregion
1036
+ //#region src/oidc/types.ts
1037
+ /**
1038
+ * Custom error class for OIDC discovery failures.
1039
+ * Can be caught and mapped to APIError at the edge.
1040
+ */
1041
+ var DiscoveryError = class DiscoveryError extends Error {
1042
+ code;
1043
+ details;
1044
+ constructor(code, message, details, options) {
1045
+ super(message, options);
1046
+ this.name = "DiscoveryError";
1047
+ this.code = code;
1048
+ this.details = details;
1049
+ if (Error.captureStackTrace) Error.captureStackTrace(this, DiscoveryError);
1050
+ }
1051
+ };
1052
+ /**
1053
+ * Required fields that must be present in a valid discovery document.
1054
+ */
1055
+ const REQUIRED_DISCOVERY_FIELDS = [
1056
+ "issuer",
1057
+ "authorization_endpoint",
1058
+ "token_endpoint",
1059
+ "jwks_uri"
1060
+ ];
1061
+
1062
+ //#endregion
1063
+ //#region src/oidc/discovery.ts
1064
+ /**
1065
+ * OIDC Discovery Pipeline
1066
+ *
1067
+ * Implements OIDC discovery document fetching, validation, and hydration.
1068
+ * This module is used both at provider registration time (to persist validated config)
1069
+ * and at runtime (to hydrate legacy providers that are missing metadata).
1070
+ *
1071
+ * @see https://openid.net/specs/openid-connect-discovery-1_0.html
1072
+ */
1073
+ /** Default timeout for discovery requests (10 seconds) */
1074
+ const DEFAULT_DISCOVERY_TIMEOUT = 1e4;
1075
+ /**
1076
+ * Main entry point: Discover and hydrate OIDC configuration from an issuer.
1077
+ *
1078
+ * This function:
1079
+ * 1. Computes the discovery URL from the issuer
1080
+ * 2. Validates the discovery URL
1081
+ * 3. Fetches the discovery document
1082
+ * 4. Validates the discovery document (issuer match + required fields)
1083
+ * 5. Normalizes URLs
1084
+ * 6. Selects token endpoint auth method
1085
+ * 7. Merges with existing config (existing values take precedence)
1086
+ *
1087
+ * @param params - Discovery parameters
1088
+ * @param isTrustedOrigin - Origin verification tester function
1089
+ * @returns Hydrated OIDC configuration ready for persistence
1090
+ * @throws DiscoveryError on any failure
1091
+ */
1092
+ async function discoverOIDCConfig(params) {
1093
+ const { issuer, existingConfig, timeout = DEFAULT_DISCOVERY_TIMEOUT } = params;
1094
+ const discoveryUrl = params.discoveryEndpoint || existingConfig?.discoveryEndpoint || computeDiscoveryUrl(issuer);
1095
+ validateDiscoveryUrl(discoveryUrl, params.isTrustedOrigin);
1096
+ const discoveryDoc = await fetchDiscoveryDocument(discoveryUrl, timeout);
1097
+ validateDiscoveryDocument(discoveryDoc, issuer);
384
1098
  const normalizedDoc = normalizeDiscoveryUrls(discoveryDoc, issuer, params.isTrustedOrigin);
385
1099
  const tokenEndpointAuth = selectTokenEndpointAuthMethod(normalizedDoc, existingConfig?.tokenEndpointAuthentication);
386
1100
  return {
@@ -581,16 +1295,35 @@ function selectTokenEndpointAuthMethod(doc, existing) {
581
1295
  * and validation. Specifically checks for:
582
1296
  * - `tokenEndpoint` - required for exchanging authorization code for tokens
583
1297
  * - `jwksEndpoint` - required for validating ID token signatures
584
- *
585
- * Note: `authorizationEndpoint` is handled separately in the sign-in flow,
586
- * so it's not checked here.
1298
+ * - `authorizationEndpoint` - required for redirecting users to the IdP for login
587
1299
  *
588
1300
  * @param config - Partial OIDC config from the provider
589
1301
  * @returns true if runtime discovery should be performed
590
1302
  */
591
1303
  function needsRuntimeDiscovery(config) {
592
1304
  if (!config) return true;
593
- return !config.tokenEndpoint || !config.jwksEndpoint;
1305
+ return !config.tokenEndpoint || !config.jwksEndpoint || !config.authorizationEndpoint;
1306
+ }
1307
+ /**
1308
+ * Runs runtime OIDC discovery when the stored config is missing required
1309
+ * endpoints, and merges the hydrated fields back into the config.
1310
+ * Throws if discovery fails.
1311
+ */
1312
+ async function ensureRuntimeDiscovery(config, issuer, isTrustedOrigin) {
1313
+ if (!needsRuntimeDiscovery(config)) return config;
1314
+ const hydrated = await discoverOIDCConfig({
1315
+ issuer,
1316
+ existingConfig: config,
1317
+ isTrustedOrigin
1318
+ });
1319
+ return {
1320
+ ...config,
1321
+ authorizationEndpoint: hydrated.authorizationEndpoint,
1322
+ tokenEndpoint: hydrated.tokenEndpoint,
1323
+ tokenEndpointAuthentication: hydrated.tokenEndpointAuthentication,
1324
+ userInfoEndpoint: hydrated.userInfoEndpoint,
1325
+ jwksEndpoint: hydrated.jwksEndpoint
1326
+ };
594
1327
  }
595
1328
 
596
1329
  //#endregion
@@ -663,271 +1396,23 @@ function mapDiscoveryErrorToAPIError(error) {
663
1396
  });
664
1397
  }
665
1398
  }
666
-
667
- //#endregion
668
- //#region src/saml/parser.ts
669
- const xmlParser = new XMLParser({
670
- ignoreAttributes: false,
671
- attributeNamePrefix: "@_",
672
- removeNSPrefix: true,
673
- processEntities: false
674
- });
675
- function findNode(obj, nodeName) {
676
- if (!obj || typeof obj !== "object") return null;
677
- const record = obj;
678
- if (nodeName in record) return record[nodeName];
679
- for (const value of Object.values(record)) if (Array.isArray(value)) for (const item of value) {
680
- const found = findNode(item, nodeName);
681
- if (found) return found;
682
- }
683
- else if (typeof value === "object" && value !== null) {
684
- const found = findNode(value, nodeName);
685
- if (found) return found;
686
- }
687
- return null;
688
- }
689
- function countAllNodes(obj, nodeName) {
690
- if (!obj || typeof obj !== "object") return 0;
691
- let count = 0;
692
- const record = obj;
693
- if (nodeName in record) {
694
- const node = record[nodeName];
695
- count += Array.isArray(node) ? node.length : 1;
696
- }
697
- for (const value of Object.values(record)) if (Array.isArray(value)) for (const item of value) count += countAllNodes(item, nodeName);
698
- else if (typeof value === "object" && value !== null) count += countAllNodes(value, nodeName);
699
- return count;
700
- }
701
-
702
- //#endregion
703
- //#region src/saml/algorithms.ts
704
- const SignatureAlgorithm = {
705
- RSA_SHA1: "http://www.w3.org/2000/09/xmldsig#rsa-sha1",
706
- RSA_SHA256: "http://www.w3.org/2001/04/xmldsig-more#rsa-sha256",
707
- RSA_SHA384: "http://www.w3.org/2001/04/xmldsig-more#rsa-sha384",
708
- RSA_SHA512: "http://www.w3.org/2001/04/xmldsig-more#rsa-sha512",
709
- ECDSA_SHA256: "http://www.w3.org/2001/04/xmldsig-more#ecdsa-sha256",
710
- ECDSA_SHA384: "http://www.w3.org/2001/04/xmldsig-more#ecdsa-sha384",
711
- ECDSA_SHA512: "http://www.w3.org/2001/04/xmldsig-more#ecdsa-sha512"
712
- };
713
- const DigestAlgorithm = {
714
- SHA1: "http://www.w3.org/2000/09/xmldsig#sha1",
715
- SHA256: "http://www.w3.org/2001/04/xmlenc#sha256",
716
- SHA384: "http://www.w3.org/2001/04/xmldsig-more#sha384",
717
- SHA512: "http://www.w3.org/2001/04/xmlenc#sha512"
718
- };
719
- const KeyEncryptionAlgorithm = {
720
- RSA_1_5: "http://www.w3.org/2001/04/xmlenc#rsa-1_5",
721
- RSA_OAEP: "http://www.w3.org/2001/04/xmlenc#rsa-oaep-mgf1p",
722
- RSA_OAEP_SHA256: "http://www.w3.org/2009/xmlenc11#rsa-oaep"
723
- };
724
- const DataEncryptionAlgorithm = {
725
- TRIPLEDES_CBC: "http://www.w3.org/2001/04/xmlenc#tripledes-cbc",
726
- AES_128_CBC: "http://www.w3.org/2001/04/xmlenc#aes128-cbc",
727
- AES_192_CBC: "http://www.w3.org/2001/04/xmlenc#aes192-cbc",
728
- AES_256_CBC: "http://www.w3.org/2001/04/xmlenc#aes256-cbc",
729
- AES_128_GCM: "http://www.w3.org/2009/xmlenc11#aes128-gcm",
730
- AES_192_GCM: "http://www.w3.org/2009/xmlenc11#aes192-gcm",
731
- AES_256_GCM: "http://www.w3.org/2009/xmlenc11#aes256-gcm"
732
- };
733
- const DEPRECATED_SIGNATURE_ALGORITHMS = [SignatureAlgorithm.RSA_SHA1];
734
- const DEPRECATED_KEY_ENCRYPTION_ALGORITHMS = [KeyEncryptionAlgorithm.RSA_1_5];
735
- const DEPRECATED_DATA_ENCRYPTION_ALGORITHMS = [DataEncryptionAlgorithm.TRIPLEDES_CBC];
736
- const DEPRECATED_DIGEST_ALGORITHMS = [DigestAlgorithm.SHA1];
737
- const SECURE_SIGNATURE_ALGORITHMS = [
738
- SignatureAlgorithm.RSA_SHA256,
739
- SignatureAlgorithm.RSA_SHA384,
740
- SignatureAlgorithm.RSA_SHA512,
741
- SignatureAlgorithm.ECDSA_SHA256,
742
- SignatureAlgorithm.ECDSA_SHA384,
743
- SignatureAlgorithm.ECDSA_SHA512
744
- ];
745
- const SECURE_DIGEST_ALGORITHMS = [
746
- DigestAlgorithm.SHA256,
747
- DigestAlgorithm.SHA384,
748
- DigestAlgorithm.SHA512
749
- ];
750
- const SHORT_FORM_SIGNATURE_TO_URI = {
751
- sha1: SignatureAlgorithm.RSA_SHA1,
752
- sha256: SignatureAlgorithm.RSA_SHA256,
753
- sha384: SignatureAlgorithm.RSA_SHA384,
754
- sha512: SignatureAlgorithm.RSA_SHA512,
755
- "rsa-sha1": SignatureAlgorithm.RSA_SHA1,
756
- "rsa-sha256": SignatureAlgorithm.RSA_SHA256,
757
- "rsa-sha384": SignatureAlgorithm.RSA_SHA384,
758
- "rsa-sha512": SignatureAlgorithm.RSA_SHA512,
759
- "ecdsa-sha256": SignatureAlgorithm.ECDSA_SHA256,
760
- "ecdsa-sha384": SignatureAlgorithm.ECDSA_SHA384,
761
- "ecdsa-sha512": SignatureAlgorithm.ECDSA_SHA512
762
- };
763
- const SHORT_FORM_DIGEST_TO_URI = {
764
- sha1: DigestAlgorithm.SHA1,
765
- sha256: DigestAlgorithm.SHA256,
766
- sha384: DigestAlgorithm.SHA384,
767
- sha512: DigestAlgorithm.SHA512
768
- };
769
- function normalizeSignatureAlgorithm(alg) {
770
- return SHORT_FORM_SIGNATURE_TO_URI[alg.toLowerCase()] ?? alg;
771
- }
772
- function normalizeDigestAlgorithm(alg) {
773
- return SHORT_FORM_DIGEST_TO_URI[alg.toLowerCase()] ?? alg;
774
- }
775
- function extractEncryptionAlgorithms(xml) {
776
- try {
777
- const parsed = xmlParser.parse(xml);
778
- const keyAlg = (findNode(parsed, "EncryptedKey")?.EncryptionMethod)?.["@_Algorithm"];
779
- const dataAlg = (findNode(parsed, "EncryptedData")?.EncryptionMethod)?.["@_Algorithm"];
780
- return {
781
- keyEncryption: keyAlg || null,
782
- dataEncryption: dataAlg || null
783
- };
784
- } catch {
785
- return {
786
- keyEncryption: null,
787
- dataEncryption: null
788
- };
789
- }
790
- }
791
- function hasEncryptedAssertion(xml) {
792
- try {
793
- return findNode(xmlParser.parse(xml), "EncryptedAssertion") !== null;
794
- } catch {
795
- return false;
796
- }
797
- }
798
- function handleDeprecatedAlgorithm(message, behavior, errorCode) {
799
- switch (behavior) {
800
- case "reject": throw new APIError("BAD_REQUEST", {
801
- message,
802
- code: errorCode
803
- });
804
- case "warn":
805
- console.warn(`[SAML Security Warning] ${message}`);
806
- break;
807
- case "allow": break;
808
- }
809
- }
810
- function validateSignatureAlgorithm(algorithm, options = {}) {
811
- if (!algorithm) return;
812
- const { onDeprecated = "warn", allowedSignatureAlgorithms } = options;
813
- if (allowedSignatureAlgorithms) {
814
- if (!allowedSignatureAlgorithms.includes(algorithm)) throw new APIError("BAD_REQUEST", {
815
- message: `SAML signature algorithm not in allow-list: ${algorithm}`,
816
- code: "SAML_ALGORITHM_NOT_ALLOWED"
817
- });
818
- return;
819
- }
820
- if (DEPRECATED_SIGNATURE_ALGORITHMS.includes(algorithm)) {
821
- handleDeprecatedAlgorithm(`SAML response uses deprecated signature algorithm: ${algorithm}. Please configure your IdP to use SHA-256 or stronger.`, onDeprecated, "SAML_DEPRECATED_ALGORITHM");
822
- return;
823
- }
824
- if (!SECURE_SIGNATURE_ALGORITHMS.includes(algorithm)) throw new APIError("BAD_REQUEST", {
825
- message: `SAML signature algorithm not recognized: ${algorithm}`,
826
- code: "SAML_UNKNOWN_ALGORITHM"
827
- });
828
- }
829
- function validateEncryptionAlgorithms(algorithms, options = {}) {
830
- const { onDeprecated = "warn", allowedKeyEncryptionAlgorithms, allowedDataEncryptionAlgorithms } = options;
831
- const { keyEncryption, dataEncryption } = algorithms;
832
- if (keyEncryption) {
833
- if (allowedKeyEncryptionAlgorithms) {
834
- if (!allowedKeyEncryptionAlgorithms.includes(keyEncryption)) throw new APIError("BAD_REQUEST", {
835
- message: `SAML key encryption algorithm not in allow-list: ${keyEncryption}`,
836
- code: "SAML_ALGORITHM_NOT_ALLOWED"
837
- });
838
- } 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");
839
- }
840
- if (dataEncryption) {
841
- if (allowedDataEncryptionAlgorithms) {
842
- if (!allowedDataEncryptionAlgorithms.includes(dataEncryption)) throw new APIError("BAD_REQUEST", {
843
- message: `SAML data encryption algorithm not in allow-list: ${dataEncryption}`,
844
- code: "SAML_ALGORITHM_NOT_ALLOWED"
845
- });
846
- } 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");
847
- }
848
- }
849
- function validateSAMLAlgorithms(response, options) {
850
- validateSignatureAlgorithm(response.sigAlg, options);
851
- if (hasEncryptedAssertion(response.samlContent)) validateEncryptionAlgorithms(extractEncryptionAlgorithms(response.samlContent), options);
852
- }
853
- function validateConfigAlgorithms(config, options = {}) {
854
- const { onDeprecated = "warn", allowedSignatureAlgorithms, allowedDigestAlgorithms } = options;
855
- if (config.signatureAlgorithm) {
856
- const normalized = normalizeSignatureAlgorithm(config.signatureAlgorithm);
857
- if (allowedSignatureAlgorithms) {
858
- if (!allowedSignatureAlgorithms.map(normalizeSignatureAlgorithm).includes(normalized)) throw new APIError("BAD_REQUEST", {
859
- message: `SAML signature algorithm not in allow-list: ${config.signatureAlgorithm}`,
860
- code: "SAML_ALGORITHM_NOT_ALLOWED"
861
- });
862
- } 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");
863
- else if (!SECURE_SIGNATURE_ALGORITHMS.includes(normalized)) throw new APIError("BAD_REQUEST", {
864
- message: `SAML signature algorithm not recognized: ${config.signatureAlgorithm}`,
865
- code: "SAML_UNKNOWN_ALGORITHM"
866
- });
867
- }
868
- if (config.digestAlgorithm) {
869
- const normalized = normalizeDigestAlgorithm(config.digestAlgorithm);
870
- if (allowedDigestAlgorithms) {
871
- if (!allowedDigestAlgorithms.map(normalizeDigestAlgorithm).includes(normalized)) throw new APIError("BAD_REQUEST", {
872
- message: `SAML digest algorithm not in allow-list: ${config.digestAlgorithm}`,
873
- code: "SAML_ALGORITHM_NOT_ALLOWED"
874
- });
875
- } 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");
876
- else if (!SECURE_DIGEST_ALGORITHMS.includes(normalized)) throw new APIError("BAD_REQUEST", {
877
- message: `SAML digest algorithm not recognized: ${config.digestAlgorithm}`,
878
- code: "SAML_UNKNOWN_ALGORITHM"
879
- });
880
- }
881
- }
882
-
883
- //#endregion
884
- //#region src/saml/assertions.ts
885
- /** @lintignore used in tests */
886
- function countAssertions(xml) {
887
- let parsed;
888
- try {
889
- parsed = xmlParser.parse(xml);
890
- } catch {
891
- throw new APIError("BAD_REQUEST", {
892
- message: "Failed to parse SAML response XML",
893
- code: "SAML_INVALID_XML"
894
- });
895
- }
896
- const assertions = countAllNodes(parsed, "Assertion");
897
- const encryptedAssertions = countAllNodes(parsed, "EncryptedAssertion");
898
- return {
899
- assertions,
900
- encryptedAssertions,
901
- total: assertions + encryptedAssertions
902
- };
903
- }
904
- function validateSingleAssertion(samlResponse) {
905
- let xml;
906
- try {
907
- xml = new TextDecoder().decode(base64.decode(samlResponse));
908
- if (!xml.includes("<")) throw new Error("Not XML");
909
- } catch {
910
- throw new APIError("BAD_REQUEST", {
911
- message: "Invalid base64-encoded SAML response",
912
- code: "SAML_INVALID_ENCODING"
913
- });
914
- }
915
- const counts = countAssertions(xml);
916
- if (counts.total === 0) throw new APIError("BAD_REQUEST", {
917
- message: "SAML response contains no assertions",
918
- code: "SAML_NO_ASSERTION"
919
- });
920
- if (counts.total > 1) throw new APIError("BAD_REQUEST", {
921
- message: `SAML response contains ${counts.total} assertions, expected exactly 1`,
922
- code: "SAML_MULTIPLE_ASSERTIONS"
923
- });
924
- }
1399
+
1400
+ //#endregion
1401
+ //#region src/saml/error-codes.ts
1402
+ const SAML_ERROR_CODES = defineErrorCodes({
1403
+ SINGLE_LOGOUT_NOT_ENABLED: "Single Logout is not enabled",
1404
+ INVALID_LOGOUT_RESPONSE: "Invalid LogoutResponse",
1405
+ INVALID_LOGOUT_REQUEST: "Invalid LogoutRequest",
1406
+ LOGOUT_FAILED_AT_IDP: "Logout failed at IdP",
1407
+ IDP_SLO_NOT_SUPPORTED: "IdP does not support Single Logout Service",
1408
+ SAML_PROVIDER_NOT_FOUND: "SAML provider not found"
1409
+ });
925
1410
 
926
1411
  //#endregion
927
1412
  //#region src/saml-state.ts
928
1413
  async function generateRelayState(c, link, additionalData) {
929
1414
  const callbackURL = c.body.callbackURL;
930
- if (!callbackURL) throw new APIError$1("BAD_REQUEST", { message: "callbackURL is required" });
1415
+ if (!callbackURL) throw new APIError("BAD_REQUEST", { message: "callbackURL is required" });
931
1416
  const codeVerifier = generateRandomString(128);
932
1417
  const stateData = {
933
1418
  ...additionalData ? additionalData : {},
@@ -943,7 +1428,7 @@ async function generateRelayState(c, link, additionalData) {
943
1428
  return generateGenericState(c, stateData, { cookieName: "relay_state" });
944
1429
  } catch (error) {
945
1430
  c.context.logger.error("Failed to create verification for relay state", error);
946
- throw new APIError$1("INTERNAL_SERVER_ERROR", {
1431
+ throw new APIError("INTERNAL_SERVER_ERROR", {
947
1432
  message: "State error: Unable to create verification for relay state",
948
1433
  cause: error
949
1434
  });
@@ -957,7 +1442,7 @@ async function parseRelayState(c) {
957
1442
  parsedData = await parseGenericState(c, state, { cookieName: "relay_state" });
958
1443
  } catch (error) {
959
1444
  c.context.logger.error("Failed to parse relay state", error);
960
- throw new APIError$1("BAD_REQUEST", {
1445
+ throw new APIError("BAD_REQUEST", {
961
1446
  message: "State error: failed to validate relay state",
962
1447
  cause: error
963
1448
  });
@@ -967,36 +1452,101 @@ async function parseRelayState(c) {
967
1452
  }
968
1453
 
969
1454
  //#endregion
970
- //#region src/utils.ts
971
- /**
972
- * Safely parses a value that might be a JSON string or already a parsed object.
973
- * This handles cases where ORMs like Drizzle might return already parsed objects
974
- * instead of JSON strings from TEXT/JSON columns.
975
- *
976
- * @param value - The value to parse (string, object, null, or undefined)
977
- * @returns The parsed object or null
978
- * @throws Error if string parsing fails
979
- */
980
- function safeJsonParse(value) {
981
- if (!value) return null;
982
- if (typeof value === "object") return value;
983
- if (typeof value === "string") try {
984
- return JSON.parse(value);
985
- } catch (error) {
986
- throw new Error(`Failed to parse JSON: ${error instanceof Error ? error.message : "Unknown error"}`);
1455
+ //#region src/routes/helpers.ts
1456
+ async function findSAMLProvider(providerId, options, adapter) {
1457
+ if (options?.defaultSSO?.length) {
1458
+ const match = options.defaultSSO.find((p) => p.providerId === providerId);
1459
+ if (match) return {
1460
+ ...match,
1461
+ userId: "default",
1462
+ issuer: match.samlConfig?.issuer || "",
1463
+ ...options.domainVerification?.enabled ? { domainVerified: true } : {}
1464
+ };
987
1465
  }
988
- return null;
1466
+ const res = await adapter.findOne({
1467
+ model: "ssoProvider",
1468
+ where: [{
1469
+ field: "providerId",
1470
+ value: providerId
1471
+ }]
1472
+ });
1473
+ if (!res) return null;
1474
+ return {
1475
+ ...res,
1476
+ samlConfig: res.samlConfig ? safeJsonParse(res.samlConfig) || void 0 : void 0
1477
+ };
1478
+ }
1479
+ function createSP(config, baseURL, providerId, sloOptions) {
1480
+ const sloLocation = `${baseURL}/sso/saml2/sp/slo/${providerId}`;
1481
+ return saml.ServiceProvider({
1482
+ entityID: config.spMetadata?.entityID || config.issuer,
1483
+ assertionConsumerService: [{
1484
+ Binding: "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST",
1485
+ Location: config.callbackUrl || `${baseURL}/sso/saml2/sp/acs/${providerId}`
1486
+ }],
1487
+ singleLogoutService: [{
1488
+ Binding: "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST",
1489
+ Location: sloLocation
1490
+ }, {
1491
+ Binding: "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect",
1492
+ Location: sloLocation
1493
+ }],
1494
+ wantMessageSigned: config.wantAssertionsSigned || false,
1495
+ wantLogoutRequestSigned: sloOptions?.wantLogoutRequestSigned ?? false,
1496
+ wantLogoutResponseSigned: sloOptions?.wantLogoutResponseSigned ?? false,
1497
+ metadata: config.spMetadata?.metadata,
1498
+ privateKey: config.spMetadata?.privateKey || config.privateKey,
1499
+ privateKeyPass: config.spMetadata?.privateKeyPass
1500
+ });
1501
+ }
1502
+ function createIdP(config) {
1503
+ const idpData = config.idpMetadata;
1504
+ if (idpData?.metadata) return saml.IdentityProvider({
1505
+ metadata: idpData.metadata,
1506
+ privateKey: idpData.privateKey,
1507
+ privateKeyPass: idpData.privateKeyPass,
1508
+ encPrivateKey: idpData.encPrivateKey,
1509
+ encPrivateKeyPass: idpData.encPrivateKeyPass
1510
+ });
1511
+ return saml.IdentityProvider({
1512
+ entityID: idpData?.entityID || config.issuer,
1513
+ singleSignOnService: idpData?.singleSignOnService || [{
1514
+ Binding: "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect",
1515
+ Location: config.entryPoint
1516
+ }],
1517
+ singleLogoutService: idpData?.singleLogoutService,
1518
+ signingCert: idpData?.cert || config.cert
1519
+ });
1520
+ }
1521
+ function escapeHtml(str) {
1522
+ if (!str) return "";
1523
+ return String(str).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;");
1524
+ }
1525
+ function createSAMLPostForm(action, samlParam, samlValue, relayState) {
1526
+ const safeAction = escapeHtml(action);
1527
+ const safeSamlParam = escapeHtml(samlParam);
1528
+ const safeSamlValue = escapeHtml(samlValue);
1529
+ const safeRelayState = relayState ? escapeHtml(relayState) : void 0;
1530
+ const html = `<!DOCTYPE html><html><body onload="document.forms[0].submit();"><form method="POST" action="${safeAction}"><input type="hidden" name="${safeSamlParam}" value="${safeSamlValue}" />${safeRelayState ? `<input type="hidden" name="RelayState" value="${safeRelayState}" />` : ""}<noscript><input type="submit" value="Continue" /></noscript></form></body></html>`;
1531
+ return new Response(html, { headers: { "Content-Type": "text/html" } });
989
1532
  }
990
- const validateEmailDomain = (email, domain) => {
991
- const emailDomain = email.split("@")[1]?.toLowerCase();
992
- const providerDomain = domain.toLowerCase();
993
- if (!emailDomain || !providerDomain) return false;
994
- return emailDomain === providerDomain || emailDomain.endsWith(`.${providerDomain}`);
995
- };
996
1533
 
997
1534
  //#endregion
998
1535
  //#region src/routes/sso.ts
999
1536
  /**
1537
+ * Builds the OIDC redirect URI. Uses the shared `redirectURI` option
1538
+ * when set, otherwise falls back to `/sso/callback/:providerId`.
1539
+ */
1540
+ function getOIDCRedirectURI(baseURL, providerId, options) {
1541
+ if (options?.redirectURI?.trim()) try {
1542
+ new URL(options.redirectURI);
1543
+ return options.redirectURI;
1544
+ } catch {
1545
+ return `${baseURL}${options.redirectURI.startsWith("/") ? options.redirectURI : `/${options.redirectURI}`}`;
1546
+ }
1547
+ return `${baseURL}/sso/callback/${providerId}`;
1548
+ }
1549
+ /**
1000
1550
  * Validates SAML assertion timestamp conditions (NotBefore/NotOnOrAfter).
1001
1551
  * Prevents acceptance of expired or future-dated assertions.
1002
1552
  * @throws {APIError} If timestamps are invalid, expired, or not yet valid
@@ -1060,7 +1610,7 @@ const spMetadataQuerySchema = z.object({
1060
1610
  providerId: z.string(),
1061
1611
  format: z.enum(["xml", "json"]).default("xml")
1062
1612
  });
1063
- const spMetadata = () => {
1613
+ const spMetadata = (options) => {
1064
1614
  return createAuthEndpoint("/sso/saml2/sp/metadata", {
1065
1615
  method: "GET",
1066
1616
  query: spMetadataQuerySchema,
@@ -1081,13 +1631,23 @@ const spMetadata = () => {
1081
1631
  if (!provider) throw new APIError("NOT_FOUND", { message: "No provider found for the given providerId" });
1082
1632
  const parsedSamlConfig = safeJsonParse(provider.samlConfig);
1083
1633
  if (!parsedSamlConfig) throw new APIError("BAD_REQUEST", { message: "Invalid SAML configuration" });
1634
+ const sloLocation = `${ctx.context.baseURL}/sso/saml2/sp/slo/${ctx.query.providerId}`;
1635
+ const singleLogoutService = options?.saml?.enableSingleLogout ? [{
1636
+ Binding: "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST",
1637
+ Location: sloLocation
1638
+ }, {
1639
+ Binding: "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect",
1640
+ Location: sloLocation
1641
+ }] : void 0;
1084
1642
  const sp = parsedSamlConfig.spMetadata.metadata ? saml.ServiceProvider({ metadata: parsedSamlConfig.spMetadata.metadata }) : saml.SPMetadata({
1085
1643
  entityID: parsedSamlConfig.spMetadata?.entityID || parsedSamlConfig.issuer,
1086
1644
  assertionConsumerService: [{
1087
1645
  Binding: "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST",
1088
1646
  Location: parsedSamlConfig.callbackUrl || `${ctx.context.baseURL}/sso/saml2/sp/acs/${provider.id}`
1089
1647
  }],
1648
+ singleLogoutService,
1090
1649
  wantMessageSigned: parsedSamlConfig.wantAssertionsSigned || false,
1650
+ authnRequestsSigned: parsedSamlConfig.authnRequestsSigned || false,
1091
1651
  nameIDFormat: parsedSamlConfig.identifierFormat ? [parsedSamlConfig.identifierFormat] : void 0
1092
1652
  });
1093
1653
  return new Response(sp.getMetadata(), { headers: { "Content-Type": "application/xml" } });
@@ -1096,7 +1656,7 @@ const spMetadata = () => {
1096
1656
  const ssoProviderBodySchema = z.object({
1097
1657
  providerId: z.string({}).meta({ description: "The ID of the provider. This is used to identify the provider during login and callback" }),
1098
1658
  issuer: z.string({}).meta({ description: "The issuer of the provider" }),
1099
- domain: z.string({}).meta({ description: "The domain of the provider. This is used for email matching" }),
1659
+ 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')" }),
1100
1660
  oidcConfig: z.object({
1101
1661
  clientId: z.string({}).meta({ description: "The client ID" }),
1102
1662
  clientSecret: z.string({}).meta({ description: "The client secret" }),
@@ -1148,6 +1708,7 @@ const ssoProviderBodySchema = z.object({
1148
1708
  encPrivateKeyPass: z.string().optional()
1149
1709
  }),
1150
1710
  wantAssertionsSigned: z.boolean().optional(),
1711
+ authnRequestsSigned: z.boolean().optional(),
1151
1712
  signatureAlgorithm: z.string().optional(),
1152
1713
  digestAlgorithm: z.string().optional(),
1153
1714
  identifierFormat: z.string().optional(),
@@ -1450,6 +2011,7 @@ const registerSSOProvider = (options) => {
1450
2011
  idpMetadata: body.samlConfig.idpMetadata,
1451
2012
  spMetadata: body.samlConfig.spMetadata,
1452
2013
  wantAssertionsSigned: body.samlConfig.wantAssertionsSigned,
2014
+ authnRequestsSigned: body.samlConfig.authnRequestsSigned,
1453
2015
  signatureAlgorithm: body.samlConfig.signatureAlgorithm,
1454
2016
  digestAlgorithm: body.samlConfig.digestAlgorithm,
1455
2017
  identifierFormat: body.samlConfig.identifierFormat,
@@ -1471,7 +2033,7 @@ const registerSSOProvider = (options) => {
1471
2033
  await ctx.context.adapter.create({
1472
2034
  model: "verification",
1473
2035
  data: {
1474
- identifier: options.domainVerification?.tokenPrefix ? `${options.domainVerification?.tokenPrefix}-${provider.providerId}` : `better-auth-token-${provider.providerId}`,
2036
+ identifier: getVerificationIdentifier(options, provider.providerId),
1475
2037
  createdAt: /* @__PURE__ */ new Date(),
1476
2038
  updatedAt: /* @__PURE__ */ new Date(),
1477
2039
  value: domainVerificationToken,
@@ -1483,7 +2045,7 @@ const registerSSOProvider = (options) => {
1483
2045
  ...provider,
1484
2046
  oidcConfig: safeJsonParse(provider.oidcConfig),
1485
2047
  samlConfig: safeJsonParse(provider.samlConfig),
1486
- redirectURI: `${ctx.context.baseURL}/sso/callback/${provider.providerId}`,
2048
+ redirectURI: getOIDCRedirectURI(ctx.context.baseURL, provider.providerId, options),
1487
2049
  ...options?.domainVerification?.enabled ? { domainVerified } : {},
1488
2050
  ...options?.domainVerification?.enabled ? { domainVerificationToken } : {}
1489
2051
  };
@@ -1595,20 +2157,33 @@ const signInSSO = (options) => {
1595
2157
  };
1596
2158
  }
1597
2159
  if (!providerId && !orgId && !domain) throw new APIError("BAD_REQUEST", { message: "providerId, orgId or domain is required" });
1598
- if (!provider) provider = await ctx.context.adapter.findOne({
1599
- model: "ssoProvider",
1600
- where: [{
1601
- field: providerId ? "providerId" : orgId ? "organizationId" : "domain",
1602
- value: providerId || orgId || domain
1603
- }]
1604
- }).then((res) => {
1605
- if (!res) return null;
1606
- return {
1607
- ...res,
1608
- oidcConfig: res.oidcConfig ? safeJsonParse(res.oidcConfig) || void 0 : void 0,
1609
- samlConfig: res.samlConfig ? safeJsonParse(res.samlConfig) || void 0 : void 0
2160
+ if (!provider) {
2161
+ const parseProvider = (res) => {
2162
+ if (!res) return null;
2163
+ return {
2164
+ ...res,
2165
+ oidcConfig: res.oidcConfig ? safeJsonParse(res.oidcConfig) || void 0 : void 0,
2166
+ samlConfig: res.samlConfig ? safeJsonParse(res.samlConfig) || void 0 : void 0
2167
+ };
1610
2168
  };
1611
- });
2169
+ if (providerId || orgId) provider = parseProvider(await ctx.context.adapter.findOne({
2170
+ model: "ssoProvider",
2171
+ where: [{
2172
+ field: providerId ? "providerId" : "organizationId",
2173
+ value: providerId || orgId
2174
+ }]
2175
+ }));
2176
+ else if (domain) {
2177
+ provider = parseProvider(await ctx.context.adapter.findOne({
2178
+ model: "ssoProvider",
2179
+ where: [{
2180
+ field: "domain",
2181
+ value: domain
2182
+ }]
2183
+ }));
2184
+ if (!provider) provider = parseProvider((await ctx.context.adapter.findMany({ model: "ssoProvider" })).find((p) => domainMatches(domain, p.domain)) ?? null);
2185
+ }
2186
+ }
1612
2187
  if (!provider) throw new APIError("NOT_FOUND", { message: "No provider found for the issuer" });
1613
2188
  if (body.providerType) {
1614
2189
  if (body.providerType === "oidc" && !provider.oidcConfig) throw new APIError("BAD_REQUEST", { message: "OIDC provider is not configured" });
@@ -1616,31 +2191,33 @@ const signInSSO = (options) => {
1616
2191
  }
1617
2192
  if (options?.domainVerification?.enabled && !("domainVerified" in provider && provider.domainVerified)) throw new APIError("UNAUTHORIZED", { message: "Provider domain has not been verified" });
1618
2193
  if (provider.oidcConfig && body.providerType !== "saml") {
1619
- let finalAuthUrl = provider.oidcConfig.authorizationEndpoint;
1620
- if (!finalAuthUrl && provider.oidcConfig.discoveryEndpoint) {
1621
- const discovery = await betterFetch(provider.oidcConfig.discoveryEndpoint, { method: "GET" });
1622
- if (discovery.data) finalAuthUrl = discovery.data.authorization_endpoint;
2194
+ let config = provider.oidcConfig;
2195
+ try {
2196
+ config = await ensureRuntimeDiscovery(provider.oidcConfig, provider.issuer, (url) => ctx.context.isTrustedOrigin(url));
2197
+ } catch (error) {
2198
+ if (error instanceof DiscoveryError) throw mapDiscoveryErrorToAPIError(error);
2199
+ throw error;
1623
2200
  }
1624
- if (!finalAuthUrl) throw new APIError("BAD_REQUEST", { message: "Invalid OIDC configuration. Authorization URL not found." });
1625
- const state = await generateState(ctx, void 0, false);
1626
- const redirectURI = `${ctx.context.baseURL}/sso/callback/${provider.providerId}`;
2201
+ if (!config.authorizationEndpoint) throw new APIError("BAD_REQUEST", { message: "Invalid OIDC configuration. Authorization URL not found." });
2202
+ const state = await generateState(ctx, void 0, options?.redirectURI?.trim() ? { ssoProviderId: provider.providerId } : false);
2203
+ const redirectURI = getOIDCRedirectURI(ctx.context.baseURL, provider.providerId, options);
1627
2204
  const authorizationURL = await createAuthorizationURL({
1628
2205
  id: provider.issuer,
1629
2206
  options: {
1630
- clientId: provider.oidcConfig.clientId,
1631
- clientSecret: provider.oidcConfig.clientSecret
2207
+ clientId: config.clientId,
2208
+ clientSecret: config.clientSecret
1632
2209
  },
1633
2210
  redirectURI,
1634
2211
  state: state.state,
1635
- codeVerifier: provider.oidcConfig.pkce ? state.codeVerifier : void 0,
1636
- scopes: ctx.body.scopes || provider.oidcConfig.scopes || [
2212
+ codeVerifier: config.pkce ? state.codeVerifier : void 0,
2213
+ scopes: ctx.body.scopes || config.scopes || [
1637
2214
  "openid",
1638
2215
  "email",
1639
2216
  "profile",
1640
2217
  "offline_access"
1641
2218
  ],
1642
2219
  loginHint: ctx.body.loginHint || email,
1643
- authorizationEndpoint: finalAuthUrl
2220
+ authorizationEndpoint: config.authorizationEndpoint
1644
2221
  });
1645
2222
  return ctx.json({
1646
2223
  url: authorizationURL.toString(),
@@ -1650,6 +2227,7 @@ const signInSSO = (options) => {
1650
2227
  if (provider.samlConfig) {
1651
2228
  const parsedSamlConfig = typeof provider.samlConfig === "object" ? provider.samlConfig : safeJsonParse(provider.samlConfig);
1652
2229
  if (!parsedSamlConfig) throw new APIError("BAD_REQUEST", { message: "Invalid SAML configuration" });
2230
+ 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 });
1653
2231
  let metadata = parsedSamlConfig.spMetadata.metadata;
1654
2232
  if (!metadata) metadata = saml.SPMetadata({
1655
2233
  entityID: parsedSamlConfig.spMetadata?.entityID || parsedSamlConfig.issuer,
@@ -1658,17 +2236,36 @@ const signInSSO = (options) => {
1658
2236
  Location: parsedSamlConfig.callbackUrl || `${ctx.context.baseURL}/sso/saml2/sp/acs/${provider.providerId}`
1659
2237
  }],
1660
2238
  wantMessageSigned: parsedSamlConfig.wantAssertionsSigned || false,
2239
+ authnRequestsSigned: parsedSamlConfig.authnRequestsSigned || false,
1661
2240
  nameIDFormat: parsedSamlConfig.identifierFormat ? [parsedSamlConfig.identifierFormat] : void 0
1662
2241
  }).getMetadata() || "";
1663
2242
  const sp = saml.ServiceProvider({
1664
2243
  metadata,
1665
- allowCreate: true
2244
+ allowCreate: true,
2245
+ privateKey: parsedSamlConfig.spMetadata?.privateKey || parsedSamlConfig.privateKey,
2246
+ privateKeyPass: parsedSamlConfig.spMetadata?.privateKeyPass
2247
+ });
2248
+ const idpData = parsedSamlConfig.idpMetadata;
2249
+ let idp;
2250
+ if (!idpData?.metadata) idp = saml.IdentityProvider({
2251
+ entityID: idpData?.entityID || parsedSamlConfig.issuer,
2252
+ singleSignOnService: idpData?.singleSignOnService || [{
2253
+ Binding: "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect",
2254
+ Location: parsedSamlConfig.entryPoint
2255
+ }],
2256
+ signingCert: idpData?.cert || parsedSamlConfig.cert,
2257
+ wantAuthnRequestsSigned: parsedSamlConfig.authnRequestsSigned || false,
2258
+ isAssertionEncrypted: idpData?.isAssertionEncrypted || false,
2259
+ encPrivateKey: idpData?.encPrivateKey,
2260
+ encPrivateKeyPass: idpData?.encPrivateKeyPass
1666
2261
  });
1667
- const idp = saml.IdentityProvider({
1668
- metadata: parsedSamlConfig.idpMetadata?.metadata,
1669
- entityID: parsedSamlConfig.idpMetadata?.entityID,
1670
- encryptCert: parsedSamlConfig.idpMetadata?.cert,
1671
- singleSignOnService: parsedSamlConfig.idpMetadata?.singleSignOnService
2262
+ else idp = saml.IdentityProvider({
2263
+ metadata: idpData.metadata,
2264
+ privateKey: idpData.privateKey,
2265
+ privateKeyPass: idpData.privateKeyPass,
2266
+ isAssertionEncrypted: idpData.isAssertionEncrypted,
2267
+ encPrivateKey: idpData.encPrivateKey,
2268
+ encPrivateKeyPass: idpData.encPrivateKeyPass
1672
2269
  });
1673
2270
  const loginRequest = sp.createLoginRequest(idp, "redirect");
1674
2271
  if (!loginRequest) throw new APIError("BAD_REQUEST", { message: "Invalid SAML request" });
@@ -1701,169 +2298,216 @@ const callbackSSOQuerySchema = z.object({
1701
2298
  error: z.string().optional(),
1702
2299
  error_description: z.string().optional()
1703
2300
  });
2301
+ /**
2302
+ * Core OIDC callback handler logic, shared between the per-provider and
2303
+ * shared callback endpoints. Resolves the provider, exchanges the
2304
+ * authorization code for tokens, and creates a session.
2305
+ *
2306
+ * @param stateData - Pre-parsed state data. If not provided, it will be
2307
+ * parsed from the request context.
2308
+ */
2309
+ async function handleOIDCCallback(ctx, options, providerId, stateData) {
2310
+ const { code, error, error_description } = ctx.query;
2311
+ if (!stateData) stateData = await parseState(ctx);
2312
+ if (!stateData) {
2313
+ const errorURL = ctx.context.options.onAPIError?.errorURL || `${ctx.context.baseURL}/error`;
2314
+ throw ctx.redirect(`${errorURL}?error=invalid_state`);
2315
+ }
2316
+ const { callbackURL, errorURL, newUserURL, requestSignUp } = stateData;
2317
+ if (!code || error) throw ctx.redirect(`${errorURL || callbackURL}?error=${error}&error_description=${error_description}`);
2318
+ let provider = null;
2319
+ if (options?.defaultSSO?.length) {
2320
+ const matchingDefault = options.defaultSSO.find((defaultProvider) => defaultProvider.providerId === providerId);
2321
+ if (matchingDefault) provider = {
2322
+ ...matchingDefault,
2323
+ issuer: matchingDefault.oidcConfig?.issuer || "",
2324
+ userId: "default",
2325
+ ...options.domainVerification?.enabled ? { domainVerified: true } : {}
2326
+ };
2327
+ }
2328
+ if (!provider) provider = await ctx.context.adapter.findOne({
2329
+ model: "ssoProvider",
2330
+ where: [{
2331
+ field: "providerId",
2332
+ value: providerId
2333
+ }]
2334
+ }).then((res) => {
2335
+ if (!res) return null;
2336
+ return {
2337
+ ...res,
2338
+ oidcConfig: safeJsonParse(res.oidcConfig) || void 0
2339
+ };
2340
+ });
2341
+ if (!provider) throw ctx.redirect(`${errorURL || callbackURL}?error=invalid_provider&error_description=provider not found`);
2342
+ if (options?.domainVerification?.enabled && !("domainVerified" in provider && provider.domainVerified)) throw new APIError("UNAUTHORIZED", { message: "Provider domain has not been verified" });
2343
+ let config = provider.oidcConfig;
2344
+ if (!config) throw ctx.redirect(`${errorURL || callbackURL}?error=invalid_provider&error_description=provider not found`);
2345
+ try {
2346
+ config = await ensureRuntimeDiscovery(config, provider.issuer, (url) => ctx.context.isTrustedOrigin(url));
2347
+ } catch (error) {
2348
+ if (error instanceof DiscoveryError) throw ctx.redirect(`${errorURL || callbackURL}?error=discovery_failed&error_description=${encodeURIComponent(error.message)}`);
2349
+ throw ctx.redirect(`${errorURL || callbackURL}?error=discovery_failed&error_description=unexpected_discovery_error`);
2350
+ }
2351
+ if (!config.scopes) config = {
2352
+ ...config,
2353
+ scopes: [
2354
+ "openid",
2355
+ "email",
2356
+ "profile",
2357
+ "offline_access"
2358
+ ]
2359
+ };
2360
+ if (!config.tokenEndpoint) throw ctx.redirect(`${errorURL || callbackURL}?error=invalid_provider&error_description=token_endpoint_not_found`);
2361
+ const tokenResponse = await validateAuthorizationCode({
2362
+ code,
2363
+ codeVerifier: config.pkce ? stateData.codeVerifier : void 0,
2364
+ redirectURI: getOIDCRedirectURI(ctx.context.baseURL, provider.providerId, options),
2365
+ options: {
2366
+ clientId: config.clientId,
2367
+ clientSecret: config.clientSecret
2368
+ },
2369
+ tokenEndpoint: config.tokenEndpoint,
2370
+ authentication: config.tokenEndpointAuthentication === "client_secret_post" ? "post" : "basic"
2371
+ }).catch((e) => {
2372
+ if (e instanceof BetterFetchError) throw ctx.redirect(`${errorURL || callbackURL}?error=invalid_provider&error_description=${e.message}`);
2373
+ return null;
2374
+ });
2375
+ if (!tokenResponse) throw ctx.redirect(`${errorURL || callbackURL}?error=invalid_provider&error_description=token_response_not_found`);
2376
+ let userInfo = null;
2377
+ if (tokenResponse.idToken) {
2378
+ const idToken = decodeJwt(tokenResponse.idToken);
2379
+ if (!config.jwksEndpoint) throw ctx.redirect(`${errorURL || callbackURL}?error=invalid_provider&error_description=jwks_endpoint_not_found`);
2380
+ const verified = await validateToken(tokenResponse.idToken, config.jwksEndpoint, {
2381
+ audience: config.clientId,
2382
+ issuer: provider.issuer
2383
+ }).catch((e) => {
2384
+ ctx.context.logger.error(e);
2385
+ return null;
2386
+ });
2387
+ if (!verified) throw ctx.redirect(`${errorURL || callbackURL}?error=invalid_provider&error_description=token_not_verified`);
2388
+ const mapping = config.mapping || {};
2389
+ userInfo = {
2390
+ ...Object.fromEntries(Object.entries(mapping.extraFields || {}).map(([key, value]) => [key, verified.payload[value]])),
2391
+ id: idToken[mapping.id || "sub"],
2392
+ email: idToken[mapping.email || "email"],
2393
+ emailVerified: options?.trustEmailVerified ? idToken[mapping.emailVerified || "email_verified"] : false,
2394
+ name: idToken[mapping.name || "name"],
2395
+ image: idToken[mapping.image || "picture"]
2396
+ };
2397
+ }
2398
+ if (!userInfo) {
2399
+ if (!config.userInfoEndpoint) throw ctx.redirect(`${errorURL || callbackURL}?error=invalid_provider&error_description=user_info_endpoint_not_found`);
2400
+ const userInfoResponse = await betterFetch(config.userInfoEndpoint, { headers: { Authorization: `Bearer ${tokenResponse.accessToken}` } });
2401
+ if (userInfoResponse.error) throw ctx.redirect(`${errorURL || callbackURL}?error=invalid_provider&error_description=${userInfoResponse.error.message}`);
2402
+ userInfo = userInfoResponse.data;
2403
+ }
2404
+ if (!userInfo.email || !userInfo.id) throw ctx.redirect(`${errorURL || callbackURL}?error=invalid_provider&error_description=missing_user_info`);
2405
+ const isTrustedProvider = "domainVerified" in provider && provider.domainVerified === true && validateEmailDomain(userInfo.email, provider.domain);
2406
+ const linked = await handleOAuthUserInfo(ctx, {
2407
+ userInfo: {
2408
+ email: userInfo.email,
2409
+ name: userInfo.name || "",
2410
+ id: userInfo.id,
2411
+ image: userInfo.image,
2412
+ emailVerified: options?.trustEmailVerified ? userInfo.emailVerified || false : false
2413
+ },
2414
+ account: {
2415
+ idToken: tokenResponse.idToken,
2416
+ accessToken: tokenResponse.accessToken,
2417
+ refreshToken: tokenResponse.refreshToken,
2418
+ accountId: userInfo.id,
2419
+ providerId: provider.providerId,
2420
+ accessTokenExpiresAt: tokenResponse.accessTokenExpiresAt,
2421
+ refreshTokenExpiresAt: tokenResponse.refreshTokenExpiresAt,
2422
+ scope: tokenResponse.scopes?.join(",")
2423
+ },
2424
+ callbackURL,
2425
+ disableSignUp: options?.disableImplicitSignUp && !requestSignUp,
2426
+ overrideUserInfo: config.overrideUserInfo,
2427
+ isTrustedProvider
2428
+ });
2429
+ if (linked.error) throw ctx.redirect(`${errorURL || callbackURL}?error=${linked.error}`);
2430
+ const { session, user } = linked.data;
2431
+ if (options?.provisionUser && linked.isRegister) await options.provisionUser({
2432
+ user,
2433
+ userInfo,
2434
+ token: tokenResponse,
2435
+ provider
2436
+ });
2437
+ await assignOrganizationFromProvider(ctx, {
2438
+ user,
2439
+ profile: {
2440
+ providerType: "oidc",
2441
+ providerId: provider.providerId,
2442
+ accountId: userInfo.id,
2443
+ email: userInfo.email,
2444
+ emailVerified: Boolean(userInfo.emailVerified),
2445
+ rawAttributes: userInfo
2446
+ },
2447
+ provider,
2448
+ token: tokenResponse,
2449
+ provisioningOptions: options?.organizationProvisioning
2450
+ });
2451
+ await setSessionCookie(ctx, {
2452
+ session,
2453
+ user
2454
+ });
2455
+ let toRedirectTo;
2456
+ try {
2457
+ toRedirectTo = (linked.isRegister ? newUserURL || callbackURL : callbackURL).toString();
2458
+ } catch {
2459
+ toRedirectTo = linked.isRegister ? newUserURL || callbackURL : callbackURL;
2460
+ }
2461
+ throw ctx.redirect(toRedirectTo);
2462
+ }
2463
+ const callbackSSOEndpointConfig = {
2464
+ method: "GET",
2465
+ query: callbackSSOQuerySchema,
2466
+ allowedMediaTypes: ["application/x-www-form-urlencoded", "application/json"],
2467
+ metadata: {
2468
+ ...HIDE_METADATA,
2469
+ openapi: {
2470
+ operationId: "handleSSOCallback",
2471
+ summary: "Callback URL for SSO provider",
2472
+ description: "This endpoint is used as the callback URL for SSO providers. It handles the authorization code and exchanges it for an access token",
2473
+ responses: { "302": { description: "Redirects to the callback URL" } }
2474
+ }
2475
+ }
2476
+ };
1704
2477
  const callbackSSO = (options) => {
1705
- return createAuthEndpoint("/sso/callback/:providerId", {
1706
- method: "GET",
1707
- query: callbackSSOQuerySchema,
1708
- allowedMediaTypes: ["application/x-www-form-urlencoded", "application/json"],
2478
+ return createAuthEndpoint("/sso/callback/:providerId", callbackSSOEndpointConfig, async (ctx) => {
2479
+ return handleOIDCCallback(ctx, options, ctx.params.providerId);
2480
+ });
2481
+ };
2482
+ /**
2483
+ * Shared OIDC callback endpoint (no `:providerId` in path).
2484
+ * Used when `options.redirectURI` is set — the `providerId` is read from
2485
+ * the OAuth state instead of the URL path.
2486
+ */
2487
+ const callbackSSOShared = (options) => {
2488
+ return createAuthEndpoint("/sso/callback", {
2489
+ ...callbackSSOEndpointConfig,
1709
2490
  metadata: {
1710
- ...HIDE_METADATA,
2491
+ ...callbackSSOEndpointConfig.metadata,
1711
2492
  openapi: {
1712
- operationId: "handleSSOCallback",
1713
- summary: "Callback URL for SSO provider",
1714
- description: "This endpoint is used as the callback URL for SSO providers. It handles the authorization code and exchanges it for an access token",
1715
- responses: { "302": { description: "Redirects to the callback URL" } }
2493
+ ...callbackSSOEndpointConfig.metadata.openapi,
2494
+ operationId: "handleSSOCallbackShared",
2495
+ summary: "Shared callback URL for all SSO providers",
2496
+ description: "This endpoint is used as a shared callback URL for all SSO providers when `redirectURI` is configured. The provider is identified via the OAuth state parameter."
1716
2497
  }
1717
2498
  }
1718
2499
  }, async (ctx) => {
1719
- const { code, error, error_description } = ctx.query;
1720
2500
  const stateData = await parseState(ctx);
1721
2501
  if (!stateData) {
1722
- const errorURL$1 = ctx.context.options.onAPIError?.errorURL || `${ctx.context.baseURL}/error`;
1723
- throw ctx.redirect(`${errorURL$1}?error=invalid_state`);
1724
- }
1725
- const { callbackURL, errorURL, newUserURL, requestSignUp } = stateData;
1726
- if (!code || error) throw ctx.redirect(`${errorURL || callbackURL}?error=${error}&error_description=${error_description}`);
1727
- let provider = null;
1728
- if (options?.defaultSSO?.length) {
1729
- const matchingDefault = options.defaultSSO.find((defaultProvider) => defaultProvider.providerId === ctx.params.providerId);
1730
- if (matchingDefault) provider = {
1731
- ...matchingDefault,
1732
- issuer: matchingDefault.oidcConfig?.issuer || "",
1733
- userId: "default",
1734
- ...options.domainVerification?.enabled ? { domainVerified: true } : {}
1735
- };
1736
- }
1737
- if (!provider) provider = await ctx.context.adapter.findOne({
1738
- model: "ssoProvider",
1739
- where: [{
1740
- field: "providerId",
1741
- value: ctx.params.providerId
1742
- }]
1743
- }).then((res) => {
1744
- if (!res) return null;
1745
- return {
1746
- ...res,
1747
- oidcConfig: safeJsonParse(res.oidcConfig) || void 0
1748
- };
1749
- });
1750
- if (!provider) throw ctx.redirect(`${errorURL || callbackURL}/error?error=invalid_provider&error_description=provider not found`);
1751
- if (options?.domainVerification?.enabled && !("domainVerified" in provider && provider.domainVerified)) throw new APIError("UNAUTHORIZED", { message: "Provider domain has not been verified" });
1752
- let config = provider.oidcConfig;
1753
- if (!config) throw ctx.redirect(`${errorURL || callbackURL}/error?error=invalid_provider&error_description=provider not found`);
1754
- const discovery = await betterFetch(config.discoveryEndpoint);
1755
- if (discovery.data) config = {
1756
- tokenEndpoint: discovery.data.token_endpoint,
1757
- tokenEndpointAuthentication: discovery.data.token_endpoint_auth_method,
1758
- userInfoEndpoint: discovery.data.userinfo_endpoint,
1759
- scopes: [
1760
- "openid",
1761
- "email",
1762
- "profile",
1763
- "offline_access"
1764
- ],
1765
- ...config
1766
- };
1767
- if (!config.tokenEndpoint) throw ctx.redirect(`${errorURL || callbackURL}/error?error=invalid_provider&error_description=token_endpoint_not_found`);
1768
- const tokenResponse = await validateAuthorizationCode({
1769
- code,
1770
- codeVerifier: config.pkce ? stateData.codeVerifier : void 0,
1771
- redirectURI: `${ctx.context.baseURL}/sso/callback/${provider.providerId}`,
1772
- options: {
1773
- clientId: config.clientId,
1774
- clientSecret: config.clientSecret
1775
- },
1776
- tokenEndpoint: config.tokenEndpoint,
1777
- authentication: config.tokenEndpointAuthentication === "client_secret_post" ? "post" : "basic"
1778
- }).catch((e) => {
1779
- if (e instanceof BetterFetchError) throw ctx.redirect(`${errorURL || callbackURL}?error=invalid_provider&error_description=${e.message}`);
1780
- return null;
1781
- });
1782
- if (!tokenResponse) throw ctx.redirect(`${errorURL || callbackURL}/error?error=invalid_provider&error_description=token_response_not_found`);
1783
- let userInfo = null;
1784
- if (tokenResponse.idToken) {
1785
- const idToken = decodeJwt(tokenResponse.idToken);
1786
- if (!config.jwksEndpoint) throw ctx.redirect(`${errorURL || callbackURL}/error?error=invalid_provider&error_description=jwks_endpoint_not_found`);
1787
- const verified = await validateToken(tokenResponse.idToken, config.jwksEndpoint).catch((e) => {
1788
- ctx.context.logger.error(e);
1789
- return null;
1790
- });
1791
- if (!verified) throw ctx.redirect(`${errorURL || callbackURL}/error?error=invalid_provider&error_description=token_not_verified`);
1792
- if (verified.payload.iss !== provider.issuer) throw ctx.redirect(`${errorURL || callbackURL}/error?error=invalid_provider&error_description=issuer_mismatch`);
1793
- const mapping = config.mapping || {};
1794
- userInfo = {
1795
- ...Object.fromEntries(Object.entries(mapping.extraFields || {}).map(([key, value]) => [key, verified.payload[value]])),
1796
- id: idToken[mapping.id || "sub"],
1797
- email: idToken[mapping.email || "email"],
1798
- emailVerified: options?.trustEmailVerified ? idToken[mapping.emailVerified || "email_verified"] : false,
1799
- name: idToken[mapping.name || "name"],
1800
- image: idToken[mapping.image || "picture"]
1801
- };
2502
+ const errorURL = ctx.context.options.onAPIError?.errorURL || `${ctx.context.baseURL}/error`;
2503
+ throw ctx.redirect(`${errorURL}?error=invalid_state`);
1802
2504
  }
1803
- if (!userInfo) {
1804
- if (!config.userInfoEndpoint) throw ctx.redirect(`${errorURL || callbackURL}/error?error=invalid_provider&error_description=user_info_endpoint_not_found`);
1805
- const userInfoResponse = await betterFetch(config.userInfoEndpoint, { headers: { Authorization: `Bearer ${tokenResponse.accessToken}` } });
1806
- if (userInfoResponse.error) throw ctx.redirect(`${errorURL || callbackURL}/error?error=invalid_provider&error_description=${userInfoResponse.error.message}`);
1807
- userInfo = userInfoResponse.data;
1808
- }
1809
- if (!userInfo.email || !userInfo.id) throw ctx.redirect(`${errorURL || callbackURL}/error?error=invalid_provider&error_description=missing_user_info`);
1810
- const isTrustedProvider = "domainVerified" in provider && provider.domainVerified === true && validateEmailDomain(userInfo.email, provider.domain);
1811
- const linked = await handleOAuthUserInfo(ctx, {
1812
- userInfo: {
1813
- email: userInfo.email,
1814
- name: userInfo.name || userInfo.email,
1815
- id: userInfo.id,
1816
- image: userInfo.image,
1817
- emailVerified: options?.trustEmailVerified ? userInfo.emailVerified || false : false
1818
- },
1819
- account: {
1820
- idToken: tokenResponse.idToken,
1821
- accessToken: tokenResponse.accessToken,
1822
- refreshToken: tokenResponse.refreshToken,
1823
- accountId: userInfo.id,
1824
- providerId: provider.providerId,
1825
- accessTokenExpiresAt: tokenResponse.accessTokenExpiresAt,
1826
- refreshTokenExpiresAt: tokenResponse.refreshTokenExpiresAt,
1827
- scope: tokenResponse.scopes?.join(",")
1828
- },
1829
- callbackURL,
1830
- disableSignUp: options?.disableImplicitSignUp && !requestSignUp,
1831
- overrideUserInfo: config.overrideUserInfo,
1832
- isTrustedProvider
1833
- });
1834
- if (linked.error) throw ctx.redirect(`${errorURL || callbackURL}/error?error=${linked.error}`);
1835
- const { session, user } = linked.data;
1836
- if (options?.provisionUser) await options.provisionUser({
1837
- user,
1838
- userInfo,
1839
- token: tokenResponse,
1840
- provider
1841
- });
1842
- await assignOrganizationFromProvider(ctx, {
1843
- user,
1844
- profile: {
1845
- providerType: "oidc",
1846
- providerId: provider.providerId,
1847
- accountId: userInfo.id,
1848
- email: userInfo.email,
1849
- emailVerified: Boolean(userInfo.emailVerified),
1850
- rawAttributes: userInfo
1851
- },
1852
- provider,
1853
- token: tokenResponse,
1854
- provisioningOptions: options?.organizationProvisioning
1855
- });
1856
- await setSessionCookie(ctx, {
1857
- session,
1858
- user
1859
- });
1860
- let toRedirectTo;
1861
- try {
1862
- toRedirectTo = (linked.isRegister ? newUserURL || callbackURL : callbackURL).toString();
1863
- } catch {
1864
- toRedirectTo = linked.isRegister ? newUserURL || callbackURL : callbackURL;
2505
+ const providerId = stateData.ssoProviderId;
2506
+ if (!providerId) {
2507
+ const errorURL = stateData.errorURL || stateData.callbackURL;
2508
+ throw ctx.redirect(`${errorURL}?error=invalid_state&error_description=missing_provider_id`);
1865
2509
  }
1866
- throw ctx.redirect(toRedirectTo);
2510
+ return handleOIDCCallback(ctx, options, providerId, stateData);
1867
2511
  });
1868
2512
  };
1869
2513
  const callbackSSOSAMLBodySchema = z.object({
@@ -1924,9 +2568,9 @@ const callbackSSOSAML = (options) => {
1924
2568
  const currentCallbackPath = `${ctx.context.baseURL}/sso/saml2/callback/${providerId}`;
1925
2569
  if (ctx.method === "GET" && !ctx.body?.SAMLResponse) {
1926
2570
  if (!(await getSessionFromCtx(ctx))?.session) throw ctx.redirect(`${errorURL}?error=invalid_request`);
1927
- const relayState$1 = ctx.query?.RelayState;
1928
- const safeRedirectUrl$1 = getSafeRedirectUrl(relayState$1, currentCallbackPath, appOrigin, (url, settings) => ctx.context.isTrustedOrigin(url, settings));
1929
- throw ctx.redirect(safeRedirectUrl$1);
2571
+ const relayState = ctx.query?.RelayState;
2572
+ const safeRedirectUrl = getSafeRedirectUrl(relayState, currentCallbackPath, appOrigin, (url, settings) => ctx.context.isTrustedOrigin(url, settings));
2573
+ throw ctx.redirect(safeRedirectUrl);
1930
2574
  }
1931
2575
  if (!ctx.body?.SAMLResponse) throw new APIError("BAD_REQUEST", { message: "SAMLResponse is required for POST requests" });
1932
2576
  const { SAMLResponse } = ctx.body;
@@ -1969,12 +2613,12 @@ const callbackSSOSAML = (options) => {
1969
2613
  let idp = null;
1970
2614
  if (!idpData?.metadata) idp = saml.IdentityProvider({
1971
2615
  entityID: idpData?.entityID || parsedSamlConfig.issuer,
1972
- singleSignOnService: [{
2616
+ singleSignOnService: idpData?.singleSignOnService || [{
1973
2617
  Binding: "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect",
1974
2618
  Location: parsedSamlConfig.entryPoint
1975
2619
  }],
1976
2620
  signingCert: idpData?.cert || parsedSamlConfig.cert,
1977
- wantAuthnRequestsSigned: parsedSamlConfig.wantAssertionsSigned || false,
2621
+ wantAuthnRequestsSigned: parsedSamlConfig.authnRequestsSigned || false,
1978
2622
  isAssertionEncrypted: idpData?.isAssertionEncrypted || false,
1979
2623
  encPrivateKey: idpData?.encPrivateKey,
1980
2624
  encPrivateKeyPass: idpData?.encPrivateKeyPass
@@ -2108,7 +2752,7 @@ const callbackSSOSAML = (options) => {
2108
2752
  const userInfo = {
2109
2753
  ...Object.fromEntries(Object.entries(mapping.extraFields || {}).map(([key, value]) => [key, attributes[value]])),
2110
2754
  id: attributes[mapping.id || "nameID"] || extract.nameID,
2111
- email: attributes[mapping.email || "email"] || extract.nameID,
2755
+ email: (attributes[mapping.email || "email"] || extract.nameID).toLowerCase(),
2112
2756
  name: [attributes[mapping.firstName || "givenName"], attributes[mapping.lastName || "surname"]].filter(Boolean).join(" ") || attributes[mapping.name || "displayName"] || extract.nameID,
2113
2757
  emailVerified: options?.trustEmailVerified && mapping.emailVerified ? attributes[mapping.emailVerified] || false : false
2114
2758
  };
@@ -2121,7 +2765,7 @@ const callbackSSOSAML = (options) => {
2121
2765
  });
2122
2766
  throw new APIError("BAD_REQUEST", { message: "Unable to extract user ID or email from SAML response" });
2123
2767
  }
2124
- const isTrustedProvider = !!ctx.context.options.account?.accountLinking?.trustedProviders?.includes(provider.providerId) || "domainVerified" in provider && !!provider.domainVerified && validateEmailDomain(userInfo.email, provider.domain);
2768
+ const isTrustedProvider = ctx.context.trustedProviders.includes(provider.providerId) || "domainVerified" in provider && !!provider.domainVerified && validateEmailDomain(userInfo.email, provider.domain);
2125
2769
  const callbackUrl = relayState?.callbackURL || parsedSamlConfig.callbackUrl || ctx.context.baseURL;
2126
2770
  const result = await handleOAuthUserInfo(ctx, {
2127
2771
  userInfo: {
@@ -2164,11 +2808,29 @@ const callbackSSOSAML = (options) => {
2164
2808
  session,
2165
2809
  user
2166
2810
  });
2811
+ if (options?.saml?.enableSingleLogout && extract.nameID) {
2812
+ const samlSessionKey = `${SAML_SESSION_KEY_PREFIX}${provider.providerId}:${extract.nameID}`;
2813
+ const samlSessionData = {
2814
+ sessionId: session.id,
2815
+ providerId: provider.providerId,
2816
+ nameID: extract.nameID,
2817
+ sessionIndex: extract.sessionIndex
2818
+ };
2819
+ await ctx.context.internalAdapter.createVerificationValue({
2820
+ identifier: samlSessionKey,
2821
+ value: JSON.stringify(samlSessionData),
2822
+ expiresAt: session.expiresAt
2823
+ }).catch((e) => ctx.context.logger.warn("Failed to create SAML session record", { error: e }));
2824
+ await ctx.context.internalAdapter.createVerificationValue({
2825
+ identifier: `${SAML_SESSION_BY_ID_PREFIX}${session.id}`,
2826
+ value: samlSessionKey,
2827
+ expiresAt: session.expiresAt
2828
+ }).catch((e) => ctx.context.logger.warn("Failed to create SAML session lookup record", e));
2829
+ }
2167
2830
  const safeRedirectUrl = getSafeRedirectUrl(relayState?.callbackURL || parsedSamlConfig.callbackUrl, currentCallbackPath, appOrigin, (url, settings) => ctx.context.isTrustedOrigin(url, settings));
2168
2831
  throw ctx.redirect(safeRedirectUrl);
2169
2832
  });
2170
2833
  };
2171
- const acsEndpointParamsSchema = z.object({ providerId: z.string().optional() });
2172
2834
  const acsEndpointBodySchema = z.object({
2173
2835
  SAMLResponse: z.string(),
2174
2836
  RelayState: z.string().optional()
@@ -2176,7 +2838,6 @@ const acsEndpointBodySchema = z.object({
2176
2838
  const acsEndpoint = (options) => {
2177
2839
  return createAuthEndpoint("/sso/saml2/sp/acs/:providerId", {
2178
2840
  method: "POST",
2179
- params: acsEndpointParamsSchema,
2180
2841
  body: acsEndpointBodySchema,
2181
2842
  metadata: {
2182
2843
  ...HIDE_METADATA,
@@ -2189,10 +2850,18 @@ const acsEndpoint = (options) => {
2189
2850
  }
2190
2851
  }
2191
2852
  }, async (ctx) => {
2192
- const { SAMLResponse, RelayState = "" } = ctx.body;
2853
+ const { SAMLResponse } = ctx.body;
2193
2854
  const { providerId } = ctx.params;
2855
+ const currentCallbackPath = `${ctx.context.baseURL}/sso/saml2/sp/acs/${providerId}`;
2856
+ const appOrigin = new URL(ctx.context.baseURL).origin;
2194
2857
  const maxResponseSize = options?.saml?.maxResponseSize ?? DEFAULT_MAX_SAML_RESPONSE_SIZE;
2195
2858
  if (new TextEncoder().encode(SAMLResponse).length > maxResponseSize) throw new APIError("BAD_REQUEST", { message: `SAML response exceeds maximum allowed size (${maxResponseSize} bytes)` });
2859
+ let relayState = null;
2860
+ if (ctx.body.RelayState) try {
2861
+ relayState = await parseRelayState(ctx);
2862
+ } catch {
2863
+ relayState = null;
2864
+ }
2196
2865
  let provider = null;
2197
2866
  if (options?.defaultSSO?.length) {
2198
2867
  const matchingDefault = providerId ? options.defaultSSO.find((defaultProvider) => defaultProvider.providerId === providerId) : options.defaultSSO[0];
@@ -2208,7 +2877,7 @@ const acsEndpoint = (options) => {
2208
2877
  model: "ssoProvider",
2209
2878
  where: [{
2210
2879
  field: "providerId",
2211
- value: providerId ?? "sso"
2880
+ value: providerId
2212
2881
  }]
2213
2882
  }).then((res) => {
2214
2883
  if (!res) return null;
@@ -2245,7 +2914,7 @@ const acsEndpoint = (options) => {
2245
2914
  validateSingleAssertion(SAMLResponse);
2246
2915
  } catch (error) {
2247
2916
  if (error instanceof APIError) {
2248
- const redirectUrl = RelayState || parsedSamlConfig.callbackUrl || ctx.context.baseURL;
2917
+ const redirectUrl = relayState?.callbackURL || parsedSamlConfig.callbackUrl || ctx.context.baseURL;
2249
2918
  const errorCode = error.body?.code === "SAML_MULTIPLE_ASSERTIONS" ? "multiple_assertions" : "no_assertion";
2250
2919
  throw ctx.redirect(`${redirectUrl}?error=${errorCode}&error_description=${encodeURIComponent(error.message)}`);
2251
2920
  }
@@ -2255,7 +2924,7 @@ const acsEndpoint = (options) => {
2255
2924
  try {
2256
2925
  parsedResponse = await sp.parseLoginResponse(idp, "post", { body: {
2257
2926
  SAMLResponse,
2258
- RelayState: RelayState || void 0
2927
+ RelayState: ctx.body.RelayState || void 0
2259
2928
  } });
2260
2929
  if (!parsedResponse?.extract) throw new Error("Invalid SAML response structure");
2261
2930
  } catch (error) {
@@ -2292,7 +2961,7 @@ const acsEndpoint = (options) => {
2292
2961
  inResponseTo: inResponseToAcs,
2293
2962
  providerId
2294
2963
  });
2295
- const redirectUrl = RelayState || parsedSamlConfig.callbackUrl || ctx.context.baseURL;
2964
+ const redirectUrl = relayState?.callbackURL || parsedSamlConfig.callbackUrl || ctx.context.baseURL;
2296
2965
  throw ctx.redirect(`${redirectUrl}?error=invalid_saml_response&error_description=Unknown+or+expired+request+ID`);
2297
2966
  }
2298
2967
  if (storedRequest.providerId !== providerId) {
@@ -2302,13 +2971,13 @@ const acsEndpoint = (options) => {
2302
2971
  actualProvider: providerId
2303
2972
  });
2304
2973
  await ctx.context.internalAdapter.deleteVerificationByIdentifier(`${AUTHN_REQUEST_KEY_PREFIX}${inResponseToAcs}`);
2305
- const redirectUrl = RelayState || parsedSamlConfig.callbackUrl || ctx.context.baseURL;
2974
+ const redirectUrl = relayState?.callbackURL || parsedSamlConfig.callbackUrl || ctx.context.baseURL;
2306
2975
  throw ctx.redirect(`${redirectUrl}?error=invalid_saml_response&error_description=Provider+mismatch`);
2307
2976
  }
2308
2977
  await ctx.context.internalAdapter.deleteVerificationByIdentifier(`${AUTHN_REQUEST_KEY_PREFIX}${inResponseToAcs}`);
2309
2978
  } else if (!allowIdpInitiated) {
2310
2979
  ctx.context.logger.error("SAML IdP-initiated SSO rejected: InResponseTo missing and allowIdpInitiated is false", { providerId });
2311
- const redirectUrl = RelayState || parsedSamlConfig.callbackUrl || ctx.context.baseURL;
2980
+ const redirectUrl = relayState?.callbackURL || parsedSamlConfig.callbackUrl || ctx.context.baseURL;
2312
2981
  throw ctx.redirect(`${redirectUrl}?error=unsolicited_response&error_description=IdP-initiated+SSO+not+allowed`);
2313
2982
  }
2314
2983
  }
@@ -2334,7 +3003,7 @@ const acsEndpoint = (options) => {
2334
3003
  issuer,
2335
3004
  providerId
2336
3005
  });
2337
- const redirectUrl = RelayState || parsedSamlConfig.callbackUrl || ctx.context.baseURL;
3006
+ const redirectUrl = relayState?.callbackURL || parsedSamlConfig.callbackUrl || ctx.context.baseURL;
2338
3007
  throw ctx.redirect(`${redirectUrl}?error=replay_detected&error_description=SAML+assertion+has+already+been+used`);
2339
3008
  }
2340
3009
  await ctx.context.internalAdapter.createVerificationValue({
@@ -2354,7 +3023,7 @@ const acsEndpoint = (options) => {
2354
3023
  const userInfo = {
2355
3024
  ...Object.fromEntries(Object.entries(mapping.extraFields || {}).map(([key, value]) => [key, attributes[value]])),
2356
3025
  id: attributes[mapping.id || "nameID"] || extract.nameID,
2357
- email: attributes[mapping.email || "email"] || extract.nameID,
3026
+ email: (attributes[mapping.email || "email"] || extract.nameID).toLowerCase(),
2358
3027
  name: [attributes[mapping.firstName || "givenName"], attributes[mapping.lastName || "surname"]].filter(Boolean).join(" ") || attributes[mapping.name || "displayName"] || extract.nameID,
2359
3028
  emailVerified: options?.trustEmailVerified && mapping.emailVerified ? attributes[mapping.emailVerified] || false : false
2360
3029
  };
@@ -2367,8 +3036,8 @@ const acsEndpoint = (options) => {
2367
3036
  });
2368
3037
  throw new APIError("BAD_REQUEST", { message: "Unable to extract user ID or email from SAML response" });
2369
3038
  }
2370
- const isTrustedProvider = !!ctx.context.options.account?.accountLinking?.trustedProviders?.includes(provider.providerId) || "domainVerified" in provider && !!provider.domainVerified && validateEmailDomain(userInfo.email, provider.domain);
2371
- const callbackUrl = RelayState || parsedSamlConfig.callbackUrl || ctx.context.baseURL;
3039
+ const isTrustedProvider = ctx.context.trustedProviders.includes(provider.providerId) || "domainVerified" in provider && !!provider.domainVerified && validateEmailDomain(userInfo.email, provider.domain);
3040
+ const callbackUrl = relayState?.callbackURL || parsedSamlConfig.callbackUrl || ctx.context.baseURL;
2372
3041
  const result = await handleOAuthUserInfo(ctx, {
2373
3042
  userInfo: {
2374
3043
  email: userInfo.email,
@@ -2410,7 +3079,184 @@ const acsEndpoint = (options) => {
2410
3079
  session,
2411
3080
  user
2412
3081
  });
2413
- throw ctx.redirect(callbackUrl);
3082
+ if (options?.saml?.enableSingleLogout && extract.nameID) {
3083
+ const samlSessionKey = `${SAML_SESSION_KEY_PREFIX}${providerId}:${extract.nameID}`;
3084
+ const samlSessionData = {
3085
+ sessionId: session.id,
3086
+ providerId,
3087
+ nameID: extract.nameID,
3088
+ sessionIndex: extract.sessionIndex
3089
+ };
3090
+ await ctx.context.internalAdapter.createVerificationValue({
3091
+ identifier: samlSessionKey,
3092
+ value: JSON.stringify(samlSessionData),
3093
+ expiresAt: session.expiresAt
3094
+ }).catch((e) => ctx.context.logger.warn("Failed to create SAML session record", { error: e }));
3095
+ await ctx.context.internalAdapter.createVerificationValue({
3096
+ identifier: `${SAML_SESSION_BY_ID_PREFIX}${session.id}`,
3097
+ value: samlSessionKey,
3098
+ expiresAt: session.expiresAt
3099
+ }).catch((e) => ctx.context.logger.warn("Failed to create SAML session lookup record", e));
3100
+ }
3101
+ const safeRedirectUrl = getSafeRedirectUrl(relayState?.callbackURL || parsedSamlConfig.callbackUrl, currentCallbackPath, appOrigin, (url, settings) => ctx.context.isTrustedOrigin(url, settings));
3102
+ throw ctx.redirect(safeRedirectUrl);
3103
+ });
3104
+ };
3105
+ const sloSchema = z.object({
3106
+ SAMLRequest: z.string().optional(),
3107
+ SAMLResponse: z.string().optional(),
3108
+ RelayState: z.string().optional(),
3109
+ SigAlg: z.string().optional(),
3110
+ Signature: z.string().optional()
3111
+ });
3112
+ const sloEndpoint = (options) => {
3113
+ return createAuthEndpoint("/sso/saml2/sp/slo/:providerId", {
3114
+ method: ["GET", "POST"],
3115
+ body: sloSchema.optional(),
3116
+ query: sloSchema.optional(),
3117
+ metadata: {
3118
+ ...HIDE_METADATA,
3119
+ allowedMediaTypes: ["application/x-www-form-urlencoded", "application/json"]
3120
+ }
3121
+ }, async (ctx) => {
3122
+ if (!options?.saml?.enableSingleLogout) throw APIError.from("BAD_REQUEST", SAML_ERROR_CODES.SINGLE_LOGOUT_NOT_ENABLED);
3123
+ const { providerId } = ctx.params;
3124
+ const samlRequest = ctx.body?.SAMLRequest || ctx.query?.SAMLRequest;
3125
+ const samlResponse = ctx.body?.SAMLResponse || ctx.query?.SAMLResponse;
3126
+ const relayState = ctx.body?.RelayState || ctx.query?.RelayState;
3127
+ const appOrigin = new URL(ctx.context.baseURL).origin;
3128
+ const safeErrorURL = getSafeRedirectUrl(relayState, `${appOrigin}/sso/saml2/sp/slo/${providerId}`, appOrigin, (url, settings) => ctx.context.isTrustedOrigin(url, settings));
3129
+ if (!samlRequest && !samlResponse) throw ctx.redirect(`${safeErrorURL}?error=invalid_request&error_description=missing_logout_data`);
3130
+ const provider = await findSAMLProvider(providerId, options, ctx.context.adapter);
3131
+ if (!provider?.samlConfig) throw APIError.from("NOT_FOUND", SAML_ERROR_CODES.SAML_PROVIDER_NOT_FOUND);
3132
+ const config = provider.samlConfig;
3133
+ const sp = createSP(config, ctx.context.baseURL, providerId, {
3134
+ wantLogoutRequestSigned: options?.saml?.wantLogoutRequestSigned,
3135
+ wantLogoutResponseSigned: options?.saml?.wantLogoutResponseSigned
3136
+ });
3137
+ const idp = createIdP(config);
3138
+ if (samlResponse) return handleLogoutResponse(ctx, sp, idp, relayState, providerId);
3139
+ return handleLogoutRequest(ctx, sp, idp, relayState, providerId);
3140
+ });
3141
+ };
3142
+ async function handleLogoutResponse(ctx, sp, idp, relayState, providerId) {
3143
+ const binding = ctx.method === "POST" && ctx.body?.SAMLResponse ? "post" : "redirect";
3144
+ let parsed;
3145
+ try {
3146
+ parsed = await sp.parseLogoutResponse(idp, binding, {
3147
+ body: ctx.body,
3148
+ query: ctx.query
3149
+ });
3150
+ } catch (error) {
3151
+ ctx.context.logger.error("LogoutResponse validation failed", { error });
3152
+ throw APIError.from("BAD_REQUEST", SAML_ERROR_CODES.INVALID_LOGOUT_RESPONSE);
3153
+ }
3154
+ const extract = parsed?.extract;
3155
+ const statusCode = extract?.statusCode || extract?.status || parsed?.samlContent?.status?.statusCode;
3156
+ if (statusCode && statusCode !== SAML_STATUS_SUCCESS) {
3157
+ ctx.context.logger.warn("LogoutResponse indicates failure", { statusCode });
3158
+ throw APIError.from("BAD_REQUEST", SAML_ERROR_CODES.LOGOUT_FAILED_AT_IDP);
3159
+ }
3160
+ const inResponseTo = extract?.response?.inResponseTo;
3161
+ if (inResponseTo) {
3162
+ const key = `${LOGOUT_REQUEST_KEY_PREFIX}${inResponseTo}`;
3163
+ if (!await ctx.context.internalAdapter.findVerificationValue(key)) ctx.context.logger.warn("LogoutResponse references unknown or expired request", { inResponseTo });
3164
+ await ctx.context.internalAdapter.deleteVerificationValue(key).catch((e) => ctx.context.logger.warn("Failed to delete logout request verification value", e));
3165
+ }
3166
+ deleteSessionCookie(ctx);
3167
+ const appOrigin = new URL(ctx.context.baseURL).origin;
3168
+ const safeRedirectUrl = getSafeRedirectUrl(relayState, `${appOrigin}/sso/saml2/sp/slo/${providerId}`, appOrigin, (url, settings) => ctx.context.isTrustedOrigin(url, settings));
3169
+ throw ctx.redirect(safeRedirectUrl);
3170
+ }
3171
+ async function handleLogoutRequest(ctx, sp, idp, relayState, providerId) {
3172
+ const binding = ctx.method === "POST" && ctx.body?.SAMLRequest ? "post" : "redirect";
3173
+ let parsed;
3174
+ try {
3175
+ parsed = await sp.parseLogoutRequest(idp, binding, {
3176
+ body: ctx.body,
3177
+ query: ctx.query
3178
+ });
3179
+ } catch (error) {
3180
+ ctx.context.logger.error("LogoutRequest validation failed", { error });
3181
+ throw APIError.from("BAD_REQUEST", SAML_ERROR_CODES.INVALID_LOGOUT_REQUEST);
3182
+ }
3183
+ if (!parsed?.extract) throw APIError.from("BAD_REQUEST", SAML_ERROR_CODES.INVALID_LOGOUT_REQUEST);
3184
+ const { nameID } = parsed.extract;
3185
+ const sessionIndex = parsed.extract.sessionIndex;
3186
+ const key = `${SAML_SESSION_KEY_PREFIX}${providerId}:${nameID}`;
3187
+ const stored = await ctx.context.internalAdapter.findVerificationValue(key);
3188
+ if (stored) {
3189
+ const data = safeJsonParse(stored.value);
3190
+ if (data) if (!sessionIndex || !data.sessionIndex || sessionIndex === data.sessionIndex) {
3191
+ await ctx.context.internalAdapter.deleteSession(data.sessionId).catch((e) => ctx.context.logger.warn("Failed to delete session during SLO", { error: e }));
3192
+ await ctx.context.internalAdapter.deleteVerificationValue(`${SAML_SESSION_BY_ID_PREFIX}${data.sessionId}`).catch((e) => ctx.context.logger.warn("Failed to delete SAML session lookup during SLO", e));
3193
+ } else ctx.context.logger.warn("SessionIndex mismatch in LogoutRequest - skipping session deletion", {
3194
+ providerId,
3195
+ requestedSessionIndex: sessionIndex,
3196
+ storedSessionIndex: data.sessionIndex
3197
+ });
3198
+ await ctx.context.internalAdapter.deleteVerificationValue(key).catch((e) => ctx.context.logger.warn("Failed to delete SAML session key during SLO", e));
3199
+ }
3200
+ const currentSession = await getSessionFromCtx(ctx);
3201
+ if (currentSession?.session) await ctx.context.internalAdapter.deleteSession(currentSession.session.id);
3202
+ deleteSessionCookie(ctx);
3203
+ const requestId = parsed.extract.request?.id || "";
3204
+ const res = sp.createLogoutResponse(idp, null, binding, relayState || "", (template) => template.replace("{InResponseTo}", requestId).replace("{StatusCode}", SAML_STATUS_SUCCESS));
3205
+ if (binding === "post" && res.entityEndpoint) return createSAMLPostForm(res.entityEndpoint, "SAMLResponse", res.context, relayState);
3206
+ throw ctx.redirect(res.context);
3207
+ }
3208
+ const initiateSLO = (options) => {
3209
+ return createAuthEndpoint("/sso/saml2/logout/:providerId", {
3210
+ method: "POST",
3211
+ body: z.object({ callbackURL: z.string().optional() }),
3212
+ use: [sessionMiddleware],
3213
+ metadata: HIDE_METADATA
3214
+ }, async (ctx) => {
3215
+ if (!options?.saml?.enableSingleLogout) throw APIError.from("BAD_REQUEST", SAML_ERROR_CODES.SINGLE_LOGOUT_NOT_ENABLED);
3216
+ const { providerId } = ctx.params;
3217
+ const callbackURL = ctx.body.callbackURL || ctx.context.baseURL;
3218
+ const provider = await findSAMLProvider(providerId, options, ctx.context.adapter);
3219
+ if (!provider?.samlConfig) throw APIError.from("NOT_FOUND", SAML_ERROR_CODES.SAML_PROVIDER_NOT_FOUND);
3220
+ const config = provider.samlConfig;
3221
+ if (!(config.idpMetadata?.singleLogoutService?.length || config.idpMetadata?.metadata && config.idpMetadata.metadata.includes("SingleLogoutService"))) throw APIError.from("BAD_REQUEST", SAML_ERROR_CODES.IDP_SLO_NOT_SUPPORTED);
3222
+ const sp = createSP(config, ctx.context.baseURL, providerId, {
3223
+ wantLogoutRequestSigned: options?.saml?.wantLogoutRequestSigned,
3224
+ wantLogoutResponseSigned: options?.saml?.wantLogoutResponseSigned
3225
+ });
3226
+ const idp = createIdP(config);
3227
+ const session = ctx.context.session;
3228
+ const sessionLookupKey = `${SAML_SESSION_BY_ID_PREFIX}${session.session.id}`;
3229
+ const sessionLookup = await ctx.context.internalAdapter.findVerificationValue(sessionLookupKey);
3230
+ let nameID = session.user.email;
3231
+ let sessionIndex;
3232
+ let samlSessionKey;
3233
+ if (sessionLookup) {
3234
+ samlSessionKey = sessionLookup.value;
3235
+ const stored = await ctx.context.internalAdapter.findVerificationValue(samlSessionKey);
3236
+ if (stored) {
3237
+ const data = safeJsonParse(stored.value);
3238
+ if (data) {
3239
+ nameID = data.nameID || nameID;
3240
+ sessionIndex = data.sessionIndex;
3241
+ }
3242
+ }
3243
+ }
3244
+ const logoutRequest = sp.createLogoutRequest(idp, "redirect", {
3245
+ logoutNameID: nameID,
3246
+ sessionIndex,
3247
+ relayState: callbackURL
3248
+ });
3249
+ const ttl = options?.saml?.logoutRequestTTL ?? DEFAULT_LOGOUT_REQUEST_TTL_MS;
3250
+ await ctx.context.internalAdapter.createVerificationValue({
3251
+ identifier: `${LOGOUT_REQUEST_KEY_PREFIX}${logoutRequest.id}`,
3252
+ value: providerId,
3253
+ expiresAt: new Date(Date.now() + ttl)
3254
+ });
3255
+ if (samlSessionKey) await ctx.context.internalAdapter.deleteVerificationValue(samlSessionKey).catch((e) => ctx.context.logger.warn("Failed to delete SAML session key during logout", e));
3256
+ await ctx.context.internalAdapter.deleteVerificationValue(sessionLookupKey).catch((e) => ctx.context.logger.warn("Failed to delete session lookup key during logout", e));
3257
+ await ctx.context.internalAdapter.deleteSession(session.session.id);
3258
+ deleteSessionCookie(ctx);
3259
+ throw ctx.redirect(logoutRequest.context);
2414
3260
  });
2415
3261
  };
2416
3262
 
@@ -2425,16 +3271,27 @@ saml.setSchemaValidator({ async validate(xml) {
2425
3271
  * These endpoints receive POST requests from external Identity Providers,
2426
3272
  * which won't have a matching Origin header.
2427
3273
  */
2428
- const SAML_SKIP_ORIGIN_CHECK_PATHS = ["/sso/saml2/callback", "/sso/saml2/sp/acs"];
3274
+ const SAML_SKIP_ORIGIN_CHECK_PATHS = [
3275
+ "/sso/saml2/callback",
3276
+ "/sso/saml2/sp/acs",
3277
+ "/sso/saml2/sp/slo"
3278
+ ];
2429
3279
  function sso(options) {
2430
3280
  const optionsWithStore = options;
2431
3281
  let endpoints = {
2432
- spMetadata: spMetadata(),
3282
+ spMetadata: spMetadata(optionsWithStore),
2433
3283
  registerSSOProvider: registerSSOProvider(optionsWithStore),
2434
3284
  signInSSO: signInSSO(optionsWithStore),
2435
3285
  callbackSSO: callbackSSO(optionsWithStore),
3286
+ callbackSSOShared: callbackSSOShared(optionsWithStore),
2436
3287
  callbackSSOSAML: callbackSSOSAML(optionsWithStore),
2437
- acsEndpoint: acsEndpoint(optionsWithStore)
3288
+ acsEndpoint: acsEndpoint(optionsWithStore),
3289
+ sloEndpoint: sloEndpoint(optionsWithStore),
3290
+ initiateSLO: initiateSLO(optionsWithStore),
3291
+ listSSOProviders: listSSOProviders(),
3292
+ getSSOProvider: getSSOProvider(),
3293
+ updateSSOProvider: updateSSOProvider(optionsWithStore),
3294
+ deleteSSOProvider: deleteSSOProvider()
2438
3295
  };
2439
3296
  if (options?.domainVerification?.enabled) {
2440
3297
  const domainVerificationEndpoints = {
@@ -2454,21 +3311,39 @@ function sso(options) {
2454
3311
  return { context: { skipOriginCheck: [...Array.isArray(existing) ? existing : [], ...SAML_SKIP_ORIGIN_CHECK_PATHS] } };
2455
3312
  },
2456
3313
  endpoints,
2457
- hooks: { after: [{
2458
- matcher(context) {
2459
- return context.path?.startsWith("/callback/") ?? false;
2460
- },
2461
- handler: createAuthMiddleware(async (ctx) => {
2462
- const newSession = ctx.context.newSession;
2463
- if (!newSession?.user) return;
2464
- if (!ctx.context.options.plugins?.find((plugin) => plugin.id === "organization")) return;
2465
- await assignOrganizationByDomain(ctx, {
2466
- user: newSession.user,
2467
- provisioningOptions: options?.organizationProvisioning,
2468
- domainVerification: options?.domainVerification
2469
- });
2470
- })
2471
- }] },
3314
+ hooks: {
3315
+ before: [{
3316
+ matcher(context) {
3317
+ return context.path === "/sign-out";
3318
+ },
3319
+ handler: createAuthMiddleware(async (ctx) => {
3320
+ if (!options?.saml?.enableSingleLogout) return;
3321
+ const session = await getSessionFromCtx(ctx);
3322
+ if (!session?.session?.id) return;
3323
+ const sessionLookupKey = `${SAML_SESSION_BY_ID_PREFIX}${session.session.id}`;
3324
+ const sessionLookup = await ctx.context.internalAdapter.findVerificationValue(sessionLookupKey);
3325
+ if (sessionLookup?.value) {
3326
+ await ctx.context.internalAdapter.deleteVerificationValue(sessionLookup.value).catch(() => {});
3327
+ await ctx.context.internalAdapter.deleteVerificationValue(sessionLookupKey).catch(() => {});
3328
+ }
3329
+ })
3330
+ }],
3331
+ after: [{
3332
+ matcher(context) {
3333
+ return context.path?.startsWith("/callback/") ?? false;
3334
+ },
3335
+ handler: createAuthMiddleware(async (ctx) => {
3336
+ const newSession = ctx.context.newSession;
3337
+ if (!newSession?.user) return;
3338
+ if (!ctx.context.hasPlugin("organization")) return;
3339
+ await assignOrganizationByDomain(ctx, {
3340
+ user: newSession.user,
3341
+ provisioningOptions: options?.organizationProvisioning,
3342
+ domainVerification: options?.domainVerification
3343
+ });
3344
+ })
3345
+ }]
3346
+ },
2472
3347
  schema: { ssoProvider: {
2473
3348
  modelName: options?.modelName ?? "ssoProvider",
2474
3349
  fields: {
@@ -2522,4 +3397,5 @@ function sso(options) {
2522
3397
  }
2523
3398
 
2524
3399
  //#endregion
2525
- 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 };
3400
+ 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 };
3401
+ //# sourceMappingURL=index.mjs.map