@better-auth/sso 1.4.7-beta.4 → 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
  /**
@@ -268,24 +346,25 @@ const DEFAULT_DISCOVERY_TIMEOUT = 1e4;
268
346
  *
269
347
  * This function:
270
348
  * 1. Computes the discovery URL from the issuer
271
- * 2. Validates the discovery URL (stub for now)
349
+ * 2. Validates the discovery URL
272
350
  * 3. Fetches the discovery document
273
351
  * 4. Validates the discovery document (issuer match + required fields)
274
- * 5. Normalizes URLs (stub for now)
352
+ * 5. Normalizes URLs
275
353
  * 6. Selects token endpoint auth method
276
354
  * 7. Merges with existing config (existing values take precedence)
277
355
  *
278
356
  * @param params - Discovery parameters
357
+ * @param isTrustedOrigin - Origin verification tester function
279
358
  * @returns Hydrated OIDC configuration ready for persistence
280
359
  * @throws DiscoveryError on any failure
281
360
  */
282
361
  async function discoverOIDCConfig(params) {
283
362
  const { issuer, existingConfig, timeout = DEFAULT_DISCOVERY_TIMEOUT } = params;
284
363
  const discoveryUrl = params.discoveryEndpoint || existingConfig?.discoveryEndpoint || computeDiscoveryUrl(issuer);
285
- validateDiscoveryUrl(discoveryUrl);
364
+ validateDiscoveryUrl(discoveryUrl, params.isTrustedOrigin);
286
365
  const discoveryDoc = await fetchDiscoveryDocument(discoveryUrl, timeout);
287
366
  validateDiscoveryDocument(discoveryDoc, issuer);
288
- const normalizedDoc = normalizeDiscoveryUrls(discoveryDoc, issuer);
367
+ const normalizedDoc = normalizeDiscoveryUrls(discoveryDoc, issuer, params.isTrustedOrigin);
289
368
  const tokenEndpointAuth = selectTokenEndpointAuthMethod(normalizedDoc, existingConfig?.tokenEndpointAuthentication);
290
369
  return {
291
370
  issuer: existingConfig?.issuer ?? normalizedDoc.issuer,
@@ -313,19 +392,12 @@ function computeDiscoveryUrl(issuer) {
313
392
  * Validate a discovery URL before fetching.
314
393
  *
315
394
  * @param url - The discovery URL to validate
395
+ * @param isTrustedOrigin - Origin verification tester function
316
396
  * @throws DiscoveryError if URL is invalid
317
397
  */
318
- function validateDiscoveryUrl(url) {
319
- try {
320
- const parsed = new URL(url);
321
- if (parsed.protocol !== "https:" && parsed.protocol !== "http:") throw new DiscoveryError("discovery_invalid_url", `Discovery URL must use HTTP or HTTPS protocol: ${url}`, {
322
- url,
323
- protocol: parsed.protocol
324
- });
325
- } catch (error) {
326
- if (error instanceof DiscoveryError) throw error;
327
- throw new DiscoveryError("discovery_invalid_url", `Invalid discovery URL: ${url}`, { url }, { cause: error });
328
- }
398
+ function validateDiscoveryUrl(url, isTrustedOrigin) {
399
+ const discoveryEndpoint = parseURL("discoveryEndpoint", url).toString();
400
+ if (!isTrustedOrigin(discoveryEndpoint)) throw new DiscoveryError("discovery_untrusted_origin", `The main discovery endpoint "${discoveryEndpoint}" is not trusted by your trusted origins configuration.`, { url: discoveryEndpoint });
329
401
  }
330
402
  /**
331
403
  * Fetch the OIDC discovery document from the IdP.
@@ -399,22 +471,76 @@ function validateDiscoveryDocument(doc, configuredIssuer) {
399
471
  /**
400
472
  * Normalize URLs in the discovery document.
401
473
  *
402
- * @param doc - The discovery document
403
- * @param _issuerBase - The base issuer URL
474
+ * @param document - The discovery document
475
+ * @param issuer - The base issuer URL
476
+ * @param isTrustedOrigin - Origin verification tester function
404
477
  * @returns The normalized discovery document
405
478
  */
406
- function normalizeDiscoveryUrls(doc, _issuerBase) {
479
+ function normalizeDiscoveryUrls(document, issuer, isTrustedOrigin) {
480
+ const doc = { ...document };
481
+ doc.token_endpoint = normalizeAndValidateUrl("token_endpoint", doc.token_endpoint, issuer, isTrustedOrigin);
482
+ doc.authorization_endpoint = normalizeAndValidateUrl("authorization_endpoint", doc.authorization_endpoint, issuer, isTrustedOrigin);
483
+ doc.jwks_uri = normalizeAndValidateUrl("jwks_uri", doc.jwks_uri, issuer, isTrustedOrigin);
484
+ if (doc.userinfo_endpoint) doc.userinfo_endpoint = normalizeAndValidateUrl("userinfo_endpoint", doc.userinfo_endpoint, issuer, isTrustedOrigin);
485
+ if (doc.revocation_endpoint) doc.revocation_endpoint = normalizeAndValidateUrl("revocation_endpoint", doc.revocation_endpoint, issuer, isTrustedOrigin);
486
+ if (doc.end_session_endpoint) doc.end_session_endpoint = normalizeAndValidateUrl("end_session_endpoint", doc.end_session_endpoint, issuer, isTrustedOrigin);
487
+ if (doc.introspection_endpoint) doc.introspection_endpoint = normalizeAndValidateUrl("introspection_endpoint", doc.introspection_endpoint, issuer, isTrustedOrigin);
407
488
  return doc;
408
489
  }
409
490
  /**
491
+ * Normalizes and validates a single URL endpoint
492
+ * @param name The url name
493
+ * @param endpoint The url to validate
494
+ * @param issuer The issuer base url
495
+ * @param isTrustedOrigin - Origin verification tester function
496
+ * @returns
497
+ */
498
+ function normalizeAndValidateUrl(name, endpoint, issuer, isTrustedOrigin) {
499
+ const url = normalizeUrl(name, endpoint, issuer);
500
+ if (!isTrustedOrigin(url)) throw new DiscoveryError("discovery_untrusted_origin", `The ${name} "${url}" is not trusted by your trusted origins configuration.`, {
501
+ endpoint: name,
502
+ url
503
+ });
504
+ return url;
505
+ }
506
+ /**
410
507
  * Normalize a single URL endpoint.
411
508
  *
509
+ * @param name - The endpoint name (e.g token_endpoint)
412
510
  * @param endpoint - The endpoint URL to normalize
413
- * @param _issuerBase - The base issuer URL
511
+ * @param issuer - The base issuer URL
414
512
  * @returns The normalized endpoint URL
415
513
  */
416
- function normalizeUrl(endpoint, _issuerBase) {
417
- return endpoint;
514
+ function normalizeUrl(name, endpoint, issuer) {
515
+ try {
516
+ return parseURL(name, endpoint).toString();
517
+ } catch {
518
+ const issuerURL = parseURL(name, issuer);
519
+ const basePath = issuerURL.pathname.replace(/\/+$/, "");
520
+ const endpointPath = endpoint.replace(/^\/+/, "");
521
+ return parseURL(name, basePath + "/" + endpointPath, issuerURL.origin).toString();
522
+ }
523
+ }
524
+ /**
525
+ * Parses the given URL or throws in case of invalid or unsupported protocols
526
+ *
527
+ * @param name the url name
528
+ * @param endpoint the endpoint url
529
+ * @param [base] optional base path
530
+ * @returns
531
+ */
532
+ function parseURL(name, endpoint, base) {
533
+ let endpointURL;
534
+ try {
535
+ endpointURL = new URL(endpoint, base);
536
+ if (endpointURL.protocol === "http:" || endpointURL.protocol === "https:") return endpointURL;
537
+ } catch (error) {
538
+ throw new DiscoveryError("discovery_invalid_url", `The url "${name}" must be valid: ${endpoint}`, { url: endpoint }, { cause: error });
539
+ }
540
+ throw new DiscoveryError("discovery_invalid_url", `The url "${name}" must use the http or https supported protocols: ${endpoint}`, {
541
+ url: endpoint,
542
+ protocol: endpointURL.protocol
543
+ });
418
544
  }
419
545
  /**
420
546
  * Select the token endpoint authentication method.
@@ -492,6 +618,10 @@ function mapDiscoveryErrorToAPIError(error) {
492
618
  message: `Invalid OIDC discovery URL: ${error.message}`,
493
619
  code: error.code
494
620
  });
621
+ case "discovery_untrusted_origin": return new APIError("BAD_REQUEST", {
622
+ message: `Untrusted OIDC discovery URL: ${error.message}`,
623
+ code: error.code
624
+ });
495
625
  case "discovery_invalid_json": return new APIError("BAD_REQUEST", {
496
626
  message: `OIDC discovery returned invalid data: ${error.message}`,
497
627
  code: error.code
@@ -517,6 +647,146 @@ function mapDiscoveryErrorToAPIError(error) {
517
647
  }
518
648
  }
519
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
+
520
790
  //#endregion
521
791
  //#region src/utils.ts
522
792
  /**
@@ -547,9 +817,6 @@ const validateEmailDomain = (email, domain) => {
547
817
 
548
818
  //#endregion
549
819
  //#region src/routes/sso.ts
550
- const AUTHN_REQUEST_KEY_PREFIX = "saml-authn-request:";
551
- /** Default clock skew tolerance: 5 minutes */
552
- const DEFAULT_CLOCK_SKEW_MS = 300 * 1e3;
553
820
  /**
554
821
  * Validates SAML assertion timestamp conditions (NotBefore/NotOnOrAfter).
555
822
  * Prevents acceptance of expired or future-dated assertions.
@@ -589,6 +856,27 @@ function validateSAMLTimestamp(conditions, options = {}) {
589
856
  });
590
857
  }
591
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
+ }
592
880
  const spMetadataQuerySchema = z.object({
593
881
  providerId: z.string(),
594
882
  format: z.enum(["xml", "json"]).default("xml")
@@ -918,7 +1206,8 @@ const registerSSOProvider = (options) => {
918
1206
  jwksEndpoint: body.oidcConfig.jwksEndpoint,
919
1207
  userInfoEndpoint: body.oidcConfig.userInfoEndpoint,
920
1208
  tokenEndpointAuthentication: body.oidcConfig.tokenEndpointAuthentication
921
- }
1209
+ },
1210
+ isTrustedOrigin: ctx.context.isTrustedOrigin
922
1211
  });
923
1212
  } catch (error) {
924
1213
  if (error instanceof DiscoveryError) throw mapDiscoveryErrorToAPIError(error);
@@ -1195,7 +1484,7 @@ const signInSSO = (options) => {
1195
1484
  });
1196
1485
  const loginRequest = sp.createLoginRequest(idp, "redirect");
1197
1486
  if (!loginRequest) throw new APIError("BAD_REQUEST", { message: "Invalid SAML request" });
1198
- if (loginRequest.id && (options?.saml?.authnRequestStore || options?.saml?.enableInResponseToValidation)) {
1487
+ if (loginRequest.id && options?.saml?.enableInResponseToValidation) {
1199
1488
  const ttl = options?.saml?.requestTTL ?? DEFAULT_AUTHN_REQUEST_TTL_MS;
1200
1489
  const record = {
1201
1490
  id: loginRequest.id,
@@ -1203,8 +1492,7 @@ const signInSSO = (options) => {
1203
1492
  createdAt: Date.now(),
1204
1493
  expiresAt: Date.now() + ttl
1205
1494
  };
1206
- if (options?.saml?.authnRequestStore) await options.saml.authnRequestStore.save(record);
1207
- else await ctx.context.internalAdapter.createVerificationValue({
1495
+ await ctx.context.internalAdapter.createVerificationValue({
1208
1496
  identifier: `${AUTHN_REQUEST_KEY_PREFIX}${record.id}`,
1209
1497
  value: JSON.stringify(record),
1210
1498
  expiresAt: new Date(record.expiresAt)
@@ -1362,37 +1650,20 @@ const callbackSSO = (options) => {
1362
1650
  token: tokenResponse,
1363
1651
  provider
1364
1652
  });
1365
- if (provider.organizationId && !options?.organizationProvisioning?.disabled) {
1366
- if (ctx.context.options.plugins?.find((plugin) => plugin.id === "organization")) {
1367
- if (!await ctx.context.adapter.findOne({
1368
- model: "member",
1369
- where: [{
1370
- field: "organizationId",
1371
- value: provider.organizationId
1372
- }, {
1373
- field: "userId",
1374
- value: user.id
1375
- }]
1376
- })) {
1377
- const role = options?.organizationProvisioning?.getRole ? await options.organizationProvisioning.getRole({
1378
- user,
1379
- userInfo,
1380
- token: tokenResponse,
1381
- provider
1382
- }) : options?.organizationProvisioning?.defaultRole || "member";
1383
- await ctx.context.adapter.create({
1384
- model: "member",
1385
- data: {
1386
- organizationId: provider.organizationId,
1387
- userId: user.id,
1388
- role,
1389
- createdAt: /* @__PURE__ */ new Date(),
1390
- updatedAt: /* @__PURE__ */ new Date()
1391
- }
1392
- });
1393
- }
1394
- }
1395
- }
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
+ });
1396
1667
  await setSessionCookie(ctx, {
1397
1668
  session,
1398
1669
  user
@@ -1514,25 +1785,23 @@ const callbackSSOSAML = (options) => {
1514
1785
  });
1515
1786
  }
