@better-auth/sso 1.4.7 → 1.4.8-beta.1

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,56 +1,102 @@
1
- import { XMLValidator } from "fast-xml-parser";
1
+ import { APIError, createAuthEndpoint, createAuthMiddleware, sessionMiddleware } from "better-auth/api";
2
+ import { XMLParser, XMLValidator } from "fast-xml-parser";
2
3
  import * as saml from "samlify";
3
- import { APIError, createAuthEndpoint, sessionMiddleware } from "better-auth/api";
4
4
  import { generateRandomString } from "better-auth/crypto";
5
- import * as z from "zod/v4";
5
+ import * as z$1 from "zod/v4";
6
+ import z from "zod/v4";
6
7
  import { BetterFetchError, betterFetch } from "@better-fetch/fetch";
7
8
  import { HIDE_METADATA, createAuthorizationURL, generateState, parseState, validateAuthorizationCode, validateToken } from "better-auth";
8
9
  import { setSessionCookie } from "better-auth/cookies";
9
10
  import { handleOAuthUserInfo } from "better-auth/oauth2";
10
11
  import { decodeJwt } from "jose";
11
12
 
12
- //#region src/authn-request-store.ts
13
+ //#region src/linking/org-assignment.ts
13
14
  /**
14
- * Default TTL for AuthnRequest records (5 minutes).
15
- * This should be sufficient for most IdPs while protecting against stale requests.
15
+ * Assigns a user to an organization based on the SSO provider's organizationId.
16
+ * Used in SSO flows (OIDC, SAML) where the provider is already linked to an org.
16
17
  */
17
- const DEFAULT_AUTHN_REQUEST_TTL_MS = 300 * 1e3;
18
+ async function assignOrganizationFromProvider(ctx, options) {
19
+ const { user, profile, provider, token, provisioningOptions } = options;
20
+ if (!provider.organizationId) return;
21
+ if (provisioningOptions?.disabled) return;
22
+ if (!ctx.context.options.plugins?.find((plugin) => plugin.id === "organization")) return;
23
+ if (await ctx.context.adapter.findOne({
24
+ model: "member",
25
+ where: [{
26
+ field: "organizationId",
27
+ value: provider.organizationId
28
+ }, {
29
+ field: "userId",
30
+ value: user.id
31
+ }]
32
+ })) return;
33
+ const role = provisioningOptions?.getRole ? await provisioningOptions.getRole({
34
+ user,
35
+ userInfo: profile.rawAttributes || {},
36
+ token,
37
+ provider
38
+ }) : provisioningOptions?.defaultRole || "member";
39
+ await ctx.context.adapter.create({
40
+ model: "member",
41
+ data: {
42
+ organizationId: provider.organizationId,
43
+ userId: user.id,
44
+ role,
45
+ createdAt: /* @__PURE__ */ new Date()
46
+ }
47
+ });
48
+ }
18
49
  /**
19
- * In-memory implementation of AuthnRequestStore.
20
- * ⚠️ Only suitable for testing or single-instance non-serverless deployments.
21
- * For production, rely on the default behavior (uses verification table)
22
- * or provide a custom Redis-backed store.
50
+ * Assigns a user to an organization based on their email domain.
51
+ * Looks up SSO providers that match the user's email domain and assigns
52
+ * the user to the associated organization.
53
+ *
54
+ * This enables domain-based org assignment for non-SSO sign-in methods
55
+ * (e.g., Google OAuth with @acme.com email gets added to Acme's org).
23
56
  */
