@better-auth/sso 1.4.17 → 1.4.19

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.mjs CHANGED
@@ -1,16 +1,68 @@
1
- import { APIError, createAuthEndpoint, createAuthMiddleware, sessionMiddleware } from "better-auth/api";
1
+ import { APIError, createAuthEndpoint, createAuthMiddleware, getSessionFromCtx, sessionMiddleware } from "better-auth/api";
2
2
  import { XMLParser, XMLValidator } from "fast-xml-parser";
3
3
  import * as saml from "samlify";
4
+ import { X509Certificate } from "node:crypto";
4
5
  import { generateRandomString } from "better-auth/crypto";
5
6
  import * as z$1 from "zod/v4";
6
7
  import z from "zod/v4";
8
+ import { base64 } from "@better-auth/utils/base64";
7
9
  import { BetterFetchError, betterFetch } from "@better-fetch/fetch";
8
- import { HIDE_METADATA, createAuthorizationURL, generateState, parseState, validateAuthorizationCode, validateToken } from "better-auth";
10
+ import { HIDE_METADATA, createAuthorizationURL, generateGenericState, generateState, parseGenericState, parseState, validateAuthorizationCode, validateToken } from "better-auth";
9
11
  import { setSessionCookie } from "better-auth/cookies";
10
12
  import { handleOAuthUserInfo } from "better-auth/oauth2";
11
13
  import { decodeJwt } from "jose";
12
- import { base64 } from "@better-auth/utils/base64";
13
14
 
15
+ //#region src/utils.ts
16
+ /**
17
+ * Safely parses a value that might be a JSON string or already a parsed object.
18
+ * This handles cases where ORMs like Drizzle might return already parsed objects
19
+ * instead of JSON strings from TEXT/JSON columns.
20
+ *
21
+ * @param value - The value to parse (string, object, null, or undefined)
22
+ * @returns The parsed object or null
23
+ * @throws Error if string parsing fails
24
+ */
25
+ function safeJsonParse(value) {
26
+ if (!value) return null;
27
+ if (typeof value === "object") return value;
28
+ if (typeof value === "string") try {
29
+ return JSON.parse(value);
30
+ } catch (error) {
31
+ throw new Error(`Failed to parse JSON: ${error instanceof Error ? error.message : "Unknown error"}`);
32
+ }
33
+ return null;
34
+ }
35
+ /**
36
+ * Checks if a domain matches any domain in a comma-separated list.
37
+ */
38
+ const domainMatches = (searchDomain, domainList) => {
39
+ const search = searchDomain.toLowerCase();
40
+ return domainList.split(",").map((d) => d.trim().toLowerCase()).filter(Boolean).some((d) => search === d || search.endsWith(`.${d}`));
41
+ };
42
+ /**
43
+ * Validates email domain against allowed domain(s).
44
+ * Supports comma-separated domains for multi-domain SSO.
45
+ */
46
+ const validateEmailDomain = (email, domain) => {
47
+ const emailDomain = email.split("@")[1]?.toLowerCase();
48
+ if (!emailDomain || !domain) return false;
49
+ return domainMatches(emailDomain, domain);
50
+ };
51
+ function parseCertificate(certPem) {
52
+ const cert = new X509Certificate(certPem.includes("-----BEGIN") ? certPem : `-----BEGIN CERTIFICATE-----\n${certPem}\n-----END CERTIFICATE-----`);
53
+ return {
54
+ fingerprintSha256: cert.fingerprint256,
55
+ notBefore: cert.validFrom,
56
+ notAfter: cert.validTo,
57
+ publicKeyAlgorithm: cert.publicKey.asymmetricKeyType?.toUpperCase() || "UNKNOWN"
58
+ };
59
+ }
60
+ function maskClientId(clientId) {
61
+ if (clientId.length <= 4) return "****";
62
+ return `****${clientId.slice(-4)}`;
63
+ }
64
+
65
+ //#endregion
14
66
  //#region src/linking/org-assignment.ts
15
67
  /**
16
68
  * Assigns a user to an organization based on the SSO provider's organizationId.
@@ -20,7 +72,7 @@ async function assignOrganizationFromProvider(ctx, options) {
20
72
  const { user, profile, provider, token, provisioningOptions } = options;
21
73
  if (!provider.organizationId) return;
22
74
  if (provisioningOptions?.disabled) return;
23
- if (!ctx.context.options.plugins?.find((plugin) => plugin.id === "organization")) return;
75
+ if (!(ctx.context.options.plugins?.some((plugin) => plugin.id === "organization") ?? false)) return;
24
76
  if (await ctx.context.adapter.findOne({
25
77
  model: "member",
26
78
  where: [{
@@ -58,7 +110,7 @@ async function assignOrganizationFromProvider(ctx, options) {
58
110
  async function assignOrganizationByDomain(ctx, options) {
59
111
  const { user, provisioningOptions, domainVerification } = options;
60
112
  if (provisioningOptions?.disabled) return;
61
- if (!ctx.context.options.plugins?.find((plugin) => plugin.id === "organization")) return;
113
+ if (!(ctx.context.options.plugins?.some((plugin) => plugin.id === "organization") ?? false)) return;
62
114
  const domain = user.email.split("@")[1];
63
115
  if (!domain) return;
64
116
  const whereClause = [{
@@ -69,10 +121,17 @@ async function assignOrganizationByDomain(ctx, options) {
69
121
  field: "domainVerified",
70
122
  value: true
71
123
  });
72
- const ssoProvider = await ctx.context.adapter.findOne({
124
+ let ssoProvider = await ctx.context.adapter.findOne({
73
125
  model: "ssoProvider",
74
126
  where: whereClause
75
127
  });
128
+ if (!ssoProvider) ssoProvider = (await ctx.context.adapter.findMany({
129
+ model: "ssoProvider",
130
+ where: domainVerification?.enabled ? [{
131
+ field: "domainVerified",
132
+ value: true
133
+ }] : []
134
+ })).find((p) => domainMatches(domain, p.domain)) ?? null;
76
135
  if (!ssoProvider || !ssoProvider.organizationId) return;
77
136
  if (await ctx.context.adapter.findOne({
78
137
  model: "member",
@@ -102,7 +161,12 @@ async function assignOrganizationByDomain(ctx, options) {
102
161
 
103
162
  //#endregion
104
163
  //#region src/routes/domain-verification.ts
164
+ const DNS_LABEL_MAX_LENGTH = 63;
165
+ const DEFAULT_TOKEN_PREFIX = "better-auth-token";
105
166
  const domainVerificationBodySchema = z$1.object({ providerId: z$1.string() });
167
+ function getVerificationIdentifier(options, providerId) {
168
+ return `_${options.domainVerification?.tokenPrefix || DEFAULT_TOKEN_PREFIX}-${providerId}`;
169
+ }
106
170
  const requestDomainVerification = (options) => {
107
171
  return createAuthEndpoint("/sso/request-domain-verification", {
108
172
  method: "POST",
@@ -150,11 +214,12 @@ const requestDomainVerification = (options) => {
150
214
  message: "Domain has already been verified",
151
215
  code: "DOMAIN_VERIFIED"
152
216
  });
217
+ const identifier = getVerificationIdentifier(options, provider.providerId);
153
218
  const activeVerification = await ctx.context.adapter.findOne({
154
219
  model: "verification",
155
220
  where: [{
156
221
  field: "identifier",
157
- value: options.domainVerification?.tokenPrefix ? `${options.domainVerification?.tokenPrefix}-${provider.providerId}` : `better-auth-token-${provider.providerId}`
222
+ value: identifier
158
223
  }, {
159
224
  field: "expiresAt",
160
225
  value: /* @__PURE__ */ new Date(),
