@better-auth/sso 1.7.0-beta.9 → 1.7.0-rc.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/client.d.mts
CHANGED
package/dist/client.mjs
CHANGED
|
@@ -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-
|
|
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-
|
|
1
|
+
import { t as PACKAGE_VERSION } from "./version-lPD-GbqS.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
|
-
|
|
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
|
-
|
|
1546
|
-
const
|
|
1547
|
-
|
|
1548
|
-
|
|
1549
|
-
|
|
1550
|
-
|
|
1551
|
-
|
|
1552
|
-
|
|
1553
|
-
if (body.
|
|
1554
|
-
|
|
1555
|
-
if (
|
|
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
|
|
1558
|
-
|
|
1559
|
-
|
|
1560
|
-
|
|
1561
|
-
|
|
1562
|
-
|
|
1563
|
-
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
|
|
1567
|
-
|
|
1568
|
-
|
|
1569
|
-
|
|
1570
|
-
|
|
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
|
-
|
|
1574
|
-
|
|
1575
|
-
|
|
1576
|
-
|
|
1577
|
-
|
|
1578
|
-
|
|
1579
|
-
|
|
1580
|
-
|
|
1581
|
-
|
|
1582
|
-
|
|
1583
|
-
|
|
1584
|
-
|
|
1585
|
-
|
|
1586
|
-
|
|
1587
|
-
|
|
1588
|
-
|
|
1589
|
-
|
|
1590
|
-
|
|
1591
|
-
|
|
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
|
|
1617
|
-
|
|
1618
|
-
|
|
1619
|
-
|
|
1620
|
-
|
|
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
|
|
1715
|
-
if (!
|
|
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
|
-
|
|
1720
|
-
records =
|
|
1721
|
-
|
|
1722
|
-
|
|
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, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
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
|
|
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(
|
|
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
|
|
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
|
|
2102
|
-
|
|
2103
|
-
|
|
2104
|
-
|
|
2105
|
-
|
|
2106
|
-
|
|
2107
|
-
|
|
2108
|
-
|
|
2109
|
-
|
|
2110
|
-
|
|
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
|
-
|
|
2121
|
-
|
|
2122
|
-
|
|
2123
|
-
|
|
2124
|
-
|
|
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
|
-
|
|
2129
|
-
|
|
2130
|
-
|
|
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(
|
|
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
|
-
|
|
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) =>
|
|
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
|
|
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
|
|
2973
|
-
|
|
2974
|
-
|
|
2975
|
-
|
|
2976
|
-
|
|
2977
|
-
|
|
2978
|
-
|
|
2979
|
-
|
|
2980
|
-
|
|
2981
|
-
|
|
2982
|
-
|
|
2983
|
-
|
|
2984
|
-
|
|
2985
|
-
|
|
2986
|
-
|
|
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
|
-
|
|
2998
|
-
|
|
2999
|
-
|
|
3000
|
-
|
|
3001
|
-
|
|
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:
|
|
3032
|
-
email:
|
|
3397
|
+
accountId: userInfoId,
|
|
3398
|
+
email: userInfoEmail,
|
|
3033
3399
|
emailVerified: Boolean(userInfo.emailVerified),
|
|
3034
3400
|
rawAttributes: userInfo
|
|
3035
3401
|
},
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@better-auth/sso",
|
|
3
|
-
"version": "1.7.0-
|
|
3
|
+
"version": "1.7.0-rc.1",
|
|
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.
|
|
60
|
+
"jose": "^6.2.3",
|
|
61
61
|
"samlify": "^2.13.1",
|
|
62
|
-
"tldts": "^
|
|
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.
|
|
68
|
+
"better-call": "1.3.7",
|
|
69
69
|
"body-parser": "^2.2.2",
|
|
70
70
|
"express": "^5.2.1",
|
|
71
|
-
"oauth2-mock-server": "^
|
|
71
|
+
"oauth2-mock-server": "^9.0.0",
|
|
72
72
|
"tsdown": "0.21.1",
|
|
73
|
-
"@better-auth/core": "1.7.0-
|
|
74
|
-
"better-auth": "1.7.0-
|
|
73
|
+
"@better-auth/core": "1.7.0-rc.1",
|
|
74
|
+
"better-auth": "1.7.0-rc.1"
|
|
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.7.0-
|
|
81
|
-
"better-auth": "^1.7.0-
|
|
79
|
+
"better-call": "1.3.7",
|
|
80
|
+
"@better-auth/core": "^1.7.0-rc.1",
|
|
81
|
+
"better-auth": "^1.7.0-rc.1"
|
|
82
82
|
},
|
|
83
83
|
"scripts": {
|
|
84
84
|
"build": "tsdown",
|