1516
1787
  const { extract } = parsedResponse;
1788
+ validateSAMLAlgorithms(parsedResponse, options?.saml?.algorithms);
1517
1789
  validateSAMLTimestamp(extract.conditions, {
1518
1790
  clockSkew: options?.saml?.clockSkew,
1519
1791
  requireTimestamps: options?.saml?.requireTimestamps,
1520
1792
  logger: ctx.context.logger
1521
1793
  });
1522
1794
  const inResponseTo = extract.inResponseTo;
1523
- if (options?.saml?.authnRequestStore || options?.saml?.enableInResponseToValidation) {
1795
+ if (options?.saml?.enableInResponseToValidation) {
1524
1796
  const allowIdpInitiated = options?.saml?.allowIdpInitiated !== false;
1525
1797
  if (inResponseTo) {
1526
1798
  let storedRequest = null;
1527
- if (options?.saml?.authnRequestStore) storedRequest = await options.saml.authnRequestStore.get(inResponseTo);
1528
- else {
1529
- const verification = await ctx.context.internalAdapter.findVerificationValue(`${AUTHN_REQUEST_KEY_PREFIX}${inResponseTo}`);
1530
- if (verification) try {
1531
- storedRequest = JSON.parse(verification.value);
1532
- if (storedRequest && storedRequest.expiresAt < Date.now()) storedRequest = null;
1533
- } catch {
1534
- storedRequest = null;
1535
- }
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;
1536
1805
  }
1537
1806
  if (!storedRequest) {
1538
1807
  ctx.context.logger.error("SAML InResponseTo validation failed: unknown or expired request ID", {
@@ -1548,19 +1817,55 @@ const callbackSSOSAML = (options) => {
1548
1817
  expectedProvider: storedRequest.providerId,
1549
1818
  actualProvider: provider.providerId
1550
1819
  });
1551
- if (options?.saml?.authnRequestStore) await options.saml.authnRequestStore.delete(inResponseTo);
1552
- else await ctx.context.internalAdapter.deleteVerificationByIdentifier(`${AUTHN_REQUEST_KEY_PREFIX}${inResponseTo}`);
1820
+ await ctx.context.internalAdapter.deleteVerificationByIdentifier(`${AUTHN_REQUEST_KEY_PREFIX}${inResponseTo}`);
1553
1821
  const redirectUrl = RelayState || parsedSamlConfig.callbackUrl || ctx.context.baseURL;
1554
1822
  throw ctx.redirect(`${redirectUrl}?error=invalid_saml_response&error_description=Provider+mismatch`);
1555
1823
  }
1556
- if (options?.saml?.authnRequestStore) await options.saml.authnRequestStore.delete(inResponseTo);
1557
- else await ctx.context.internalAdapter.deleteVerificationByIdentifier(`${AUTHN_REQUEST_KEY_PREFIX}${inResponseTo}`);
1824
+ await ctx.context.internalAdapter.deleteVerificationByIdentifier(`${AUTHN_REQUEST_KEY_PREFIX}${inResponseTo}`);
1558
1825
  } else if (!allowIdpInitiated) {
1559
1826
  ctx.context.logger.error("SAML IdP-initiated SSO rejected: InResponseTo missing and allowIdpInitiated is false", { providerId: provider.providerId });
1560
1827
  const redirectUrl = RelayState || parsedSamlConfig.callbackUrl || ctx.context.baseURL;
1561
1828
  throw ctx.redirect(`${redirectUrl}?error=unsolicited_response&error_description=IdP-initiated+SSO+not+allowed`);
1562
1829
  }
1563
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 });
1564
1869
  const attributes = extract.attributes || {};
1565
1870
  const mapping = parsedSamlConfig.mapping ?? {};
1566
1871
  const userInfo = {
@@ -1579,100 +1884,49 @@ const callbackSSOSAML = (options) => {
1579
1884
  });
1580
1885
  throw new APIError("BAD_REQUEST", { message: "Unable to extract user ID or email from SAML response" });
1581
1886
  }
