@better-auth/sso 1.6.19 → 1.6.21

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/client.mjs CHANGED
@@ -1,4 +1,4 @@
1
- import { t as PACKAGE_VERSION } from "./version-BBOVUjFi.mjs";
1
+ import { t as PACKAGE_VERSION } from "./version-CSXTsU_C.mjs";
2
2
  //#region src/client.ts
3
3
  const ssoClient = (options) => {
4
4
  return {
package/dist/index.mjs CHANGED
@@ -1,10 +1,11 @@
1
- import { t as PACKAGE_VERSION } from "./version-BBOVUjFi.mjs";
1
+ import { t as PACKAGE_VERSION } from "./version-CSXTsU_C.mjs";
2
2
  import { APIError, createAuthEndpoint, createAuthMiddleware, getSessionFromCtx, sessionMiddleware } from "better-auth/api";
3
3
  import { XMLParser, XMLValidator } from "fast-xml-parser";
4
4
  import { X509Certificate } from "node:crypto";
5
5
  import { getHostname } from "tldts";
6
6
  import { generateRandomString } from "better-auth/crypto";
7
7
  import * as z from "zod";
8
+ import { getCurrentAdapter, runWithTransaction } from "@better-auth/core/context";
8
9
  import { classifyHost, isPublicRoutableHost } from "@better-auth/core/utils/host";
9
10
  import { BetterFetchError, betterFetch } from "@better-fetch/fetch";
10
11
  import { base64 } from "@better-auth/utils/base64";
@@ -80,8 +81,10 @@ function safeJsonParse(value) {
80
81
  * Checks if a domain matches any domain in a comma-separated list.
81
82
  */
82
83
  const domainMatches = (searchDomain, domainList) => {
83
- const search = searchDomain.toLowerCase();
84
- return domainList.split(",").map((d) => d.trim().toLowerCase()).filter(Boolean).some((d) => search === d || search.endsWith(`.${d}`));
84
+ const search = searchDomain.trim().toLowerCase();
85
+ const domains = parseProviderDomains(domainList);
86
+ if (!search || !domains) return false;
87
+ return domains.some((domain) => search === domain || search.endsWith(`.${domain}`));
85
88
  };
86
89
  /**
87
90
  * Strictly parse a provider-supplied email-verification claim.
@@ -118,6 +121,24 @@ function normalizePem(key) {
118
121
  function getHostnameFromDomain(domain) {
119
122
  return getHostname(domain) || null;
120
123
  }
124
+ /**
125
+ * Normalize a provider `domain` value to the email domains it authorizes.
126
+ *
127
+ * TODO(next): replace the serialized provider.domain string with a canonical
128
+ * domains array and reject URL/path-shaped values at register/update once main
129
+ * and next provider schemas are reconciled.
130
+ */
131
+ function parseProviderDomains(domain) {
132
+ const entries = domain.split(",").map((entry) => entry.trim()).filter(Boolean);
133
+ if (entries.length === 0) return null;
134
+ const domains = /* @__PURE__ */ new Set();
135
+ for (const entry of entries) {
136
+ const parsedDomain = getHostnameFromDomain(entry)?.toLowerCase();
137
+ if (!parsedDomain) return null;
138
+ domains.add(parsedDomain);
139
+ }
140
+ return [...domains];
141
+ }
121
142
  function maskClientId(clientId) {
122
143
  if (clientId.length <= 4) return "****";
123
144
  return `****${clientId.slice(-4)}`;
@@ -970,6 +991,119 @@ function validateSingleAssertion(samlResponse) {
970
991
  });
971
992
  }
972
993
  //#endregion
994
+ //#region src/saml/response-binding.ts
995
+ const SAML_HTTP_POST_BINDING = "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST";
996
+ const SAML_BEARER_CONFIRMATION_METHOD = "urn:oasis:names:tc:SAML:2.0:cm:bearer";
997
+ function toNode(value) {
998
+ if (!value || typeof value !== "object" || Array.isArray(value)) return null;
999
+ return value;
1000
+ }
1001
+ function toNodeArray(value) {
1002
+ if (Array.isArray(value)) return value.map(toNode).filter((node) => !!node);
1003
+ const node = toNode(value);
1004
+ return node ? [node] : [];
1005
+ }
1006
+ function firstNode(value) {
1007
+ return toNodeArray(value)[0] ?? null;
1008
+ }
1009
+ function toStringArray(value) {
1010
+ if (typeof value === "string") return [value];
1011
+ if (Array.isArray(value)) return value.flatMap(toStringArray);
1012
+ const text = toNode(value)?.["#text"];
1013
+ return typeof text === "string" ? [text] : [];
1014
+ }
1015
+ function toStringSet(values) {
1016
+ return new Set(values.filter((value) => !!value));
1017
+ }
1018
+ function parseSAMLContent(samlContent) {
1019
+ try {
1020
+ const parsed = toNode(xmlParser.parse(samlContent));
1021
+ if (parsed) return parsed;
1022
+ } catch {}
1023
+ throw new APIError("BAD_REQUEST", {
1024
+ message: "SAML response XML could not be parsed",
1025
+ code: "SAML_RESPONSE_INVALID_XML"
1026
+ });
1027
+ }
1028
+ function getSAMLPostAssertionConsumerServiceUrls(metadata) {
1029
+ if (!metadata) return [];
1030
+ try {
1031
+ const locations = toNodeArray(findNode(toNode(xmlParser.parse(metadata)), "SPSSODescriptor")).flatMap((descriptor) => toNodeArray(descriptor.AssertionConsumerService).filter((service) => service["@_Binding"] === SAML_HTTP_POST_BINDING).map((service) => service["@_Location"]).filter((location) => typeof location === "string" && !!location));
1032
+ return [...new Set(locations)];
1033
+ } catch {
1034
+ return [];
1035
+ }
1036
+ }
1037
+ function hasSAMLEncryptedAssertion(samlContent) {
1038
+ try {
1039
+ return countAllNodes(xmlParser.parse(samlContent), "EncryptedAssertion") > 0;
1040
+ } catch {
1041
+ return false;
1042
+ }
1043
+ }
1044
+ function getResponseNode(parsed) {
1045
+ return firstNode(parsed.Response);
1046
+ }
1047
+ function getAssertionNode(parsed, response) {
1048
+ const assertion = firstNode(response?.Assertion) ?? firstNode(parsed.Assertion);
1049
+ if (assertion) return assertion;
1050
+ throw new APIError("BAD_REQUEST", {
1051
+ message: "SAML response is missing an assertion",
1052
+ code: "SAML_ASSERTION_MISSING"
1053
+ });
1054
+ }
1055
+ function getAudienceRestrictionGroups(assertion) {
1056
+ return toNodeArray(firstNode(assertion.Conditions)?.AudienceRestriction).map((restriction) => toStringArray(restriction.Audience).filter(Boolean));
1057
+ }
1058
+ function validateAudienceRestrictions(assertion, expectedAudiences) {
1059
+ const audienceGroups = getAudienceRestrictionGroups(assertion);
1060
+ if (audienceGroups.length === 0 || audienceGroups.every((group) => !group.length)) throw new APIError("BAD_REQUEST", {
1061
+ message: "SAML assertion is missing an AudienceRestriction",
1062
+ code: "SAML_AUDIENCE_MISSING"
1063
+ });
1064
+ if (audienceGroups.some((group) => !group.some((audience) => expectedAudiences.has(audience)))) throw new APIError("BAD_REQUEST", {
1065
+ message: "SAML assertion audience does not match this Service Provider",
1066
+ code: "SAML_AUDIENCE_MISMATCH"
1067
+ });
1068
+ }
1069
+ function getBearerSubjectConfirmationData(assertion) {
1070
+ return toNodeArray(firstNode(assertion.Subject)?.SubjectConfirmation).filter((confirmation) => confirmation["@_Method"] === SAML_BEARER_CONFIRMATION_METHOD).map((confirmation) => firstNode(confirmation.SubjectConfirmationData)).filter((data) => !!data);
1071
+ }
1072
+ function validateBearerRecipient(assertion, expectedRecipients) {
1073
+ const confirmationData = getBearerSubjectConfirmationData(assertion);
1074
+ if (!confirmationData.length) throw new APIError("BAD_REQUEST", {
1075
+ message: "SAML assertion is missing bearer SubjectConfirmationData",
1076
+ code: "SAML_BEARER_CONFIRMATION_MISSING"
1077
+ });
1078
+ const recipients = confirmationData.map((data) => data["@_Recipient"]).filter((recipient) => typeof recipient === "string");
1079
+ if (!recipients.length) throw new APIError("BAD_REQUEST", {
1080
+ message: "SAML bearer SubjectConfirmationData is missing a Recipient",
1081
+ code: "SAML_RECIPIENT_MISSING"
1082
+ });
1083
+ if (!recipients.some((recipient) => expectedRecipients.has(recipient))) throw new APIError("BAD_REQUEST", {
1084
+ message: "SAML bearer SubjectConfirmationData Recipient does not match this Service Provider",
1085
+ code: "SAML_RECIPIENT_MISMATCH"
1086
+ });
1087
+ }
1088
+ function validateResponseDestination(response, expectedRecipients) {
1089
+ const destination = response?.["@_Destination"];
1090
+ if (typeof destination !== "string" || !destination) return;
1091
+ if (!expectedRecipients.has(destination)) throw new APIError("BAD_REQUEST", {
1092
+ message: "SAML response Destination does not match this Service Provider",
1093
+ code: "SAML_DESTINATION_MISMATCH"
1094
+ });
1095
+ }
1096
+ function validateSAMLResponseBinding(samlContent, options) {
1097
+ const expectedAudiences = toStringSet(options.expectedAudiences);
1098
+ const expectedRecipients = toStringSet(options.expectedRecipients);
1099
+ const parsed = parseSAMLContent(samlContent);
1100
+ const response = getResponseNode(parsed);
1101
+ const assertion = getAssertionNode(parsed, response);
1102
+ validateAudienceRestrictions(assertion, expectedAudiences);
1103
+ validateBearerRecipient(assertion, expectedRecipients);
1104
+ validateResponseDestination(response, expectedRecipients);
1105
+ }
1106
+ //#endregion
973
1107
  //#region src/routes/schemas.ts
974
1108
  const oidcMappingSchema = z.object({
975
1109
  id: z.string().optional(),
@@ -1050,6 +1184,46 @@ const updateSSOProviderBodySchema = z.object({
1050
1184
  //#endregion
1051
1185
  //#region src/routes/providers.ts
1052
1186
  const ADMIN_ROLES = ["owner", "admin"];
1187
+ const OIDC_IDENTITY_BOUNDARY_FIELDS = [
1188
+ "authorizationEndpoint",
1189
+ "clientId",
1190
+ "discoveryEndpoint",
1191
+ "jwksEndpoint",
1192
+ "tokenEndpoint",
1193
+ "userInfoEndpoint"
1194
+ ];
1195
+ const SAML_IDENTITY_BOUNDARY_FIELDS = [
1196
+ "audience",
1197
+ "callbackUrl",
1198
+ "entryPoint",
1199
+ "identifierFormat"
1200
+ ];
1201
+ const SAML_IDP_BOUNDARY_FIELDS = [
1202
+ "metadata",
1203
+ "entityID",
1204
+ "singleSignOnService"
1205
+ ];
1206
+ const SAML_SP_BOUNDARY_FIELDS = ["metadata", "entityID"];
1207
+ function isRecord(value) {
1208
+ return typeof value === "object" && value !== null && !Array.isArray(value);
1209
+ }
1210
+ function stableStringify(value) {
1211
+ if (Array.isArray(value)) return `[${value.map(stableStringify).join(",")}]`;
1212
+ if (isRecord(value)) return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${stableStringify(value[key])}`).join(",")}}`;
1213
+ return JSON.stringify(value) ?? String(value);
1214
+ }
1215
+ function identityValueChanged(current, updated) {
1216
+ return stableStringify(current) !== stableStringify(updated);
1217
+ }
1218
+ function hasChangedField(current, updated, fields) {
1219
+ return fields.some((field) => identityValueChanged(current?.[field], updated?.[field]));
1220
+ }
1221
+ function oidcIdentityBoundaryChanged(current, updated) {
1222
+ return hasChangedField(current, updated, OIDC_IDENTITY_BOUNDARY_FIELDS) || identityValueChanged(current.mapping?.id, updated.mapping?.id);
1223
+ }
1224
+ function samlIdentityBoundaryChanged(current, updated) {
1225
+ return hasChangedField(current, updated, SAML_IDENTITY_BOUNDARY_FIELDS) || identityValueChanged(current.mapping?.id, updated.mapping?.id) || hasChangedField(current.idpMetadata, updated.idpMetadata, SAML_IDP_BOUNDARY_FIELDS) || hasChangedField(current.spMetadata, updated.spMetadata, SAML_SP_BOUNDARY_FIELDS);
1226
+ }
1053
1227
  function hasOrgAdminRole(member) {
1054
1228
  return member.role.split(",").some((r) => ADMIN_ROLES.includes(r.trim()));
1055
1229
  }
@@ -1271,6 +1445,7 @@ const updateSSOProvider = (options) => {
1271
1445
  if (!issuer && !domain && !samlConfig && !oidcConfig) throw new APIError("BAD_REQUEST", { message: "No fields provided for update" });
1272
1446
  const existingProvider = await checkProviderAccess(ctx, providerId);
1273
1447
  const updateData = {};
1448
+ let providerIdentityBoundaryChanged = body.issuer !== void 0 && body.issuer !== existingProvider.issuer;
1274
1449
  if (body.issuer !== void 0) updateData.issuer = body.issuer;
1275
1450
  if (body.domain !== void 0) {
1276
1451
  updateData.domain = body.domain;
@@ -1287,6 +1462,7 @@ const updateSSOProvider = (options) => {
1287
1462
  }, options?.saml?.algorithms);
1288
1463
  const currentSamlConfig = parseAndValidateConfig(existingProvider.samlConfig, "SAML");
1289
1464
  const updatedSamlConfig = mergeSAMLConfig(currentSamlConfig, body.samlConfig, updateData.issuer || currentSamlConfig.issuer || existingProvider.issuer);
1465
+ if (samlIdentityBoundaryChanged(currentSamlConfig, updatedSamlConfig)) providerIdentityBoundaryChanged = true;
1290
1466
  updateData.samlConfig = JSON.stringify(updatedSamlConfig);
1291
1467
  }
1292
1468
  if (body.oidcConfig) {
@@ -1298,8 +1474,18 @@ const updateSSOProvider = (options) => {
1298
1474
  }
1299
1475
  const currentOidcConfig = parseAndValidateConfig(existingProvider.oidcConfig, "OIDC");
1300
1476
  const updatedOidcConfig = mergeOIDCConfig(currentOidcConfig, body.oidcConfig, updateData.issuer || currentOidcConfig.issuer || existingProvider.issuer);
1477
+ if (oidcIdentityBoundaryChanged(currentOidcConfig, updatedOidcConfig)) providerIdentityBoundaryChanged = true;
1301
1478
  updateData.oidcConfig = JSON.stringify(updatedOidcConfig);
1302
1479
  }
1480
+ if (providerIdentityBoundaryChanged) {
1481
+ if (await ctx.context.adapter.findOne({
1482
+ model: "account",
1483
+ where: [{
1484
+ field: "providerId",
1485
+ value: providerId
1486
+ }]
1487
+ })) throw new APIError("CONFLICT", { message: "Cannot change SSO provider identity fields while linked accounts exist" });
1488
+ }
1303
1489
  await ctx.context.adapter.update({
1304
1490
  model: "ssoProvider",
1305
1491
  where: [{
@@ -1337,12 +1523,22 @@ const deleteSSOProvider = () => {
1337
1523
  }, async (ctx) => {
1338
1524
  const { providerId } = ctx.body;
1339
1525
  await checkProviderAccess(ctx, providerId);
1340
- await ctx.context.adapter.delete({
1341
- model: "ssoProvider",
1342
- where: [{
1343
- field: "providerId",
1344
- value: providerId
1345
- }]
1526
+ await runWithTransaction(ctx.context.adapter, async () => {
1527
+ const trx = await getCurrentAdapter(ctx.context.adapter);
1528
+ await trx.deleteMany({
1529
+ model: "account",
1530
+ where: [{
1531
+ field: "providerId",
1532
+ value: providerId
1533
+ }]
1534
+ });
1535
+ await trx.delete({
1536
+ model: "ssoProvider",
1537
+ where: [{
1538
+ field: "providerId",
1539
+ value: providerId
1540
+ }]
1541
+ });
1346
1542
  });
1347
1543
  return ctx.json({ success: true });
1348
1544
  });
@@ -1424,7 +1620,6 @@ const verifyDomain = (options) => {
1424
1620
  message: "No pending domain verification exists",
1425
1621
  code: "NO_PENDING_VERIFICATION"
1426
1622
  });
1427
- let records = [];
1428
1623
  let dns;
1429
1624
  try {
1430
1625
  dns = await import("node:dns/promises");
@@ -1435,20 +1630,28 @@ const verifyDomain = (options) => {
1435
1630
  code: "DOMAIN_VERIFICATION_FAILED"
1436
1631
  });
1437
1632
  }
1438
- const hostname = getHostnameFromDomain(provider.domain);
1439
- if (!hostname) throw new APIError("BAD_REQUEST", {
1633
+ const domains = parseProviderDomains(provider.domain);
1634
+ if (!domains) throw new APIError("BAD_REQUEST", {
1440
1635
  message: "Invalid domain",
1441
1636
  code: "INVALID_DOMAIN"
1442
1637
  });
1443
- try {
1444
- records = (await dns.resolveTxt(`${identifier}.${hostname}`)).flat();
1445
- } catch (error) {
1446
- ctx.context.logger.warn("DNS resolution failure while validating domain ownership", error);
1638
+ for (const domain of domains) {
1639
+ let records = [];
1640
+ try {
1641
+ records = (await dns.resolveTxt(`${identifier}.${domain}`)).map((record) => record.join(""));
1642
+ } catch (error) {
1643
+ ctx.context.logger.warn(`DNS resolution failure while validating domain ownership for ${domain}`, error);
1644
+ }
1645
+ const verificationValue = activeVerification.value;
1646
+ const verificationRecord = `${activeVerification.identifier}=${verificationValue}`;
1647
+ if (!records.find((record) => {
1648
+ const normalizedRecord = record.trim();
1649
+ return normalizedRecord === verificationRecord || normalizedRecord === verificationValue;
1650
+ })) throw new APIError("BAD_GATEWAY", {
1651
+ message: `Unable to verify domain ownership for ${domain}. Try again later`,
1652
+ code: "DOMAIN_VERIFICATION_FAILED"
1653
+ });
1447
1654
  }
1448
- if (!records.find((record) => record.includes(`${activeVerification.identifier}=${activeVerification.value}`))) throw new APIError("BAD_GATEWAY", {
1449
- message: "Unable to verify domain ownership. Try again later",
1450
- code: "DOMAIN_VERIFICATION_FAILED"
1451
- });
1452
1655
  await ctx.context.adapter.update({
1453
1656
  model: "ssoProvider",
1454
1657
  where: [{
@@ -1600,7 +1803,17 @@ function escapeHtml(str) {
1600
1803
  if (!str) return "";
1601
1804
  return String(str).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;");
1602
1805
  }
1806
+ function isSAMLPostBindingLocation(value) {
1807
+ let url;
1808
+ try {
1809
+ url = new URL(value);
1810
+ } catch {
1811
+ return false;
1812
+ }
1813
+ return url.protocol === "http:" || url.protocol === "https:";
1814
+ }
1603
1815
  function createSAMLPostForm(action, samlParam, samlValue, relayState) {
1816
+ if (!isSAMLPostBindingLocation(action)) throw new APIError("BAD_REQUEST", { message: "SAML POST binding location must be an absolute http or https URL" });
1604
1817
  const safeAction = escapeHtml(action);
1605
1818
  const safeSamlParam = escapeHtml(samlParam);
1606
1819
  const safeSamlValue = escapeHtml(samlValue);
@@ -1679,6 +1892,28 @@ function getSafeRedirectUrl(url, callbackPath, appOrigin, isTrustedOrigin) {
1679
1892
  }
1680
1893
  return url;
1681
1894
  }
1895
+ function buildSAMLRedirectUrl(url, params) {
1896
+ const searchParams = new URLSearchParams(params);
1897
+ return `${url}${url.includes("?") ? "&" : "?"}${searchParams.toString()}`;
1898
+ }
1899
+ function toArray(value) {
1900
+ if (Array.isArray(value)) return value;
1901
+ return value ? [value] : [];
1902
+ }
1903
+ function getExpectedSAMLRecipients(config, baseURL, providerId, currentCallbackPath, assertionConsumerServiceUrl) {
1904
+ const configuredPostAssertionConsumerServiceUrls = getSAMLPostAssertionConsumerServiceUrls(config.spMetadata?.metadata);
1905
+ return [
1906
+ currentCallbackPath,
1907
+ `${baseURL}/sso/saml2/sp/acs/${providerId}`,
1908
+ ...configuredPostAssertionConsumerServiceUrls,
1909
+ ...toArray(assertionConsumerServiceUrl)
1910
+ ];
1911
+ }
1912
+ async function getSAMLResponseBindingContent(sp, samlContent) {
1913
+ if (!hasSAMLEncryptedAssertion(samlContent)) return samlContent;
1914
+ const [decryptedContent] = await saml.SamlLib.decryptAssertion(sp, samlContent);
1915
+ return decryptedContent;
1916
+ }
1682
1917
  /**
1683
1918
  * Extracts the Assertion ID from a SAML response XML.
1684
1919
  * Used for replay protection per SAML 2.0 Core section 2.3.3.
@@ -1747,12 +1982,47 @@ async function processSAMLResponse(ctx, params, options) {
1747
1982
  });
1748
1983
  }
1749
1984
  const { extract } = parsedResponse;
1985
+ const samlContent = parsedResponse.samlContent;
1750
1986
  validateSAMLAlgorithms(parsedResponse, options?.saml?.algorithms);
1751
1987
  validateSAMLTimestamp(extract.conditions, {
1752
1988
  clockSkew: options?.saml?.clockSkew,
1753
1989
  requireTimestamps: options?.saml?.requireTimestamps,
1754
1990
  logger: ctx.context.logger
1755
1991
  });
1992
+ const expectedAudiences = [sp.entityMeta.getEntityID(), parsedSamlConfig.audience];
1993
+ const assertionConsumerServiceUrl = sp.entityMeta.getAssertionConsumerService(SAML_HTTP_POST_BINDING);
1994
+ const expectedRecipients = getExpectedSAMLRecipients(parsedSamlConfig, ctx.context.baseURL, providerId, currentCallbackPath, assertionConsumerServiceUrl);
1995
+ let samlBindingContent;
1996
+ try {
1997
+ samlBindingContent = await getSAMLResponseBindingContent(sp, samlContent);
1998
+ validateSAMLResponseBinding(samlBindingContent, {
1999
+ expectedAudiences,
2000
+ expectedRecipients
2001
+ });
2002
+ } catch (error) {
2003
+ if (isAPIError(error)) {
2004
+ ctx.context.logger.error("SAML response binding validation failed", {
2005
+ providerId,
2006
+ code: error.body?.code,
2007
+ expectedAudiences: expectedAudiences.filter(Boolean),
2008
+ expectedRecipients: expectedRecipients.filter(Boolean)
2009
+ });
2010
+ throw ctx.redirect(buildSAMLRedirectUrl(samlRedirectUrl, {
2011
+ error: "invalid_saml_response",
2012
+ error_description: error.body?.message || error.message || "Invalid SAML response"
2013
+ }));
2014
+ }
2015
+ ctx.context.logger.error("SAML response binding validation failed", {
2016
+ providerId,
2017
+ error,
2018
+ expectedAudiences: expectedAudiences.filter(Boolean),
2019
+ expectedRecipients: expectedRecipients.filter(Boolean)
2020
+ });
2021
+ throw ctx.redirect(buildSAMLRedirectUrl(samlRedirectUrl, {
2022
+ error: "invalid_saml_response",
2023
+ error_description: "SAML response binding could not be validated"
2024
+ }));
2025
+ }
1756
2026
  const inResponseTo = extract.inResponseTo;
1757
2027
  if (options?.saml?.enableInResponseToValidation !== false) {
1758
2028
  const allowIdpInitiated = options?.saml?.allowIdpInitiated !== false;
@@ -1769,7 +2039,10 @@ async function processSAMLResponse(ctx, params, options) {
1769
2039
  inResponseTo,
1770
2040
  providerId
1771
2041
  });
1772
- throw ctx.redirect(`${samlRedirectUrl}?error=invalid_saml_response&error_description=Unknown+or+expired+request+ID`);
2042
+ throw ctx.redirect(buildSAMLRedirectUrl(samlRedirectUrl, {
2043
+ error: "invalid_saml_response",
2044
+ error_description: "Unknown or expired request ID"
2045
+ }));
1773
2046
  }
1774
2047
  if (storedRequest.providerId !== providerId) {
1775
2048
  ctx.context.logger.error("SAML InResponseTo validation failed: provider mismatch", {
@@ -1777,15 +2050,20 @@ async function processSAMLResponse(ctx, params, options) {
1777
2050
  expectedProvider: storedRequest.providerId,
1778
2051
  actualProvider: providerId
1779
2052
  });
1780
- throw ctx.redirect(`${samlRedirectUrl}?error=invalid_saml_response&error_description=Provider+mismatch`);
2053
+ throw ctx.redirect(buildSAMLRedirectUrl(samlRedirectUrl, {
2054
+ error: "invalid_saml_response",
2055
+ error_description: "Provider mismatch"
2056
+ }));
1781
2057
  }
1782
2058
  } else if (!allowIdpInitiated) {
1783
2059
  ctx.context.logger.error("SAML IdP-initiated SSO rejected: InResponseTo missing and allowIdpInitiated is false", { providerId });
1784
- throw ctx.redirect(`${samlRedirectUrl}?error=unsolicited_response&error_description=IdP-initiated+SSO+not+allowed`);
2060
+ throw ctx.redirect(buildSAMLRedirectUrl(samlRedirectUrl, {
2061
+ error: "unsolicited_response",
2062
+ error_description: "IdP-initiated SSO not allowed"
2063
+ }));
1785
2064
  }
1786
2065
  }
1787
- const samlContent = parsedResponse.samlContent;
1788
- const assertionId = samlContent ? extractAssertionId(samlContent) : null;
2066
+ const assertionId = extractAssertionId(samlBindingContent);
1789
2067
  if (assertionId) {
1790
2068
  const issuer = idp.entityMeta.getEntityID();
1791
2069
  const conditions = extract.conditions;
@@ -1807,7 +2085,10 @@ async function processSAMLResponse(ctx, params, options) {
1807
2085
  issuer,
1808
2086
  providerId
1809
2087
  });
1810
- throw ctx.redirect(`${samlRedirectUrl}?error=replay_detected&error_description=SAML+assertion+has+already+been+used`);
2088
+ throw ctx.redirect(buildSAMLRedirectUrl(samlRedirectUrl, {
2089
+ error: "replay_detected",
2090
+ error_description: "SAML assertion has already been used"
2091
+ }));
1811
2092
  }
1812
2093
  } else ctx.context.logger.warn("Could not extract assertion ID for replay protection", { providerId });
1813
2094
  const attributes = extract.attributes || {};
@@ -1856,15 +2137,13 @@ async function processSAMLResponse(ctx, params, options) {
1856
2137
  trustProviderByName: false
1857
2138
  });
1858
2139
  } catch (e) {
1859
- if (isAPIError(e) && e.body?.code) {
1860
- const params = new URLSearchParams({ error: e.body.code });
1861
- if (e.body.message) params.set("error_description", e.body.message);
1862
- const sep = errorUrl.includes("?") ? "&" : "?";
1863
- throw ctx.redirect(`${errorUrl}${sep}${params.toString()}`);
1864
- }
2140
+ if (isAPIError(e) && e.body?.code) throw ctx.redirect(buildSAMLRedirectUrl(errorUrl, {
2141
+ error: e.body.code,
2142
+ ...e.body.message ? { error_description: e.body.message } : {}
2143
+ }));
1865
2144
  throw e;
1866
2145
  }
1867
- if (result.error) throw ctx.redirect(`${callbackUrl}?error=${result.error.split(" ").join("_")}`);
2146
+ if (result.error) throw ctx.redirect(buildSAMLRedirectUrl(callbackUrl, { error: result.error.split(" ").join("_") }));
1868
2147
  const { session, user } = result.data;
1869
2148
  if (options?.provisionUser && (result.isRegister || options.provisionUserOnEveryLogin)) await options.provisionUser({
1870
2149
  user,
@@ -1912,6 +2191,14 @@ async function processSAMLResponse(ctx, params, options) {
1912
2191
  }
1913
2192
  //#endregion
1914
2193
  //#region src/routes/sso.ts
2194
+ const BUILT_IN_ACCOUNT_PROVIDER_IDS = [
2195
+ "credential",
2196
+ "email-otp",
2197
+ "magic-link",
2198
+ "phone-number",
2199
+ "anonymous",
2200
+ "siwe"
2201
+ ];
1915
2202
  /**
1916
2203
  * Builds the OIDC redirect URI. Uses the shared `redirectURI` option
1917
2204
  * when set, otherwise falls back to `/sso/callback/:providerId`.
@@ -2245,13 +2532,27 @@ const registerSSOProvider = (options) => {
2245
2532
  if (ctx.context.hasPlugin("organization") && !hasOrgAdminRole(member)) throw new APIError("FORBIDDEN", { message: "You must be an organization owner or admin to register SSO providers" });
2246
2533
  }
2247
2534
  if (new Set([
2248
- "credential",
2535
+ ...BUILT_IN_ACCOUNT_PROVIDER_IDS,
2536
+ ...Object.keys(ctx.context.options.socialProviders ?? {}),
2249
2537
  ...ctx.context.socialProviders.map((p) => p.id),
2250
- ...ctx.context.trustedProviders
2538
+ ...ctx.context.trustedProviders,
2539
+ ...options?.defaultSSO?.map((p) => p.providerId) ?? []
2251
2540
  ]).has(body.providerId)) {
2252
2541
  ctx.context.logger.warn(`SSO provider registration rejected for reserved providerId: ${body.providerId}`);
2253
2542
  throw new APIError("UNPROCESSABLE_ENTITY", { message: "This providerId is reserved and cannot be used for an SSO provider" });
2254
2543
  }
2544
+ if (ctx.context.hasPlugin("scim")) {
2545
+ if (await ctx.context.adapter.findOne({
2546
+ model: "scimProvider",
2547
+ where: [{
2548
+ field: "providerId",
2549
+ value: body.providerId
2550
+ }]
2551
+ })) {
2552
+ ctx.context.logger.warn(`SSO provider registration rejected for SCIM providerId: ${body.providerId}`);
2553
+ throw new APIError("UNPROCESSABLE_ENTITY", { message: "This providerId is already used by a SCIM provider and cannot be used for an SSO provider" });
2554
+ }
2555
+ }
2255
2556
  if (await ctx.context.adapter.findOne({
2256
2557
  model: "ssoProvider",
2257
2558
  where: [{
@@ -2479,7 +2780,7 @@ const signInSSO = (options) => {
2479
2780
  });
2480
2781
  let provider = null;
2481
2782
  if (options?.defaultSSO?.length) {
2482
- const matchingDefault = providerId ? options.defaultSSO.find((defaultProvider) => defaultProvider.providerId === providerId) : options.defaultSSO.find((defaultProvider) => defaultProvider.domain === domain);
2783
+ const matchingDefault = providerId ? options.defaultSSO.find((defaultProvider) => defaultProvider.providerId === providerId) : options.defaultSSO.find((defaultProvider) => domain && domainMatches(domain, defaultProvider.domain));
2483
2784
  if (matchingDefault) provider = {
2484
2785
  issuer: matchingDefault.samlConfig?.issuer || matchingDefault.oidcConfig?.issuer || "",
2485
2786
  providerId: matchingDefault.providerId,
@@ -1,5 +1,5 @@
1
1
  //#endregion
2
2
  //#region src/version.ts
3
- const PACKAGE_VERSION = "1.6.19";
3
+ const PACKAGE_VERSION = "1.6.21";
4
4
  //#endregion
5
5
  export { PACKAGE_VERSION as t };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@better-auth/sso",
3
- "version": "1.6.19",
3
+ "version": "1.6.21",
4
4
  "description": "SSO plugin for Better Auth",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -65,20 +65,20 @@
65
65
  "devDependencies": {
66
66
  "@types/body-parser": "^1.19.6",
67
67
  "@types/express": "^5.0.6",
68
- "better-call": "1.3.6",
68
+ "better-call": "1.3.7",
69
69
  "body-parser": "^2.2.2",
70
70
  "express": "^5.2.1",
71
71
  "oauth2-mock-server": "^8.2.2",
72
72
  "tsdown": "0.21.1",
73
- "@better-auth/core": "1.6.19",
74
- "better-auth": "1.6.19"
73
+ "@better-auth/core": "1.6.21",
74
+ "better-auth": "1.6.21"
75
75
  },
76
76
  "peerDependencies": {
77
77
  "@better-auth/utils": "0.4.2",
78
78
  "@better-fetch/fetch": "1.3.1",
79
- "better-call": "1.3.6",
80
- "@better-auth/core": "^1.6.19",
81
- "better-auth": "^1.6.19"
79
+ "better-call": "1.3.7",
80
+ "@better-auth/core": "^1.6.21",
81
+ "better-auth": "^1.6.21"
82
82
  },
83
83
  "scripts": {
84
84
  "build": "tsdown",