24
- function createInMemoryAuthnRequestStore() {
25
- const store = /* @__PURE__ */ new Map();
26
- const cleanup = () => {
27
- const now = Date.now();
28
- for (const [id, record] of store.entries()) if (record.expiresAt < now) store.delete(id);
29
- };
30
- const cleanupInterval = setInterval(cleanup, 60 * 1e3);
31
- if (typeof cleanupInterval.unref === "function") cleanupInterval.unref();
32
- return {
33
- async save(record) {
34
- store.set(record.id, record);
35
- },
36
- async get(id) {
37
- const record = store.get(id);
38
- if (!record) return null;
39
- if (record.expiresAt < Date.now()) {
40
- store.delete(id);
41
- return null;
42
- }
43
- return record;
44
- },
45
- async delete(id) {
46
- store.delete(id);
57
+ async function assignOrganizationByDomain(ctx, options) {
58
+ const { user, provisioningOptions } = options;
59
+ if (provisioningOptions?.disabled) return;
60
+ if (!ctx.context.options.plugins?.find((plugin) => plugin.id === "organization")) return;
61
+ const domain = user.email.split("@")[1];
62
+ if (!domain) return;
63
+ const ssoProvider = await ctx.context.adapter.findOne({
64
+ model: "ssoProvider",
65
+ where: [{
66
+ field: "domain",
67
+ value: domain
68
+ }]
69
+ });
70
+ if (!ssoProvider || !ssoProvider.organizationId) return;
71
+ if (await ctx.context.adapter.findOne({
72
+ model: "member",
73
+ where: [{
74
+ field: "organizationId",
75
+ value: ssoProvider.organizationId
76
+ }, {
77
+ field: "userId",
78
+ value: user.id
79
+ }]
80
+ })) return;
81
+ const role = provisioningOptions?.getRole ? await provisioningOptions.getRole({
82
+ user,
83
+ userInfo: {},
84
+ provider: ssoProvider
85
+ }) : provisioningOptions?.defaultRole || "member";
86
+ await ctx.context.adapter.create({
87
+ model: "member",
88
+ data: {
89
+ organizationId: ssoProvider.organizationId,
90
+ userId: user.id,
91
+ role,
92
+ createdAt: /* @__PURE__ */ new Date()
47
93
  }
48
- };
94
+ });
49
95
  }
50
96
 
51
97
  //#endregion
52
98
  //#region src/routes/domain-verification.ts
53
- const domainVerificationBodySchema = z.object({ providerId: z.string() });
99
+ const domainVerificationBodySchema = z$1.object({ providerId: z$1.string() });
54
100
  const requestDomainVerification = (options) => {
55
101
  return createAuthEndpoint("/sso/request-domain-verification", {
56
102
  method: "POST",
@@ -223,6 +269,38 @@ const verifyDomain = (options) => {
223
269
  });
224
270
  };
225
271
 
272
+ //#endregion
273
+ //#region src/constants.ts
274
+ /**
275
+ * SAML Constants
276
+ *
277
+ * Centralized constants for SAML SSO functionality.
278
+ */
279
+ /** Prefix for AuthnRequest IDs used in InResponseTo validation */
280
+ const AUTHN_REQUEST_KEY_PREFIX = "saml-authn-request:";
281
+ /** Prefix for used Assertion IDs used in replay protection */
282
+ const USED_ASSERTION_KEY_PREFIX = "saml-used-assertion:";
283
+ /**
284
+ * Default TTL for AuthnRequest records (5 minutes).
285
+ * This should be sufficient for most IdPs while protecting against stale requests.
286
+ */
287
+ const DEFAULT_AUTHN_REQUEST_TTL_MS = 300 * 1e3;
288
+ /**
289
+ * Default TTL for used assertion records (15 minutes).
290
+ * This should match the maximum expected NotOnOrAfter window plus clock skew.
291
+ */
292
+ const DEFAULT_ASSERTION_TTL_MS = 900 * 1e3;
293
+ /**
294
+ * Default clock skew tolerance (5 minutes).
295
+ * Allows for minor time differences between IdP and SP servers.
296
+ *
297
+ * Accommodates:
298
+ * - Network latency and processing time
299
+ * - Clock synchronization differences (NTP drift)
300
+ * - Distributed systems across timezones
301
+ */
302
+ const DEFAULT_CLOCK_SKEW_MS = 300 * 1e3;
303
+
226
304
  //#endregion
227
305
  //#region src/oidc/types.ts
228
306
  /**
@@ -569,6 +647,146 @@ function mapDiscoveryErrorToAPIError(error) {
569
647
  }
570
648
  }
571
649
 
650
+ //#endregion
651
+ //#region src/saml/algorithms.ts
652
+ const SignatureAlgorithm = {
653
+ RSA_SHA1: "http://www.w3.org/2000/09/xmldsig#rsa-sha1",
654
+ RSA_SHA256: "http://www.w3.org/2001/04/xmldsig-more#rsa-sha256",
655
+ RSA_SHA384: "http://www.w3.org/2001/04/xmldsig-more#rsa-sha384",
656
+ RSA_SHA512: "http://www.w3.org/2001/04/xmldsig-more#rsa-sha512",
657
+ ECDSA_SHA256: "http://www.w3.org/2001/04/xmldsig-more#ecdsa-sha256",
658
+ ECDSA_SHA384: "http://www.w3.org/2001/04/xmldsig-more#ecdsa-sha384",
659
+ ECDSA_SHA512: "http://www.w3.org/2001/04/xmldsig-more#ecdsa-sha512"
660
+ };
661
+ const DigestAlgorithm = {
662
+ SHA1: "http://www.w3.org/2000/09/xmldsig#sha1",
663
+ SHA256: "http://www.w3.org/2001/04/xmlenc#sha256",
664
+ SHA384: "http://www.w3.org/2001/04/xmldsig-more#sha384",
665
+ SHA512: "http://www.w3.org/2001/04/xmlenc#sha512"
666
+ };
667
+ const KeyEncryptionAlgorithm = {
668
+ RSA_1_5: "http://www.w3.org/2001/04/xmlenc#rsa-1_5",
669
+ RSA_OAEP: "http://www.w3.org/2001/04/xmlenc#rsa-oaep-mgf1p",
670
+ RSA_OAEP_SHA256: "http://www.w3.org/2009/xmlenc11#rsa-oaep"
671
+ };
672
+ const DataEncryptionAlgorithm = {
673
+ TRIPLEDES_CBC: "http://www.w3.org/2001/04/xmlenc#tripledes-cbc",
674
+ AES_128_CBC: "http://www.w3.org/2001/04/xmlenc#aes128-cbc",
675
+ AES_192_CBC: "http://www.w3.org/2001/04/xmlenc#aes192-cbc",
676
+ AES_256_CBC: "http://www.w3.org/2001/04/xmlenc#aes256-cbc",
677
+ AES_128_GCM: "http://www.w3.org/2009/xmlenc11#aes128-gcm",
678
+ AES_192_GCM: "http://www.w3.org/2009/xmlenc11#aes192-gcm",
679
+ AES_256_GCM: "http://www.w3.org/2009/xmlenc11#aes256-gcm"
680
+ };
681
+ const DEPRECATED_SIGNATURE_ALGORITHMS = [SignatureAlgorithm.RSA_SHA1];
682
+ const DEPRECATED_KEY_ENCRYPTION_ALGORITHMS = [KeyEncryptionAlgorithm.RSA_1_5];
683
+ const DEPRECATED_DATA_ENCRYPTION_ALGORITHMS = [DataEncryptionAlgorithm.TRIPLEDES_CBC];
684
+ const SECURE_SIGNATURE_ALGORITHMS = [
685
+ SignatureAlgorithm.RSA_SHA256,
686
+ SignatureAlgorithm.RSA_SHA384,
687
+ SignatureAlgorithm.RSA_SHA512,
688
+ SignatureAlgorithm.ECDSA_SHA256,
689
+ SignatureAlgorithm.ECDSA_SHA384,
690
+ SignatureAlgorithm.ECDSA_SHA512
691
+ ];
692
+ const xmlParser = new XMLParser({
693
+ ignoreAttributes: false,
694
+ attributeNamePrefix: "@_",
695
+ removeNSPrefix: true
696
+ });
697
+ function findNode(obj, nodeName) {
698
+ if (!obj || typeof obj !== "object") return null;
699
+ const record = obj;
700
+ if (nodeName in record) return record[nodeName];
701
+ for (const value of Object.values(record)) if (Array.isArray(value)) for (const item of value) {
702
+ const found = findNode(item, nodeName);
703
+ if (found) return found;
704
+ }
705
+ else if (typeof value === "object" && value !== null) {
706
+ const found = findNode(value, nodeName);
707
+ if (found) return found;
708
+ }
709
+ return null;
710
+ }
711
+ function extractEncryptionAlgorithms(xml) {
712
+ try {
713
+ const parsed = xmlParser.parse(xml);
714
+ const keyAlg = (findNode(parsed, "EncryptedKey")?.EncryptionMethod)?.["@_Algorithm"];
715
+ const dataAlg = (findNode(parsed, "EncryptedData")?.EncryptionMethod)?.["@_Algorithm"];
716
+ return {
717
+ keyEncryption: keyAlg || null,
718
+ dataEncryption: dataAlg || null
719
+ };
720
+ } catch {
721
+ return {
722
+ keyEncryption: null,
723
+ dataEncryption: null
724
+ };
725
+ }
726
+ }
727
+ function hasEncryptedAssertion(xml) {
728
+ try {
729
+ return findNode(xmlParser.parse(xml), "EncryptedAssertion") !== null;
730
+ } catch {
731
+ return false;
732
+ }
733
+ }
734
+ function handleDeprecatedAlgorithm(message, behavior, errorCode) {
735
+ switch (behavior) {
736
+ case "reject": throw new APIError("BAD_REQUEST", {
737
+ message,
738
+ code: errorCode
739
+ });
740
+ case "warn":
741
+ console.warn(`[SAML Security Warning] ${message}`);
742
+ break;
743
+ case "allow": break;
744
+ }
745
+ }
746
+ function validateSignatureAlgorithm(algorithm, options = {}) {
747
+ if (!algorithm) return;
748
+ const { onDeprecated = "warn", allowedSignatureAlgorithms } = options;
749
+ if (allowedSignatureAlgorithms) {
750
+ if (!allowedSignatureAlgorithms.includes(algorithm)) throw new APIError("BAD_REQUEST", {
751
+ message: `SAML signature algorithm not in allow-list: ${algorithm}`,
752
+ code: "SAML_ALGORITHM_NOT_ALLOWED"
753
+ });
754
+ return;
755
+ }
756
+ if (DEPRECATED_SIGNATURE_ALGORITHMS.includes(algorithm)) {
757
+ handleDeprecatedAlgorithm(`SAML response uses deprecated signature algorithm: ${algorithm}. Please configure your IdP to use SHA-256 or stronger.`, onDeprecated, "SAML_DEPRECATED_ALGORITHM");
758
+ return;
759
+ }
760
+ if (!SECURE_SIGNATURE_ALGORITHMS.includes(algorithm)) throw new APIError("BAD_REQUEST", {
761
+ message: `SAML signature algorithm not recognized: ${algorithm}`,
762
+ code: "SAML_UNKNOWN_ALGORITHM"
763
+ });
764
+ }
765
+ function validateEncryptionAlgorithms(algorithms, options = {}) {
766
+ const { onDeprecated = "warn", allowedKeyEncryptionAlgorithms, allowedDataEncryptionAlgorithms } = options;
767
+ const { keyEncryption, dataEncryption } = algorithms;
768
+ if (keyEncryption) {
769
+ if (allowedKeyEncryptionAlgorithms) {
770
+ if (!allowedKeyEncryptionAlgorithms.includes(keyEncryption)) throw new APIError("BAD_REQUEST", {
771
+ message: `SAML key encryption algorithm not in allow-list: ${keyEncryption}`,
772
+ code: "SAML_ALGORITHM_NOT_ALLOWED"
773
+ });
774
+ } 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");
775
+ }
776
+ if (dataEncryption) {
777
+ if (allowedDataEncryptionAlgorithms) {
778
+ if (!allowedDataEncryptionAlgorithms.includes(dataEncryption)) throw new APIError("BAD_REQUEST", {
779
+ message: `SAML data encryption algorithm not in allow-list: ${dataEncryption}`,
780
+ code: "SAML_ALGORITHM_NOT_ALLOWED"
781
+ });
782
+ } 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");
783
+ }
784
+ }
785
+ function validateSAMLAlgorithms(response, options) {
786
+ validateSignatureAlgorithm(response.sigAlg, options);
787
+ if (hasEncryptedAssertion(response.samlContent)) validateEncryptionAlgorithms(extractEncryptionAlgorithms(response.samlContent), options);
788
+ }
789
+
572
790
  //#endregion
573
791
  //#region src/utils.ts
574
792
  /**
@@ -599,9 +817,6 @@ const validateEmailDomain = (email, domain) => {
599
817
 
600
818
  //#endregion
601
819
  //#region src/routes/sso.ts
602
- const AUTHN_REQUEST_KEY_PREFIX = "saml-authn-request:";
603
- /** Default clock skew tolerance: 5 minutes */
604
- const DEFAULT_CLOCK_SKEW_MS = 300 * 1e3;
605
820
  /**
606
821
  * Validates SAML assertion timestamp conditions (NotBefore/NotOnOrAfter).
607
822
  * Prevents acceptance of expired or future-dated assertions.
@@ -641,6 +856,27 @@ function validateSAMLTimestamp(conditions, options = {}) {
641
856
  });
642
857
  }
643
858
  }
859
+ /**
860
+ * Extracts the Assertion ID from a SAML response XML.
861
+ * Returns null if the assertion ID cannot be found.
862
+ */
863
+ function extractAssertionId(samlContent) {
864
+ try {
865
+ const parsed = new XMLParser({
866
+ ignoreAttributes: false,
867
+ attributeNamePrefix: "@_",
868
+ removeNSPrefix: true
869
+ }).parse(samlContent);
870
+ const response = parsed.Response || parsed["samlp:Response"];
871
+ if (!response) return null;
872
+ const rawAssertion = response.Assertion || response["saml:Assertion"];
873
+ const assertion = Array.isArray(rawAssertion) ? rawAssertion[0] : rawAssertion;
874
+ if (!assertion) return null;
875
+ return assertion["@_ID"] || null;
876
+ } catch {
877
+ return null;
878
+ }
879
+ }
644
880
  const spMetadataQuerySchema = z.object({
645
881
  providerId: z.string(),
646
882
  format: z.enum(["xml", "json"]).default("xml")
@@ -1248,7 +1484,7 @@ const signInSSO = (options) => {
1248
1484
  });
1249
1485
  const loginRequest = sp.createLoginRequest(idp, "redirect");
1250
1486
  if (!loginRequest) throw new APIError("BAD_REQUEST", { message: "Invalid SAML request" });
1251
- if (loginRequest.id && (options?.saml?.authnRequestStore || options?.saml?.enableInResponseToValidation)) {
1487
+ if (loginRequest.id && options?.saml?.enableInResponseToValidation) {
1252
1488
  const ttl = options?.saml?.requestTTL ?? DEFAULT_AUTHN_REQUEST_TTL_MS;
1253
1489
  const record = {
1254
1490
  id: loginRequest.id,
@@ -1256,8 +1492,7 @@ const signInSSO = (options) => {
1256
1492
  createdAt: Date.now(),
1257
1493
  expiresAt: Date.now() + ttl
1258
1494
  };
1259
- if (options?.saml?.authnRequestStore) await options.saml.authnRequestStore.save(record);
1260
- else await ctx.context.internalAdapter.createVerificationValue({
1495
+ await ctx.context.internalAdapter.createVerificationValue({
1261
1496
  identifier: `${AUTHN_REQUEST_KEY_PREFIX}${record.id}`,
1262
1497
  value: JSON.stringify(record),
1263
1498
  expiresAt: new Date(record.expiresAt)
@@ -1415,37 +1650,20 @@ const callbackSSO = (options) => {
1415
1650
  token: tokenResponse,
1416
1651
  provider
1417
1652
  });
1418
- if (provider.organizationId && !options?.organizationProvisioning?.disabled) {
1419
- if (ctx.context.options.plugins?.find((plugin) => plugin.id === "organization")) {
1420
- if (!await ctx.context.adapter.findOne({
1421
- model: "member",
1422
- where: [{
1423
- field: "organizationId",
1424
- value: provider.organizationId
1425
- }, {
1426
- field: "userId",
1427
- value: user.id
1428
- }]
1429
- })) {
1430
- const role = options?.organizationProvisioning?.getRole ? await options.organizationProvisioning.getRole({
1431
- user,
1432
- userInfo,
1433
- token: tokenResponse,
1434
- provider
1435
- }) : options?.organizationProvisioning?.defaultRole || "member";
1436
- await ctx.context.adapter.create({
1437
- model: "member",
1438
- data: {
1439
- organizationId: provider.organizationId,
1440
- userId: user.id,
1441
- role,
1442
- createdAt: /* @__PURE__ */ new Date(),
1443
- updatedAt: /* @__PURE__ */ new Date()
1444
- }
1445
- });
1446
- }
1447
- }
1448
- }
1653
+ await assignOrganizationFromProvider(ctx, {
1654
+ user,
1655
+ profile: {
1656
+ providerType: "oidc",
1657
+ providerId: provider.providerId,
1658
+ accountId: userInfo.id,
1659
+ email: userInfo.email,
1660
+ emailVerified: Boolean(userInfo.emailVerified),
1661
+ rawAttributes: userInfo
1662
+ },
1663
+ provider,
1664
+ token: tokenResponse,
1665
+ provisioningOptions: options?.organizationProvisioning
1666
+ });
1449
1667
  await setSessionCookie(ctx, {
1450
1668
  session,
1451
1669
  user
@@ -1567,25 +1785,23 @@ const callbackSSOSAML = (options) => {
1567
1785
  });
1568
1786
  }
1569
1787
  const { extract } = parsedResponse;
1788
+ validateSAMLAlgorithms(parsedResponse, options?.saml?.algorithms);
1570
1789
  validateSAMLTimestamp(extract.conditions, {
1571
1790
  clockSkew: options?.saml?.clockSkew,
1572
1791
  requireTimestamps: options?.saml?.requireTimestamps,
1573
1792
  logger: ctx.context.logger
1574
1793
  });
1575
1794
  const inResponseTo = extract.inResponseTo;
1576
- if (options?.saml?.authnRequestStore || options?.saml?.enableInResponseToValidation) {
1795
+ if (options?.saml?.enableInResponseToValidation) {
1577
1796
  const allowIdpInitiated = options?.saml?.allowIdpInitiated !== false;
1578
1797
  if (inResponseTo) {
1579
1798
  let storedRequest = null;
1580
- if (options?.saml?.authnRequestStore) storedRequest = await options.saml.authnRequestStore.get(inResponseTo);
1581
- else {
1582
- const verification = await ctx.context.internalAdapter.findVerificationValue(`${AUTHN_REQUEST_KEY_PREFIX}${inResponseTo}`);
1583
- if (verification) try {
1584
- storedRequest = JSON.parse(verification.value);
1585
- if (storedRequest && storedRequest.expiresAt < Date.now()) storedRequest = null;
1586
- } catch {
1587
- storedRequest = null;
1588
- }
1799
+ const verification = await ctx.context.internalAdapter.findVerificationValue(`${AUTHN_REQUEST_KEY_PREFIX}${inResponseTo}`);
1800
+ if (verification) try {
1801
+ storedRequest = JSON.parse(verification.value);
1802
+ if (storedRequest && storedRequest.expiresAt < Date.now()) storedRequest = null;
1803
+ } catch {
1804
+ storedRequest = null;
1589
1805
  }
1590
1806
  if (!storedRequest) {
1591
1807
  ctx.context.logger.error("SAML InResponseTo validation failed: unknown or expired request ID", {
@@ -1601,19 +1817,55 @@ const callbackSSOSAML = (options) => {
1601
1817
  expectedProvider: storedRequest.providerId,
1602
1818
  actualProvider: provider.providerId
1603
1819
  });
1604
- if (options?.saml?.authnRequestStore) await options.saml.authnRequestStore.delete(inResponseTo);
1605
- else await ctx.context.internalAdapter.deleteVerificationByIdentifier(`${AUTHN_REQUEST_KEY_PREFIX}${inResponseTo}`);
1820
+ await ctx.context.internalAdapter.deleteVerificationByIdentifier(`${AUTHN_REQUEST_KEY_PREFIX}${inResponseTo}`);
1606
1821
  const redirectUrl = RelayState || parsedSamlConfig.callbackUrl || ctx.context.baseURL;
1607
1822
  throw ctx.redirect(`${redirectUrl}?error=invalid_saml_response&error_description=Provider+mismatch`);
1608
1823
  }
1609
- if (options?.saml?.authnRequestStore) await options.saml.authnRequestStore.delete(inResponseTo);
1610
- else await ctx.context.internalAdapter.deleteVerificationByIdentifier(`${AUTHN_REQUEST_KEY_PREFIX}${inResponseTo}`);
1824
+ await ctx.context.internalAdapter.deleteVerificationByIdentifier(`${AUTHN_REQUEST_KEY_PREFIX}${inResponseTo}`);
1611
1825
  } else if (!allowIdpInitiated) {
1612
1826
  ctx.context.logger.error("SAML IdP-initiated SSO rejected: InResponseTo missing and allowIdpInitiated is false", { providerId: provider.providerId });
1613
1827
  const redirectUrl = RelayState || parsedSamlConfig.callbackUrl || ctx.context.baseURL;
1614
1828
  throw ctx.redirect(`${redirectUrl}?error=unsolicited_response&error_description=IdP-initiated+SSO+not+allowed`);
1615
1829
  }
1616
1830
  }
1831
+ const samlContent = parsedResponse.samlContent;
1832
+ const assertionId = samlContent ? extractAssertionId(samlContent) : null;
1833
+ if (assertionId) {
1834
+ const issuer = idp.entityMeta.getEntityID();
1835
+ const conditions = extract.conditions;
1836
+ const clockSkew = options?.saml?.clockSkew ?? DEFAULT_CLOCK_SKEW_MS;
1837
+ const expiresAt = conditions?.notOnOrAfter ? new Date(conditions.notOnOrAfter).getTime() + clockSkew : Date.now() + DEFAULT_ASSERTION_TTL_MS;
1838
+ const existingAssertion = await ctx.context.internalAdapter.findVerificationValue(`${USED_ASSERTION_KEY_PREFIX}${assertionId}`);
1839
+ let isReplay = false;
1840
+ if (existingAssertion) try {
1841
+ if (JSON.parse(existingAssertion.value).expiresAt >= Date.now()) isReplay = true;
1842
+ } catch (error) {
1843
+ ctx.context.logger.warn("Failed to parse stored assertion record", {
1844
+ assertionId,
1845
+ error
1846
+ });
1847
+ }
1848
+ if (isReplay) {
1849
+ ctx.context.logger.error("SAML assertion replay detected: assertion ID already used", {
1850
+ assertionId,
1851
+ issuer,
1852
+ providerId: provider.providerId
1853
+ });
1854
+ const redirectUrl = RelayState || parsedSamlConfig.callbackUrl || ctx.context.baseURL;
1855
+ throw ctx.redirect(`${redirectUrl}?error=replay_detected&error_description=SAML+assertion+has+already+been+used`);
1856
+ }
1857
+ await ctx.context.internalAdapter.createVerificationValue({
1858
+ identifier: `${USED_ASSERTION_KEY_PREFIX}${assertionId}`,
1859
+ value: JSON.stringify({
1860
+ assertionId,
1861
+ issuer,
1862
+ providerId: provider.providerId,
1863
+ usedAt: Date.now(),
1864
+ expiresAt
1865
+ }),
1866
+ expiresAt: new Date(expiresAt)
1867
+ });
1868
+ } else ctx.context.logger.warn("Could not extract assertion ID for replay protection", { providerId: provider.providerId });
1617
1869
  const attributes = extract.attributes || {};
1618
1870
  const mapping = parsedSamlConfig.mapping ?? {};
1619
1871
  const userInfo = {
@@ -1632,100 +1884,49 @@ const callbackSSOSAML = (options) => {
1632
1884
  });
1633
1885
  throw new APIError("BAD_REQUEST", { message: "Unable to extract user ID or email from SAML response" });
1634
1886
  }
1635
- let user;
1636
- const existingUser = await ctx.context.adapter.findOne({
1637
- model: "user",
1638
- where: [{
1639
- field: "email",
1640
- value: userInfo.email
1641
- }]
1642
- });
1643
- if (existingUser) {
1644
- if (!await ctx.context.adapter.findOne({
1645
- model: "account",
1646
- where: [
1647
- {
1648
- field: "userId",
1649
- value: existingUser.id
1650
- },
1651
- {
1652
- field: "providerId",
1653
- value: provider.providerId
1654
- },
1655
- {
1656
- field: "accountId",
1657
- value: userInfo.id
1658
- }
1659
- ]
1660
- })) {
1661
- if (!(ctx.context.options.account?.accountLinking?.trustedProviders?.includes(provider.providerId) || "domainVerified" in provider && provider.domainVerified && validateEmailDomain(userInfo.email, provider.domain))) {
1662
- const redirectUrl = RelayState || parsedSamlConfig.callbackUrl || ctx.context.baseURL;
1663
- throw ctx.redirect(`${redirectUrl}?error=account_not_linked`);
1664
- }
1665
- await ctx.context.internalAdapter.createAccount({
1666
- userId: existingUser.id,
1667
- providerId: provider.providerId,
1668
- accountId: userInfo.id,
1669
- accessToken: "",
1670
- refreshToken: ""
1671
- });
1672
- }
1673
- user = existingUser;
1674
- } else {
1675
- if (options?.disableImplicitSignUp) throw new APIError("UNAUTHORIZED", { message: "User not found and implicit sign up is disabled for this provider" });
1676
- user = await ctx.context.internalAdapter.createUser({
1887
+ const isTrustedProvider = !!ctx.context.options.account?.accountLinking?.trustedProviders?.includes(provider.providerId) || "domainVerified" in provider && !!provider.domainVerified && validateEmailDomain(userInfo.email, provider.domain);
1888
+ const callbackUrl = RelayState || parsedSamlConfig.callbackUrl || ctx.context.baseURL;
1889
+ const result = await handleOAuthUserInfo(ctx, {
1890
+ userInfo: {
1677
1891
  email: userInfo.email,
1678
- name: userInfo.name,
1679
- emailVerified: userInfo.emailVerified
1680
- });
1681
- await ctx.context.internalAdapter.createAccount({
1682
- userId: user.id,
1892
+ name: userInfo.name || userInfo.email,
1893
+ id: userInfo.id,
1894
+ emailVerified: Boolean(userInfo.emailVerified)
1895
+ },
1896
+ account: {
1683
1897
  providerId: provider.providerId,
1684
1898
  accountId: userInfo.id,
1685
1899
  accessToken: "",
1686
1900
  refreshToken: ""
1687
- });
1688
- }
1901
+ },
1902
+ callbackURL: callbackUrl,
1903
+ disableSignUp: options?.disableImplicitSignUp,
1904
+ isTrustedProvider
1905
+ });
1906
+ if (result.error) throw ctx.redirect(`${callbackUrl}?error=${result.error.split(" ").join("_")}`);
1907
+ const { session, user } = result.data;
1689
1908
  if (options?.provisionUser) await options.provisionUser({
1690
1909
  user,
1691
1910
  userInfo,
1692
1911
  provider
1693
1912
  });
1694
- if (provider.organizationId && !options?.organizationProvisioning?.disabled) {
1695
- if (ctx.context.options.plugins?.find((plugin) => plugin.id === "organization")) {
1696
- if (!await ctx.context.adapter.findOne({
1697
- model: "member",
1698
- where: [{
1699
- field: "organizationId",
1700
- value: provider.organizationId
1701
- }, {
1702
- field: "userId",
1703
- value: user.id
1704
- }]
1705
- })) {
1706
- const role = options?.organizationProvisioning?.getRole ? await options.organizationProvisioning.getRole({
1707
- user,
1708
- userInfo,
1709
- provider
1710
- }) : options?.organizationProvisioning?.defaultRole || "member";
1711
- await ctx.context.adapter.create({
1712
- model: "member",
1713
- data: {
1714
- organizationId: provider.organizationId,
1715
- userId: user.id,
1716
- role,
1717
- createdAt: /* @__PURE__ */ new Date(),
1718
- updatedAt: /* @__PURE__ */ new Date()
1719
- }
1720
- });
1721
- }
1722
- }
1723
- }
1913
+ await assignOrganizationFromProvider(ctx, {
1914
+ user,
1915
+ profile: {
1916
+ providerType: "saml",
1917
+ providerId: provider.providerId,
1918
+ accountId: userInfo.id,
1919
+ email: userInfo.email,
1920
+ emailVerified: Boolean(userInfo.emailVerified),
1921
+ rawAttributes: attributes
1922
+ },
1923
+ provider,
1924
+ provisioningOptions: options?.organizationProvisioning
1925
+ });
1724
1926
  await setSessionCookie(ctx, {
1725
- session: await ctx.context.internalAdapter.createSession(user.id),
1927
+ session,
1726
1928
  user
1727
1929
  });
1728
- const callbackUrl = RelayState || parsedSamlConfig.callbackUrl || ctx.context.baseURL;
1729
1930
  throw ctx.redirect(callbackUrl);
1730
1931
  });
1731
1932
  };
@@ -1818,25 +2019,23 @@ const acsEndpoint = (options) => {
1818
2019
  });
1819
2020
  }
1820
2021
  const { extract } = parsedResponse;
2022
+ validateSAMLAlgorithms(parsedResponse, options?.saml?.algorithms);
1821
2023
  validateSAMLTimestamp(extract.conditions, {
1822
2024
  clockSkew: options?.saml?.clockSkew,
1823
2025
  requireTimestamps: options?.saml?.requireTimestamps,
1824
2026
  logger: ctx.context.logger
1825
2027
  });
1826
2028
  const inResponseToAcs = extract.inResponseTo;
1827
- if (options?.saml?.authnRequestStore || options?.saml?.enableInResponseToValidation) {
2029
+ if (options?.saml?.enableInResponseToValidation) {
1828
2030
  const allowIdpInitiated = options?.saml?.allowIdpInitiated !== false;
1829
2031
  if (inResponseToAcs) {
1830
2032
  let storedRequest = null;
1831
- if (options?.saml?.authnRequestStore) storedRequest = await options.saml.authnRequestStore.get(inResponseToAcs);
1832
- else {
1833
- const verification = await ctx.context.internalAdapter.findVerificationValue(`${AUTHN_REQUEST_KEY_PREFIX}${inResponseToAcs}`);
1834
- if (verification) try {
1835
- storedRequest = JSON.parse(verification.value);
1836
- if (storedRequest && storedRequest.expiresAt < Date.now()) storedRequest = null;
1837
- } catch {
1838
- storedRequest = null;
1839
- }
2033
+ const verification = await ctx.context.internalAdapter.findVerificationValue(`${AUTHN_REQUEST_KEY_PREFIX}${inResponseToAcs}`);
2034
+ if (verification) try {
2035
+ storedRequest = JSON.parse(verification.value);
2036
+ if (storedRequest && storedRequest.expiresAt < Date.now()) storedRequest = null;
2037
+ } catch {
2038
+ storedRequest = null;
1840
2039
  }
1841
2040
  if (!storedRequest) {
1842
2041
  ctx.context.logger.error("SAML InResponseTo validation failed: unknown or expired request ID", {
@@ -1852,19 +2051,54 @@ const acsEndpoint = (options) => {
1852
2051
  expectedProvider: storedRequest.providerId,
1853
2052
  actualProvider: providerId
1854
2053
  });
1855
- if (options?.saml?.authnRequestStore) await options.saml.authnRequestStore.delete(inResponseToAcs);
1856
- else await ctx.context.internalAdapter.deleteVerificationByIdentifier(`${AUTHN_REQUEST_KEY_PREFIX}${inResponseToAcs}`);
2054
+ await ctx.context.internalAdapter.deleteVerificationByIdentifier(`${AUTHN_REQUEST_KEY_PREFIX}${inResponseToAcs}`);
1857
2055
  const redirectUrl = RelayState || parsedSamlConfig.callbackUrl || ctx.context.baseURL;
1858
2056
  throw ctx.redirect(`${redirectUrl}?error=invalid_saml_response&error_description=Provider+mismatch`);
1859
2057
  }
1860
- if (options?.saml?.authnRequestStore) await options.saml.authnRequestStore.delete(inResponseToAcs);
1861
- else await ctx.context.internalAdapter.deleteVerificationByIdentifier(`${AUTHN_REQUEST_KEY_PREFIX}${inResponseToAcs}`);
2058
+ await ctx.context.internalAdapter.deleteVerificationByIdentifier(`${AUTHN_REQUEST_KEY_PREFIX}${inResponseToAcs}`);
1862
2059
  } else if (!allowIdpInitiated) {
1863
2060
  ctx.context.logger.error("SAML IdP-initiated SSO rejected: InResponseTo missing and allowIdpInitiated is false", { providerId });
1864
2061
  const redirectUrl = RelayState || parsedSamlConfig.callbackUrl || ctx.context.baseURL;
1865
2062
  throw ctx.redirect(`${redirectUrl}?error=unsolicited_response&error_description=IdP-initiated+SSO+not+allowed`);
1866
2063
  }
1867
2064
  }
2065
+ const assertionIdAcs = extractAssertionId(Buffer.from(SAMLResponse, "base64").toString("utf-8"));
2066
+ if (assertionIdAcs) {
2067
+ const issuer = idp.entityMeta.getEntityID();
2068
+ const conditions = extract.conditions;
2069
+ const clockSkew = options?.saml?.clockSkew ?? DEFAULT_CLOCK_SKEW_MS;
2070
+ const expiresAt = conditions?.notOnOrAfter ? new Date(conditions.notOnOrAfter).getTime() + clockSkew : Date.now() + DEFAULT_ASSERTION_TTL_MS;
2071
+ const existingAssertion = await ctx.context.internalAdapter.findVerificationValue(`${USED_ASSERTION_KEY_PREFIX}${assertionIdAcs}`);
2072
+ let isReplay = false;
2073
+ if (existingAssertion) try {
2074
+ if (JSON.parse(existingAssertion.value).expiresAt >= Date.now()) isReplay = true;
2075
+ } catch (error) {
2076
+ ctx.context.logger.warn("Failed to parse stored assertion record", {
2077
+ assertionId: assertionIdAcs,
2078
+ error
2079
+ });
2080
+ }
2081
+ if (isReplay) {
2082
+ ctx.context.logger.error("SAML assertion replay detected: assertion ID already used", {
2083
+ assertionId: assertionIdAcs,
2084
+ issuer,
2085
+ providerId
2086
+ });
2087
+ const redirectUrl = RelayState || parsedSamlConfig.callbackUrl || ctx.context.baseURL;
2088
+ throw ctx.redirect(`${redirectUrl}?error=replay_detected&error_description=SAML+assertion+has+already+been+used`);
2089
+ }
2090
+ await ctx.context.internalAdapter.createVerificationValue({
2091
+ identifier: `${USED_ASSERTION_KEY_PREFIX}${assertionIdAcs}`,
2092
+ value: JSON.stringify({
2093
+ assertionId: assertionIdAcs,
2094
+ issuer,
2095
+ providerId,
2096
+ usedAt: Date.now(),
2097
+ expiresAt
2098
+ }),
2099
+ expiresAt: new Date(expiresAt)
2100
+ });
2101
+ } else ctx.context.logger.warn("Could not extract assertion ID for replay protection", { providerId });
1868
2102
  const attributes = extract.attributes || {};
1869
2103
  const mapping = parsedSamlConfig.mapping ?? {};
1870
2104
  const userInfo = {
@@ -1883,99 +2117,49 @@ const acsEndpoint = (options) => {
1883
2117
  });
1884
2118
  throw new APIError("BAD_REQUEST", { message: "Unable to extract user ID or email from SAML response" });
1885
2119
  }
1886
- let user;
1887
- const existingUser = await ctx.context.adapter.findOne({
1888
- model: "user",
1889
- where: [{
1890
- field: "email",
1891
- value: userInfo.email
1892
- }]
1893
- });
1894
- if (existingUser) {
1895
- if (!await ctx.context.adapter.findOne({
1896
- model: "account",
1897
- where: [
1898
- {
1899
- field: "userId",
1900
- value: existingUser.id
1901
- },
1902
- {
1903
- field: "providerId",
1904
- value: provider.providerId
1905
- },
1906
- {
1907
- field: "accountId",
1908
- value: userInfo.id
1909
- }
1910
- ]
1911
- })) {
1912
- if (!(ctx.context.options.account?.accountLinking?.trustedProviders?.includes(provider.providerId) || "domainVerified" in provider && provider.domainVerified && validateEmailDomain(userInfo.email, provider.domain))) throw ctx.redirect(`${parsedSamlConfig.callbackUrl}?error=account_not_found`);
1913
- await ctx.context.internalAdapter.createAccount({
1914
- userId: existingUser.id,
1915
- providerId: provider.providerId,
1916
- accountId: userInfo.id,
1917
- accessToken: "",
1918
- refreshToken: ""
1919
- });
1920
- }
1921
- user = existingUser;
1922
- } else {
1923
- user = await ctx.context.internalAdapter.createUser({
2120
+ const isTrustedProvider = !!ctx.context.options.account?.accountLinking?.trustedProviders?.includes(provider.providerId) || "domainVerified" in provider && !!provider.domainVerified && validateEmailDomain(userInfo.email, provider.domain);
2121
+ const callbackUrl = RelayState || parsedSamlConfig.callbackUrl || ctx.context.baseURL;
2122
+ const result = await handleOAuthUserInfo(ctx, {
2123
+ userInfo: {
1924
2124
  email: userInfo.email,
1925
- name: userInfo.name,
1926
- emailVerified: options?.trustEmailVerified ? userInfo.emailVerified || false : false
1927
- });
1928
- await ctx.context.internalAdapter.createAccount({
1929
- userId: user.id,
2125
+ name: userInfo.name || userInfo.email,
2126
+ id: userInfo.id,
2127
+ emailVerified: Boolean(userInfo.emailVerified)
2128
+ },
2129
+ account: {
1930
2130
  providerId: provider.providerId,
1931
2131
  accountId: userInfo.id,
1932
2132
  accessToken: "",
1933
- refreshToken: "",
1934
- accessTokenExpiresAt: /* @__PURE__ */ new Date(),
1935
- refreshTokenExpiresAt: /* @__PURE__ */ new Date(),
1936
- scope: ""
1937
- });
1938
- }
2133
+ refreshToken: ""
2134
+ },
2135
+ callbackURL: callbackUrl,
2136
+ disableSignUp: options?.disableImplicitSignUp,
2137
+ isTrustedProvider
2138
+ });
2139
+ if (result.error) throw ctx.redirect(`${callbackUrl}?error=${result.error.split(" ").join("_")}`);
2140
+ const { session, user } = result.data;
1939
2141
  if (options?.provisionUser) await options.provisionUser({
1940
2142
  user,
1941
2143
  userInfo,
1942
2144
  provider
1943
2145
  });
1944
- if (provider.organizationId && !options?.organizationProvisioning?.disabled) {
1945
- if (ctx.context.options.plugins?.find((plugin) => plugin.id === "organization")) {
1946
- if (!await ctx.context.adapter.findOne({
1947
- model: "member",
1948
- where: [{
1949
- field: "organizationId",
1950
- value: provider.organizationId
1951
- }, {
1952
- field: "userId",
1953
- value: user.id
1954
- }]
1955
- })) {
1956
- const role = options?.organizationProvisioning?.getRole ? await options.organizationProvisioning.getRole({
1957
- user,
1958
- userInfo,
1959
- provider
1960
- }) : options?.organizationProvisioning?.defaultRole || "member";
1961
- await ctx.context.adapter.create({
1962
- model: "member",
1963
- data: {
1964
- organizationId: provider.organizationId,
1965
- userId: user.id,
1966
- role,
1967
- createdAt: /* @__PURE__ */ new Date(),
1968
- updatedAt: /* @__PURE__ */ new Date()
1969
- }
1970
- });
1971
- }
1972
- }
1973
- }
2146
+ await assignOrganizationFromProvider(ctx, {
2147
+ user,
2148
+ profile: {
2149
+ providerType: "saml",
2150
+ providerId: provider.providerId,
2151
+ accountId: userInfo.id,
2152
+ email: userInfo.email,
2153
+ emailVerified: Boolean(userInfo.emailVerified),
2154
+ rawAttributes: attributes
2155
+ },
2156
+ provider,
2157
+ provisioningOptions: options?.organizationProvisioning
2158
+ });
1974
2159
  await setSessionCookie(ctx, {
1975
- session: await ctx.context.internalAdapter.createSession(user.id),
2160
+ session,
1976
2161
  user
1977
2162
  });
1978
- const callbackUrl = RelayState || parsedSamlConfig.callbackUrl || ctx.context.baseURL;
1979
2163
  throw ctx.redirect(callbackUrl);
1980
2164
  });
1981
2165
  };
@@ -2009,6 +2193,20 @@ function sso(options) {
2009
2193
  return {
2010
2194
  id: "sso",
2011
2195
  endpoints,
2196
+ hooks: { after: [{
2197
+ matcher(context) {
2198
+ return context.path?.startsWith("/callback/") ?? false;
2199
+ },
2200
+ handler: createAuthMiddleware(async (ctx) => {
2201
+ const newSession = ctx.context.newSession;
2202
+ if (!newSession?.user) return;
2203
+ if (!ctx.context.options.plugins?.find((plugin) => plugin.id === "organization")) return;
2204
+ await assignOrganizationByDomain(ctx, {
2205
+ user: newSession.user,
2206
+ provisioningOptions: options?.organizationProvisioning
2207
+ });
2208
+ })
2209
+ }] },
2012
2210
  schema: { ssoProvider: {
2013
2211
  modelName: options?.modelName ?? "ssoProvider",
2014
2212
  fields: {
@@ -2061,4 +2259,4 @@ function sso(options) {
2061
2259
  }
2062
2260
 
2063
2261
  //#endregion
2064
- export { DEFAULT_AUTHN_REQUEST_TTL_MS, DEFAULT_CLOCK_SKEW_MS, DiscoveryError, REQUIRED_DISCOVERY_FIELDS, computeDiscoveryUrl, createInMemoryAuthnRequestStore, discoverOIDCConfig, fetchDiscoveryDocument, needsRuntimeDiscovery, normalizeDiscoveryUrls, normalizeUrl, selectTokenEndpointAuthMethod, sso, validateDiscoveryDocument, validateDiscoveryUrl, validateSAMLTimestamp };
2262
+ export { DataEncryptionAlgorithm, DigestAlgorithm, DiscoveryError, KeyEncryptionAlgorithm, REQUIRED_DISCOVERY_FIELDS, SignatureAlgorithm, computeDiscoveryUrl, discoverOIDCConfig, fetchDiscoveryDocument, needsRuntimeDiscovery, normalizeDiscoveryUrls, normalizeUrl, selectTokenEndpointAuthMethod, sso, validateDiscoveryDocument, validateDiscoveryUrl, validateSAMLTimestamp };