1582
- let user;
1583
- const existingUser = await ctx.context.adapter.findOne({
1584
- model: "user",
1585
- where: [{
1586
- field: "email",
1587
- value: userInfo.email
1588
- }]
1589
- });
1590
- if (existingUser) {
1591
- if (!await ctx.context.adapter.findOne({
1592
- model: "account",
1593
- where: [
1594
- {
1595
- field: "userId",
1596
- value: existingUser.id
1597
- },
1598
- {
1599
- field: "providerId",
1600
- value: provider.providerId
1601
- },
1602
- {
1603
- field: "accountId",
1604
- value: userInfo.id
1605
- }
1606
- ]
1607
- })) {
1608
- if (!(ctx.context.options.account?.accountLinking?.trustedProviders?.includes(provider.providerId) || "domainVerified" in provider && provider.domainVerified && validateEmailDomain(userInfo.email, provider.domain))) {
1609
- const redirectUrl = RelayState || parsedSamlConfig.callbackUrl || ctx.context.baseURL;
1610
- throw ctx.redirect(`${redirectUrl}?error=account_not_linked`);
1611
- }
1612
- await ctx.context.internalAdapter.createAccount({
1613
- userId: existingUser.id,
1614
- providerId: provider.providerId,
1615
- accountId: userInfo.id,
1616
- accessToken: "",
1617
- refreshToken: ""
1618
- });
1619
- }
1620
- user = existingUser;
1621
- } else {
1622
- if (options?.disableImplicitSignUp) throw new APIError("UNAUTHORIZED", { message: "User not found and implicit sign up is disabled for this provider" });
1623
- 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: {
1624
1891
  email: userInfo.email,
1625
- name: userInfo.name,
1626
- emailVerified: userInfo.emailVerified
1627
- });
1628
- await ctx.context.internalAdapter.createAccount({
1629
- userId: user.id,
1892
+ name: userInfo.name || userInfo.email,
1893
+ id: userInfo.id,
1894
+ emailVerified: Boolean(userInfo.emailVerified)
1895
+ },
1896
+ account: {
1630
1897
  providerId: provider.providerId,
1631
1898
  accountId: userInfo.id,
1632
1899
  accessToken: "",
1633
1900
  refreshToken: ""
1634
- });
1635
- }
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;
1636
1908
  if (options?.provisionUser) await options.provisionUser({
1637
1909
  user,
1638
1910
  userInfo,
1639
1911
  provider
1640
1912
  });