@@ -169,7 +234,7 @@ const requestDomainVerification = (options) => {
169
234
  await ctx.context.adapter.create({
170
235
  model: "verification",
171
236
  data: {
172
- identifier: options.domainVerification?.tokenPrefix ? `${options.domainVerification?.tokenPrefix}-${provider.providerId}` : `better-auth-token-${provider.providerId}`,
237
+ identifier,
173
238
  createdAt: /* @__PURE__ */ new Date(),
174
239
  updatedAt: /* @__PURE__ */ new Date(),
175
240
  value: domainVerificationToken,
@@ -228,11 +293,16 @@ const verifyDomain = (options) => {
228
293
  message: "Domain has already been verified",
229
294
  code: "DOMAIN_VERIFIED"
230
295
  });
296
+ const identifier = getVerificationIdentifier(options, provider.providerId);
297
+ if (identifier.length > DNS_LABEL_MAX_LENGTH) throw new APIError("BAD_REQUEST", {
298
+ message: `Verification identifier exceeds the DNS label limit of ${DNS_LABEL_MAX_LENGTH} characters`,
299
+ code: "IDENTIFIER_TOO_LONG"
300
+ });
231
301
  const activeVerification = await ctx.context.adapter.findOne({
232
302
  model: "verification",
233
303
  where: [{
234
304
  field: "identifier",
235
- value: options.domainVerification?.tokenPrefix ? `${options.domainVerification?.tokenPrefix}-${provider.providerId}` : `better-auth-token-${provider.providerId}`
305
+ value: identifier
236
306
  }, {
237
307
  field: "expiresAt",
238
308
  value: /* @__PURE__ */ new Date(),
@@ -255,7 +325,8 @@ const verifyDomain = (options) => {
255
325
  });
256
326
  }
257
327
  try {
258
- records = (await dns.resolveTxt(new URL(provider.domain).hostname)).flat();
328
+ const hostname = new URL(provider.domain).hostname;
329
+ records = (await dns.resolveTxt(`${identifier}.${hostname}`)).flat();
259
330
  } catch (error) {
260
331
  ctx.context.logger.warn("DNS resolution failure while validating domain ownership", error);
261
332
  }
@@ -318,637 +389,1023 @@ const DEFAULT_MAX_SAML_RESPONSE_SIZE = 256 * 1024;
318
389
  const DEFAULT_MAX_SAML_METADATA_SIZE = 100 * 1024;
319
390
 
320
391
  //#endregion
321
- //#region src/oidc/types.ts
322
- /**
323
- * Custom error class for OIDC discovery failures.
324
- * Can be caught and mapped to APIError at the edge.
325
- */
326
- var DiscoveryError = class DiscoveryError extends Error {
327
- code;
328
- details;
329
- constructor(code, message, details, options) {
330
- super(message, options);
331
- this.name = "DiscoveryError";
332
- this.code = code;
333
- this.details = details;
334
- if (Error.captureStackTrace) Error.captureStackTrace(this, DiscoveryError);
392
+ //#region src/saml/parser.ts
393
+ const xmlParser = new XMLParser({
394
+ ignoreAttributes: false,
395
+ attributeNamePrefix: "@_",
396
+ removeNSPrefix: true,
397
+ processEntities: false
398
+ });
399
+ function findNode(obj, nodeName) {
400
+ if (!obj || typeof obj !== "object") return null;
401
+ const record = obj;
402
+ if (nodeName in record) return record[nodeName];
403
+ for (const value of Object.values(record)) if (Array.isArray(value)) for (const item of value) {
404
+ const found = findNode(item, nodeName);
405
+ if (found) return found;
335
406
  }
336
- };
337
- /**
338
- * Required fields that must be present in a valid discovery document.
339
- */
340
- const REQUIRED_DISCOVERY_FIELDS = [
341
- "issuer",
342
- "authorization_endpoint",
343
- "token_endpoint",
344
- "jwks_uri"
345
- ];
407
+ else if (typeof value === "object" && value !== null) {
408
+ const found = findNode(value, nodeName);
409
+ if (found) return found;
410
+ }
411
+ return null;
412
+ }
413
+ function countAllNodes(obj, nodeName) {
414
+ if (!obj || typeof obj !== "object") return 0;
415
+ let count = 0;
416
+ const record = obj;
417
+ if (nodeName in record) {
418
+ const node = record[nodeName];
419
+ count += Array.isArray(node) ? node.length : 1;
420
+ }
421
+ for (const value of Object.values(record)) if (Array.isArray(value)) for (const item of value) count += countAllNodes(item, nodeName);
422
+ else if (typeof value === "object" && value !== null) count += countAllNodes(value, nodeName);
423
+ return count;
424
+ }
346
425
 
347
426
  //#endregion
348
- //#region src/oidc/discovery.ts
349
- /**
350
- * OIDC Discovery Pipeline
351
- *
352
- * Implements OIDC discovery document fetching, validation, and hydration.
353
- * This module is used both at provider registration time (to persist validated config)
354
- * and at runtime (to hydrate legacy providers that are missing metadata).
355
- *
356
- * @see https://openid.net/specs/openid-connect-discovery-1_0.html
357
- */
358
- /** Default timeout for discovery requests (10 seconds) */
359
- const DEFAULT_DISCOVERY_TIMEOUT = 1e4;
360
- /**
361
- * Main entry point: Discover and hydrate OIDC configuration from an issuer.
362
- *
363
- * This function:
364
- * 1. Computes the discovery URL from the issuer
365
- * 2. Validates the discovery URL
366
- * 3. Fetches the discovery document
367
- * 4. Validates the discovery document (issuer match + required fields)
368
- * 5. Normalizes URLs
369
- * 6. Selects token endpoint auth method
370
- * 7. Merges with existing config (existing values take precedence)
371
- *
372
- * @param params - Discovery parameters
373
- * @param isTrustedOrigin - Origin verification tester function
374
- * @returns Hydrated OIDC configuration ready for persistence
375
- * @throws DiscoveryError on any failure
376
- */
377
- async function discoverOIDCConfig(params) {
378
- const { issuer, existingConfig, timeout = DEFAULT_DISCOVERY_TIMEOUT } = params;
379
- const discoveryUrl = params.discoveryEndpoint || existingConfig?.discoveryEndpoint || computeDiscoveryUrl(issuer);
380
- validateDiscoveryUrl(discoveryUrl, params.isTrustedOrigin);
381
- const discoveryDoc = await fetchDiscoveryDocument(discoveryUrl, timeout);
382
- validateDiscoveryDocument(discoveryDoc, issuer);
383
- const normalizedDoc = normalizeDiscoveryUrls(discoveryDoc, issuer, params.isTrustedOrigin);
384
- const tokenEndpointAuth = selectTokenEndpointAuthMethod(normalizedDoc, existingConfig?.tokenEndpointAuthentication);
385
- return {
386
- issuer: existingConfig?.issuer ?? normalizedDoc.issuer,
387
- discoveryEndpoint: existingConfig?.discoveryEndpoint ?? discoveryUrl,
388
- authorizationEndpoint: existingConfig?.authorizationEndpoint ?? normalizedDoc.authorization_endpoint,
389
- tokenEndpoint: existingConfig?.tokenEndpoint ?? normalizedDoc.token_endpoint,
390
- jwksEndpoint: existingConfig?.jwksEndpoint ?? normalizedDoc.jwks_uri,
391
- userInfoEndpoint: existingConfig?.userInfoEndpoint ?? normalizedDoc.userinfo_endpoint,
392
- tokenEndpointAuthentication: existingConfig?.tokenEndpointAuthentication ?? tokenEndpointAuth,
393
- scopesSupported: existingConfig?.scopesSupported ?? normalizedDoc.scopes_supported
394
- };
395
- }
396
- /**
397
- * Compute the discovery URL from an issuer URL.
398
- *
399
- * Per OIDC Discovery spec, the discovery document is located at:
400
- * <issuer>/.well-known/openid-configuration
401
- *
402
- * Handles trailing slashes correctly.
403
- */
404
- function computeDiscoveryUrl(issuer) {
405
- return `${issuer.endsWith("/") ? issuer.slice(0, -1) : issuer}/.well-known/openid-configuration`;
427
+ //#region src/saml/algorithms.ts
428
+ const SignatureAlgorithm = {
429
+ RSA_SHA1: "http://www.w3.org/2000/09/xmldsig#rsa-sha1",
430
+ RSA_SHA256: "http://www.w3.org/2001/04/xmldsig-more#rsa-sha256",
431
+ RSA_SHA384: "http://www.w3.org/2001/04/xmldsig-more#rsa-sha384",
432
+ RSA_SHA512: "http://www.w3.org/2001/04/xmldsig-more#rsa-sha512",
433
+ ECDSA_SHA256: "http://www.w3.org/2001/04/xmldsig-more#ecdsa-sha256",
434
+ ECDSA_SHA384: "http://www.w3.org/2001/04/xmldsig-more#ecdsa-sha384",
435
+ ECDSA_SHA512: "http://www.w3.org/2001/04/xmldsig-more#ecdsa-sha512"
436
+ };
437
+ const DigestAlgorithm = {
438
+ SHA1: "http://www.w3.org/2000/09/xmldsig#sha1",
439
+ SHA256: "http://www.w3.org/2001/04/xmlenc#sha256",
440
+ SHA384: "http://www.w3.org/2001/04/xmldsig-more#sha384",
441
+ SHA512: "http://www.w3.org/2001/04/xmlenc#sha512"
442
+ };
443
+ const KeyEncryptionAlgorithm = {
444
+ RSA_1_5: "http://www.w3.org/2001/04/xmlenc#rsa-1_5",
445
+ RSA_OAEP: "http://www.w3.org/2001/04/xmlenc#rsa-oaep-mgf1p",
446
+ RSA_OAEP_SHA256: "http://www.w3.org/2009/xmlenc11#rsa-oaep"
447
+ };
448
+ const DataEncryptionAlgorithm = {
449
+ TRIPLEDES_CBC: "http://www.w3.org/2001/04/xmlenc#tripledes-cbc",
450
+ AES_128_CBC: "http://www.w3.org/2001/04/xmlenc#aes128-cbc",
451
+ AES_192_CBC: "http://www.w3.org/2001/04/xmlenc#aes192-cbc",
452
+ AES_256_CBC: "http://www.w3.org/2001/04/xmlenc#aes256-cbc",
453
+ AES_128_GCM: "http://www.w3.org/2009/xmlenc11#aes128-gcm",
454
+ AES_192_GCM: "http://www.w3.org/2009/xmlenc11#aes192-gcm",
455
+ AES_256_GCM: "http://www.w3.org/2009/xmlenc11#aes256-gcm"
456
+ };
457
+ const DEPRECATED_SIGNATURE_ALGORITHMS = [SignatureAlgorithm.RSA_SHA1];
458
+ const DEPRECATED_KEY_ENCRYPTION_ALGORITHMS = [KeyEncryptionAlgorithm.RSA_1_5];
459
+ const DEPRECATED_DATA_ENCRYPTION_ALGORITHMS = [DataEncryptionAlgorithm.TRIPLEDES_CBC];
460
+ const DEPRECATED_DIGEST_ALGORITHMS = [DigestAlgorithm.SHA1];
461
+ const SECURE_SIGNATURE_ALGORITHMS = [
462
+ SignatureAlgorithm.RSA_SHA256,
463
+ SignatureAlgorithm.RSA_SHA384,
464
+ SignatureAlgorithm.RSA_SHA512,
465
+ SignatureAlgorithm.ECDSA_SHA256,
466
+ SignatureAlgorithm.ECDSA_SHA384,
467
+ SignatureAlgorithm.ECDSA_SHA512
468
+ ];
469
+ const SECURE_DIGEST_ALGORITHMS = [
470
+ DigestAlgorithm.SHA256,
471
+ DigestAlgorithm.SHA384,
472
+ DigestAlgorithm.SHA512
473
+ ];
474
+ const SHORT_FORM_SIGNATURE_TO_URI = {
475
+ sha1: SignatureAlgorithm.RSA_SHA1,
476
+ sha256: SignatureAlgorithm.RSA_SHA256,
477
+ sha384: SignatureAlgorithm.RSA_SHA384,
478
+ sha512: SignatureAlgorithm.RSA_SHA512,
479
+ "rsa-sha1": SignatureAlgorithm.RSA_SHA1,
480
+ "rsa-sha256": SignatureAlgorithm.RSA_SHA256,
481
+ "rsa-sha384": SignatureAlgorithm.RSA_SHA384,
482
+ "rsa-sha512": SignatureAlgorithm.RSA_SHA512,
483
+ "ecdsa-sha256": SignatureAlgorithm.ECDSA_SHA256,
484
+ "ecdsa-sha384": SignatureAlgorithm.ECDSA_SHA384,
485
+ "ecdsa-sha512": SignatureAlgorithm.ECDSA_SHA512
486
+ };
487
+ const SHORT_FORM_DIGEST_TO_URI = {
488
+ sha1: DigestAlgorithm.SHA1,
489
+ sha256: DigestAlgorithm.SHA256,
490
+ sha384: DigestAlgorithm.SHA384,
491
+ sha512: DigestAlgorithm.SHA512
492
+ };
493
+ function normalizeSignatureAlgorithm(alg) {
494
+ return SHORT_FORM_SIGNATURE_TO_URI[alg.toLowerCase()] ?? alg;
406
495
  }
407
- /**
408
- * Validate a discovery URL before fetching.
409
- *
410
- * @param url - The discovery URL to validate
411
- * @param isTrustedOrigin - Origin verification tester function
412
- * @throws DiscoveryError if URL is invalid
413
- */
414
- function validateDiscoveryUrl(url, isTrustedOrigin) {
415
- const discoveryEndpoint = parseURL("discoveryEndpoint", url).toString();
416
- if (!isTrustedOrigin(discoveryEndpoint)) throw new DiscoveryError("discovery_untrusted_origin", `The main discovery endpoint "${discoveryEndpoint}" is not trusted by your trusted origins configuration.`, { url: discoveryEndpoint });
496
+ function normalizeDigestAlgorithm(alg) {
497
+ return SHORT_FORM_DIGEST_TO_URI[alg.toLowerCase()] ?? alg;
417
498
  }
418
- /**
419
- * Fetch the OIDC discovery document from the IdP.
420
- *
421
- * @param url - The discovery endpoint URL
422
- * @param timeout - Request timeout in milliseconds
423
- * @returns The parsed discovery document
424
- * @throws DiscoveryError on network errors, timeouts, or invalid responses
425
- */
426
- async function fetchDiscoveryDocument(url, timeout = DEFAULT_DISCOVERY_TIMEOUT) {
499
+ function extractEncryptionAlgorithms(xml) {
427
500
  try {
428
- const response = await betterFetch(url, {
429
- method: "GET",
430
- timeout
431
- });
432
- if (response.error) {
433
- const { status } = response.error;
434
- if (status === 404) throw new DiscoveryError("discovery_not_found", "Discovery endpoint not found", {
435
- url,
436
- status
437
- });
438
- if (status === 408) throw new DiscoveryError("discovery_timeout", "Discovery request timed out", {
439
- url,
440
- timeout
441
- });
442
- throw new DiscoveryError("discovery_unexpected_error", `Unexpected discovery error: ${response.error.statusText}`, {
443
- url,
444
- ...response.error
445
- });
446
- }
447
- if (!response.data) throw new DiscoveryError("discovery_invalid_json", "Discovery endpoint returned an empty response", { url });
448
- const data = response.data;
449
- if (typeof data === "string") throw new DiscoveryError("discovery_invalid_json", "Discovery endpoint returned invalid JSON", {
450
- url,
451
- bodyPreview: data.slice(0, 200)
452
- });
453
- return data;
454
- } catch (error) {
455
- if (error instanceof DiscoveryError) throw error;
456
- if (error instanceof Error && error.name === "AbortError") throw new DiscoveryError("discovery_timeout", "Discovery request timed out", {
457
- url,
458
- timeout
501
+ const parsed = xmlParser.parse(xml);
502
+ const keyAlg = (findNode(parsed, "EncryptedKey")?.EncryptionMethod)?.["@_Algorithm"];
503
+ const dataAlg = (findNode(parsed, "EncryptedData")?.EncryptionMethod)?.["@_Algorithm"];
504
+ return {
505
+ keyEncryption: keyAlg || null,
506
+ dataEncryption: dataAlg || null
507
+ };
508
+ } catch {
509
+ return {
510
+ keyEncryption: null,
511
+ dataEncryption: null
512
+ };
513
+ }
514
+ }
515
+ function hasEncryptedAssertion(xml) {
516
+ try {
517
+ return findNode(xmlParser.parse(xml), "EncryptedAssertion") !== null;
518
+ } catch {
519
+ return false;
520
+ }
521
+ }
522
+ function handleDeprecatedAlgorithm(message, behavior, errorCode) {
523
+ switch (behavior) {
524
+ case "reject": throw new APIError("BAD_REQUEST", {
525
+ message,
526
+ code: errorCode
459
527
  });
460
- throw new DiscoveryError("discovery_unexpected_error", `Unexpected error during discovery: ${error instanceof Error ? error.message : String(error)}`, { url }, { cause: error });
528
+ case "warn":
529
+ console.warn(`[SAML Security Warning] ${message}`);
530
+ break;
531
+ case "allow": break;
461
532
  }
462
533
  }
463
- /**
464
- * Validate a discovery document.
465
- *
466
- * Checks:
467
- * 1. All required fields are present
468
- * 2. Issuer matches the configured issuer (case-sensitive, exact match)
469
- *
470
- * Invariant: If this function returns without throwing, the document is safe
471
- * to use for hydrating OIDC config (required fields present, issuer matches
472
- * configured value, basic structural sanity verified).
473
- *
474
- * @param doc - The discovery document to validate
475
- * @param configuredIssuer - The expected issuer value
476
- * @throws DiscoveryError if validation fails
477
- */
478
- function validateDiscoveryDocument(doc, configuredIssuer) {
479
- const missingFields = [];
480
- for (const field of REQUIRED_DISCOVERY_FIELDS) if (!doc[field]) missingFields.push(field);
481
- if (missingFields.length > 0) throw new DiscoveryError("discovery_incomplete", `Discovery document is missing required fields: ${missingFields.join(", ")}`, { missingFields });
482
- if ((doc.issuer.endsWith("/") ? doc.issuer.slice(0, -1) : doc.issuer) !== (configuredIssuer.endsWith("/") ? configuredIssuer.slice(0, -1) : configuredIssuer)) throw new DiscoveryError("issuer_mismatch", `Discovered issuer "${doc.issuer}" does not match configured issuer "${configuredIssuer}"`, {
483
- discovered: doc.issuer,
484
- configured: configuredIssuer
534
+ function validateSignatureAlgorithm(algorithm, options = {}) {
535
+ if (!algorithm) return;
536
+ const { onDeprecated = "warn", allowedSignatureAlgorithms } = options;
537
+ if (allowedSignatureAlgorithms) {
538
+ if (!allowedSignatureAlgorithms.includes(algorithm)) throw new APIError("BAD_REQUEST", {
539
+ message: `SAML signature algorithm not in allow-list: ${algorithm}`,
540
+ code: "SAML_ALGORITHM_NOT_ALLOWED"
541
+ });
542
+ return;
543
+ }
544
+ if (DEPRECATED_SIGNATURE_ALGORITHMS.includes(algorithm)) {
545
+ handleDeprecatedAlgorithm(`SAML response uses deprecated signature algorithm: ${algorithm}. Please configure your IdP to use SHA-256 or stronger.`, onDeprecated, "SAML_DEPRECATED_ALGORITHM");
546
+ return;
547
+ }
548
+ if (!SECURE_SIGNATURE_ALGORITHMS.includes(algorithm)) throw new APIError("BAD_REQUEST", {
549
+ message: `SAML signature algorithm not recognized: ${algorithm}`,
550
+ code: "SAML_UNKNOWN_ALGORITHM"
485
551
  });
486
552
  }
487
- /**
488
- * Normalize URLs in the discovery document.
489
- *
490
- * @param document - The discovery document
491
- * @param issuer - The base issuer URL
492
- * @param isTrustedOrigin - Origin verification tester function
493
- * @returns The normalized discovery document
494
- */
495
- function normalizeDiscoveryUrls(document, issuer, isTrustedOrigin) {
496
- const doc = { ...document };
497
- doc.token_endpoint = normalizeAndValidateUrl("token_endpoint", doc.token_endpoint, issuer, isTrustedOrigin);
498
- doc.authorization_endpoint = normalizeAndValidateUrl("authorization_endpoint", doc.authorization_endpoint, issuer, isTrustedOrigin);
499
- doc.jwks_uri = normalizeAndValidateUrl("jwks_uri", doc.jwks_uri, issuer, isTrustedOrigin);
500
- if (doc.userinfo_endpoint) doc.userinfo_endpoint = normalizeAndValidateUrl("userinfo_endpoint", doc.userinfo_endpoint, issuer, isTrustedOrigin);
501
- if (doc.revocation_endpoint) doc.revocation_endpoint = normalizeAndValidateUrl("revocation_endpoint", doc.revocation_endpoint, issuer, isTrustedOrigin);
502
- if (doc.end_session_endpoint) doc.end_session_endpoint = normalizeAndValidateUrl("end_session_endpoint", doc.end_session_endpoint, issuer, isTrustedOrigin);
503
- if (doc.introspection_endpoint) doc.introspection_endpoint = normalizeAndValidateUrl("introspection_endpoint", doc.introspection_endpoint, issuer, isTrustedOrigin);
504
- return doc;
553
+ function validateEncryptionAlgorithms(algorithms, options = {}) {
554
+ const { onDeprecated = "warn", allowedKeyEncryptionAlgorithms, allowedDataEncryptionAlgorithms } = options;
555
+ const { keyEncryption, dataEncryption } = algorithms;
556
+ if (keyEncryption) {
557
+ if (allowedKeyEncryptionAlgorithms) {
558
+ if (!allowedKeyEncryptionAlgorithms.includes(keyEncryption)) throw new APIError("BAD_REQUEST", {
559
+ message: `SAML key encryption algorithm not in allow-list: ${keyEncryption}`,
560
+ code: "SAML_ALGORITHM_NOT_ALLOWED"
561
+ });
562
+ } 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");
563
+ }
564
+ if (dataEncryption) {
565
+ if (allowedDataEncryptionAlgorithms) {
566
+ if (!allowedDataEncryptionAlgorithms.includes(dataEncryption)) throw new APIError("BAD_REQUEST", {
567
+ message: `SAML data encryption algorithm not in allow-list: ${dataEncryption}`,
568
+ code: "SAML_ALGORITHM_NOT_ALLOWED"
569
+ });
570
+ } 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");
571
+ }
505
572
  }
506
- /**
507
- * Normalizes and validates a single URL endpoint
508
- * @param name The url name
509
- * @param endpoint The url to validate
510
- * @param issuer The issuer base url
511
- * @param isTrustedOrigin - Origin verification tester function
512
- * @returns
513
- */
514
- function normalizeAndValidateUrl(name, endpoint, issuer, isTrustedOrigin) {
515
- const url = normalizeUrl(name, endpoint, issuer);
516
- if (!isTrustedOrigin(url)) throw new DiscoveryError("discovery_untrusted_origin", `The ${name} "${url}" is not trusted by your trusted origins configuration.`, {
517
- endpoint: name,
518
- url
519
- });
520
- return url;
573
+ function validateSAMLAlgorithms(response, options) {
574
+ validateSignatureAlgorithm(response.sigAlg, options);
575
+ if (hasEncryptedAssertion(response.samlContent)) validateEncryptionAlgorithms(extractEncryptionAlgorithms(response.samlContent), options);
521
576
  }
522
- /**
523
- * Normalize a single URL endpoint.
524
- *
525
- * @param name - The endpoint name (e.g token_endpoint)
526
- * @param endpoint - The endpoint URL to normalize
527
- * @param issuer - The base issuer URL
528
- * @returns The normalized endpoint URL
529
- */
530
- function normalizeUrl(name, endpoint, issuer) {
577
+ function validateConfigAlgorithms(config, options = {}) {
578
+ const { onDeprecated = "warn", allowedSignatureAlgorithms, allowedDigestAlgorithms } = options;
579
+ if (config.signatureAlgorithm) {
580
+ const normalized = normalizeSignatureAlgorithm(config.signatureAlgorithm);
581
+ if (allowedSignatureAlgorithms) {
582
+ if (!allowedSignatureAlgorithms.map(normalizeSignatureAlgorithm).includes(normalized)) throw new APIError("BAD_REQUEST", {
583
+ message: `SAML signature algorithm not in allow-list: ${config.signatureAlgorithm}`,
584
+ code: "SAML_ALGORITHM_NOT_ALLOWED"
585
+ });
586
+ } 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");
587
+ else if (!SECURE_SIGNATURE_ALGORITHMS.includes(normalized)) throw new APIError("BAD_REQUEST", {
588
+ message: `SAML signature algorithm not recognized: ${config.signatureAlgorithm}`,
589
+ code: "SAML_UNKNOWN_ALGORITHM"
590
+ });
591
+ }
592
+ if (config.digestAlgorithm) {
593
+ const normalized = normalizeDigestAlgorithm(config.digestAlgorithm);
594
+ if (allowedDigestAlgorithms) {
595
+ if (!allowedDigestAlgorithms.map(normalizeDigestAlgorithm).includes(normalized)) throw new APIError("BAD_REQUEST", {
596
+ message: `SAML digest algorithm not in allow-list: ${config.digestAlgorithm}`,
597
+ code: "SAML_ALGORITHM_NOT_ALLOWED"
598
+ });
599
+ } 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");
600
+ else if (!SECURE_DIGEST_ALGORITHMS.includes(normalized)) throw new APIError("BAD_REQUEST", {
601
+ message: `SAML digest algorithm not recognized: ${config.digestAlgorithm}`,
602
+ code: "SAML_UNKNOWN_ALGORITHM"
603
+ });
604
+ }
605
+ }
606
+
607
+ //#endregion
608
+ //#region src/saml/assertions.ts
609
+ /** @lintignore used in tests */
610
+ function countAssertions(xml) {
611
+ let parsed;
531
612
  try {
532
- return parseURL(name, endpoint).toString();
613
+ parsed = xmlParser.parse(xml);
533
614
  } catch {
534
- const issuerURL = parseURL(name, issuer);
535
- const basePath = issuerURL.pathname.replace(/\/+$/, "");
536
- const endpointPath = endpoint.replace(/^\/+/, "");
537
- return parseURL(name, basePath + "/" + endpointPath, issuerURL.origin).toString();
615
+ throw new APIError("BAD_REQUEST", {
616
+ message: "Failed to parse SAML response XML",
617
+ code: "SAML_INVALID_XML"
618
+ });
538
619
  }
620
+ const assertions = countAllNodes(parsed, "Assertion");
621
+ const encryptedAssertions = countAllNodes(parsed, "EncryptedAssertion");
622
+ return {
623
+ assertions,
624
+ encryptedAssertions,
625
+ total: assertions + encryptedAssertions
626
+ };
539
627
  }
540
- /**
541
- * Parses the given URL or throws in case of invalid or unsupported protocols
542
- *
543
- * @param name the url name
544
- * @param endpoint the endpoint url
545
- * @param [base] optional base path
546
- * @returns
547
- */
548
- function parseURL(name, endpoint, base) {
549
- let endpointURL;
628
+ function validateSingleAssertion(samlResponse) {
629
+ let xml;
550
630
  try {
551
- endpointURL = new URL(endpoint, base);
552
- if (endpointURL.protocol === "http:" || endpointURL.protocol === "https:") return endpointURL;
553
- } catch (error) {
554
- throw new DiscoveryError("discovery_invalid_url", `The url "${name}" must be valid: ${endpoint}`, { url: endpoint }, { cause: error });
631
+ xml = new TextDecoder().decode(base64.decode(samlResponse));
632
+ if (!xml.includes("<")) throw new Error("Not XML");
633
+ } catch {
634
+ throw new APIError("BAD_REQUEST", {
635
+ message: "Invalid base64-encoded SAML response",
636
+ code: "SAML_INVALID_ENCODING"
637
+ });
555
638
  }
556
- throw new DiscoveryError("discovery_invalid_url", `The url "${name}" must use the http or https supported protocols: ${endpoint}`, {
557
- url: endpoint,
558
- protocol: endpointURL.protocol
639
+ const counts = countAssertions(xml);
640
+ if (counts.total === 0) throw new APIError("BAD_REQUEST", {
641
+ message: "SAML response contains no assertions",
642
+ code: "SAML_NO_ASSERTION"
643
+ });
644
+ if (counts.total > 1) throw new APIError("BAD_REQUEST", {
645
+ message: `SAML response contains ${counts.total} assertions, expected exactly 1`,
646
+ code: "SAML_MULTIPLE_ASSERTIONS"
559
647
  });
560
- }
561
- /**
562
- * Select the token endpoint authentication method.
563
- *
564
- * @param doc - The discovery document
565
- * @param existing - Existing authentication method from config
566
- * @returns The selected authentication method
567
- */
568
- function selectTokenEndpointAuthMethod(doc, existing) {
569
- if (existing) return existing;
570
- const supported = doc.token_endpoint_auth_methods_supported;
571
- if (!supported || supported.length === 0) return "client_secret_basic";
572
- if (supported.includes("client_secret_basic")) return "client_secret_basic";
573
- if (supported.includes("client_secret_post")) return "client_secret_post";
574
- return "client_secret_basic";
575
- }
576
- /**
577
- * Check if a provider configuration needs runtime discovery.
578
- *
579
- * Returns true if we need discovery at runtime to complete the token exchange
580
- * and validation. Specifically checks for:
581
- * - `tokenEndpoint` - required for exchanging authorization code for tokens
582
- * - `jwksEndpoint` - required for validating ID token signatures
583
- *
584
- * Note: `authorizationEndpoint` is handled separately in the sign-in flow,
585
- * so it's not checked here.
586
- *
587
- * @param config - Partial OIDC config from the provider
588
- * @returns true if runtime discovery should be performed
589
- */
590
- function needsRuntimeDiscovery(config) {
591
- if (!config) return true;
592
- return !config.tokenEndpoint || !config.jwksEndpoint;
593
- }
594
-
595
- //#endregion
596
- //#region src/oidc/errors.ts
597
- /**
598
- * OIDC Discovery Error Mapping
599
- *
600
- * Maps DiscoveryError codes to appropriate APIError responses.
601
- * Used at the boundary between the discovery pipeline and HTTP handlers.
602
- */
603
- /**
604
- * Maps a DiscoveryError to an appropriate APIError for HTTP responses.
605
- *
606
- * Error code mapping:
607
- * - discovery_invalid_url → 400 BAD_REQUEST
608
- * - discovery_not_found → 400 BAD_REQUEST
609
- * - discovery_invalid_json → 400 BAD_REQUEST
610
- * - discovery_incomplete → 400 BAD_REQUEST
611
- * - issuer_mismatch → 400 BAD_REQUEST
612
- * - unsupported_token_auth_method → 400 BAD_REQUEST
613
- * - discovery_timeout → 502 BAD_GATEWAY
614
- * - discovery_unexpected_error → 502 BAD_GATEWAY
615
- *
616
- * @param error - The DiscoveryError to map
617
- * @returns An APIError with appropriate status and message
618
- */
619
- function mapDiscoveryErrorToAPIError(error) {
620
- switch (error.code) {
621
- case "discovery_timeout": return new APIError("BAD_GATEWAY", {
622
- message: `OIDC discovery timed out: ${error.message}`,
623
- code: error.code
624
- });
625
- case "discovery_unexpected_error": return new APIError("BAD_GATEWAY", {
626
- message: `OIDC discovery failed: ${error.message}`,
627
- code: error.code
628
- });
629
- case "discovery_not_found": return new APIError("BAD_REQUEST", {
630
- message: `OIDC discovery endpoint not found. The issuer may not support OIDC discovery, or the URL is incorrect. ${error.message}`,
631
- code: error.code
632
- });
633
- case "discovery_invalid_url": return new APIError("BAD_REQUEST", {
634
- message: `Invalid OIDC discovery URL: ${error.message}`,
635
- code: error.code
636
- });
637
- case "discovery_untrusted_origin": return new APIError("BAD_REQUEST", {
638
- message: `Untrusted OIDC discovery URL: ${error.message}`,
639
- code: error.code
640
- });
641
- case "discovery_invalid_json": return new APIError("BAD_REQUEST", {
642
- message: `OIDC discovery returned invalid data: ${error.message}`,
643
- code: error.code
644
- });
645
- case "discovery_incomplete": return new APIError("BAD_REQUEST", {
646
- message: `OIDC discovery document is missing required fields: ${error.message}`,
647
- code: error.code
648
- });
649
- case "issuer_mismatch": return new APIError("BAD_REQUEST", {
650
- message: `OIDC issuer mismatch: ${error.message}`,
651
- code: error.code
652
- });
653
- case "unsupported_token_auth_method": return new APIError("BAD_REQUEST", {
654
- message: `Incompatible OIDC provider: ${error.message}`,
655
- code: error.code
656
- });
657
- default:
658
- error.code;
659
- return new APIError("INTERNAL_SERVER_ERROR", {
660
- message: `Unexpected discovery error: ${error.message}`,
661
- code: "discovery_unexpected_error"
662
- });
663
- }
664
648
  }
665
649
 
666
650
  //#endregion
667
- //#region src/saml/parser.ts
668
- const xmlParser = new XMLParser({
669
- ignoreAttributes: false,
670
- attributeNamePrefix: "@_",
671
- removeNSPrefix: true,
672
- processEntities: false
651
+ //#region src/routes/schemas.ts
652
+ const oidcMappingSchema = z.object({
653
+ id: z.string().optional(),
654
+ email: z.string().optional(),
655
+ emailVerified: z.string().optional(),
656
+ name: z.string().optional(),
657
+ image: z.string().optional(),
658
+ extraFields: z.record(z.string(), z.any()).optional()
659
+ }).optional();
660
+ const samlMappingSchema = z.object({
661
+ id: z.string().optional(),
662
+ email: z.string().optional(),
663
+ emailVerified: z.string().optional(),
664
+ name: z.string().optional(),
665
+ firstName: z.string().optional(),
666
+ lastName: z.string().optional(),
667
+ extraFields: z.record(z.string(), z.any()).optional()
668
+ }).optional();
669
+ const oidcConfigSchema = z.object({
670
+ clientId: z.string().optional(),
671
+ clientSecret: z.string().optional(),
672
+ authorizationEndpoint: z.string().url().optional(),
673
+ tokenEndpoint: z.string().url().optional(),
674
+ userInfoEndpoint: z.string().url().optional(),
675
+ tokenEndpointAuthentication: z.enum(["client_secret_post", "client_secret_basic"]).optional(),
676
+ jwksEndpoint: z.string().url().optional(),
677
+ discoveryEndpoint: z.string().url().optional(),
678
+ scopes: z.array(z.string()).optional(),
679
+ pkce: z.boolean().optional(),
680
+ overrideUserInfo: z.boolean().optional(),
681
+ mapping: oidcMappingSchema
682
+ });
683
+ const samlConfigSchema = z.object({
684
+ entryPoint: z.string().url().optional(),
685
+ cert: z.string().optional(),
686
+ callbackUrl: z.string().url().optional(),
687
+ audience: z.string().optional(),
688
+ idpMetadata: z.object({
689
+ metadata: z.string().optional(),
690
+ entityID: z.string().optional(),
691
+ cert: z.string().optional(),
692
+ privateKey: z.string().optional(),
693
+ privateKeyPass: z.string().optional(),
694
+ isAssertionEncrypted: z.boolean().optional(),
695
+ encPrivateKey: z.string().optional(),
696
+ encPrivateKeyPass: z.string().optional(),
697
+ singleSignOnService: z.array(z.object({
698
+ Binding: z.string(),
699
+ Location: z.string().url()
700
+ })).optional()
701
+ }).optional(),
702
+ spMetadata: z.object({
703
+ metadata: z.string().optional(),
704
+ entityID: z.string().optional(),
705
+ binding: z.string().optional(),
706
+ privateKey: z.string().optional(),
707
+ privateKeyPass: z.string().optional(),
708
+ isAssertionEncrypted: z.boolean().optional(),
709
+ encPrivateKey: z.string().optional(),
710
+ encPrivateKeyPass: z.string().optional()
711
+ }).optional(),
712
+ wantAssertionsSigned: z.boolean().optional(),
713
+ signatureAlgorithm: z.string().optional(),
714
+ digestAlgorithm: z.string().optional(),
715
+ identifierFormat: z.string().optional(),
716
+ privateKey: z.string().optional(),
717
+ decryptionPvk: z.string().optional(),
718
+ additionalParams: z.record(z.string(), z.any()).optional(),
719
+ mapping: samlMappingSchema
720
+ });
721
+ const updateSSOProviderBodySchema = z.object({
722
+ issuer: z.string().url().optional(),
723
+ domain: z.string().optional(),
724
+ oidcConfig: oidcConfigSchema.optional(),
725
+ samlConfig: samlConfigSchema.optional()
673
726
  });
674
- function findNode(obj, nodeName) {
675
- if (!obj || typeof obj !== "object") return null;
676
- const record = obj;
677
- if (nodeName in record) return record[nodeName];
678
- for (const value of Object.values(record)) if (Array.isArray(value)) for (const item of value) {
679
- const found = findNode(item, nodeName);
680
- if (found) return found;
681
- }
682
- else if (typeof value === "object" && value !== null) {
683
- const found = findNode(value, nodeName);
684
- if (found) return found;
685
- }
686
- return null;
687
- }
688
- function countAllNodes(obj, nodeName) {
689
- if (!obj || typeof obj !== "object") return 0;
690
- let count = 0;
691
- const record = obj;
692
- if (nodeName in record) {
693
- const node = record[nodeName];
694
- count += Array.isArray(node) ? node.length : 1;
695
- }
696
- for (const value of Object.values(record)) if (Array.isArray(value)) for (const item of value) count += countAllNodes(item, nodeName);
697
- else if (typeof value === "object" && value !== null) count += countAllNodes(value, nodeName);
698
- return count;
699
- }
700
727
 
701
728
  //#endregion
702
- //#region src/saml/algorithms.ts
703
- const SignatureAlgorithm = {
704
- RSA_SHA1: "http://www.w3.org/2000/09/xmldsig#rsa-sha1",
705
- RSA_SHA256: "http://www.w3.org/2001/04/xmldsig-more#rsa-sha256",
706
- RSA_SHA384: "http://www.w3.org/2001/04/xmldsig-more#rsa-sha384",
707
- RSA_SHA512: "http://www.w3.org/2001/04/xmldsig-more#rsa-sha512",
708
- ECDSA_SHA256: "http://www.w3.org/2001/04/xmldsig-more#ecdsa-sha256",
709
- ECDSA_SHA384: "http://www.w3.org/2001/04/xmldsig-more#ecdsa-sha384",
710
- ECDSA_SHA512: "http://www.w3.org/2001/04/xmldsig-more#ecdsa-sha512"
711
- };
712
- const DigestAlgorithm = {
713
- SHA1: "http://www.w3.org/2000/09/xmldsig#sha1",
714
- SHA256: "http://www.w3.org/2001/04/xmlenc#sha256",
715
- SHA384: "http://www.w3.org/2001/04/xmldsig-more#sha384",
716
- SHA512: "http://www.w3.org/2001/04/xmlenc#sha512"
717
- };
718
- const KeyEncryptionAlgorithm = {
719
- RSA_1_5: "http://www.w3.org/2001/04/xmlenc#rsa-1_5",
720
- RSA_OAEP: "http://www.w3.org/2001/04/xmlenc#rsa-oaep-mgf1p",
721
- RSA_OAEP_SHA256: "http://www.w3.org/2009/xmlenc11#rsa-oaep"
722
- };
723
- const DataEncryptionAlgorithm = {
724
- TRIPLEDES_CBC: "http://www.w3.org/2001/04/xmlenc#tripledes-cbc",
725
- AES_128_CBC: "http://www.w3.org/2001/04/xmlenc#aes128-cbc",
726
- AES_192_CBC: "http://www.w3.org/2001/04/xmlenc#aes192-cbc",
727
- AES_256_CBC: "http://www.w3.org/2001/04/xmlenc#aes256-cbc",
728
- AES_128_GCM: "http://www.w3.org/2009/xmlenc11#aes128-gcm",
729
- AES_192_GCM: "http://www.w3.org/2009/xmlenc11#aes192-gcm",
730
- AES_256_GCM: "http://www.w3.org/2009/xmlenc11#aes256-gcm"
731
- };
732
- const DEPRECATED_SIGNATURE_ALGORITHMS = [SignatureAlgorithm.RSA_SHA1];
733
- const DEPRECATED_KEY_ENCRYPTION_ALGORITHMS = [KeyEncryptionAlgorithm.RSA_1_5];
734
- const DEPRECATED_DATA_ENCRYPTION_ALGORITHMS = [DataEncryptionAlgorithm.TRIPLEDES_CBC];
735
- const DEPRECATED_DIGEST_ALGORITHMS = [DigestAlgorithm.SHA1];
736
- const SECURE_SIGNATURE_ALGORITHMS = [
737
- SignatureAlgorithm.RSA_SHA256,
738
- SignatureAlgorithm.RSA_SHA384,
739
- SignatureAlgorithm.RSA_SHA512,
740
- SignatureAlgorithm.ECDSA_SHA256,
741
- SignatureAlgorithm.ECDSA_SHA384,
742
- SignatureAlgorithm.ECDSA_SHA512
743
- ];
744
- const SECURE_DIGEST_ALGORITHMS = [
745
- DigestAlgorithm.SHA256,
746
- DigestAlgorithm.SHA384,
747
- DigestAlgorithm.SHA512
748
- ];
749
- const SHORT_FORM_SIGNATURE_TO_URI = {
750
- sha1: SignatureAlgorithm.RSA_SHA1,
751
- sha256: SignatureAlgorithm.RSA_SHA256,
752
- sha384: SignatureAlgorithm.RSA_SHA384,
753
- sha512: SignatureAlgorithm.RSA_SHA512,
754
- "rsa-sha1": SignatureAlgorithm.RSA_SHA1,
755
- "rsa-sha256": SignatureAlgorithm.RSA_SHA256,
756
- "rsa-sha384": SignatureAlgorithm.RSA_SHA384,
757
- "rsa-sha512": SignatureAlgorithm.RSA_SHA512,
758
- "ecdsa-sha256": SignatureAlgorithm.ECDSA_SHA256,
759
- "ecdsa-sha384": SignatureAlgorithm.ECDSA_SHA384,
760
- "ecdsa-sha512": SignatureAlgorithm.ECDSA_SHA512
761
- };
762
- const SHORT_FORM_DIGEST_TO_URI = {
763
- sha1: DigestAlgorithm.SHA1,
764
- sha256: DigestAlgorithm.SHA256,
765
- sha384: DigestAlgorithm.SHA384,
766
- sha512: DigestAlgorithm.SHA512
767
- };
768
- function normalizeSignatureAlgorithm(alg) {
769
- return SHORT_FORM_SIGNATURE_TO_URI[alg.toLowerCase()] ?? alg;
729
+ //#region src/routes/providers.ts
730
+ const ADMIN_ROLES = ["owner", "admin"];
731
+ async function isOrgAdmin(ctx, userId, organizationId) {
732
+ const member = await ctx.context.adapter.findOne({
733
+ model: "member",
734
+ where: [{
735
+ field: "userId",
736
+ value: userId
737
+ }, {
738
+ field: "organizationId",
739
+ value: organizationId
740
+ }]
741
+ });
742
+ if (!member) return false;
743
+ return member.role.split(",").some((r) => ADMIN_ROLES.includes(r.trim()));
770
744
  }
771
- function normalizeDigestAlgorithm(alg) {
772
- return SHORT_FORM_DIGEST_TO_URI[alg.toLowerCase()] ?? alg;
745
+ async function batchCheckOrgAdmin(ctx, userId, organizationIds) {
746
+ if (organizationIds.length === 0) return /* @__PURE__ */ new Set();
747
+ const members = await ctx.context.adapter.findMany({
748
+ model: "member",
749
+ where: [{
750
+ field: "userId",
751
+ value: userId
752
+ }, {
753
+ field: "organizationId",
754
+ value: organizationIds,
755
+ operator: "in"
756
+ }]
757
+ });
758
+ const adminOrgIds = /* @__PURE__ */ new Set();
759
+ for (const member of members) if (member.role.split(",").some((r) => ADMIN_ROLES.includes(r.trim()))) adminOrgIds.add(member.organizationId);
760
+ return adminOrgIds;
773
761
  }
774
- function extractEncryptionAlgorithms(xml) {
762
+ function sanitizeProvider(provider, baseURL) {
763
+ let oidcConfig = null;
764
+ let samlConfig = null;
775
765
  try {
776
- const parsed = xmlParser.parse(xml);
777
- const keyAlg = (findNode(parsed, "EncryptedKey")?.EncryptionMethod)?.["@_Algorithm"];
778
- const dataAlg = (findNode(parsed, "EncryptedData")?.EncryptionMethod)?.["@_Algorithm"];
779
- return {
780
- keyEncryption: keyAlg || null,
781
- dataEncryption: dataAlg || null
782
- };
766
+ oidcConfig = safeJsonParse(provider.oidcConfig);
783
767
  } catch {
784
- return {
785
- keyEncryption: null,
786
- dataEncryption: null
787
- };
768
+ oidcConfig = null;
788
769
  }
789
- }
790
- function hasEncryptedAssertion(xml) {
791
770
  try {
792
- return findNode(xmlParser.parse(xml), "EncryptedAssertion") !== null;
771
+ samlConfig = safeJsonParse(provider.samlConfig);
793
772
  } catch {
794
- return false;
773
+ samlConfig = null;
795
774
  }
775
+ const type = samlConfig ? "saml" : "oidc";
776
+ return {
777
+ providerId: provider.providerId,
778
+ type,
779
+ issuer: provider.issuer,
780
+ domain: provider.domain,
781
+ organizationId: provider.organizationId || null,
782
+ domainVerified: provider.domainVerified ?? false,
783
+ oidcConfig: oidcConfig ? {
784
+ discoveryEndpoint: oidcConfig.discoveryEndpoint,
785
+ clientIdLastFour: maskClientId(oidcConfig.clientId),
786
+ pkce: oidcConfig.pkce,
787
+ authorizationEndpoint: oidcConfig.authorizationEndpoint,
788
+ tokenEndpoint: oidcConfig.tokenEndpoint,
789
+ userInfoEndpoint: oidcConfig.userInfoEndpoint,
790
+ jwksEndpoint: oidcConfig.jwksEndpoint,
791
+ scopes: oidcConfig.scopes,
792
+ tokenEndpointAuthentication: oidcConfig.tokenEndpointAuthentication
793
+ } : void 0,
794
+ samlConfig: samlConfig ? {
795
+ entryPoint: samlConfig.entryPoint,
796
+ callbackUrl: samlConfig.callbackUrl,
797
+ audience: samlConfig.audience,
798
+ wantAssertionsSigned: samlConfig.wantAssertionsSigned,
799
+ identifierFormat: samlConfig.identifierFormat,
800
+ signatureAlgorithm: samlConfig.signatureAlgorithm,
801
+ digestAlgorithm: samlConfig.digestAlgorithm,
802
+ certificate: (() => {
803
+ try {
804
+ return parseCertificate(samlConfig.cert);
805
+ } catch {
806
+ return { error: "Failed to parse certificate" };
807
+ }
808
+ })()
809
+ } : void 0,
810
+ spMetadataUrl: `${baseURL}/sso/saml2/sp/metadata?providerId=${encodeURIComponent(provider.providerId)}`
811
+ };
796
812
  }
797
- function handleDeprecatedAlgorithm(message, behavior, errorCode) {
798
- switch (behavior) {
799
- case "reject": throw new APIError("BAD_REQUEST", {
800
- message,
801
- code: errorCode
802
- });
803
- case "warn":
804
- console.warn(`[SAML Security Warning] ${message}`);
805
- break;
806
- case "allow": break;
807
- }
813
+ const listSSOProviders = () => {
814
+ return createAuthEndpoint("/sso/providers", {
815
+ method: "GET",
816
+ use: [sessionMiddleware],
817
+ metadata: { openapi: {
818
+ operationId: "listSSOProviders",
819
+ summary: "List SSO providers",
820
+ description: "Returns a list of SSO providers the user has access to",
821
+ responses: { "200": { description: "List of SSO providers" } }
822
+ } }
823
+ }, async (ctx) => {
824
+ const userId = ctx.context.session.user.id;
825
+ const allProviders = await ctx.context.adapter.findMany({ model: "ssoProvider" });
826
+ const userOwnedProviders = allProviders.filter((p) => p.userId === userId && !p.organizationId);
827
+ const orgProviders = allProviders.filter((p) => p.organizationId !== null && p.organizationId !== void 0);
828
+ const orgPluginEnabled = ctx.context.options.plugins?.some(({ id }) => id === "organization") ?? false;
829
+ let accessibleProviders = [...userOwnedProviders];
830
+ if (orgPluginEnabled && orgProviders.length > 0) {
831
+ const adminOrgIds = await batchCheckOrgAdmin(ctx, userId, [...new Set(orgProviders.map((p) => p.organizationId).filter((id) => id !== null && id !== void 0))]);
832
+ const orgAccessibleProviders = orgProviders.filter((provider) => provider.organizationId && adminOrgIds.has(provider.organizationId));
833
+ accessibleProviders = [...accessibleProviders, ...orgAccessibleProviders];
834
+ } else if (!orgPluginEnabled) {
835
+ const userOwnedOrgProviders = orgProviders.filter((p) => p.userId === userId);
836
+ accessibleProviders = [...accessibleProviders, ...userOwnedOrgProviders];
837
+ }
838
+ const providers = accessibleProviders.map((p) => sanitizeProvider(p, ctx.context.baseURL));
839
+ return ctx.json({ providers });
840
+ });
841
+ };
842
+ const getSSOProviderParamsSchema = z.object({ providerId: z.string() });
843
+ async function checkProviderAccess(ctx, providerId) {
844
+ const userId = ctx.context.session.user.id;
845
+ const provider = await ctx.context.adapter.findOne({
846
+ model: "ssoProvider",
847
+ where: [{
848
+ field: "providerId",
849
+ value: providerId
850
+ }]
851
+ });
852
+ if (!provider) throw new APIError("NOT_FOUND", { message: "Provider not found" });
853
+ let hasAccess = false;
854
+ if (provider.organizationId) if (ctx.context.options.plugins?.some(({ id }) => id === "organization") ?? false) hasAccess = await isOrgAdmin(ctx, userId, provider.organizationId);
855
+ else hasAccess = provider.userId === userId;
856
+ else hasAccess = provider.userId === userId;
857
+ if (!hasAccess) throw new APIError("FORBIDDEN", { message: "You don't have access to this provider" });
858
+ return provider;
808
859
  }
809
- function validateSignatureAlgorithm(algorithm, options = {}) {
810
- if (!algorithm) return;
811
- const { onDeprecated = "warn", allowedSignatureAlgorithms } = options;
812
- if (allowedSignatureAlgorithms) {
813
- if (!allowedSignatureAlgorithms.includes(algorithm)) throw new APIError("BAD_REQUEST", {
814
- message: `SAML signature algorithm not in allow-list: ${algorithm}`,
815
- code: "SAML_ALGORITHM_NOT_ALLOWED"
860
+ const getSSOProvider = () => {
861
+ return createAuthEndpoint("/sso/providers/:providerId", {
862
+ method: "GET",
863
+ use: [sessionMiddleware],
864
+ params: getSSOProviderParamsSchema,
865
+ metadata: { openapi: {
866
+ operationId: "getSSOProvider",
867
+ summary: "Get SSO provider details",
868
+ description: "Returns sanitized details for a specific SSO provider",
869
+ responses: {
870
+ "200": { description: "SSO provider details" },
871
+ "404": { description: "Provider not found" },
872
+ "403": { description: "Access denied" }
873
+ }
874
+ } }
875
+ }, async (ctx) => {
876
+ const { providerId } = ctx.params;
877
+ const provider = await checkProviderAccess(ctx, providerId);
878
+ return ctx.json(sanitizeProvider(provider, ctx.context.baseURL));
879
+ });
880
+ };
881
+ function parseAndValidateConfig(configString, configType) {
882
+ let config = null;
883
+ try {
884
+ config = safeJsonParse(configString);
885
+ } catch {
886
+ config = null;
887
+ }
888
+ if (!config) throw new APIError("BAD_REQUEST", { message: `Cannot update ${configType} config for a provider that doesn't have ${configType} configured` });
889
+ return config;
890
+ }
891
+ function mergeSAMLConfig(current, updates, issuer) {
892
+ return {
893
+ ...current,
894
+ ...updates,
895
+ issuer,
896
+ entryPoint: updates.entryPoint ?? current.entryPoint,
897
+ cert: updates.cert ?? current.cert,
898
+ callbackUrl: updates.callbackUrl ?? current.callbackUrl,
899
+ spMetadata: updates.spMetadata ?? current.spMetadata,
900
+ idpMetadata: updates.idpMetadata ?? current.idpMetadata,
901
+ mapping: updates.mapping ?? current.mapping,
902
+ audience: updates.audience ?? current.audience,
903
+ wantAssertionsSigned: updates.wantAssertionsSigned ?? current.wantAssertionsSigned,
904
+ identifierFormat: updates.identifierFormat ?? current.identifierFormat,
905
+ signatureAlgorithm: updates.signatureAlgorithm ?? current.signatureAlgorithm,
906
+ digestAlgorithm: updates.digestAlgorithm ?? current.digestAlgorithm
907
+ };
908
+ }
909
+ function mergeOIDCConfig(current, updates, issuer) {
910
+ return {
911
+ ...current,
912
+ ...updates,
913
+ issuer,
914
+ pkce: updates.pkce ?? current.pkce ?? true,
915
+ clientId: updates.clientId ?? current.clientId,
916
+ clientSecret: updates.clientSecret ?? current.clientSecret,
917
+ discoveryEndpoint: updates.discoveryEndpoint ?? current.discoveryEndpoint,
918
+ mapping: updates.mapping ?? current.mapping,
919
+ scopes: updates.scopes ?? current.scopes,
920
+ authorizationEndpoint: updates.authorizationEndpoint ?? current.authorizationEndpoint,
921
+ tokenEndpoint: updates.tokenEndpoint ?? current.tokenEndpoint,
922
+ userInfoEndpoint: updates.userInfoEndpoint ?? current.userInfoEndpoint,
923
+ jwksEndpoint: updates.jwksEndpoint ?? current.jwksEndpoint,
924
+ tokenEndpointAuthentication: updates.tokenEndpointAuthentication ?? current.tokenEndpointAuthentication
925
+ };
926
+ }
927
+ const updateSSOProvider = (options) => {
928
+ return createAuthEndpoint("/sso/providers/:providerId", {
929
+ method: "PATCH",
930
+ use: [sessionMiddleware],
931
+ params: getSSOProviderParamsSchema,
932
+ body: updateSSOProviderBodySchema,
933
+ metadata: { openapi: {
934
+ operationId: "updateSSOProvider",
935
+ summary: "Update SSO provider",
936
+ description: "Partially update an SSO provider. Only provided fields are updated. If domain changes, domainVerified is reset to false.",
937
+ responses: {
938
+ "200": { description: "SSO provider updated successfully" },
939
+ "404": { description: "Provider not found" },
940
+ "403": { description: "Access denied" }
941
+ }
942
+ } }
943
+ }, async (ctx) => {
944
+ const { providerId } = ctx.params;
945
+ const body = ctx.body;
946
+ const { issuer, domain, samlConfig, oidcConfig } = body;
947
+ if (!issuer && !domain && !samlConfig && !oidcConfig) throw new APIError("BAD_REQUEST", { message: "No fields provided for update" });
948
+ const existingProvider = await checkProviderAccess(ctx, providerId);
949
+ const updateData = {};
950
+ if (body.issuer !== void 0) updateData.issuer = body.issuer;
951
+ if (body.domain !== void 0) {
952
+ updateData.domain = body.domain;
953
+ if (body.domain !== existingProvider.domain) updateData.domainVerified = false;
954
+ }
955
+ if (body.samlConfig) {
956
+ if (body.samlConfig.idpMetadata?.metadata) {
957
+ const maxMetadataSize = options?.saml?.maxMetadataSize ?? DEFAULT_MAX_SAML_METADATA_SIZE;
958
+ if (new TextEncoder().encode(body.samlConfig.idpMetadata.metadata).length > maxMetadataSize) throw new APIError("BAD_REQUEST", { message: `IdP metadata exceeds maximum allowed size (${maxMetadataSize} bytes)` });
959
+ }
960
+ if (body.samlConfig.signatureAlgorithm !== void 0 || body.samlConfig.digestAlgorithm !== void 0) validateConfigAlgorithms({
961
+ signatureAlgorithm: body.samlConfig.signatureAlgorithm,
962
+ digestAlgorithm: body.samlConfig.digestAlgorithm
963
+ }, options?.saml?.algorithms);
964
+ const currentSamlConfig = parseAndValidateConfig(existingProvider.samlConfig, "SAML");
965
+ const updatedSamlConfig = mergeSAMLConfig(currentSamlConfig, body.samlConfig, updateData.issuer || currentSamlConfig.issuer || existingProvider.issuer);
966
+ updateData.samlConfig = JSON.stringify(updatedSamlConfig);
967
+ }
968
+ if (body.oidcConfig) {
969
+ const currentOidcConfig = parseAndValidateConfig(existingProvider.oidcConfig, "OIDC");
970
+ const updatedOidcConfig = mergeOIDCConfig(currentOidcConfig, body.oidcConfig, updateData.issuer || currentOidcConfig.issuer || existingProvider.issuer);
971
+ updateData.oidcConfig = JSON.stringify(updatedOidcConfig);
972
+ }
973
+ await ctx.context.adapter.update({
974
+ model: "ssoProvider",
975
+ where: [{
976
+ field: "providerId",
977
+ value: providerId
978
+ }],
979
+ update: updateData
816
980
  });
817
- return;
981
+ const fullProvider = await ctx.context.adapter.findOne({
982
+ model: "ssoProvider",
983
+ where: [{
984
+ field: "providerId",
985
+ value: providerId
986
+ }]
987
+ });
988
+ if (!fullProvider) throw new APIError("NOT_FOUND", { message: "Provider not found after update" });
989
+ return ctx.json(sanitizeProvider(fullProvider, ctx.context.baseURL));
990
+ });
991
+ };
992
+ const deleteSSOProvider = () => {
993
+ return createAuthEndpoint("/sso/providers/:providerId", {
994
+ method: "DELETE",
995
+ use: [sessionMiddleware],
996
+ params: getSSOProviderParamsSchema,
997
+ metadata: { openapi: {
998
+ operationId: "deleteSSOProvider",
999
+ summary: "Delete SSO provider",
1000
+ description: "Deletes an SSO provider",
1001
+ responses: {
1002
+ "200": { description: "SSO provider deleted successfully" },
1003
+ "404": { description: "Provider not found" },
1004
+ "403": { description: "Access denied" }
1005
+ }
1006
+ } }
1007
+ }, async (ctx) => {
1008
+ const { providerId } = ctx.params;
1009
+ await checkProviderAccess(ctx, providerId);
1010
+ await ctx.context.adapter.delete({
1011
+ model: "ssoProvider",
1012
+ where: [{
1013
+ field: "providerId",
1014
+ value: providerId
1015
+ }]
1016
+ });
1017
+ return ctx.json({ success: true });
1018
+ });
1019
+ };
1020
+
1021
+ //#endregion
1022
+ //#region src/oidc/types.ts
1023
+ /**
1024
+ * Custom error class for OIDC discovery failures.
1025
+ * Can be caught and mapped to APIError at the edge.
1026
+ */
1027
+ var DiscoveryError = class DiscoveryError extends Error {
1028
+ code;
1029
+ details;
1030
+ constructor(code, message, details, options) {
1031
+ super(message, options);
1032
+ this.name = "DiscoveryError";
1033
+ this.code = code;
1034
+ this.details = details;
1035
+ if (Error.captureStackTrace) Error.captureStackTrace(this, DiscoveryError);
818
1036
  }
819
- if (DEPRECATED_SIGNATURE_ALGORITHMS.includes(algorithm)) {
820
- handleDeprecatedAlgorithm(`SAML response uses deprecated signature algorithm: ${algorithm}. Please configure your IdP to use SHA-256 or stronger.`, onDeprecated, "SAML_DEPRECATED_ALGORITHM");
821
- return;
1037
+ };
1038
+ /**
1039
+ * Required fields that must be present in a valid discovery document.
1040
+ */
1041
+ const REQUIRED_DISCOVERY_FIELDS = [
1042
+ "issuer",
1043
+ "authorization_endpoint",
1044
+ "token_endpoint",
1045
+ "jwks_uri"
1046
+ ];
1047
+
1048
+ //#endregion
1049
+ //#region src/oidc/discovery.ts
1050
+ /**
1051
+ * OIDC Discovery Pipeline
1052
+ *
1053
+ * Implements OIDC discovery document fetching, validation, and hydration.
1054
+ * This module is used both at provider registration time (to persist validated config)
1055
+ * and at runtime (to hydrate legacy providers that are missing metadata).
1056
+ *
1057
+ * @see https://openid.net/specs/openid-connect-discovery-1_0.html
1058
+ */
1059
+ /** Default timeout for discovery requests (10 seconds) */
1060
+ const DEFAULT_DISCOVERY_TIMEOUT = 1e4;
1061
+ /**
1062
+ * Main entry point: Discover and hydrate OIDC configuration from an issuer.
1063
+ *
1064
+ * This function:
1065
+ * 1. Computes the discovery URL from the issuer
1066
+ * 2. Validates the discovery URL
1067
+ * 3. Fetches the discovery document
1068
+ * 4. Validates the discovery document (issuer match + required fields)
1069
+ * 5. Normalizes URLs
1070
+ * 6. Selects token endpoint auth method
1071
+ * 7. Merges with existing config (existing values take precedence)
1072
+ *
1073
+ * @param params - Discovery parameters
1074
+ * @param isTrustedOrigin - Origin verification tester function
1075
+ * @returns Hydrated OIDC configuration ready for persistence
1076
+ * @throws DiscoveryError on any failure
1077
+ */
1078
+ async function discoverOIDCConfig(params) {
1079
+ const { issuer, existingConfig, timeout = DEFAULT_DISCOVERY_TIMEOUT } = params;
1080
+ const discoveryUrl = params.discoveryEndpoint || existingConfig?.discoveryEndpoint || computeDiscoveryUrl(issuer);
1081
+ validateDiscoveryUrl(discoveryUrl, params.isTrustedOrigin);
1082
+ const discoveryDoc = await fetchDiscoveryDocument(discoveryUrl, timeout);
1083
+ validateDiscoveryDocument(discoveryDoc, issuer);
1084
+ const normalizedDoc = normalizeDiscoveryUrls(discoveryDoc, issuer, params.isTrustedOrigin);
1085
+ const tokenEndpointAuth = selectTokenEndpointAuthMethod(normalizedDoc, existingConfig?.tokenEndpointAuthentication);
1086
+ return {
1087
+ issuer: existingConfig?.issuer ?? normalizedDoc.issuer,
1088
+ discoveryEndpoint: existingConfig?.discoveryEndpoint ?? discoveryUrl,
1089
+ authorizationEndpoint: existingConfig?.authorizationEndpoint ?? normalizedDoc.authorization_endpoint,
1090
+ tokenEndpoint: existingConfig?.tokenEndpoint ?? normalizedDoc.token_endpoint,
1091
+ jwksEndpoint: existingConfig?.jwksEndpoint ?? normalizedDoc.jwks_uri,
1092
+ userInfoEndpoint: existingConfig?.userInfoEndpoint ?? normalizedDoc.userinfo_endpoint,
1093
+ tokenEndpointAuthentication: existingConfig?.tokenEndpointAuthentication ?? tokenEndpointAuth,
1094
+ scopesSupported: existingConfig?.scopesSupported ?? normalizedDoc.scopes_supported
1095
+ };
1096
+ }
1097
+ /**
1098
+ * Compute the discovery URL from an issuer URL.
1099
+ *
1100
+ * Per OIDC Discovery spec, the discovery document is located at:
1101
+ * <issuer>/.well-known/openid-configuration
1102
+ *
1103
+ * Handles trailing slashes correctly.
1104
+ */
1105
+ function computeDiscoveryUrl(issuer) {
1106
+ return `${issuer.endsWith("/") ? issuer.slice(0, -1) : issuer}/.well-known/openid-configuration`;
1107
+ }
1108
+ /**
1109
+ * Validate a discovery URL before fetching.
1110
+ *
1111
+ * @param url - The discovery URL to validate
1112
+ * @param isTrustedOrigin - Origin verification tester function
1113
+ * @throws DiscoveryError if URL is invalid
1114
+ */
1115
+ function validateDiscoveryUrl(url, isTrustedOrigin) {
1116
+ const discoveryEndpoint = parseURL("discoveryEndpoint", url).toString();
1117
+ if (!isTrustedOrigin(discoveryEndpoint)) throw new DiscoveryError("discovery_untrusted_origin", `The main discovery endpoint "${discoveryEndpoint}" is not trusted by your trusted origins configuration.`, { url: discoveryEndpoint });
1118
+ }
1119
+ /**
1120
+ * Fetch the OIDC discovery document from the IdP.
1121
+ *
1122
+ * @param url - The discovery endpoint URL
1123
+ * @param timeout - Request timeout in milliseconds
1124
+ * @returns The parsed discovery document
1125
+ * @throws DiscoveryError on network errors, timeouts, or invalid responses
1126
+ */
1127
+ async function fetchDiscoveryDocument(url, timeout = DEFAULT_DISCOVERY_TIMEOUT) {
1128
+ try {
1129
+ const response = await betterFetch(url, {
1130
+ method: "GET",
1131
+ timeout
1132
+ });
1133
+ if (response.error) {
1134
+ const { status } = response.error;
1135
+ if (status === 404) throw new DiscoveryError("discovery_not_found", "Discovery endpoint not found", {
1136
+ url,
1137
+ status
1138
+ });
1139
+ if (status === 408) throw new DiscoveryError("discovery_timeout", "Discovery request timed out", {
1140
+ url,
1141
+ timeout
1142
+ });
1143
+ throw new DiscoveryError("discovery_unexpected_error", `Unexpected discovery error: ${response.error.statusText}`, {
1144
+ url,
1145
+ ...response.error
1146
+ });
1147
+ }
1148
+ if (!response.data) throw new DiscoveryError("discovery_invalid_json", "Discovery endpoint returned an empty response", { url });
1149
+ const data = response.data;
1150
+ if (typeof data === "string") throw new DiscoveryError("discovery_invalid_json", "Discovery endpoint returned invalid JSON", {
1151
+ url,
1152
+ bodyPreview: data.slice(0, 200)
1153
+ });
1154
+ return data;
1155
+ } catch (error) {
1156
+ if (error instanceof DiscoveryError) throw error;
1157
+ if (error instanceof Error && error.name === "AbortError") throw new DiscoveryError("discovery_timeout", "Discovery request timed out", {
1158
+ url,
1159
+ timeout
1160
+ });
1161
+ throw new DiscoveryError("discovery_unexpected_error", `Unexpected error during discovery: ${error instanceof Error ? error.message : String(error)}`, { url }, { cause: error });
1162
+ }
1163
+ }
1164
+ /**
1165
+ * Validate a discovery document.
1166
+ *
1167
+ * Checks:
1168
+ * 1. All required fields are present
1169
+ * 2. Issuer matches the configured issuer (case-sensitive, exact match)
1170
+ *
1171
+ * Invariant: If this function returns without throwing, the document is safe
1172
+ * to use for hydrating OIDC config (required fields present, issuer matches
1173
+ * configured value, basic structural sanity verified).
1174
+ *
1175
+ * @param doc - The discovery document to validate
1176
+ * @param configuredIssuer - The expected issuer value
1177
+ * @throws DiscoveryError if validation fails
1178
+ */
1179
+ function validateDiscoveryDocument(doc, configuredIssuer) {
1180
+ const missingFields = [];
1181
+ for (const field of REQUIRED_DISCOVERY_FIELDS) if (!doc[field]) missingFields.push(field);
1182
+ if (missingFields.length > 0) throw new DiscoveryError("discovery_incomplete", `Discovery document is missing required fields: ${missingFields.join(", ")}`, { missingFields });
1183
+ if ((doc.issuer.endsWith("/") ? doc.issuer.slice(0, -1) : doc.issuer) !== (configuredIssuer.endsWith("/") ? configuredIssuer.slice(0, -1) : configuredIssuer)) throw new DiscoveryError("issuer_mismatch", `Discovered issuer "${doc.issuer}" does not match configured issuer "${configuredIssuer}"`, {
1184
+ discovered: doc.issuer,
1185
+ configured: configuredIssuer
1186
+ });
1187
+ }
1188
+ /**
1189
+ * Normalize URLs in the discovery document.
1190
+ *
1191
+ * @param document - The discovery document
1192
+ * @param issuer - The base issuer URL
1193
+ * @param isTrustedOrigin - Origin verification tester function
1194
+ * @returns The normalized discovery document
1195
+ */
1196
+ function normalizeDiscoveryUrls(document, issuer, isTrustedOrigin) {
1197
+ const doc = { ...document };
1198
+ doc.token_endpoint = normalizeAndValidateUrl("token_endpoint", doc.token_endpoint, issuer, isTrustedOrigin);
1199
+ doc.authorization_endpoint = normalizeAndValidateUrl("authorization_endpoint", doc.authorization_endpoint, issuer, isTrustedOrigin);
1200
+ doc.jwks_uri = normalizeAndValidateUrl("jwks_uri", doc.jwks_uri, issuer, isTrustedOrigin);
1201
+ if (doc.userinfo_endpoint) doc.userinfo_endpoint = normalizeAndValidateUrl("userinfo_endpoint", doc.userinfo_endpoint, issuer, isTrustedOrigin);
1202
+ if (doc.revocation_endpoint) doc.revocation_endpoint = normalizeAndValidateUrl("revocation_endpoint", doc.revocation_endpoint, issuer, isTrustedOrigin);
1203
+ if (doc.end_session_endpoint) doc.end_session_endpoint = normalizeAndValidateUrl("end_session_endpoint", doc.end_session_endpoint, issuer, isTrustedOrigin);
1204
+ if (doc.introspection_endpoint) doc.introspection_endpoint = normalizeAndValidateUrl("introspection_endpoint", doc.introspection_endpoint, issuer, isTrustedOrigin);
1205
+ return doc;
1206
+ }
1207
+ /**
1208
+ * Normalizes and validates a single URL endpoint
1209
+ * @param name The url name
1210
+ * @param endpoint The url to validate
1211
+ * @param issuer The issuer base url
1212
+ * @param isTrustedOrigin - Origin verification tester function
1213
+ * @returns
1214
+ */
1215
+ function normalizeAndValidateUrl(name, endpoint, issuer, isTrustedOrigin) {
1216
+ const url = normalizeUrl(name, endpoint, issuer);
1217
+ if (!isTrustedOrigin(url)) throw new DiscoveryError("discovery_untrusted_origin", `The ${name} "${url}" is not trusted by your trusted origins configuration.`, {
1218
+ endpoint: name,
1219
+ url
1220
+ });
1221
+ return url;
1222
+ }
1223
+ /**
1224
+ * Normalize a single URL endpoint.
1225
+ *
1226
+ * @param name - The endpoint name (e.g token_endpoint)
1227
+ * @param endpoint - The endpoint URL to normalize
1228
+ * @param issuer - The base issuer URL
1229
+ * @returns The normalized endpoint URL
1230
+ */
1231
+ function normalizeUrl(name, endpoint, issuer) {
1232
+ try {
1233
+ return parseURL(name, endpoint).toString();
1234
+ } catch {
1235
+ const issuerURL = parseURL(name, issuer);
1236
+ const basePath = issuerURL.pathname.replace(/\/+$/, "");
1237
+ const endpointPath = endpoint.replace(/^\/+/, "");
1238
+ return parseURL(name, basePath + "/" + endpointPath, issuerURL.origin).toString();
1239
+ }
1240
+ }
1241
+ /**
1242
+ * Parses the given URL or throws in case of invalid or unsupported protocols
1243
+ *
1244
+ * @param name the url name
1245
+ * @param endpoint the endpoint url
1246
+ * @param [base] optional base path
1247
+ * @returns
1248
+ */
1249
+ function parseURL(name, endpoint, base) {
1250
+ let endpointURL;
1251
+ try {
1252
+ endpointURL = new URL(endpoint, base);
1253
+ if (endpointURL.protocol === "http:" || endpointURL.protocol === "https:") return endpointURL;
1254
+ } catch (error) {
1255
+ throw new DiscoveryError("discovery_invalid_url", `The url "${name}" must be valid: ${endpoint}`, { url: endpoint }, { cause: error });
822
1256
  }
823
- if (!SECURE_SIGNATURE_ALGORITHMS.includes(algorithm)) throw new APIError("BAD_REQUEST", {
824
- message: `SAML signature algorithm not recognized: ${algorithm}`,
825
- code: "SAML_UNKNOWN_ALGORITHM"
1257
+ throw new DiscoveryError("discovery_invalid_url", `The url "${name}" must use the http or https supported protocols: ${endpoint}`, {
1258
+ url: endpoint,
1259
+ protocol: endpointURL.protocol
826
1260
  });
827
1261
  }
828
- function validateEncryptionAlgorithms(algorithms, options = {}) {
829
- const { onDeprecated = "warn", allowedKeyEncryptionAlgorithms, allowedDataEncryptionAlgorithms } = options;
830
- const { keyEncryption, dataEncryption } = algorithms;
831
- if (keyEncryption) {
832
- if (allowedKeyEncryptionAlgorithms) {
833
- if (!allowedKeyEncryptionAlgorithms.includes(keyEncryption)) throw new APIError("BAD_REQUEST", {
834
- message: `SAML key encryption algorithm not in allow-list: ${keyEncryption}`,
835
- code: "SAML_ALGORITHM_NOT_ALLOWED"
836
- });
837
- } else if (DEPRECATED_KEY_ENCRYPTION_ALGORITHMS.includes(keyEncryption)) handleDeprecatedAlgorithm(`SAML response uses deprecated key encryption algorithm: ${keyEncryption}. Please configure your IdP to use RSA-OAEP.`, onDeprecated, "SAML_DEPRECATED_ALGORITHM");
838
- }
839
- if (dataEncryption) {
840
- if (allowedDataEncryptionAlgorithms) {
841
- if (!allowedDataEncryptionAlgorithms.includes(dataEncryption)) throw new APIError("BAD_REQUEST", {
842
- message: `SAML data encryption algorithm not in allow-list: ${dataEncryption}`,
843
- code: "SAML_ALGORITHM_NOT_ALLOWED"
844
- });
845
- } else if (DEPRECATED_DATA_ENCRYPTION_ALGORITHMS.includes(dataEncryption)) handleDeprecatedAlgorithm(`SAML response uses deprecated data encryption algorithm: ${dataEncryption}. Please configure your IdP to use AES-GCM.`, onDeprecated, "SAML_DEPRECATED_ALGORITHM");
846
- }
1262
+ /**
1263
+ * Select the token endpoint authentication method.
1264
+ *
1265
+ * @param doc - The discovery document
1266
+ * @param existing - Existing authentication method from config
1267
+ * @returns The selected authentication method
1268
+ */
1269
+ function selectTokenEndpointAuthMethod(doc, existing) {
1270
+ if (existing) return existing;
1271
+ const supported = doc.token_endpoint_auth_methods_supported;
1272
+ if (!supported || supported.length === 0) return "client_secret_basic";
1273
+ if (supported.includes("client_secret_basic")) return "client_secret_basic";
1274
+ if (supported.includes("client_secret_post")) return "client_secret_post";
1275
+ return "client_secret_basic";
847
1276
  }
848
- function validateSAMLAlgorithms(response, options) {
849
- validateSignatureAlgorithm(response.sigAlg, options);
850
- if (hasEncryptedAssertion(response.samlContent)) validateEncryptionAlgorithms(extractEncryptionAlgorithms(response.samlContent), options);
1277
+ /**
1278
+ * Check if a provider configuration needs runtime discovery.
1279
+ *
1280
+ * Returns true if we need discovery at runtime to complete the token exchange
1281
+ * and validation. Specifically checks for:
1282
+ * - `tokenEndpoint` - required for exchanging authorization code for tokens
1283
+ * - `jwksEndpoint` - required for validating ID token signatures
1284
+ *
1285
+ * Note: `authorizationEndpoint` is handled separately in the sign-in flow,
1286
+ * so it's not checked here.
1287
+ *
1288
+ * @param config - Partial OIDC config from the provider
1289
+ * @returns true if runtime discovery should be performed
1290
+ */
1291
+ function needsRuntimeDiscovery(config) {
1292
+ if (!config) return true;
1293
+ return !config.tokenEndpoint || !config.jwksEndpoint;
851
1294
  }
852
- function validateConfigAlgorithms(config, options = {}) {
853
- const { onDeprecated = "warn", allowedSignatureAlgorithms, allowedDigestAlgorithms } = options;
854
- if (config.signatureAlgorithm) {
855
- const normalized = normalizeSignatureAlgorithm(config.signatureAlgorithm);
856
- if (allowedSignatureAlgorithms) {
857
- if (!allowedSignatureAlgorithms.map(normalizeSignatureAlgorithm).includes(normalized)) throw new APIError("BAD_REQUEST", {
858
- message: `SAML signature algorithm not in allow-list: ${config.signatureAlgorithm}`,
859
- code: "SAML_ALGORITHM_NOT_ALLOWED"
860
- });
861
- } else if (DEPRECATED_SIGNATURE_ALGORITHMS.includes(normalized)) handleDeprecatedAlgorithm(`SAML config uses deprecated signature algorithm: ${config.signatureAlgorithm}. Consider using SHA-256 or stronger.`, onDeprecated, "SAML_DEPRECATED_CONFIG_ALGORITHM");
862
- else if (!SECURE_SIGNATURE_ALGORITHMS.includes(normalized)) throw new APIError("BAD_REQUEST", {
863
- message: `SAML signature algorithm not recognized: ${config.signatureAlgorithm}`,
864
- code: "SAML_UNKNOWN_ALGORITHM"
1295
+
1296
+ //#endregion
1297
+ //#region src/oidc/errors.ts
1298
+ /**
1299
+ * OIDC Discovery Error Mapping
1300
+ *
1301
+ * Maps DiscoveryError codes to appropriate APIError responses.
1302
+ * Used at the boundary between the discovery pipeline and HTTP handlers.
1303
+ */
1304
+ /**
1305
+ * Maps a DiscoveryError to an appropriate APIError for HTTP responses.
1306
+ *
1307
+ * Error code mapping:
1308
+ * - discovery_invalid_url → 400 BAD_REQUEST
1309
+ * - discovery_not_found → 400 BAD_REQUEST
1310
+ * - discovery_invalid_json → 400 BAD_REQUEST
1311
+ * - discovery_incomplete → 400 BAD_REQUEST
1312
+ * - issuer_mismatch → 400 BAD_REQUEST
1313
+ * - unsupported_token_auth_method → 400 BAD_REQUEST
1314
+ * - discovery_timeout → 502 BAD_GATEWAY
1315
+ * - discovery_unexpected_error → 502 BAD_GATEWAY
1316
+ *
1317
+ * @param error - The DiscoveryError to map
1318
+ * @returns An APIError with appropriate status and message
1319
+ */
1320
+ function mapDiscoveryErrorToAPIError(error) {
1321
+ switch (error.code) {
1322
+ case "discovery_timeout": return new APIError("BAD_GATEWAY", {
1323
+ message: `OIDC discovery timed out: ${error.message}`,
1324
+ code: error.code
865
1325
  });
866
- }
867
- if (config.digestAlgorithm) {
868
- const normalized = normalizeDigestAlgorithm(config.digestAlgorithm);
869
- if (allowedDigestAlgorithms) {
870
- if (!allowedDigestAlgorithms.map(normalizeDigestAlgorithm).includes(normalized)) throw new APIError("BAD_REQUEST", {
871
- message: `SAML digest algorithm not in allow-list: ${config.digestAlgorithm}`,
872
- code: "SAML_ALGORITHM_NOT_ALLOWED"
873
- });
874
- } else if (DEPRECATED_DIGEST_ALGORITHMS.includes(normalized)) handleDeprecatedAlgorithm(`SAML config uses deprecated digest algorithm: ${config.digestAlgorithm}. Consider using SHA-256 or stronger.`, onDeprecated, "SAML_DEPRECATED_CONFIG_ALGORITHM");
875
- else if (!SECURE_DIGEST_ALGORITHMS.includes(normalized)) throw new APIError("BAD_REQUEST", {
876
- message: `SAML digest algorithm not recognized: ${config.digestAlgorithm}`,
877
- code: "SAML_UNKNOWN_ALGORITHM"
1326
+ case "discovery_unexpected_error": return new APIError("BAD_GATEWAY", {
1327
+ message: `OIDC discovery failed: ${error.message}`,
1328
+ code: error.code
1329
+ });
1330
+ case "discovery_not_found": return new APIError("BAD_REQUEST", {
1331
+ message: `OIDC discovery endpoint not found. The issuer may not support OIDC discovery, or the URL is incorrect. ${error.message}`,
1332
+ code: error.code
1333
+ });
1334
+ case "discovery_invalid_url": return new APIError("BAD_REQUEST", {
1335
+ message: `Invalid OIDC discovery URL: ${error.message}`,
1336
+ code: error.code
1337
+ });
1338
+ case "discovery_untrusted_origin": return new APIError("BAD_REQUEST", {
1339
+ message: `Untrusted OIDC discovery URL: ${error.message}`,
1340
+ code: error.code
1341
+ });
1342
+ case "discovery_invalid_json": return new APIError("BAD_REQUEST", {
1343
+ message: `OIDC discovery returned invalid data: ${error.message}`,
1344
+ code: error.code
1345
+ });
1346
+ case "discovery_incomplete": return new APIError("BAD_REQUEST", {
1347
+ message: `OIDC discovery document is missing required fields: ${error.message}`,
1348
+ code: error.code
1349
+ });
1350
+ case "issuer_mismatch": return new APIError("BAD_REQUEST", {
1351
+ message: `OIDC issuer mismatch: ${error.message}`,
1352
+ code: error.code
1353
+ });
1354
+ case "unsupported_token_auth_method": return new APIError("BAD_REQUEST", {
1355
+ message: `Incompatible OIDC provider: ${error.message}`,
1356
+ code: error.code
878
1357
  });
1358
+ default:
1359
+ error.code;
1360
+ return new APIError("INTERNAL_SERVER_ERROR", {
1361
+ message: `Unexpected discovery error: ${error.message}`,
1362
+ code: "discovery_unexpected_error"
1363
+ });
879
1364
  }
880
1365
  }
881
1366
 
882
1367
  //#endregion
883
- //#region src/saml/assertions.ts
884
- /** @lintignore used in tests */
885
- function countAssertions(xml) {
886
- let parsed;
1368
+ //#region src/saml-state.ts
1369
+ async function generateRelayState(c, link, additionalData) {
1370
+ const callbackURL = c.body.callbackURL;
1371
+ if (!callbackURL) throw new APIError("BAD_REQUEST", { message: "callbackURL is required" });
1372
+ const codeVerifier = generateRandomString(128);
1373
+ const stateData = {
1374
+ ...additionalData ? additionalData : {},
1375
+ callbackURL,
1376
+ codeVerifier,
1377
+ errorURL: c.body.errorCallbackURL,
1378
+ newUserURL: c.body.newUserCallbackURL,
1379
+ link,
1380
+ expiresAt: Date.now() + 600 * 1e3,
1381
+ requestSignUp: c.body.requestSignUp
1382
+ };
887
1383
  try {
888
- parsed = xmlParser.parse(xml);
889
- } catch {
890
- throw new APIError("BAD_REQUEST", {
891
- message: "Failed to parse SAML response XML",
892
- code: "SAML_INVALID_XML"
1384
+ return generateGenericState(c, stateData, { cookieName: "relay_state" });
1385
+ } catch (error) {
1386
+ c.context.logger.error("Failed to create verification for relay state", error);
1387
+ throw new APIError("INTERNAL_SERVER_ERROR", {
1388
+ message: "State error: Unable to create verification for relay state",
1389
+ cause: error
893
1390
  });
894
1391
  }
895
- const assertions = countAllNodes(parsed, "Assertion");
896
- const encryptedAssertions = countAllNodes(parsed, "EncryptedAssertion");
897
- return {
898
- assertions,
899
- encryptedAssertions,
900
- total: assertions + encryptedAssertions
901
- };
902
1392
  }
903
- function validateSingleAssertion(samlResponse) {
904
- let xml;
1393
+ async function parseRelayState(c) {
1394
+ const state = c.body.RelayState;
1395
+ const errorURL = c.context.options.onAPIError?.errorURL || `${c.context.baseURL}/error`;
1396
+ let parsedData;
905
1397
  try {
906
- xml = new TextDecoder().decode(base64.decode(samlResponse));
907
- if (!xml.includes("<")) throw new Error("Not XML");
908
- } catch {
1398
+ parsedData = await parseGenericState(c, state, { cookieName: "relay_state" });
1399
+ } catch (error) {
1400
+ c.context.logger.error("Failed to parse relay state", error);
909
1401
  throw new APIError("BAD_REQUEST", {
910
- message: "Invalid base64-encoded SAML response",
911
- code: "SAML_INVALID_ENCODING"
1402
+ message: "State error: failed to validate relay state",
1403
+ cause: error
912
1404
  });
913
1405
  }
914
- const counts = countAssertions(xml);
915
- if (counts.total === 0) throw new APIError("BAD_REQUEST", {
916
- message: "SAML response contains no assertions",
917
- code: "SAML_NO_ASSERTION"
918
- });
919
- if (counts.total > 1) throw new APIError("BAD_REQUEST", {
920
- message: `SAML response contains ${counts.total} assertions, expected exactly 1`,
921
- code: "SAML_MULTIPLE_ASSERTIONS"
922
- });
923
- }
924
-
925
- //#endregion
926
- //#region src/utils.ts
927
- /**
928
- * Safely parses a value that might be a JSON string or already a parsed object.
929
- * This handles cases where ORMs like Drizzle might return already parsed objects
930
- * instead of JSON strings from TEXT/JSON columns.
931
- *
932
- * @param value - The value to parse (string, object, null, or undefined)
933
- * @returns The parsed object or null
934
- * @throws Error if string parsing fails
935
- */
936
- function safeJsonParse(value) {
937
- if (!value) return null;
938
- if (typeof value === "object") return value;
939
- if (typeof value === "string") try {
940
- return JSON.parse(value);
941
- } catch (error) {
942
- throw new Error(`Failed to parse JSON: ${error instanceof Error ? error.message : "Unknown error"}`);
943
- }
944
- return null;
1406
+ if (!parsedData.errorURL) parsedData.errorURL = errorURL;
1407
+ return parsedData;
945
1408
  }
946
- const validateEmailDomain = (email, domain) => {
947
- const emailDomain = email.split("@")[1]?.toLowerCase();
948
- const providerDomain = domain.toLowerCase();
949
- if (!emailDomain || !providerDomain) return false;
950
- return emailDomain === providerDomain || emailDomain.endsWith(`.${providerDomain}`);
951
- };
952
1409
 
953
1410
  //#endregion
954
1411
  //#region src/routes/sso.ts
@@ -1043,6 +1500,7 @@ const spMetadata = () => {
1043
1500
  Binding: "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST",
1044
1501
  Location: parsedSamlConfig.callbackUrl || `${ctx.context.baseURL}/sso/saml2/sp/acs/${provider.id}`
1045
1502
  }],
1503
+ authnRequestsSigned: parsedSamlConfig.authnRequestsSigned || false,
1046
1504
  wantMessageSigned: parsedSamlConfig.wantAssertionsSigned || false,
1047
1505
  nameIDFormat: parsedSamlConfig.identifierFormat ? [parsedSamlConfig.identifierFormat] : void 0
1048
1506
  });
@@ -1052,7 +1510,7 @@ const spMetadata = () => {
1052
1510
  const ssoProviderBodySchema = z.object({
1053
1511
  providerId: z.string({}).meta({ description: "The ID of the provider. This is used to identify the provider during login and callback" }),
1054
1512
  issuer: z.string({}).meta({ description: "The issuer of the provider" }),
1055
- domain: z.string({}).meta({ description: "The domain of the provider. This is used for email matching" }),
1513
+ domain: z.string({}).meta({ description: "The domain(s) of the provider. For enterprise multi-domain SSO where a single IdP serves multiple email domains, use comma-separated values (e.g., 'company.com,subsidiary.com,acquired-company.com')" }),
1056
1514
  oidcConfig: z.object({
1057
1515
  clientId: z.string({}).meta({ description: "The client ID" }),
1058
1516
  clientSecret: z.string({}).meta({ description: "The client secret" }),
@@ -1427,7 +1885,7 @@ const registerSSOProvider = (options) => {
1427
1885
  await ctx.context.adapter.create({
1428
1886
  model: "verification",
1429
1887
  data: {
1430
- identifier: options.domainVerification?.tokenPrefix ? `${options.domainVerification?.tokenPrefix}-${provider.providerId}` : `better-auth-token-${provider.providerId}`,
1888
+ identifier: getVerificationIdentifier(options, provider.providerId),
1431
1889
  createdAt: /* @__PURE__ */ new Date(),
1432
1890
  updatedAt: /* @__PURE__ */ new Date(),
1433
1891
  value: domainVerificationToken,
@@ -1551,20 +2009,33 @@ const signInSSO = (options) => {
1551
2009
  };
1552
2010
  }
1553
2011
  if (!providerId && !orgId && !domain) throw new APIError("BAD_REQUEST", { message: "providerId, orgId or domain is required" });
1554
- if (!provider) provider = await ctx.context.adapter.findOne({
1555
- model: "ssoProvider",
1556
- where: [{
1557
- field: providerId ? "providerId" : orgId ? "organizationId" : "domain",
1558
- value: providerId || orgId || domain
1559
- }]
1560
- }).then((res) => {
1561
- if (!res) return null;
1562
- return {
1563
- ...res,
1564
- oidcConfig: res.oidcConfig ? safeJsonParse(res.oidcConfig) || void 0 : void 0,
1565
- samlConfig: res.samlConfig ? safeJsonParse(res.samlConfig) || void 0 : void 0
2012
+ if (!provider) {
2013
+ const parseProvider = (res) => {
2014
+ if (!res) return null;
2015
+ return {
2016
+ ...res,
2017
+ oidcConfig: res.oidcConfig ? safeJsonParse(res.oidcConfig) || void 0 : void 0,
2018
+ samlConfig: res.samlConfig ? safeJsonParse(res.samlConfig) || void 0 : void 0
2019
+ };
1566
2020
  };
1567
- });
2021
+ if (providerId || orgId) provider = parseProvider(await ctx.context.adapter.findOne({
2022
+ model: "ssoProvider",
2023
+ where: [{
2024
+ field: providerId ? "providerId" : "organizationId",
2025
+ value: providerId || orgId
2026
+ }]
2027
+ }));
2028
+ else if (domain) {
2029
+ provider = parseProvider(await ctx.context.adapter.findOne({
2030
+ model: "ssoProvider",
2031
+ where: [{
2032
+ field: "domain",
2033
+ value: domain
2034
+ }]
2035
+ }));
2036
+ if (!provider) provider = parseProvider((await ctx.context.adapter.findMany({ model: "ssoProvider" })).find((p) => domainMatches(domain, p.domain)) ?? null);
2037
+ }
2038
+ }
1568
2039
  if (!provider) throw new APIError("NOT_FOUND", { message: "No provider found for the issuer" });
1569
2040
  if (body.providerType) {
1570
2041
  if (body.providerType === "oidc" && !provider.oidcConfig) throw new APIError("BAD_REQUEST", { message: "OIDC provider is not configured" });
@@ -1613,21 +2084,41 @@ const signInSSO = (options) => {
1613
2084
  Binding: "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST",
1614
2085
  Location: parsedSamlConfig.callbackUrl || `${ctx.context.baseURL}/sso/saml2/sp/acs/${provider.providerId}`
1615
2086
  }],
2087
+ authnRequestsSigned: parsedSamlConfig.authnRequestsSigned || false,
1616
2088
  wantMessageSigned: parsedSamlConfig.wantAssertionsSigned || false,
1617
2089
  nameIDFormat: parsedSamlConfig.identifierFormat ? [parsedSamlConfig.identifierFormat] : void 0
1618
2090
  }).getMetadata() || "";
1619
2091
  const sp = saml.ServiceProvider({
1620
2092
  metadata,
2093
+ privateKey: parsedSamlConfig.spMetadata?.privateKey || parsedSamlConfig.privateKey,
2094
+ privateKeyPass: parsedSamlConfig.spMetadata?.privateKeyPass,
1621
2095
  allowCreate: true
1622
2096
  });
1623
- const idp = saml.IdentityProvider({
1624
- metadata: parsedSamlConfig.idpMetadata?.metadata,
1625
- entityID: parsedSamlConfig.idpMetadata?.entityID,
1626
- encryptCert: parsedSamlConfig.idpMetadata?.cert,
1627
- singleSignOnService: parsedSamlConfig.idpMetadata?.singleSignOnService
2097
+ const idpData = parsedSamlConfig.idpMetadata;
2098
+ let idp;
2099
+ if (!idpData?.metadata) idp = saml.IdentityProvider({
2100
+ entityID: idpData?.entityID || parsedSamlConfig.issuer,
2101
+ singleSignOnService: idpData?.singleSignOnService || [{
2102
+ Binding: "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect",
2103
+ Location: parsedSamlConfig.entryPoint
2104
+ }],
2105
+ signingCert: idpData?.cert || parsedSamlConfig.cert,
2106
+ wantAuthnRequestsSigned: parsedSamlConfig.authnRequestsSigned || false,
2107
+ isAssertionEncrypted: idpData?.isAssertionEncrypted || false,
2108
+ encPrivateKey: idpData?.encPrivateKey,
2109
+ encPrivateKeyPass: idpData?.encPrivateKeyPass
2110
+ });
2111
+ else idp = saml.IdentityProvider({
2112
+ metadata: idpData.metadata,
2113
+ privateKey: idpData.privateKey,
2114
+ privateKeyPass: idpData.privateKeyPass,
2115
+ isAssertionEncrypted: idpData.isAssertionEncrypted,
2116
+ encPrivateKey: idpData.encPrivateKey,
2117
+ encPrivateKeyPass: idpData.encPrivateKeyPass
1628
2118
  });
1629
2119
  const loginRequest = sp.createLoginRequest(idp, "redirect");
1630
2120
  if (!loginRequest) throw new APIError("BAD_REQUEST", { message: "Invalid SAML request" });
2121
+ const { state: relayState } = await generateRelayState(ctx, void 0, false);
1631
2122
  if (loginRequest.id && options?.saml?.enableInResponseToValidation) {
1632
2123
  const ttl = options?.saml?.requestTTL ?? DEFAULT_AUTHN_REQUEST_TTL_MS;
1633
2124
  const record = {
@@ -1643,7 +2134,7 @@ const signInSSO = (options) => {
1643
2134
  });
1644
2135
  }
1645
2136
  return ctx.json({
1646
- url: `${loginRequest.context}&RelayState=${encodeURIComponent(body.callbackURL)}`,
2137
+ url: `${loginRequest.context}&RelayState=${encodeURIComponent(relayState)}`,
1647
2138
  redirect: true
1648
2139
  });
1649
2140
  }
@@ -1702,10 +2193,10 @@ const callbackSSO = (options) => {
1702
2193
  oidcConfig: safeJsonParse(res.oidcConfig) || void 0
1703
2194
  };
1704
2195
  });
1705
- if (!provider) throw ctx.redirect(`${errorURL || callbackURL}/error?error=invalid_provider&error_description=provider not found`);
2196
+ if (!provider) throw ctx.redirect(`${errorURL || callbackURL}?error=invalid_provider&error_description=provider not found`);
1706
2197
  if (options?.domainVerification?.enabled && !("domainVerified" in provider && provider.domainVerified)) throw new APIError("UNAUTHORIZED", { message: "Provider domain has not been verified" });
1707
2198
  let config = provider.oidcConfig;
1708
- if (!config) throw ctx.redirect(`${errorURL || callbackURL}/error?error=invalid_provider&error_description=provider not found`);
2199
+ if (!config) throw ctx.redirect(`${errorURL || callbackURL}?error=invalid_provider&error_description=provider not found`);
1709
2200
  const discovery = await betterFetch(config.discoveryEndpoint);
1710
2201
  if (discovery.data) config = {
1711
2202
  tokenEndpoint: discovery.data.token_endpoint,
@@ -1719,7 +2210,7 @@ const callbackSSO = (options) => {
1719
2210
  ],
1720
2211
  ...config
1721
2212
  };
1722
- if (!config.tokenEndpoint) throw ctx.redirect(`${errorURL || callbackURL}/error?error=invalid_provider&error_description=token_endpoint_not_found`);
2213
+ if (!config.tokenEndpoint) throw ctx.redirect(`${errorURL || callbackURL}?error=invalid_provider&error_description=token_endpoint_not_found`);
1723
2214
  const tokenResponse = await validateAuthorizationCode({
1724
2215
  code,
1725
2216
  codeVerifier: config.pkce ? stateData.codeVerifier : void 0,
@@ -1734,17 +2225,19 @@ const callbackSSO = (options) => {
1734
2225
  if (e instanceof BetterFetchError) throw ctx.redirect(`${errorURL || callbackURL}?error=invalid_provider&error_description=${e.message}`);
1735
2226
  return null;
1736
2227
  });
1737
- if (!tokenResponse) throw ctx.redirect(`${errorURL || callbackURL}/error?error=invalid_provider&error_description=token_response_not_found`);
2228
+ if (!tokenResponse) throw ctx.redirect(`${errorURL || callbackURL}?error=invalid_provider&error_description=token_response_not_found`);
1738
2229
  let userInfo = null;
1739
2230
  if (tokenResponse.idToken) {
1740
2231
  const idToken = decodeJwt(tokenResponse.idToken);
1741
- if (!config.jwksEndpoint) throw ctx.redirect(`${errorURL || callbackURL}/error?error=invalid_provider&error_description=jwks_endpoint_not_found`);
1742
- const verified = await validateToken(tokenResponse.idToken, config.jwksEndpoint).catch((e) => {
2232
+ if (!config.jwksEndpoint) throw ctx.redirect(`${errorURL || callbackURL}?error=invalid_provider&error_description=jwks_endpoint_not_found`);
2233
+ const verified = await validateToken(tokenResponse.idToken, config.jwksEndpoint, {
2234
+ audience: config.clientId,
2235
+ issuer: provider.issuer
2236
+ }).catch((e) => {
1743
2237
  ctx.context.logger.error(e);
1744
2238
  return null;
1745
2239
  });
1746
- if (!verified) throw ctx.redirect(`${errorURL || callbackURL}/error?error=invalid_provider&error_description=token_not_verified`);
1747
- if (verified.payload.iss !== provider.issuer) throw ctx.redirect(`${errorURL || callbackURL}/error?error=invalid_provider&error_description=issuer_mismatch`);
2240
+ if (!verified) throw ctx.redirect(`${errorURL || callbackURL}?error=invalid_provider&error_description=token_not_verified`);
1748
2241
  const mapping = config.mapping || {};
1749
2242
  userInfo = {
1750
2243
  ...Object.fromEntries(Object.entries(mapping.extraFields || {}).map(([key, value]) => [key, verified.payload[value]])),
@@ -1756,12 +2249,12 @@ const callbackSSO = (options) => {
1756
2249
  };
1757
2250
  }
1758
2251
  if (!userInfo) {
1759
- if (!config.userInfoEndpoint) throw ctx.redirect(`${errorURL || callbackURL}/error?error=invalid_provider&error_description=user_info_endpoint_not_found`);
2252
+ if (!config.userInfoEndpoint) throw ctx.redirect(`${errorURL || callbackURL}?error=invalid_provider&error_description=user_info_endpoint_not_found`);
1760
2253
  const userInfoResponse = await betterFetch(config.userInfoEndpoint, { headers: { Authorization: `Bearer ${tokenResponse.accessToken}` } });
1761
- if (userInfoResponse.error) throw ctx.redirect(`${errorURL || callbackURL}/error?error=invalid_provider&error_description=${userInfoResponse.error.message}`);
2254
+ if (userInfoResponse.error) throw ctx.redirect(`${errorURL || callbackURL}?error=invalid_provider&error_description=${userInfoResponse.error.message}`);
1762
2255
  userInfo = userInfoResponse.data;
1763
2256
  }
1764
- if (!userInfo.email || !userInfo.id) throw ctx.redirect(`${errorURL || callbackURL}/error?error=invalid_provider&error_description=missing_user_info`);
2257
+ if (!userInfo.email || !userInfo.id) throw ctx.redirect(`${errorURL || callbackURL}?error=invalid_provider&error_description=missing_user_info`);
1765
2258
  const isTrustedProvider = "domainVerified" in provider && provider.domainVerified === true && validateEmailDomain(userInfo.email, provider.domain);
1766
2259
  const linked = await handleOAuthUserInfo(ctx, {
1767
2260
  userInfo: {
@@ -1786,7 +2279,7 @@ const callbackSSO = (options) => {
1786
2279
  overrideUserInfo: config.overrideUserInfo,
1787
2280
  isTrustedProvider
1788
2281
  });
1789
- if (linked.error) throw ctx.redirect(`${errorURL || callbackURL}/error?error=${linked.error}`);
2282
+ if (linked.error) throw ctx.redirect(`${errorURL || callbackURL}?error=${linked.error}`);
1790
2283
  const { session, user } = linked.data;
1791
2284
  if (options?.provisionUser) await options.provisionUser({
1792
2285
  user,
@@ -1825,17 +2318,46 @@ const callbackSSOSAMLBodySchema = z.object({
1825
2318
  SAMLResponse: z.string(),
1826
2319
  RelayState: z.string().optional()
1827
2320
  });
2321
+ /**
2322
+ * Validates and returns a safe redirect URL.
2323
+ * - Prevents open redirect attacks by validating against trusted origins
2324
+ * - Prevents redirect loops by checking if URL points to callback route
2325
+ * - Falls back to appOrigin if URL is invalid or unsafe
2326
+ */
2327
+ const getSafeRedirectUrl = (url, callbackPath, appOrigin, isTrustedOrigin) => {
2328
+ if (!url) return appOrigin;
2329
+ if (url.startsWith("/") && !url.startsWith("//")) {
2330
+ try {
2331
+ const absoluteUrl = new URL(url, appOrigin);
2332
+ if (absoluteUrl.origin !== appOrigin) return appOrigin;
2333
+ const callbackPathname = new URL(callbackPath).pathname;
2334
+ if (absoluteUrl.pathname === callbackPathname) return appOrigin;
2335
+ } catch {
2336
+ return appOrigin;
2337
+ }
2338
+ return url;
2339
+ }
2340
+ if (!isTrustedOrigin(url, { allowRelativePaths: false })) return appOrigin;
2341
+ try {
2342
+ const callbackPathname = new URL(callbackPath).pathname;
2343
+ if (new URL(url).pathname === callbackPathname) return appOrigin;
2344
+ } catch {
2345
+ if (url === callbackPath || url.startsWith(`${callbackPath}?`)) return appOrigin;
2346
+ }
2347
+ return url;
2348
+ };
1828
2349
  const callbackSSOSAML = (options) => {
1829
2350
  return createAuthEndpoint("/sso/saml2/callback/:providerId", {
1830
- method: "POST",
1831
- body: callbackSSOSAMLBodySchema,
2351
+ method: ["GET", "POST"],
2352
+ body: callbackSSOSAMLBodySchema.optional(),
2353
+ query: z.object({ RelayState: z.string().optional() }).optional(),
1832
2354
  metadata: {
1833
2355
  ...HIDE_METADATA,
1834
2356
  allowedMediaTypes: ["application/x-www-form-urlencoded", "application/json"],
1835
2357
  openapi: {
1836
2358
  operationId: "handleSAMLCallback",
1837
2359
  summary: "Callback URL for SAML provider",
1838
- description: "This endpoint is used as the callback URL for SAML providers.",
2360
+ description: "This endpoint is used as the callback URL for SAML providers. Supports both GET and POST methods for IdP-initiated and SP-initiated flows.",
1839
2361
  responses: {
1840
2362
  "302": { description: "Redirects to the callback URL" },
1841
2363
  "400": { description: "Invalid SAML response" },
@@ -1844,10 +2366,26 @@ const callbackSSOSAML = (options) => {
1844
2366
  }
1845
2367
  }
1846
2368
  }, async (ctx) => {
1847
- const { SAMLResponse, RelayState } = ctx.body;
1848
2369
  const { providerId } = ctx.params;
2370
+ const appOrigin = new URL(ctx.context.baseURL).origin;
2371
+ const errorURL = ctx.context.options.onAPIError?.errorURL || `${appOrigin}/error`;
2372
+ const currentCallbackPath = `${ctx.context.baseURL}/sso/saml2/callback/${providerId}`;
2373
+ if (ctx.method === "GET" && !ctx.body?.SAMLResponse) {
2374
+ if (!(await getSessionFromCtx(ctx))?.session) throw ctx.redirect(`${errorURL}?error=invalid_request`);
2375
+ const relayState$1 = ctx.query?.RelayState;
2376
+ const safeRedirectUrl = getSafeRedirectUrl(relayState$1, currentCallbackPath, appOrigin, (url, settings) => ctx.context.isTrustedOrigin(url, settings));
2377
+ throw ctx.redirect(safeRedirectUrl);
2378
+ }
2379
+ if (!ctx.body?.SAMLResponse) throw new APIError("BAD_REQUEST", { message: "SAMLResponse is required for POST requests" });
2380
+ const { SAMLResponse } = ctx.body;
1849
2381
  const maxResponseSize = options?.saml?.maxResponseSize ?? DEFAULT_MAX_SAML_RESPONSE_SIZE;
1850
2382
  if (new TextEncoder().encode(SAMLResponse).length > maxResponseSize) throw new APIError("BAD_REQUEST", { message: `SAML response exceeds maximum allowed size (${maxResponseSize} bytes)` });
2383
+ let relayState = null;
2384
+ if (ctx.body.RelayState) try {
2385
+ relayState = await parseRelayState(ctx);
2386
+ } catch {
2387
+ relayState = null;
2388
+ }
1851
2389
  let provider = null;
1852
2390
  if (options?.defaultSSO?.length) {
1853
2391
  const matchingDefault = options.defaultSSO.find((defaultProvider) => defaultProvider.providerId === providerId);
@@ -1875,16 +2413,18 @@ const callbackSSOSAML = (options) => {
1875
2413
  if (options?.domainVerification?.enabled && !("domainVerified" in provider && provider.domainVerified)) throw new APIError("UNAUTHORIZED", { message: "Provider domain has not been verified" });
1876
2414
  const parsedSamlConfig = safeJsonParse(provider.samlConfig);
1877
2415
  if (!parsedSamlConfig) throw new APIError("BAD_REQUEST", { message: "Invalid SAML configuration" });
2416
+ const isTrusted = (url, settings) => ctx.context.isTrustedOrigin(url, settings);
2417
+ const safeErrorUrl = getSafeRedirectUrl(relayState?.errorURL || relayState?.callbackURL || parsedSamlConfig.callbackUrl, currentCallbackPath, appOrigin, isTrusted);
1878
2418
  const idpData = parsedSamlConfig.idpMetadata;
1879
2419
  let idp = null;
1880
2420
  if (!idpData?.metadata) idp = saml.IdentityProvider({
1881
2421
  entityID: idpData?.entityID || parsedSamlConfig.issuer,
1882
- singleSignOnService: [{
2422
+ singleSignOnService: idpData?.singleSignOnService || [{
1883
2423
  Binding: "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect",
1884
2424
  Location: parsedSamlConfig.entryPoint
1885
2425
  }],
1886
2426
  signingCert: idpData?.cert || parsedSamlConfig.cert,
1887
- wantAuthnRequestsSigned: parsedSamlConfig.wantAssertionsSigned || false,
2427
+ wantAuthnRequestsSigned: parsedSamlConfig.authnRequestsSigned || false,
1888
2428
  isAssertionEncrypted: idpData?.isAssertionEncrypted || false,
1889
2429
  encPrivateKey: idpData?.encPrivateKey,
1890
2430
  encPrivateKeyPass: idpData?.encPrivateKeyPass
@@ -1918,13 +2458,13 @@ const callbackSSOSAML = (options) => {
1918
2458
  try {
1919
2459
  parsedResponse = await sp.parseLoginResponse(idp, "post", { body: {
1920
2460
  SAMLResponse,
1921
- RelayState: RelayState || void 0
2461
+ RelayState: ctx.body.RelayState || void 0
1922
2462
  } });
1923
2463
  if (!parsedResponse?.extract) throw new Error("Invalid SAML response structure");
1924
2464
  } catch (error) {
1925
2465
  ctx.context.logger.error("SAML response validation failed", {
1926
2466
  error,
1927
- decodedResponse: Buffer.from(SAMLResponse, "base64").toString("utf-8")
2467
+ decodedResponse: new TextDecoder().decode(base64.decode(SAMLResponse))
1928
2468
  });
1929
2469
  throw new APIError("BAD_REQUEST", {
1930
2470
  message: "Invalid SAML response",
@@ -1955,8 +2495,7 @@ const callbackSSOSAML = (options) => {
1955
2495
  inResponseTo,
1956
2496
  providerId: provider.providerId
1957
2497
  });
1958
- const redirectUrl = RelayState || parsedSamlConfig.callbackUrl || ctx.context.baseURL;
1959
- throw ctx.redirect(`${redirectUrl}?error=invalid_saml_response&error_description=Unknown+or+expired+request+ID`);
2498
+ throw ctx.redirect(`${safeErrorUrl}?error=invalid_saml_response&error_description=Unknown+or+expired+request+ID`);
1960
2499
  }
1961
2500
  if (storedRequest.providerId !== provider.providerId) {
1962
2501
  ctx.context.logger.error("SAML InResponseTo validation failed: provider mismatch", {
@@ -1965,14 +2504,12 @@ const callbackSSOSAML = (options) => {
1965
2504
  actualProvider: provider.providerId
1966
2505
  });
1967
2506
  await ctx.context.internalAdapter.deleteVerificationByIdentifier(`${AUTHN_REQUEST_KEY_PREFIX}${inResponseTo}`);
1968
- const redirectUrl = RelayState || parsedSamlConfig.callbackUrl || ctx.context.baseURL;
1969
- throw ctx.redirect(`${redirectUrl}?error=invalid_saml_response&error_description=Provider+mismatch`);
2507
+ throw ctx.redirect(`${safeErrorUrl}?error=invalid_saml_response&error_description=Provider+mismatch`);
1970
2508
  }
1971
2509
  await ctx.context.internalAdapter.deleteVerificationByIdentifier(`${AUTHN_REQUEST_KEY_PREFIX}${inResponseTo}`);
1972
2510
  } else if (!allowIdpInitiated) {
1973
2511
  ctx.context.logger.error("SAML IdP-initiated SSO rejected: InResponseTo missing and allowIdpInitiated is false", { providerId: provider.providerId });
1974
- const redirectUrl = RelayState || parsedSamlConfig.callbackUrl || ctx.context.baseURL;
1975
- throw ctx.redirect(`${redirectUrl}?error=unsolicited_response&error_description=IdP-initiated+SSO+not+allowed`);
2512
+ throw ctx.redirect(`${safeErrorUrl}?error=unsolicited_response&error_description=IdP-initiated+SSO+not+allowed`);
1976
2513
  }
1977
2514
  }
1978
2515
  const samlContent = parsedResponse.samlContent;
@@ -1998,8 +2535,7 @@ const callbackSSOSAML = (options) => {
1998
2535
  issuer,
1999
2536
  providerId: provider.providerId
2000
2537
  });
2001
- const redirectUrl = RelayState || parsedSamlConfig.callbackUrl || ctx.context.baseURL;
2002
- throw ctx.redirect(`${redirectUrl}?error=replay_detected&error_description=SAML+assertion+has+already+been+used`);
2538
+ throw ctx.redirect(`${safeErrorUrl}?error=replay_detected&error_description=SAML+assertion+has+already+been+used`);
2003
2539
  }
2004
2540
  await ctx.context.internalAdapter.createVerificationValue({
2005
2541
  identifier: `${USED_ASSERTION_KEY_PREFIX}${assertionId}`,
@@ -2032,7 +2568,7 @@ const callbackSSOSAML = (options) => {
2032
2568
  throw new APIError("BAD_REQUEST", { message: "Unable to extract user ID or email from SAML response" });
2033
2569
  }
2034
2570
  const isTrustedProvider = !!ctx.context.options.account?.accountLinking?.trustedProviders?.includes(provider.providerId) || "domainVerified" in provider && !!provider.domainVerified && validateEmailDomain(userInfo.email, provider.domain);
2035
- const callbackUrl = RelayState || parsedSamlConfig.callbackUrl || ctx.context.baseURL;
2571
+ const safeCallbackUrl = getSafeRedirectUrl(relayState?.callbackURL || parsedSamlConfig.callbackUrl, currentCallbackPath, appOrigin, isTrusted);
2036
2572
  const result = await handleOAuthUserInfo(ctx, {
2037
2573
  userInfo: {
2038
2574
  email: userInfo.email,
@@ -2046,11 +2582,11 @@ const callbackSSOSAML = (options) => {
2046
2582
  accessToken: "",
2047
2583
  refreshToken: ""
2048
2584
  },
2049
- callbackURL: callbackUrl,
2585
+ callbackURL: safeCallbackUrl,
2050
2586
  disableSignUp: options?.disableImplicitSignUp,
2051
2587
  isTrustedProvider
2052
2588
  });
2053
- if (result.error) throw ctx.redirect(`${callbackUrl}?error=${result.error.split(" ").join("_")}`);
2589
+ if (result.error) throw ctx.redirect(`${safeCallbackUrl}?error=${result.error.split(" ").join("_")}`);
2054
2590
  const { session, user } = result.data;
2055
2591
  if (options?.provisionUser) await options.provisionUser({
2056
2592
  user,
@@ -2074,7 +2610,7 @@ const callbackSSOSAML = (options) => {
2074
2610
  session,
2075
2611
  user
2076
2612
  });
2077
- throw ctx.redirect(callbackUrl);
2613
+ throw ctx.redirect(safeCallbackUrl);
2078
2614
  });
2079
2615
  };
2080
2616
  const acsEndpointBodySchema = z.object({
@@ -2096,10 +2632,18 @@ const acsEndpoint = (options) => {
2096
2632
  }
2097
2633
  }
2098
2634
  }, async (ctx) => {
2099
- const { SAMLResponse, RelayState = "" } = ctx.body;
2635
+ const { SAMLResponse } = ctx.body;
2100
2636
  const { providerId } = ctx.params;
2637
+ const currentCallbackPath = `${ctx.context.baseURL}/sso/saml2/sp/acs/${providerId}`;
2638
+ const appOrigin = new URL(ctx.context.baseURL).origin;
2101
2639
  const maxResponseSize = options?.saml?.maxResponseSize ?? DEFAULT_MAX_SAML_RESPONSE_SIZE;
2102
2640
  if (new TextEncoder().encode(SAMLResponse).length > maxResponseSize) throw new APIError("BAD_REQUEST", { message: `SAML response exceeds maximum allowed size (${maxResponseSize} bytes)` });
2641
+ let relayState = null;
2642
+ if (ctx.body.RelayState) try {
2643
+ relayState = await parseRelayState(ctx);
2644
+ } catch {
2645
+ relayState = null;
2646
+ }
2103
2647
  let provider = null;
2104
2648
  if (options?.defaultSSO?.length) {
2105
2649
  const matchingDefault = providerId ? options.defaultSSO.find((defaultProvider) => defaultProvider.providerId === providerId) : options.defaultSSO[0];
@@ -2127,6 +2671,8 @@ const acsEndpoint = (options) => {
2127
2671
  if (!provider?.samlConfig) throw new APIError("NOT_FOUND", { message: "No SAML provider found" });
2128
2672
  if (options?.domainVerification?.enabled && !("domainVerified" in provider && provider.domainVerified)) throw new APIError("UNAUTHORIZED", { message: "Provider domain has not been verified" });
2129
2673
  const parsedSamlConfig = provider.samlConfig;
2674
+ const isTrusted = (url, settings) => ctx.context.isTrustedOrigin(url, settings);
2675
+ const safeErrorUrl = getSafeRedirectUrl(relayState?.errorURL || relayState?.callbackURL || parsedSamlConfig.callbackUrl, currentCallbackPath, appOrigin, isTrusted);
2130
2676
  const sp = saml.ServiceProvider({
2131
2677
  entityID: parsedSamlConfig.spMetadata?.entityID || parsedSamlConfig.issuer,
2132
2678
  assertionConsumerService: [{
@@ -2152,9 +2698,8 @@ const acsEndpoint = (options) => {
2152
2698
  validateSingleAssertion(SAMLResponse);
2153
2699
  } catch (error) {
2154
2700
  if (error instanceof APIError) {
2155
- const redirectUrl = RelayState || parsedSamlConfig.callbackUrl || ctx.context.baseURL;
2156
2701
  const errorCode = error.body?.code === "SAML_MULTIPLE_ASSERTIONS" ? "multiple_assertions" : "no_assertion";
2157
- throw ctx.redirect(`${redirectUrl}?error=${errorCode}&error_description=${encodeURIComponent(error.message)}`);
2702
+ throw ctx.redirect(`${safeErrorUrl}?error=${errorCode}&error_description=${encodeURIComponent(error.message)}`);
2158
2703
  }
2159
2704
  throw error;
2160
2705
  }
@@ -2162,13 +2707,13 @@ const acsEndpoint = (options) => {
2162
2707
  try {
2163
2708
  parsedResponse = await sp.parseLoginResponse(idp, "post", { body: {
2164
2709
  SAMLResponse,
2165
- RelayState: RelayState || void 0
2710
+ RelayState: ctx.body.RelayState || void 0
2166
2711
  } });
2167
2712
  if (!parsedResponse?.extract) throw new Error("Invalid SAML response structure");
2168
2713
  } catch (error) {
2169
2714
  ctx.context.logger.error("SAML response validation failed", {
2170
2715
  error,
2171
- decodedResponse: Buffer.from(SAMLResponse, "base64").toString("utf-8")
2716
+ decodedResponse: new TextDecoder().decode(base64.decode(SAMLResponse))
2172
2717
  });
2173
2718
  throw new APIError("BAD_REQUEST", {
2174
2719
  message: "Invalid SAML response",
@@ -2199,8 +2744,7 @@ const acsEndpoint = (options) => {
2199
2744
  inResponseTo: inResponseToAcs,
2200
2745
  providerId
2201
2746
  });
2202
- const redirectUrl = RelayState || parsedSamlConfig.callbackUrl || ctx.context.baseURL;
2203
- throw ctx.redirect(`${redirectUrl}?error=invalid_saml_response&error_description=Unknown+or+expired+request+ID`);
2747
+ throw ctx.redirect(`${safeErrorUrl}?error=invalid_saml_response&error_description=Unknown+or+expired+request+ID`);
2204
2748
  }
2205
2749
  if (storedRequest.providerId !== providerId) {
2206
2750
  ctx.context.logger.error("SAML InResponseTo validation failed: provider mismatch", {
@@ -2209,17 +2753,15 @@ const acsEndpoint = (options) => {
2209
2753
  actualProvider: providerId
2210
2754
  });
2211
2755
  await ctx.context.internalAdapter.deleteVerificationByIdentifier(`${AUTHN_REQUEST_KEY_PREFIX}${inResponseToAcs}`);
2212
- const redirectUrl = RelayState || parsedSamlConfig.callbackUrl || ctx.context.baseURL;
2213
- throw ctx.redirect(`${redirectUrl}?error=invalid_saml_response&error_description=Provider+mismatch`);
2756
+ throw ctx.redirect(`${safeErrorUrl}?error=invalid_saml_response&error_description=Provider+mismatch`);
2214
2757
  }
2215
2758
  await ctx.context.internalAdapter.deleteVerificationByIdentifier(`${AUTHN_REQUEST_KEY_PREFIX}${inResponseToAcs}`);
2216
2759
  } else if (!allowIdpInitiated) {
2217
2760
  ctx.context.logger.error("SAML IdP-initiated SSO rejected: InResponseTo missing and allowIdpInitiated is false", { providerId });
2218
- const redirectUrl = RelayState || parsedSamlConfig.callbackUrl || ctx.context.baseURL;
2219
- throw ctx.redirect(`${redirectUrl}?error=unsolicited_response&error_description=IdP-initiated+SSO+not+allowed`);
2761
+ throw ctx.redirect(`${safeErrorUrl}?error=unsolicited_response&error_description=IdP-initiated+SSO+not+allowed`);
2220
2762
  }
2221
2763
  }
2222
- const assertionIdAcs = extractAssertionId(Buffer.from(SAMLResponse, "base64").toString("utf-8"));
2764
+ const assertionIdAcs = extractAssertionId(new TextDecoder().decode(base64.decode(SAMLResponse)));
2223
2765
  if (assertionIdAcs) {
2224
2766
  const issuer = idp.entityMeta.getEntityID();
2225
2767
  const conditions = extract.conditions;
@@ -2241,8 +2783,7 @@ const acsEndpoint = (options) => {
2241
2783
  issuer,
2242
2784
  providerId
2243
2785
  });
2244
- const redirectUrl = RelayState || parsedSamlConfig.callbackUrl || ctx.context.baseURL;
2245
- throw ctx.redirect(`${redirectUrl}?error=replay_detected&error_description=SAML+assertion+has+already+been+used`);
2786
+ throw ctx.redirect(`${safeErrorUrl}?error=replay_detected&error_description=SAML+assertion+has+already+been+used`);
2246
2787
  }
2247
2788
  await ctx.context.internalAdapter.createVerificationValue({
2248
2789
  identifier: `${USED_ASSERTION_KEY_PREFIX}${assertionIdAcs}`,
@@ -2275,7 +2816,7 @@ const acsEndpoint = (options) => {
2275
2816
  throw new APIError("BAD_REQUEST", { message: "Unable to extract user ID or email from SAML response" });
2276
2817
  }
2277
2818
  const isTrustedProvider = !!ctx.context.options.account?.accountLinking?.trustedProviders?.includes(provider.providerId) || "domainVerified" in provider && !!provider.domainVerified && validateEmailDomain(userInfo.email, provider.domain);
2278
- const callbackUrl = RelayState || parsedSamlConfig.callbackUrl || ctx.context.baseURL;
2819
+ const safeCallbackUrl = getSafeRedirectUrl(relayState?.callbackURL || parsedSamlConfig.callbackUrl, currentCallbackPath, appOrigin, isTrusted);
2279
2820
  const result = await handleOAuthUserInfo(ctx, {
2280
2821
  userInfo: {
2281
2822
  email: userInfo.email,
@@ -2289,11 +2830,11 @@ const acsEndpoint = (options) => {
2289
2830
  accessToken: "",
2290
2831
  refreshToken: ""
2291
2832
  },
2292
- callbackURL: callbackUrl,
2833
+ callbackURL: safeCallbackUrl,
2293
2834
  disableSignUp: options?.disableImplicitSignUp,
2294
2835
  isTrustedProvider
2295
2836
  });
2296
- if (result.error) throw ctx.redirect(`${callbackUrl}?error=${result.error.split(" ").join("_")}`);
2837
+ if (result.error) throw ctx.redirect(`${safeCallbackUrl}?error=${result.error.split(" ").join("_")}`);
2297
2838
  const { session, user } = result.data;
2298
2839
  if (options?.provisionUser) await options.provisionUser({
2299
2840
  user,
@@ -2317,7 +2858,7 @@ const acsEndpoint = (options) => {
2317
2858
  session,
2318
2859
  user
2319
2860
  });
2320
- throw ctx.redirect(callbackUrl);
2861
+ throw ctx.redirect(safeCallbackUrl);
2321
2862
  });
2322
2863
  };
2323
2864
 
@@ -2327,6 +2868,12 @@ saml.setSchemaValidator({ async validate(xml) {
2327
2868
  if (XMLValidator.validate(xml, { allowBooleanAttributes: true }) === true) return "SUCCESS_VALIDATE_XML";
2328
2869
  throw "ERR_INVALID_XML";
2329
2870
  } });
2871
+ /**
2872
+ * SAML endpoint paths that should skip origin check validation.
2873
+ * These endpoints receive POST requests from external Identity Providers,
2874
+ * which won't have a matching Origin header.
2875
+ */
2876
+ const SAML_SKIP_ORIGIN_CHECK_PATHS = ["/sso/saml2/callback", "/sso/saml2/sp/acs"];
2330
2877
  function sso(options) {
2331
2878
  const optionsWithStore = options;
2332
2879
  let endpoints = {
@@ -2335,7 +2882,11 @@ function sso(options) {
2335
2882
  signInSSO: signInSSO(optionsWithStore),
2336
2883
  callbackSSO: callbackSSO(optionsWithStore),
2337
2884
  callbackSSOSAML: callbackSSOSAML(optionsWithStore),
2338
- acsEndpoint: acsEndpoint(optionsWithStore)
2885
+ acsEndpoint: acsEndpoint(optionsWithStore),
2886
+ listSSOProviders: listSSOProviders(),
2887
+ getSSOProvider: getSSOProvider(),
2888
+ updateSSOProvider: updateSSOProvider(optionsWithStore),
2889
+ deleteSSOProvider: deleteSSOProvider()
2339
2890
  };
2340
2891
  if (options?.domainVerification?.enabled) {
2341
2892
  const domainVerificationEndpoints = {
@@ -2349,6 +2900,11 @@ function sso(options) {
2349
2900
  }
2350
2901
  return {
2351
2902
  id: "sso",
2903
+ init(ctx) {
2904
+ const existing = ctx.skipOriginCheck;
2905
+ if (existing === true) return {};
2906
+ return { context: { skipOriginCheck: [...Array.isArray(existing) ? existing : [], ...SAML_SKIP_ORIGIN_CHECK_PATHS] } };
2907
+ },
2352
2908
  endpoints,
2353
2909
  hooks: { after: [{
2354
2910
  matcher(context) {
@@ -2357,7 +2913,7 @@ function sso(options) {
2357
2913
  handler: createAuthMiddleware(async (ctx) => {
2358
2914
  const newSession = ctx.context.newSession;
2359
2915
  if (!newSession?.user) return;
2360
- if (!ctx.context.options.plugins?.find((plugin) => plugin.id === "organization")) return;
2916
+ if (!(ctx.context.options.plugins?.some((plugin) => plugin.id === "organization") ?? false)) return;
2361
2917
  await assignOrganizationByDomain(ctx, {
2362
2918
  user: newSession.user,
2363
2919
  provisioningOptions: options?.organizationProvisioning,
@@ -2418,4 +2974,5 @@ function sso(options) {
2418
2974
  }
2419
2975
 
2420
2976
  //#endregion
2421
- export { DEFAULT_CLOCK_SKEW_MS, DEFAULT_MAX_SAML_METADATA_SIZE, DEFAULT_MAX_SAML_RESPONSE_SIZE, DataEncryptionAlgorithm, DigestAlgorithm, DiscoveryError, KeyEncryptionAlgorithm, REQUIRED_DISCOVERY_FIELDS, SignatureAlgorithm, computeDiscoveryUrl, discoverOIDCConfig, fetchDiscoveryDocument, needsRuntimeDiscovery, normalizeDiscoveryUrls, normalizeUrl, selectTokenEndpointAuthMethod, sso, validateDiscoveryDocument, validateDiscoveryUrl, validateSAMLTimestamp };
2977
+ 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 };
2978
+ //# sourceMappingURL=index.mjs.map