@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
package/dist/index.mjs
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
|
-
import { t as PACKAGE_VERSION } from "./version-
|
|
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
|
-
|
|
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
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
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
|
|
1439
|
-
if (!
|
|
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
|
-
|
|
1444
|
-
records =
|
|
1445
|
-
|
|
1446
|
-
|
|
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, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
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(
|
|
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(
|
|
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(
|
|
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
|
|
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(
|
|
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
|
-
|
|
1861
|
-
|
|
1862
|
-
|
|
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(
|
|
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
|
-
|
|
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) =>
|
|
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,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@better-auth/sso",
|
|
3
|
-
"version": "1.6.
|
|
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.
|
|
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.
|
|
74
|
-
"better-auth": "1.6.
|
|
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.
|
|
80
|
-
"@better-auth/core": "^1.6.
|
|
81
|
-
"better-auth": "^1.6.
|
|
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",
|