1641
- if (provider.organizationId && !options?.organizationProvisioning?.disabled) {
1642
- if (ctx.context.options.plugins?.find((plugin) => plugin.id === "organization")) {
1643
- if (!await ctx.context.adapter.findOne({
1644
- model: "member",
1645
- where: [{
1646
- field: "organizationId",
1647
- value: provider.organizationId
1648
- }, {
1649
- field: "userId",
1650
- value: user.id
1651
- }]
1652
- })) {
1653
- const role = options?.organizationProvisioning?.getRole ? await options.organizationProvisioning.getRole({
1654
- user,
1655
- userInfo,
1656
- provider
1657
- }) : options?.organizationProvisioning?.defaultRole || "member";
1658
- await ctx.context.adapter.create({
1659
- model: "member",
1660
- data: {
1661
- organizationId: provider.organizationId,
1662
- userId: user.id,
1663
- role,
1664
- createdAt: /* @__PURE__ */ new Date(),
1665
- updatedAt: /* @__PURE__ */ new Date()
1666
- }
1667
- });
1668
- }
1669
- }
1670
- }
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
+ });
1671
1926
  await setSessionCookie(ctx, {
1672
- session: await ctx.context.internalAdapter.createSession(user.id),
1927
+ session,
1673
1928
  user
1674
1929
  });
