@better-auth/sso 1.7.0-beta.8 → 1.7.0-rc.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/client.d.mts CHANGED
@@ -1,4 +1,4 @@
1
- import { t as SSOPlugin } from "./index-DsajaS2F.mjs";
1
+ import { t as SSOPlugin } from "./index-A_DJ_AqL.mjs";
2
2
  import { DBFieldAttribute } from "better-auth/db";
3
3
 
4
4
  //#region src/client.d.ts
package/dist/client.mjs CHANGED
@@ -1,4 +1,4 @@
1
- import { t as PACKAGE_VERSION } from "./version-DPq9tYZW.mjs";
1
+ import { t as PACKAGE_VERSION } from "./version-BDD29zmX.mjs";
2
2
  //#region src/client.ts
3
3
  const ssoClient = (options) => {
4
4
  return {
@@ -113,6 +113,11 @@ interface SAMLConfig {
113
113
  */
114
114
  cert?: string | string[];
115
115
  audience?: string | undefined;
116
+ /**
117
+ * Provider-level post-auth redirect URL for IdP-initiated or fallback SAML
118
+ * flows when no RelayState callback URL is available.
119
+ */
120
+ callbackUrl?: string | undefined;
116
121
  idpMetadata?: {
117
122
  metadata?: string;
118
123
  entityID?: string;
@@ -851,6 +856,7 @@ declare const updateSSOProvider: (options: SSOOptions) => better_call0.StrictEnd
851
856
  entryPoint: z.ZodOptional<z.ZodString>;
852
857
  cert: z.ZodOptional<z.ZodOptional<z.ZodUnion<readonly [z.ZodString, z.ZodArray<z.ZodString>]>>>;
853
858
  audience: z.ZodOptional<z.ZodOptional<z.ZodString>>;
859
+ callbackUrl: z.ZodOptional<z.ZodOptional<z.ZodString>>;
854
860
  idpMetadata: z.ZodOptional<z.ZodOptional<z.ZodObject<{
855
861
  metadata: z.ZodOptional<z.ZodString>;
856
862
  entityID: z.ZodOptional<z.ZodString>;
package/dist/index.d.mts CHANGED
@@ -1,2 +1,2 @@
1
- import { A as DataEncryptionAlgorithm, C as DEFAULT_MAX_SAML_METADATA_SIZE, D as SSOOptions, E as SAMLConfig, M as DigestAlgorithm, N as KeyEncryptionAlgorithm, O as SSOProvider, P as SignatureAlgorithm, S as DEFAULT_CLOCK_SKEW_MS, T as OIDCConfig, _ as REQUIRED_DISCOVERY_FIELDS, a as fetchDiscoveryDocument, b as TimestampValidationOptions, c as normalizeUrl, d as validateDiscoveryUrl, f as DiscoverOIDCConfigParams, g as OIDCDiscoveryDocument, h as HydratedOIDCConfig, i as discoverOIDCConfig, j as DeprecatedAlgorithmBehavior, k as AlgorithmValidationOptions, l as selectTokenEndpointAuthMethod, m as DiscoveryErrorCode, n as sso, o as needsRuntimeDiscovery, p as DiscoveryError, r as computeDiscoveryUrl, s as normalizeDiscoveryUrls, t as SSOPlugin, u as validateDiscoveryDocument, v as RequiredDiscoveryField, w as DEFAULT_MAX_SAML_RESPONSE_SIZE, x as validateSAMLTimestamp, y as SAMLConditions } from "./index-DsajaS2F.mjs";
1
+ import { A as DataEncryptionAlgorithm, C as DEFAULT_MAX_SAML_METADATA_SIZE, D as SSOOptions, E as SAMLConfig, M as DigestAlgorithm, N as KeyEncryptionAlgorithm, O as SSOProvider, P as SignatureAlgorithm, S as DEFAULT_CLOCK_SKEW_MS, T as OIDCConfig, _ as REQUIRED_DISCOVERY_FIELDS, a as fetchDiscoveryDocument, b as TimestampValidationOptions, c as normalizeUrl, d as validateDiscoveryUrl, f as DiscoverOIDCConfigParams, g as OIDCDiscoveryDocument, h as HydratedOIDCConfig, i as discoverOIDCConfig, j as DeprecatedAlgorithmBehavior, k as AlgorithmValidationOptions, l as selectTokenEndpointAuthMethod, m as DiscoveryErrorCode, n as sso, o as needsRuntimeDiscovery, p as DiscoveryError, r as computeDiscoveryUrl, s as normalizeDiscoveryUrls, t as SSOPlugin, u as validateDiscoveryDocument, v as RequiredDiscoveryField, w as DEFAULT_MAX_SAML_RESPONSE_SIZE, x as validateSAMLTimestamp, y as SAMLConditions } from "./index-A_DJ_AqL.mjs";
2
2
  export { AlgorithmValidationOptions, DEFAULT_CLOCK_SKEW_MS, DEFAULT_MAX_SAML_METADATA_SIZE, DEFAULT_MAX_SAML_RESPONSE_SIZE, DataEncryptionAlgorithm, DeprecatedAlgorithmBehavior, DigestAlgorithm, DiscoverOIDCConfigParams, DiscoveryError, DiscoveryErrorCode, HydratedOIDCConfig, KeyEncryptionAlgorithm, OIDCConfig, OIDCDiscoveryDocument, REQUIRED_DISCOVERY_FIELDS, RequiredDiscoveryField, SAMLConditions, SAMLConfig, SSOOptions, SSOPlugin, SSOProvider, SignatureAlgorithm, TimestampValidationOptions, computeDiscoveryUrl, discoverOIDCConfig, fetchDiscoveryDocument, needsRuntimeDiscovery, normalizeDiscoveryUrls, normalizeUrl, selectTokenEndpointAuthMethod, sso, validateDiscoveryDocument, validateDiscoveryUrl, validateSAMLTimestamp };
package/dist/index.mjs CHANGED
@@ -1,10 +1,11 @@
1
- import { t as PACKAGE_VERSION } from "./version-DPq9tYZW.mjs";
1
+ import { t as PACKAGE_VERSION } from "./version-BDD29zmX.mjs";
2
2
  import { APIError, addOAuthServerContext, 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 { filterOutputFields } from "@better-auth/core/utils/db";
9
10
  import { classifyHost, isPublicRoutableHost } from "@better-auth/core/utils/host";
10
11
  import { betterFetch } from "@better-fetch/fetch";
@@ -82,8 +83,10 @@ function safeJsonParse(value) {
82
83
  * Checks if a domain matches any domain in a comma-separated list.
83
84
  */
84
85
  const domainMatches = (searchDomain, domainList) => {
85
- const search = searchDomain.toLowerCase();
86
- return domainList.split(",").map((d) => d.trim().toLowerCase()).filter(Boolean).some((d) => search === d || search.endsWith(`.${d}`));
86
+ const search = searchDomain.trim().toLowerCase();
87
+ const domains = parseProviderDomains(domainList);
88
+ if (!search || !domains) return false;
89
+ return domains.some((domain) => search === domain || search.endsWith(`.${domain}`));
87
90
  };
88
91
  /**
89
92
  * Strictly parse a provider-supplied email-verification claim.
@@ -120,6 +123,24 @@ function normalizePem(key) {
120
123
  function getHostnameFromDomain(domain) {
121
124
  return getHostname(domain) || null;
122
125
  }
126
+ /**
127
+ * Normalize a provider `domain` value to the email domains it authorizes.
128
+ *
129
+ * TODO(next): replace the serialized provider.domain string with a canonical
130
+ * domains array and reject URL/path-shaped values at register/update once main
131
+ * and next provider schemas are reconciled.
132
+ */
133
+ function parseProviderDomains(domain) {
134
+ const entries = domain.split(",").map((entry) => entry.trim()).filter(Boolean);
135
+ if (entries.length === 0) return null;
136
+ const domains = /* @__PURE__ */ new Set();
137
+ for (const entry of entries) {
138
+ const parsedDomain = getHostnameFromDomain(entry)?.toLowerCase();
139
+ if (!parsedDomain) return null;
140
+ domains.add(parsedDomain);
141
+ }
142
+ return [...domains];
143
+ }
123
144
  function maskClientId(clientId) {
124
145
  if (clientId.length <= 4) return "****";
125
146
  return `****${clientId.slice(-4)}`;
@@ -1083,6 +1104,119 @@ function validateCertSources(config) {
1083
1104
  if (!hasMetadataXml && !hasExplicitCert) throw APIError.from("BAD_REQUEST", SAML_ERROR_CODES.CERT_SOURCE_MISSING);
1084
1105
  }
1085
1106
  //#endregion
1107
+ //#region src/saml/response-binding.ts
1108
+ const SAML_HTTP_POST_BINDING = "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST";
1109
+ const SAML_BEARER_CONFIRMATION_METHOD = "urn:oasis:names:tc:SAML:2.0:cm:bearer";
1110
+ function toNode(value) {
1111
+ if (!value || typeof value !== "object" || Array.isArray(value)) return null;
1112
+ return value;
1113
+ }
1114
+ function toNodeArray(value) {
1115
+ if (Array.isArray(value)) return value.map(toNode).filter((node) => !!node);
1116
+ const node = toNode(value);
1117
+ return node ? [node] : [];
1118
+ }
1119
+ function firstNode(value) {
1120
+ return toNodeArray(value)[0] ?? null;
1121
+ }
1122
+ function toStringArray(value) {
1123
+ if (typeof value === "string") return [value];
1124
+ if (Array.isArray(value)) return value.flatMap(toStringArray);
1125
+ const text = toNode(value)?.["#text"];
1126
+ return typeof text === "string" ? [text] : [];
1127
+ }
1128
+ function toStringSet(values) {
1129
+ return new Set(values.filter((value) => !!value));
1130
+ }
1131
+ function parseSAMLContent(samlContent) {
1132
+ try {
1133
+ const parsed = toNode(xmlParser.parse(samlContent));
1134
+ if (parsed) return parsed;
1135
+ } catch {}
1136
+ throw new APIError("BAD_REQUEST", {
1137
+ message: "SAML response XML could not be parsed",
1138
+ code: "SAML_RESPONSE_INVALID_XML"
1139
+ });
1140
+ }
1141
+ function getSAMLPostAssertionConsumerServiceUrls(metadata) {
1142
+ if (!metadata) return [];
1143
+ try {
1144
+ 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));
1145
+ return [...new Set(locations)];
1146
+ } catch {
1147
+ return [];
1148
+ }
1149
+ }
1150
+ function hasSAMLEncryptedAssertion(samlContent) {
1151
+ try {
1152
+ return countAllNodes(xmlParser.parse(samlContent), "EncryptedAssertion") > 0;
1153
+ } catch {
1154
+ return false;
1155
+ }
1156
+ }
1157
+ function getResponseNode(parsed) {
1158
+ return firstNode(parsed.Response);
1159
+ }
1160
+ function getAssertionNode(parsed, response) {
1161
+ const assertion = firstNode(response?.Assertion) ?? firstNode(parsed.Assertion);
1162
+ if (assertion) return assertion;
1163
+ throw new APIError("BAD_REQUEST", {
1164
+ message: "SAML response is missing an assertion",
1165
+ code: "SAML_ASSERTION_MISSING"
1166
+ });
1167
+ }
1168
+ function getAudienceRestrictionGroups(assertion) {
1169
+ return toNodeArray(firstNode(assertion.Conditions)?.AudienceRestriction).map((restriction) => toStringArray(restriction.Audience).filter(Boolean));
1170
+ }
1171
+ function validateAudienceRestrictions(assertion, expectedAudiences) {
1172
+ const audienceGroups = getAudienceRestrictionGroups(assertion);
1173
+ if (audienceGroups.length === 0 || audienceGroups.every((group) => !group.length)) throw new APIError("BAD_REQUEST", {
1174
+ message: "SAML assertion is missing an AudienceRestriction",
1175
+ code: "SAML_AUDIENCE_MISSING"
1176
+ });
1177
+ if (audienceGroups.some((group) => !group.some((audience) => expectedAudiences.has(audience)))) throw new APIError("BAD_REQUEST", {
1178
+ message: "SAML assertion audience does not match this Service Provider",
1179
+ code: "SAML_AUDIENCE_MISMATCH"
1180
+ });
1181
+ }
1182
+ function getBearerSubjectConfirmationData(assertion) {
1183
+ return toNodeArray(firstNode(assertion.Subject)?.SubjectConfirmation).filter((confirmation) => confirmation["@_Method"] === SAML_BEARER_CONFIRMATION_METHOD).map((confirmation) => firstNode(confirmation.SubjectConfirmationData)).filter((data) => !!data);
1184
+ }
1185
+ function validateBearerRecipient(assertion, expectedRecipients) {
1186
+ const confirmationData = getBearerSubjectConfirmationData(assertion);
1187
+ if (!confirmationData.length) throw new APIError("BAD_REQUEST", {
1188
+ message: "SAML assertion is missing bearer SubjectConfirmationData",
1189
+ code: "SAML_BEARER_CONFIRMATION_MISSING"
1190
+ });
1191
+ const recipients = confirmationData.map((data) => data["@_Recipient"]).filter((recipient) => typeof recipient === "string");
1192
+ if (!recipients.length) throw new APIError("BAD_REQUEST", {
1193
+ message: "SAML bearer SubjectConfirmationData is missing a Recipient",
1194
+ code: "SAML_RECIPIENT_MISSING"
1195
+ });
1196
+ if (!recipients.some((recipient) => expectedRecipients.has(recipient))) throw new APIError("BAD_REQUEST", {
1197
+ message: "SAML bearer SubjectConfirmationData Recipient does not match this Service Provider",
1198
+ code: "SAML_RECIPIENT_MISMATCH"
1199
+ });
1200
+ }
1201
+ function validateResponseDestination(response, expectedRecipients) {
1202
+ const destination = response?.["@_Destination"];
1203
+ if (typeof destination !== "string" || !destination) return;
1204
+ if (!expectedRecipients.has(destination)) throw new APIError("BAD_REQUEST", {
1205
+ message: "SAML response Destination does not match this Service Provider",
1206
+ code: "SAML_DESTINATION_MISMATCH"
1207
+ });
1208
+ }
1209
+ function validateSAMLResponseBinding(samlContent, options) {
1210
+ const expectedAudiences = toStringSet(options.expectedAudiences);
1211
+ const expectedRecipients = toStringSet(options.expectedRecipients);
1212
+ const parsed = parseSAMLContent(samlContent);
1213
+ const response = getResponseNode(parsed);
1214
+ const assertion = getAssertionNode(parsed, response);
1215
+ validateAudienceRestrictions(assertion, expectedAudiences);
1216
+ validateBearerRecipient(assertion, expectedRecipients);
1217
+ validateResponseDestination(response, expectedRecipients);
1218
+ }
1219
+ //#endregion
1086
1220
  //#region src/saml/response-validation.ts
1087
1221
  function errorRedirectUrl(base, error, description) {
1088
1222
  try {
@@ -1236,6 +1370,7 @@ const samlConfigSchema = z.object({
1236
1370
  entryPoint: z.string().url().meta({ description: "The IdP SSO URL (entry point)" }),
1237
1371
  cert: signingCertSchema.meta({ description: "IdP signing certificate(s). Pass a single PEM string or an array for rolling rotation. Omit when `idpMetadata.metadata` XML carries the certs. When both this and `idpMetadata.cert` are set, `idpMetadata.cert` wins." }).optional(),
1238
1372
  audience: z.string().optional(),
1373
+ callbackUrl: z.string().refine((url) => !url.includes("#"), { message: "callbackUrl must not contain a fragment" }).optional(),
1239
1374
  idpMetadata: z.object({
1240
1375
  metadata: z.string().optional(),
1241
1376
  entityID: z.string().optional(),
@@ -1299,6 +1434,84 @@ function getUpdateSSOProviderBodySchema(options) {
1299
1434
  //#endregion
1300
1435
  //#region src/routes/providers.ts
1301
1436
  const ADMIN_ROLES = ["owner", "admin"];
1437
+ const OIDC_IDENTITY_BOUNDARY_FIELDS = [
1438
+ "authorizationEndpoint",
1439
+ "clientId",
1440
+ "discoveryEndpoint",
1441
+ "jwksEndpoint",
1442
+ "tokenEndpoint",
1443
+ "userInfoEndpoint"
1444
+ ];
1445
+ const SAML_IDENTITY_BOUNDARY_FIELDS = [
1446
+ "audience",
1447
+ "callbackUrl",
1448
+ "entryPoint",
1449
+ "identifierFormat"
1450
+ ];
1451
+ const SAML_IDP_BOUNDARY_FIELDS = [
1452
+ "metadata",
1453
+ "entityID",
1454
+ "singleSignOnService"
1455
+ ];
1456
+ const SAML_SP_BOUNDARY_FIELDS = ["metadata", "entityID"];
1457
+ function isRecord(value) {
1458
+ return typeof value === "object" && value !== null && !Array.isArray(value);
1459
+ }
1460
+ function stableStringify(value) {
1461
+ if (Array.isArray(value)) return `[${value.map(stableStringify).join(",")}]`;
1462
+ if (isRecord(value)) return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${stableStringify(value[key])}`).join(",")}}`;
1463
+ return JSON.stringify(value) ?? String(value);
1464
+ }
1465
+ function identityValueChanged(current, updated) {
1466
+ return stableStringify(current) !== stableStringify(updated);
1467
+ }
1468
+ function hasChangedField(current, updated, fields) {
1469
+ return fields.some((field) => identityValueChanged(current?.[field], updated?.[field]));
1470
+ }
1471
+ function oidcIdentityBoundaryChanged(current, updated) {
1472
+ return hasChangedField(current, updated, OIDC_IDENTITY_BOUNDARY_FIELDS) || identityValueChanged(current.mapping?.id, updated.mapping?.id);
1473
+ }
1474
+ function samlIdentityBoundaryChanged(current, updated) {
1475
+ 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);
1476
+ }
1477
+ function parseConfigSnapshot(config, configType) {
1478
+ if (!config) return;
1479
+ if (typeof config === "string") return parseAndValidateConfig(config, configType);
1480
+ return config;
1481
+ }
1482
+ function ssoProviderIdentityBoundaryChanged(current, updated) {
1483
+ if (identityValueChanged(current.issuer, updated.issuer)) return true;
1484
+ const currentSamlConfig = parseConfigSnapshot(current.samlConfig, "SAML");
1485
+ const updatedSamlConfig = parseConfigSnapshot(updated.samlConfig, "SAML");
1486
+ if (currentSamlConfig && (!updatedSamlConfig || samlIdentityBoundaryChanged(currentSamlConfig, updatedSamlConfig))) return true;
1487
+ const currentOidcConfig = parseConfigSnapshot(current.oidcConfig, "OIDC");
1488
+ const updatedOidcConfig = parseConfigSnapshot(updated.oidcConfig, "OIDC");
1489
+ return Boolean(currentOidcConfig && (!updatedOidcConfig || oidcIdentityBoundaryChanged(currentOidcConfig, updatedOidcConfig)));
1490
+ }
1491
+ async function lockSSOProviderRow(adapter, providerId) {
1492
+ return (await getCurrentAdapter(adapter)).update({
1493
+ model: "ssoProvider",
1494
+ where: [{
1495
+ field: "providerId",
1496
+ value: providerId
1497
+ }],
1498
+ update: { providerId }
1499
+ });
1500
+ }
1501
+ async function lockSSOProviderForAccountLink(ctx, provider) {
1502
+ const lockedProvider = await lockSSOProviderRow(ctx.context.adapter, provider.providerId);
1503
+ if (!lockedProvider) {
1504
+ if (provider.userId === "default") return;
1505
+ throw new APIError("CONFLICT", {
1506
+ code: "SSO_PROVIDER_CHANGED",
1507
+ message: "SSO provider changed while account linking was in progress"
1508
+ });
1509
+ }
1510
+ if (ssoProviderIdentityBoundaryChanged(provider, lockedProvider)) throw new APIError("CONFLICT", {
1511
+ code: "SSO_PROVIDER_CHANGED",
1512
+ message: "SSO provider changed while account linking was in progress"
1513
+ });
1514
+ }
1302
1515
  function getSSOProviderAdditionalFields(options) {
1303
1516
  return options?.schema?.ssoProvider?.additionalFields ?? {};
1304
1517
  }
@@ -1494,6 +1707,7 @@ function mergeSAMLConfig(current, updates, issuer) {
1494
1707
  idpMetadata: updates.idpMetadata ?? current.idpMetadata,
1495
1708
  mapping: updates.mapping ?? current.mapping,
1496
1709
  audience: updates.audience ?? current.audience,
1710
+ callbackUrl: updates.callbackUrl ?? current.callbackUrl,
1497
1711
  wantAssertionsSigned: updates.wantAssertionsSigned ?? current.wantAssertionsSigned,
1498
1712
  authnRequestsSigned: updates.authnRequestsSigned ?? current.authnRequestsSigned,
1499
1713
  identifierFormat: updates.identifierFormat ?? current.identifierFormat,
@@ -1542,56 +1756,74 @@ const updateSSOProvider = (options) => {
1542
1756
  const { issuer, domain, samlConfig, oidcConfig } = body;
1543
1757
  const additionalFields = parseSSOProviderAdditionalFields(options, body, "update");
1544
1758
  if (!issuer && !domain && !samlConfig && !oidcConfig && Object.keys(additionalFields).length === 0) throw new APIError("BAD_REQUEST", { message: "No fields provided for update" });
1545
- const existingProvider = await checkProviderAccess(ctx, providerId);
1546
- const updateData = { ...additionalFields };
1547
- if (body.issuer !== void 0) updateData.issuer = body.issuer;
1548
- if (body.domain !== void 0) {
1549
- updateData.domain = body.domain;
1550
- if (body.domain !== existingProvider.domain) updateData.domainVerified = false;
1551
- }
1552
- if (body.samlConfig) {
1553
- if (body.samlConfig.idpMetadata?.metadata) {
1554
- const maxMetadataSize = options?.saml?.maxMetadataSize ?? 102400;
1555
- if (new TextEncoder().encode(body.samlConfig.idpMetadata.metadata).length > maxMetadataSize) throw new APIError("BAD_REQUEST", { message: `IdP metadata exceeds maximum allowed size (${maxMetadataSize} bytes)` });
1759
+ await checkProviderAccess(ctx, providerId);
1760
+ const fullProvider = await runWithTransaction(ctx.context.adapter, async () => {
1761
+ const trx = await getCurrentAdapter(ctx.context.adapter);
1762
+ const existingProvider = await lockSSOProviderRow(ctx.context.adapter, providerId);
1763
+ if (!existingProvider) throw new APIError("NOT_FOUND", { message: "Provider not found" });
1764
+ const updateData = { ...additionalFields };
1765
+ let providerIdentityBoundaryChanged = body.issuer !== void 0 && body.issuer !== existingProvider.issuer;
1766
+ if (body.issuer !== void 0) updateData.issuer = body.issuer;
1767
+ if (body.domain !== void 0) {
1768
+ updateData.domain = body.domain;
1769
+ if (body.domain !== existingProvider.domain) updateData.domainVerified = false;
1556
1770
  }
1557
- if (body.samlConfig.signatureAlgorithm !== void 0 || body.samlConfig.digestAlgorithm !== void 0) validateConfigAlgorithms({
1558
- signatureAlgorithm: body.samlConfig.signatureAlgorithm,
1559
- digestAlgorithm: body.samlConfig.digestAlgorithm
1560
- }, options?.saml?.algorithms);
1561
- const currentSamlConfig = parseAndValidateConfig(existingProvider.samlConfig, "SAML");
1562
- const updatedSamlConfig = mergeSAMLConfig(currentSamlConfig, body.samlConfig, updateData.issuer || currentSamlConfig.issuer || existingProvider.issuer);
1563
- validateCertSources(updatedSamlConfig);
1564
- updateData.samlConfig = JSON.stringify(updatedSamlConfig);
1565
- }
1566
- if (body.oidcConfig) {
1567
- try {
1568
- validateOIDCEndpointUrls(body.oidcConfig, (url) => ctx.context.isTrustedOrigin(url));
1569
- } catch (error) {
1570
- if (error instanceof DiscoveryError) throw mapDiscoveryErrorToAPIError(error);
1571
- throw error;
1771
+ if (body.samlConfig) {
1772
+ if (body.samlConfig.idpMetadata?.metadata) {
1773
+ const maxMetadataSize = options?.saml?.maxMetadataSize ?? 102400;
1774
+ if (new TextEncoder().encode(body.samlConfig.idpMetadata.metadata).length > maxMetadataSize) throw new APIError("BAD_REQUEST", { message: `IdP metadata exceeds maximum allowed size (${maxMetadataSize} bytes)` });
1775
+ }
1776
+ if (body.samlConfig.signatureAlgorithm !== void 0 || body.samlConfig.digestAlgorithm !== void 0) validateConfigAlgorithms({
1777
+ signatureAlgorithm: body.samlConfig.signatureAlgorithm,
1778
+ digestAlgorithm: body.samlConfig.digestAlgorithm
1779
+ }, options?.saml?.algorithms);
1780
+ const currentSamlConfig = parseAndValidateConfig(existingProvider.samlConfig, "SAML");
1781
+ const updatedSamlConfig = mergeSAMLConfig(currentSamlConfig, body.samlConfig, updateData.issuer || currentSamlConfig.issuer || existingProvider.issuer);
1782
+ validateCertSources(updatedSamlConfig);
1783
+ if (samlIdentityBoundaryChanged(currentSamlConfig, updatedSamlConfig)) providerIdentityBoundaryChanged = true;
1784
+ updateData.samlConfig = JSON.stringify(updatedSamlConfig);
1572
1785
  }
1573
- const currentOidcConfig = parseAndValidateConfig(existingProvider.oidcConfig, "OIDC");
1574
- const updatedOidcConfig = mergeOIDCConfig(currentOidcConfig, body.oidcConfig, updateData.issuer || currentOidcConfig.issuer || existingProvider.issuer);
1575
- if (updatedOidcConfig.tokenEndpointAuthentication !== "private_key_jwt" && !updatedOidcConfig.clientSecret) throw new APIError("BAD_REQUEST", { message: "clientSecret is required when using client_secret_basic or client_secret_post authentication" });
1576
- if (updatedOidcConfig.tokenEndpointAuthentication === "private_key_jwt" && !options?.resolvePrivateKey && !options?.defaultSSO?.some((p) => p.providerId === providerId && "privateKey" in p && p.privateKey)) throw new APIError("BAD_REQUEST", { message: "private_key_jwt authentication requires either a resolvePrivateKey callback or a privateKey in defaultSSO" });
1577
- updateData.oidcConfig = JSON.stringify(updatedOidcConfig);
1578
- }
1579
- await ctx.context.adapter.update({
1580
- model: "ssoProvider",
1581
- where: [{
1582
- field: "providerId",
1583
- value: providerId
1584
- }],
1585
- update: updateData
1586
- });
1587
- const fullProvider = await ctx.context.adapter.findOne({
1588
- model: "ssoProvider",
1589
- where: [{
1590
- field: "providerId",
1591
- value: providerId
1592
- }]
1786
+ if (body.oidcConfig) {
1787
+ try {
1788
+ validateOIDCEndpointUrls(body.oidcConfig, (url) => ctx.context.isTrustedOrigin(url));
1789
+ } catch (error) {
1790
+ if (error instanceof DiscoveryError) throw mapDiscoveryErrorToAPIError(error);
1791
+ throw error;
1792
+ }
1793
+ const currentOidcConfig = parseAndValidateConfig(existingProvider.oidcConfig, "OIDC");
1794
+ const updatedOidcConfig = mergeOIDCConfig(currentOidcConfig, body.oidcConfig, updateData.issuer || currentOidcConfig.issuer || existingProvider.issuer);
1795
+ if (updatedOidcConfig.tokenEndpointAuthentication !== "private_key_jwt" && !updatedOidcConfig.clientSecret) throw new APIError("BAD_REQUEST", { message: "clientSecret is required when using client_secret_basic or client_secret_post authentication" });
1796
+ if (updatedOidcConfig.tokenEndpointAuthentication === "private_key_jwt" && !options?.resolvePrivateKey && !options?.defaultSSO?.some((p) => p.providerId === providerId && "privateKey" in p && p.privateKey)) throw new APIError("BAD_REQUEST", { message: "private_key_jwt authentication requires either a resolvePrivateKey callback or a privateKey in defaultSSO" });
1797
+ if (oidcIdentityBoundaryChanged(currentOidcConfig, updatedOidcConfig)) providerIdentityBoundaryChanged = true;
1798
+ updateData.oidcConfig = JSON.stringify(updatedOidcConfig);
1799
+ }
1800
+ if (providerIdentityBoundaryChanged) {
1801
+ if (await trx.findOne({
1802
+ model: "account",
1803
+ where: [{
1804
+ field: "providerId",
1805
+ value: providerId
1806
+ }]
1807
+ })) throw new APIError("CONFLICT", { message: "Cannot change SSO provider identity fields while linked accounts exist" });
1808
+ }
1809
+ await trx.update({
1810
+ model: "ssoProvider",
1811
+ where: [{
1812
+ field: "providerId",
1813
+ value: providerId
1814
+ }],
1815
+ update: updateData
1816
+ });
1817
+ const updatedProvider = await trx.findOne({
1818
+ model: "ssoProvider",
1819
+ where: [{
1820
+ field: "providerId",
1821
+ value: providerId
1822
+ }]
1823
+ });
1824
+ if (!updatedProvider) throw new APIError("NOT_FOUND", { message: "Provider not found after update" });
1825
+ return updatedProvider;
1593
1826
  });
1594
- if (!fullProvider) throw new APIError("NOT_FOUND", { message: "Provider not found after update" });
1595
1827
  return ctx.json(sanitizeProvider(fullProvider, ctx.context.baseURL, options));
1596
1828
  });
1597
1829
  };
@@ -1613,12 +1845,22 @@ const deleteSSOProvider = () => {
1613
1845
  }, async (ctx) => {
1614
1846
  const { providerId } = ctx.body;
1615
1847
  await checkProviderAccess(ctx, providerId);
1616
- await ctx.context.adapter.delete({
1617
- model: "ssoProvider",
1618
- where: [{
1619
- field: "providerId",
1620
- value: providerId
1621
- }]
1848
+ await runWithTransaction(ctx.context.adapter, async () => {
1849
+ const trx = await getCurrentAdapter(ctx.context.adapter);
1850
+ await trx.deleteMany({
1851
+ model: "account",
1852
+ where: [{
1853
+ field: "providerId",
1854
+ value: providerId
1855
+ }]
1856
+ });
1857
+ await trx.delete({
1858
+ model: "ssoProvider",
1859
+ where: [{
1860
+ field: "providerId",
1861
+ value: providerId
1862
+ }]
1863
+ });
1622
1864
  });
1623
1865
  return ctx.json({ success: true });
1624
1866
  });
@@ -1700,7 +1942,6 @@ const verifyDomain = (options) => {
1700
1942
  message: "No pending domain verification exists",
1701
1943
  code: "NO_PENDING_VERIFICATION"
1702
1944
  });
1703
- let records = [];
1704
1945
  let dns;
1705
1946
  try {
1706
1947
  dns = await import("node:dns/promises");
@@ -1711,20 +1952,28 @@ const verifyDomain = (options) => {
1711
1952
  code: "DOMAIN_VERIFICATION_FAILED"
1712
1953
  });
1713
1954
  }
1714
- const hostname = getHostnameFromDomain(provider.domain);
1715
- if (!hostname) throw new APIError("BAD_REQUEST", {
1955
+ const domains = parseProviderDomains(provider.domain);
1956
+ if (!domains) throw new APIError("BAD_REQUEST", {
1716
1957
  message: "Invalid domain",
1717
1958
  code: "INVALID_DOMAIN"
1718
1959
  });
1719
- try {
1720
- records = (await dns.resolveTxt(`${identifier}.${hostname}`)).flat();
1721
- } catch (error) {
1722
- ctx.context.logger.warn("DNS resolution failure while validating domain ownership", error);
1960
+ for (const domain of domains) {
1961
+ let records = [];
1962
+ try {
1963
+ records = (await dns.resolveTxt(`${identifier}.${domain}`)).map((record) => record.join(""));
1964
+ } catch (error) {
1965
+ ctx.context.logger.warn(`DNS resolution failure while validating domain ownership for ${domain}`, error);
1966
+ }
1967
+ const verificationValue = activeVerification.value;
1968
+ const verificationRecord = `${activeVerification.identifier}=${verificationValue}`;
1969
+ if (!records.find((record) => {
1970
+ const normalizedRecord = record.trim();
1971
+ return normalizedRecord === verificationRecord || normalizedRecord === verificationValue;
1972
+ })) throw new APIError("BAD_GATEWAY", {
1973
+ message: `Unable to verify domain ownership for ${domain}. Try again later`,
1974
+ code: "DOMAIN_VERIFICATION_FAILED"
1975
+ });
1723
1976
  }
1724
- if (!records.find((record) => record.includes(`${activeVerification.identifier}=${activeVerification.value}`))) throw new APIError("BAD_GATEWAY", {
1725
- message: "Unable to verify domain ownership. Try again later",
1726
- code: "DOMAIN_VERIFICATION_FAILED"
1727
- });
1728
1977
  await ctx.context.adapter.update({
1729
1978
  model: "ssoProvider",
1730
1979
  where: [{
@@ -1877,7 +2126,17 @@ function escapeHtml(str) {
1877
2126
  if (!str) return "";
1878
2127
  return String(str).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;");
1879
2128
  }
2129
+ function isSAMLPostBindingLocation(value) {
2130
+ let url;
2131
+ try {
2132
+ url = new URL(value);
2133
+ } catch {
2134
+ return false;
2135
+ }
2136
+ return url.protocol === "http:" || url.protocol === "https:";
2137
+ }
1880
2138
  function createSAMLPostForm(action, samlParam, samlValue, relayState) {
2139
+ if (!isSAMLPostBindingLocation(action)) throw new APIError("BAD_REQUEST", { message: "SAML POST binding location must be an absolute http or https URL" });
1881
2140
  const safeAction = escapeHtml(action);
1882
2141
  const safeSamlParam = escapeHtml(samlParam);
1883
2142
  const safeSamlValue = escapeHtml(samlValue);
@@ -1947,6 +2206,14 @@ function getSafeRedirectUrl(url, callbackPath, appOrigin, isTrustedOrigin) {
1947
2206
  }
1948
2207
  return url;
1949
2208
  }
2209
+ try {
2210
+ const absoluteUrl = new URL(url);
2211
+ if (absoluteUrl.origin === appOrigin) {
2212
+ const callbackPathname = new URL(callbackPath).pathname;
2213
+ if (absoluteUrl.pathname === callbackPathname) return appOrigin;
2214
+ return url;
2215
+ }
2216
+ } catch {}
1950
2217
  if (!isTrustedOrigin(url, { allowRelativePaths: false })) return appOrigin;
1951
2218
  try {
1952
2219
  const callbackPathname = new URL(callbackPath).pathname;
@@ -1956,6 +2223,39 @@ function getSafeRedirectUrl(url, callbackPath, appOrigin, isTrustedOrigin) {
1956
2223
  }
1957
2224
  return url;
1958
2225
  }
2226
+ function buildSAMLRedirectUrl(url, params) {
2227
+ const searchParams = new URLSearchParams(params);
2228
+ try {
2229
+ const isRelativePath = url.startsWith("/") && !url.startsWith("//");
2230
+ const parsedUrl = new URL(url, "http://better-auth.local");
2231
+ for (const [key, value] of searchParams) parsedUrl.searchParams.set(key, value);
2232
+ if (isRelativePath) return `${parsedUrl.pathname}${parsedUrl.search}${parsedUrl.hash}`;
2233
+ return parsedUrl.toString();
2234
+ } catch {
2235
+ const hashIndex = url.indexOf("#");
2236
+ const urlWithoutFragment = hashIndex === -1 ? url : url.slice(0, hashIndex);
2237
+ const fragment = hashIndex === -1 ? "" : url.slice(hashIndex);
2238
+ return `${urlWithoutFragment}${urlWithoutFragment.includes("?") ? "&" : "?"}${searchParams.toString()}${fragment}`;
2239
+ }
2240
+ }
2241
+ function toArray(value) {
2242
+ if (Array.isArray(value)) return value;
2243
+ return value ? [value] : [];
2244
+ }
2245
+ function getExpectedSAMLRecipients(config, baseURL, providerId, currentCallbackPath, assertionConsumerServiceUrl) {
2246
+ const configuredPostAssertionConsumerServiceUrls = getSAMLPostAssertionConsumerServiceUrls(config.spMetadata?.metadata);
2247
+ return [
2248
+ currentCallbackPath,
2249
+ `${baseURL}/sso/saml2/sp/acs/${providerId}`,
2250
+ ...configuredPostAssertionConsumerServiceUrls,
2251
+ ...toArray(assertionConsumerServiceUrl)
2252
+ ];
2253
+ }
2254
+ async function getSAMLResponseBindingContent(sp, samlContent) {
2255
+ if (!hasSAMLEncryptedAssertion(samlContent)) return samlContent;
2256
+ const [decryptedContent] = await saml.SamlLib.decryptAssertion(sp, samlContent);
2257
+ return decryptedContent;
2258
+ }
1959
2259
  /**
1960
2260
  * Extracts the Assertion ID from a SAML response XML.
1961
2261
  * Used for replay protection per SAML 2.0 Core section 2.3.3.
@@ -2004,7 +2304,7 @@ async function processSAMLResponse(ctx, params, options) {
2004
2304
  if (!parsedSamlConfig) throw new APIError("BAD_REQUEST", { message: "Invalid SAML configuration" });
2005
2305
  const sp = createSP(parsedSamlConfig, ctx.context.baseURL, providerId, { clockSkew: options?.saml?.clockSkew });
2006
2306
  const idp = createIdP(parsedSamlConfig);
2007
- const samlRedirectUrl = getSafeRedirectUrl(relayState?.callbackURL, params.currentCallbackPath, appOrigin, (url, settings) => ctx.context.isTrustedOrigin(url, settings));
2307
+ const samlRedirectUrl = getSafeRedirectUrl(relayState?.callbackURL || parsedSamlConfig.callbackUrl, params.currentCallbackPath, appOrigin, (url, settings) => ctx.context.isTrustedOrigin(url, settings));
2008
2308
  validateSingleAssertion(SAMLResponse);
2009
2309
  let parsedResponse;
2010
2310
  try {
@@ -2024,12 +2324,47 @@ async function processSAMLResponse(ctx, params, options) {
2024
2324
  });
2025
2325
  }
2026
2326
  const { extract } = parsedResponse;
2327
+ const samlContent = parsedResponse.samlContent;
2027
2328
  validateSAMLAlgorithms(parsedResponse, options?.saml?.algorithms);
2028
2329
  validateSAMLTimestamp(extract.conditions, {
2029
2330
  clockSkew: options?.saml?.clockSkew,
2030
2331
  requireTimestamps: options?.saml?.requireTimestamps,
2031
2332
  logger: ctx.context.logger
2032
2333
  });
2334
+ const expectedAudiences = [sp.entityMeta.getEntityID(), parsedSamlConfig.audience];
2335
+ const assertionConsumerServiceUrl = sp.entityMeta.getAssertionConsumerService(SAML_HTTP_POST_BINDING);
2336
+ const expectedRecipients = getExpectedSAMLRecipients(parsedSamlConfig, ctx.context.baseURL, providerId, currentCallbackPath, assertionConsumerServiceUrl);
2337
+ let samlBindingContent;
2338
+ try {
2339
+ samlBindingContent = await getSAMLResponseBindingContent(sp, samlContent);
2340
+ validateSAMLResponseBinding(samlBindingContent, {
2341
+ expectedAudiences,
2342
+ expectedRecipients
2343
+ });
2344
+ } catch (error) {
2345
+ if (isAPIError(error)) {
2346
+ ctx.context.logger.error("SAML response binding validation failed", {
2347
+ providerId,
2348
+ code: error.body?.code,
2349
+ expectedAudiences: expectedAudiences.filter(Boolean),
2350
+ expectedRecipients: expectedRecipients.filter(Boolean)
2351
+ });
2352
+ throw ctx.redirect(buildSAMLRedirectUrl(samlRedirectUrl, {
2353
+ error: "invalid_saml_response",
2354
+ error_description: error.body?.message || error.message || "Invalid SAML response"
2355
+ }));
2356
+ }
2357
+ ctx.context.logger.error("SAML response binding validation failed", {
2358
+ providerId,
2359
+ error,
2360
+ expectedAudiences: expectedAudiences.filter(Boolean),
2361
+ expectedRecipients: expectedRecipients.filter(Boolean)
2362
+ });
2363
+ throw ctx.redirect(buildSAMLRedirectUrl(samlRedirectUrl, {
2364
+ error: "invalid_saml_response",
2365
+ error_description: "SAML response binding could not be validated"
2366
+ }));
2367
+ }
2033
2368
  await validateInResponseTo(ctx, {
2034
2369
  extract,
2035
2370
  providerId,
@@ -2045,8 +2380,7 @@ async function processSAMLResponse(ctx, params, options) {
2045
2380
  providerId,
2046
2381
  redirectUrl: samlRedirectUrl
2047
2382
  });
2048
- const samlContent = parsedResponse.samlContent;
2049
- const assertionId = samlContent ? extractAssertionId(samlContent) : null;
2383
+ const assertionId = extractAssertionId(samlBindingContent);
2050
2384
  if (assertionId) {
2051
2385
  const issuer = idp.entityMeta.getEntityID();
2052
2386
  const conditions = extract.conditions;
@@ -2068,7 +2402,10 @@ async function processSAMLResponse(ctx, params, options) {
2068
2402
  issuer,
2069
2403
  providerId
2070
2404
  });
2071
- throw ctx.redirect(`${samlRedirectUrl}?error=replay_detected&error_description=SAML+assertion+has+already+been+used`);
2405
+ throw ctx.redirect(buildSAMLRedirectUrl(samlRedirectUrl, {
2406
+ error: "replay_detected",
2407
+ error_description: "SAML assertion has already been used"
2408
+ }));
2072
2409
  }
2073
2410
  } else ctx.context.logger.warn("Could not extract assertion ID for replay protection", { providerId });
2074
2411
  const attributes = extract.attributes || {};
@@ -2094,45 +2431,46 @@ async function processSAMLResponse(ctx, params, options) {
2094
2431
  throw new APIError("BAD_REQUEST", { message: "Unable to extract user ID or email from SAML response" });
2095
2432
  }
2096
2433
  const isTrustedProvider = "domainVerified" in provider && !!provider.domainVerified && validateEmailDomain(userInfo.email, provider.domain);
2097
- const postAuthRedirect = relayState?.callbackURL || ctx.context.baseURL;
2434
+ const callbackUrl = relayState?.callbackURL || parsedSamlConfig.callbackUrl || ctx.context.baseURL;
2098
2435
  const errorUrl = relayState?.errorURL || samlRedirectUrl;
2099
2436
  let result;
2100
2437
  try {
2101
- result = await handleOAuthUserInfo(ctx, {
2102
- userInfo: {
2103
- email: userInfo.email,
2104
- name: userInfo.name || userInfo.email,
2105
- id: userInfo.id,
2106
- emailVerified: userInfo.emailVerified
2107
- },
2108
- account: {
2109
- providerId,
2110
- accountId: userInfo.id,
2111
- accessToken: "",
2112
- refreshToken: ""
2113
- },
2114
- callbackURL: postAuthRedirect,
2115
- disableSignUp: options?.disableImplicitSignUp,
2116
- source: {
2117
- method: "sso-saml",
2118
- sso: {
2438
+ result = await runWithTransaction(ctx.context.adapter, async () => {
2439
+ await lockSSOProviderForAccountLink(ctx, provider);
2440
+ return handleOAuthUserInfo(ctx, {
2441
+ userInfo: {
2442
+ email: userInfo.email,
2443
+ name: userInfo.name || userInfo.email,
2444
+ id: userInfo.id,
2445
+ emailVerified: userInfo.emailVerified
2446
+ },
2447
+ account: {
2119
2448
  providerId,
2120
- profile: attributes
2121
- }
2122
- },
2123
- isTrustedProvider,
2124
- trustProviderByName: false
2449
+ accountId: userInfo.id,
2450
+ accessToken: "",
2451
+ refreshToken: ""
2452
+ },
2453
+ callbackURL: callbackUrl,
2454
+ disableSignUp: options?.disableImplicitSignUp,
2455
+ source: {
2456
+ method: "sso-saml",
2457
+ sso: {
2458
+ providerId,
2459
+ profile: attributes
2460
+ }
2461
+ },
2462
+ isTrustedProvider,
2463
+ trustProviderByName: false
2464
+ });
2125
2465
  });
2126
2466
  } catch (e) {
2127
- if (isAPIError(e) && e.body?.code) {
2128
- const params = new URLSearchParams({ error: e.body.code });
2129
- if (e.body.message) params.set("error_description", e.body.message);
2130
- const sep = errorUrl.includes("?") ? "&" : "?";
2131
- throw ctx.redirect(`${errorUrl}${sep}${params.toString()}`);
2132
- }
2467
+ if (isAPIError(e) && e.body?.code) throw ctx.redirect(buildSAMLRedirectUrl(errorUrl, {
2468
+ error: e.body.code,
2469
+ ...e.body.message ? { error_description: e.body.message } : {}
2470
+ }));
2133
2471
  throw e;
2134
2472
  }
2135
- if (result.error) throw ctx.redirect(`${samlRedirectUrl}?error=${result.error.split(" ").join("_")}`);
2473
+ if (result.error) throw ctx.redirect(buildSAMLRedirectUrl(callbackUrl, { error: result.error.split(" ").join("_") }));
2136
2474
  const { session, user } = result.data;
2137
2475
  if (options?.provisionUser && (result.isRegister || options.provisionUserOnEveryLogin)) await options.provisionUser({
2138
2476
  user,
@@ -2176,10 +2514,18 @@ async function processSAMLResponse(ctx, params, options) {
2176
2514
  expiresAt: session.expiresAt
2177
2515
  }).catch((e) => ctx.context.logger.warn("Failed to create SAML session lookup record", e));
2178
2516
  }
2179
- return getSafeRedirectUrl(relayState?.callbackURL, currentCallbackPath, appOrigin, (url, settings) => ctx.context.isTrustedOrigin(url, settings));
2517
+ return getSafeRedirectUrl(relayState?.callbackURL || parsedSamlConfig.callbackUrl, currentCallbackPath, appOrigin, (url, settings) => ctx.context.isTrustedOrigin(url, settings));
2180
2518
  }
2181
2519
  //#endregion
2182
2520
  //#region src/routes/sso.ts
2521
+ const BUILT_IN_ACCOUNT_PROVIDER_IDS = [
2522
+ "credential",
2523
+ "email-otp",
2524
+ "magic-link",
2525
+ "phone-number",
2526
+ "anonymous",
2527
+ "siwe"
2528
+ ];
2183
2529
  /**
2184
2530
  * Builds the OIDC redirect URI. Uses the shared `redirectURI` option
2185
2531
  * when set, otherwise falls back to `/sso/callback/:providerId`.
@@ -2423,13 +2769,27 @@ const registerSSOProvider = (options) => {
2423
2769
  if (ctx.context.hasPlugin("organization") && !hasOrgAdminRole(member)) throw new APIError("FORBIDDEN", { message: "You must be an organization owner or admin to register SSO providers" });
2424
2770
  }
2425
2771
  if (new Set([
2426
- "credential",
2772
+ ...BUILT_IN_ACCOUNT_PROVIDER_IDS,
2773
+ ...Object.keys(ctx.context.options.socialProviders ?? {}),
2427
2774
  ...ctx.context.socialProviders.map((p) => p.id),
2428
- ...ctx.context.trustedProviders
2775
+ ...ctx.context.trustedProviders,
2776
+ ...options?.defaultSSO?.map((p) => p.providerId) ?? []
2429
2777
  ]).has(body.providerId)) {
2430
2778
  ctx.context.logger.warn(`SSO provider registration rejected for reserved providerId: ${body.providerId}`);
2431
2779
  throw new APIError("UNPROCESSABLE_ENTITY", { message: "This providerId is reserved and cannot be used for an SSO provider" });
2432
2780
  }
2781
+ if (ctx.context.hasPlugin("scim")) {
2782
+ if (await ctx.context.adapter.findOne({
2783
+ model: "scimProvider",
2784
+ where: [{
2785
+ field: "providerId",
2786
+ value: body.providerId
2787
+ }]
2788
+ })) {
2789
+ ctx.context.logger.warn(`SSO provider registration rejected for SCIM providerId: ${body.providerId}`);
2790
+ throw new APIError("UNPROCESSABLE_ENTITY", { message: "This providerId is already used by a SCIM provider and cannot be used for an SSO provider" });
2791
+ }
2792
+ }
2433
2793
  if (await ctx.context.adapter.findOne({
2434
2794
  model: "ssoProvider",
2435
2795
  where: [{
@@ -2538,6 +2898,7 @@ const registerSSOProvider = (options) => {
2538
2898
  entryPoint: body.samlConfig.entryPoint,
2539
2899
  cert: body.samlConfig.cert,
2540
2900
  audience: body.samlConfig.audience,
2901
+ callbackUrl: body.samlConfig.callbackUrl,
2541
2902
  idpMetadata: body.samlConfig.idpMetadata,
2542
2903
  spMetadata: body.samlConfig.spMetadata,
2543
2904
  wantAssertionsSigned: body.samlConfig.wantAssertionsSigned,
@@ -2692,7 +3053,7 @@ const signInSSO = (options) => {
2692
3053
  });
2693
3054
  let provider = null;
2694
3055
  if (options?.defaultSSO?.length) {
2695
- const matchingDefault = providerId ? options.defaultSSO.find((defaultProvider) => defaultProvider.providerId === providerId) : options.defaultSSO.find((defaultProvider) => defaultProvider.domain === domain);
3056
+ const matchingDefault = providerId ? options.defaultSSO.find((defaultProvider) => defaultProvider.providerId === providerId) : options.defaultSSO.find((defaultProvider) => domain && domainMatches(domain, defaultProvider.domain));
2696
3057
  if (matchingDefault) provider = {
2697
3058
  issuer: matchingDefault.samlConfig?.issuer || matchingDefault.oidcConfig?.issuer || "",
2698
3059
  providerId: matchingDefault.providerId,
@@ -2966,39 +3327,44 @@ async function handleOIDCCallback(ctx, options, providerId, stateData) {
2966
3327
  };
2967
3328
  } else throw ctx.redirect(`${errorURL || callbackURL}?error=invalid_provider&error_description=user_info_endpoint_not_found`);
2968
3329
  if (!userInfo.email || !userInfo.id) throw ctx.redirect(`${errorURL || callbackURL}?error=invalid_provider&error_description=missing_user_info`);
2969
- const isTrustedProvider = "domainVerified" in provider && provider.domainVerified === true && validateEmailDomain(userInfo.email, provider.domain);
3330
+ const userInfoEmail = userInfo.email;
3331
+ const userInfoId = userInfo.id;
3332
+ const isTrustedProvider = "domainVerified" in provider && provider.domainVerified === true && validateEmailDomain(userInfoEmail, provider.domain);
2970
3333
  let linked;
2971
3334
  try {
2972
- linked = await handleOAuthUserInfo(ctx, {
2973
- userInfo: {
2974
- email: userInfo.email,
2975
- name: userInfo.name || "",
2976
- id: userInfo.id,
2977
- image: userInfo.image,
2978
- emailVerified: options?.trustEmailVerified ? userInfo.emailVerified || false : false
2979
- },
2980
- account: {
2981
- idToken: tokenResponse.idToken,
2982
- accessToken: tokenResponse.accessToken,
2983
- refreshToken: tokenResponse.refreshToken,
2984
- accountId: userInfo.id,
2985
- providerId: provider.providerId,
2986
- accessTokenExpiresAt: tokenResponse.accessTokenExpiresAt,
2987
- refreshTokenExpiresAt: tokenResponse.refreshTokenExpiresAt,
2988
- scope: tokenResponse.scopes?.join(",")
2989
- },
2990
- callbackURL,
2991
- disableSignUp: options?.disableImplicitSignUp && !requestSignUp,
2992
- overrideUserInfo: config.overrideUserInfo,
2993
- source: {
2994
- method: "sso-oidc",
2995
- sso: {
3335
+ linked = await runWithTransaction(ctx.context.adapter, async () => {
3336
+ await lockSSOProviderForAccountLink(ctx, provider);
3337
+ return handleOAuthUserInfo(ctx, {
3338
+ userInfo: {
3339
+ email: userInfoEmail,
3340
+ name: userInfo.name || "",
3341
+ id: userInfoId,
3342
+ image: userInfo.image,
3343
+ emailVerified: options?.trustEmailVerified ? userInfo.emailVerified || false : false
3344
+ },
3345
+ account: {
3346
+ idToken: tokenResponse.idToken,
3347
+ accessToken: tokenResponse.accessToken,
3348
+ refreshToken: tokenResponse.refreshToken,
3349
+ accountId: userInfoId,
2996
3350
  providerId: provider.providerId,
2997
- profile: rawProfile
2998
- }
2999
- },
3000
- isTrustedProvider,
3001
- trustProviderByName: false
3351
+ accessTokenExpiresAt: tokenResponse.accessTokenExpiresAt,
3352
+ refreshTokenExpiresAt: tokenResponse.refreshTokenExpiresAt,
3353
+ scope: tokenResponse.scopes?.join(",")
3354
+ },
3355
+ callbackURL,
3356
+ disableSignUp: options?.disableImplicitSignUp && !requestSignUp,
3357
+ overrideUserInfo: config.overrideUserInfo,
3358
+ source: {
3359
+ method: "sso-oidc",
3360
+ sso: {
3361
+ providerId: provider.providerId,
3362
+ profile: rawProfile
3363
+ }
3364
+ },
3365
+ isTrustedProvider,
3366
+ trustProviderByName: false
3367
+ });
3002
3368
  });
3003
3369
  } catch (e) {
3004
3370
  if (isAPIError(e) && e.body?.code) {
@@ -3028,8 +3394,8 @@ async function handleOIDCCallback(ctx, options, providerId, stateData) {
3028
3394
  profile: {
3029
3395
  providerType: "oidc",
3030
3396
  providerId: provider.providerId,
3031
- accountId: userInfo.id,
3032
- email: userInfo.email,
3397
+ accountId: userInfoId,
3398
+ email: userInfoEmail,
3033
3399
  emailVerified: Boolean(userInfo.emailVerified),
3034
3400
  rawAttributes: userInfo
3035
3401
  },
@@ -1,5 +1,5 @@
1
1
  //#endregion
2
2
  //#region src/version.ts
3
- const PACKAGE_VERSION = "1.7.0-beta.8";
3
+ const PACKAGE_VERSION = "1.7.0-rc.0";
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.7.0-beta.8",
3
+ "version": "1.7.0-rc.0",
4
4
  "description": "SSO plugin for Better Auth",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -57,28 +57,28 @@
57
57
  },
58
58
  "dependencies": {
59
59
  "fast-xml-parser": "^5.8.0",
60
- "jose": "^6.1.3",
60
+ "jose": "^6.2.3",
61
61
  "samlify": "^2.13.1",
62
- "tldts": "^6.1.0",
62
+ "tldts": "^7.4.3",
63
63
  "zod": "^4.3.6"
64
64
  },
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
- "oauth2-mock-server": "^8.2.2",
71
+ "oauth2-mock-server": "^9.0.0",
72
72
  "tsdown": "0.21.1",
73
- "@better-auth/core": "1.7.0-beta.8",
74
- "better-auth": "1.7.0-beta.8"
73
+ "@better-auth/core": "1.7.0-rc.0",
74
+ "better-auth": "1.7.0-rc.0"
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.7.0-beta.8",
81
- "better-auth": "^1.7.0-beta.8"
79
+ "better-call": "1.3.7",
80
+ "@better-auth/core": "^1.7.0-rc.0",
81
+ "better-auth": "^1.7.0-rc.0"
82
82
  },
83
83
  "scripts": {
84
84
  "build": "tsdown",