1675
- const callbackUrl = RelayState || parsedSamlConfig.callbackUrl || ctx.context.baseURL;
1676
1930
  throw ctx.redirect(callbackUrl);
1677
1931
  });
1678
1932
  };
@@ -1765,25 +2019,23 @@ const acsEndpoint = (options) => {
1765
2019
  });
1766
2020
  }
1767
2021
  const { extract } = parsedResponse;
2022
+ validateSAMLAlgorithms(parsedResponse, options?.saml?.algorithms);
1768
2023
  validateSAMLTimestamp(extract.conditions, {
1769
2024
  clockSkew: options?.saml?.clockSkew,
1770
2025
  requireTimestamps: options?.saml?.requireTimestamps,
1771
2026
  logger: ctx.context.logger
1772
2027
  });
1773
2028
  const inResponseToAcs = extract.inResponseTo;
1774
- if (options?.saml?.authnRequestStore || options?.saml?.enableInResponseToValidation) {
2029
+ if (options?.saml?.enableInResponseToValidation) {
1775
2030
  const allowIdpInitiated = options?.saml?.allowIdpInitiated !== false;
1776
2031
  if (inResponseToAcs) {
1777
2032
  let storedRequest = null;
1778
- if (options?.saml?.authnRequestStore) storedRequest = await options.saml.authnRequestStore.get(inResponseToAcs);
1779
- else {
1780
- const verification = await ctx.context.internalAdapter.findVerificationValue(`${AUTHN_REQUEST_KEY_PREFIX}${inResponseToAcs}`);
1781
- if (verification) try {
1782
- storedRequest = JSON.parse(verification.value);
1783
- if (storedRequest && storedRequest.expiresAt < Date.now()) storedRequest = null;
1784
- } catch {
1785
- storedRequest = null;
1786
- }
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;
1787
2039
  }
1788
2040
  if (!storedRequest) {
1789
2041
  ctx.context.logger.error("SAML InResponseTo validation failed: unknown or expired request ID", {
@@ -1799,19 +2051,54 @@ const acsEndpoint = (options) => {
1799
2051
  expectedProvider: storedRequest.providerId,
1800
2052
  actualProvider: providerId
1801
2053
  });
1802
- if (options?.saml?.authnRequestStore) await options.saml.authnRequestStore.delete(inResponseToAcs);
1803
- else await ctx.context.internalAdapter.deleteVerificationByIdentifier(`${AUTHN_REQUEST_KEY_PREFIX}${inResponseToAcs}`);
2054
+ await ctx.context.internalAdapter.deleteVerificationByIdentifier(`${AUTHN_REQUEST_KEY_PREFIX}${inResponseToAcs}`);
1804
2055
  const redirectUrl = RelayState || parsedSamlConfig.callbackUrl || ctx.context.baseURL;
1805
2056
  throw ctx.redirect(`${redirectUrl}?error=invalid_saml_response&error_description=Provider+mismatch`);
1806
2057
  }
1807
- if (options?.saml?.authnRequestStore) await options.saml.authnRequestStore.delete(inResponseToAcs);
1808
- else await ctx.context.internalAdapter.deleteVerificationByIdentifier(`${AUTHN_REQUEST_KEY_PREFIX}${inResponseToAcs}`);
2058
+ await ctx.context.internalAdapter.deleteVerificationByIdentifier(`${AUTHN_REQUEST_KEY_PREFIX}${inResponseToAcs}`);
1809
2059
  } else if (!allowIdpInitiated) {
1810
2060
  ctx.context.logger.error("SAML IdP-initiated SSO rejected: InResponseTo missing and allowIdpInitiated is false", { providerId });
1811
2061
  const redirectUrl = RelayState || parsedSamlConfig.callbackUrl || ctx.context.baseURL;
1812
2062
  throw ctx.redirect(`${redirectUrl}?error=unsolicited_response&error_description=IdP-initiated+SSO+not+allowed`);
1813
2063
  }
1814
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 });
1815
2102
  const attributes = extract.attributes || {};
1816
2103
  const mapping = parsedSamlConfig.mapping ?? {};
1817
2104
  const userInfo = {
@@ -1830,99 +2117,49 @@ const acsEndpoint = (options) => {
1830
2117
  });
1831
2118
  throw new APIError("BAD_REQUEST", { message: "Unable to extract user ID or email from SAML response" });
1832
2119
  }
1833
- let user;
1834
- const existingUser = await ctx.context.adapter.findOne({
1835
- model: "user",
1836
- where: [{
1837
- field: "email",
1838
- value: userInfo.email
1839
- }]
1840
- });
1841
- if (existingUser) {
1842
- if (!await ctx.context.adapter.findOne({
1843
- model: "account",
1844
- where: [
1845
- {
1846
- field: "userId",
1847
- value: existingUser.id
1848
- },
1849
- {
1850
- field: "providerId",
1851
- value: provider.providerId
1852
- },
1853
- {
1854
- field: "accountId",
1855
- value: userInfo.id
1856
- }
1857
- ]
1858
- })) {
1859
- 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`);
1860
- await ctx.context.internalAdapter.createAccount({
1861
- userId: existingUser.id,
1862
- providerId: provider.providerId,
1863
- accountId: userInfo.id,
1864
- accessToken: "",
1865
- refreshToken: ""
1866
- });
1867
- }
1868
- user = existingUser;
1869
- } else {
1870
- 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: {
1871
2124
  email: userInfo.email,
1872
- name: userInfo.name,
1873
- emailVerified: options?.trustEmailVerified ? userInfo.emailVerified || false : false
1874
- });
1875
- await ctx.context.internalAdapter.createAccount({
1876
- userId: user.id,
2125
+ name: userInfo.name || userInfo.email,
2126
+ id: userInfo.id,
2127
+ emailVerified: Boolean(userInfo.emailVerified)
2128
+ },
2129
+ account: {
1877
2130
  providerId: provider.providerId,
1878
2131
  accountId: userInfo.id,
1879
2132
  accessToken: "",
1880
- refreshToken: "",
1881
- accessTokenExpiresAt: /* @__PURE__ */ new Date(),
1882
- refreshTokenExpiresAt: /* @__PURE__ */ new Date(),
1883
- scope: ""
1884
- });
1885
- }
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;
1886
2141
  if (options?.provisionUser) await options.provisionUser({
1887
2142
  user,
1888
2143
  userInfo,
1889
2144
  provider
1890
2145
  });
1891
- if (provider.organizationId && !options?.organizationProvisioning?.disabled) {
1892
- if (ctx.context.options.plugins?.find((plugin) => plugin.id === "organization")) {
1893
- if (!await ctx.context.adapter.findOne({
1894
- model: "member",
1895
- where: [{
1896
- field: "organizationId",
1897
- value: provider.organizationId
1898
- }, {
1899
- field: "userId",
1900
- value: user.id
1901
- }]
1902
- })) {
1903
- const role = options?.organizationProvisioning?.getRole ? await options.organizationProvisioning.getRole({
1904
- user,
1905
- userInfo,
1906
- provider
1907
- }) : options?.organizationProvisioning?.defaultRole || "member";
1908
- await ctx.context.adapter.create({
1909
- model: "member",
1910
- data: {
1911
- organizationId: provider.organizationId,
1912
- userId: user.id,
1913
- role,
1914
- createdAt: /* @__PURE__ */ new Date(),
1915
- updatedAt: /* @__PURE__ */ new Date()
1916
- }
1917
- });
1918
- }
1919
- }
1920
- }
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
+ });
1921
2159
  await setSessionCookie(ctx, {
1922
- session: await ctx.context.internalAdapter.createSession(user.id),
2160
+ session,
1923
2161
  user
1924
2162
  });
1925
- const callbackUrl = RelayState || parsedSamlConfig.callbackUrl || ctx.context.baseURL;
1926
2163
  throw ctx.redirect(callbackUrl);
1927
2164
  });
1928
2165
  };
@@ -1956,6 +2193,20 @@ function sso(options) {
1956
2193
  return {
1957
2194
  id: "sso",
1958
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
+ }] },
1959
2210
  schema: { ssoProvider: {
1960
2211
  modelName: options?.modelName ?? "ssoProvider",
1961
2212
  fields: {
@@ -2008,4 +2259,4 @@ function sso(options) {
2008
2259
  }
2009
2260
 
2010
2261
  //#endregion
2011
- 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 };