@better-auth/sso 1.7.0-rc.2 → 1.7.0-rc.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.mjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { t as PACKAGE_VERSION } from "./version-
|
|
1
|
+
import { t as PACKAGE_VERSION } from "./version-xoFrTW0m.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";
|
|
@@ -11,15 +11,16 @@ import { classifyHost, isPublicRoutableHost } from "@better-auth/core/utils/host
|
|
|
11
11
|
import { betterFetch } from "@better-fetch/fetch";
|
|
12
12
|
import { createRemoteJWKSet, customFetch, jwtVerify } from "jose";
|
|
13
13
|
import { base64, base64Url } from "@better-auth/utils/base64";
|
|
14
|
-
import {
|
|
14
|
+
import { createHash } from "@better-auth/utils/hash";
|
|
15
|
+
import { DOMParser } from "@xmldom/xmldom";
|
|
15
16
|
import * as samlifyNamespace from "samlify";
|
|
16
17
|
import samlifyDefault from "samlify";
|
|
18
|
+
import { defineErrorCodes } from "@better-auth/core/utils/error-codes";
|
|
17
19
|
import { parseInputData, toZodSchema } from "better-auth/db";
|
|
18
20
|
import { isAPIError } from "@better-auth/core/utils/is-api-error";
|
|
19
21
|
import { HIDE_METADATA, PRIVATE_KEY_JWT_SIGNING_ALGORITHMS, authorizationCodeRequest, createAuthorizationURL, createPrivateKeyJwtClientAssertionGetter, generateGenericState, generateState, getOAuth2Tokens, parseGenericState, parseState } from "better-auth";
|
|
20
22
|
import { deleteSessionCookie, setAccountCookie, setSessionCookie } from "better-auth/cookies";
|
|
21
23
|
import { additionalAuthorizationParamsSchema, handleOAuthUserInfo } from "better-auth/oauth2";
|
|
22
|
-
import { createHash } from "@better-auth/utils/hash";
|
|
23
24
|
//#region src/constants.ts
|
|
24
25
|
/**
|
|
25
26
|
* SAML Constants
|
|
@@ -817,6 +818,102 @@ function mapDiscoveryErrorToAPIError(error) {
|
|
|
817
818
|
}
|
|
818
819
|
}
|
|
819
820
|
//#endregion
|
|
821
|
+
//#region src/provider-reference.ts
|
|
822
|
+
const SSO_PROVIDER_STATE_KEY = "ssoProviderReference";
|
|
823
|
+
function serializeCanonical(value) {
|
|
824
|
+
if (value === null) return "null";
|
|
825
|
+
if (Array.isArray(value)) return `[${value.map((entry) => entry === void 0 ? "null" : serializeCanonical(entry)).join(",")}]`;
|
|
826
|
+
switch (typeof value) {
|
|
827
|
+
case "boolean":
|
|
828
|
+
case "number":
|
|
829
|
+
case "string": return JSON.stringify(value);
|
|
830
|
+
case "object": return `{${Object.entries(value).filter(([, entry]) => entry !== void 0).sort(([left], [right]) => left < right ? -1 : left > right ? 1 : 0).map(([key, entry]) => `${JSON.stringify(key)}:${serializeCanonical(entry)}`).join(",")}}`;
|
|
831
|
+
default: throw new TypeError("SSO provider configuration must be JSON-serializable");
|
|
832
|
+
}
|
|
833
|
+
}
|
|
834
|
+
function withoutOIDCSecret(configuration) {
|
|
835
|
+
if (!configuration) return void 0;
|
|
836
|
+
const result = { ...configuration };
|
|
837
|
+
result.clientSecret = void 0;
|
|
838
|
+
return result;
|
|
839
|
+
}
|
|
840
|
+
function withoutSAMLPrivateKeys(configuration) {
|
|
841
|
+
if (!configuration) return void 0;
|
|
842
|
+
return {
|
|
843
|
+
...configuration,
|
|
844
|
+
privateKey: void 0,
|
|
845
|
+
idpMetadata: {
|
|
846
|
+
...configuration.idpMetadata,
|
|
847
|
+
privateKey: void 0,
|
|
848
|
+
privateKeyPass: void 0,
|
|
849
|
+
encPrivateKey: void 0,
|
|
850
|
+
encPrivateKeyPass: void 0
|
|
851
|
+
},
|
|
852
|
+
spMetadata: configuration.spMetadata ? {
|
|
853
|
+
...configuration.spMetadata,
|
|
854
|
+
privateKey: void 0,
|
|
855
|
+
privateKeyPass: void 0,
|
|
856
|
+
encPrivateKey: void 0,
|
|
857
|
+
encPrivateKeyPass: void 0
|
|
858
|
+
} : void 0
|
|
859
|
+
};
|
|
860
|
+
}
|
|
861
|
+
function getProviderSource(provider) {
|
|
862
|
+
return typeof provider.id === "string" && provider.id.length > 0 ? {
|
|
863
|
+
type: "persisted",
|
|
864
|
+
recordId: provider.id
|
|
865
|
+
} : { type: "configured" };
|
|
866
|
+
}
|
|
867
|
+
async function computeProviderAuthenticationFingerprint(provider) {
|
|
868
|
+
const digest = await createHash("SHA-256").digest(serializeCanonical({
|
|
869
|
+
domain: provider.domain,
|
|
870
|
+
domainVerified: "domainVerified" in provider ? provider.domainVerified : void 0,
|
|
871
|
+
issuer: provider.issuer,
|
|
872
|
+
organizationId: provider.organizationId,
|
|
873
|
+
oidcConfig: withoutOIDCSecret(provider.oidcConfig),
|
|
874
|
+
samlConfig: withoutSAMLPrivateKeys(provider.samlConfig)
|
|
875
|
+
}));
|
|
876
|
+
return base64Url.encode(new Uint8Array(digest), { padding: false });
|
|
877
|
+
}
|
|
878
|
+
async function computeSSOProviderReference(provider) {
|
|
879
|
+
return {
|
|
880
|
+
providerId: provider.providerId,
|
|
881
|
+
source: getProviderSource(provider),
|
|
882
|
+
authenticationConfigurationFingerprint: await computeProviderAuthenticationFingerprint(provider)
|
|
883
|
+
};
|
|
884
|
+
}
|
|
885
|
+
function isRecord$2(value) {
|
|
886
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
887
|
+
}
|
|
888
|
+
function parseSSOProviderReference(value) {
|
|
889
|
+
if (!isRecord$2(value) || !isRecord$2(value.source)) return null;
|
|
890
|
+
const providerId = value.providerId;
|
|
891
|
+
const authenticationConfigurationFingerprint = value.authenticationConfigurationFingerprint;
|
|
892
|
+
const source = value.source;
|
|
893
|
+
if (typeof providerId !== "string" || providerId.length === 0 || typeof authenticationConfigurationFingerprint !== "string" || authenticationConfigurationFingerprint.length === 0) return null;
|
|
894
|
+
if (source.type === "configured") return {
|
|
895
|
+
providerId,
|
|
896
|
+
source: { type: "configured" },
|
|
897
|
+
authenticationConfigurationFingerprint
|
|
898
|
+
};
|
|
899
|
+
if (source.type === "persisted" && typeof source.recordId === "string" && source.recordId.length > 0) return {
|
|
900
|
+
providerId,
|
|
901
|
+
source: {
|
|
902
|
+
type: "persisted",
|
|
903
|
+
recordId: source.recordId
|
|
904
|
+
},
|
|
905
|
+
authenticationConfigurationFingerprint
|
|
906
|
+
};
|
|
907
|
+
return null;
|
|
908
|
+
}
|
|
909
|
+
async function isCurrentSSOProviderReference(provider, reference) {
|
|
910
|
+
if (!reference || reference.providerId !== provider.providerId) return false;
|
|
911
|
+
const source = getProviderSource(provider);
|
|
912
|
+
if (source.type !== reference.source.type) return false;
|
|
913
|
+
if (source.type === "persisted" && (reference.source.type !== "persisted" || source.recordId !== reference.source.recordId)) return false;
|
|
914
|
+
return reference.authenticationConfigurationFingerprint === await computeProviderAuthenticationFingerprint(provider);
|
|
915
|
+
}
|
|
916
|
+
//#endregion
|
|
820
917
|
//#region src/saml/parser.ts
|
|
821
918
|
const xmlParser = new XMLParser({
|
|
822
919
|
ignoreAttributes: false,
|
|
@@ -1030,8 +1127,12 @@ function validateConfigAlgorithms(config, options = {}) {
|
|
|
1030
1127
|
});
|
|
1031
1128
|
}
|
|
1032
1129
|
}
|
|
1130
|
+
const saml = typeof samlifyNamespace.SPMetadata === "function" && typeof samlifyNamespace.setSchemaValidator === "function" ? samlifyNamespace : samlifyDefault ?? samlifyNamespace;
|
|
1033
1131
|
//#endregion
|
|
1034
1132
|
//#region src/saml/assertions.ts
|
|
1133
|
+
const SAML_ASSERTION_NAMESPACE = "urn:oasis:names:tc:SAML:2.0:assertion";
|
|
1134
|
+
const SAML_PROTOCOL_NAMESPACE = "urn:oasis:names:tc:SAML:2.0:protocol";
|
|
1135
|
+
const XML_SIGNATURE_NAMESPACE = "http://www.w3.org/2000/09/xmldsig#";
|
|
1035
1136
|
function countAssertions(xml) {
|
|
1036
1137
|
let parsed;
|
|
1037
1138
|
try {
|
|
@@ -1071,6 +1172,39 @@ function validateSingleAssertion(samlResponse) {
|
|
|
1071
1172
|
code: "SAML_MULTIPLE_ASSERTIONS"
|
|
1072
1173
|
});
|
|
1073
1174
|
}
|
|
1175
|
+
function assertionSignatureFailure() {
|
|
1176
|
+
return new APIError("BAD_REQUEST", {
|
|
1177
|
+
code: "SAML_ASSERTION_SIGNATURE_REQUIRED",
|
|
1178
|
+
message: "SAML assertion signature is required"
|
|
1179
|
+
});
|
|
1180
|
+
}
|
|
1181
|
+
/**
|
|
1182
|
+
* Verifies that the sole plaintext SAML Assertion, rather than only its
|
|
1183
|
+
* enclosing Response, is authenticated by an XML signature from the IdP.
|
|
1184
|
+
*/
|
|
1185
|
+
function verifySAMLAssertionSignature(samlContent, verificationOptions) {
|
|
1186
|
+
try {
|
|
1187
|
+
const document = new DOMParser({ onError: (_level, message) => {
|
|
1188
|
+
throw new Error(message);
|
|
1189
|
+
} }).parseFromString(samlContent, "text/xml");
|
|
1190
|
+
const response = document.documentElement;
|
|
1191
|
+
if (!response || response.localName !== "Response" || response.namespaceURI !== SAML_PROTOCOL_NAMESPACE) throw assertionSignatureFailure();
|
|
1192
|
+
const assertionElements = Array.from(response.childNodes).filter((node) => node.nodeType === 1 && node.localName === "Assertion" && node.namespaceURI === SAML_ASSERTION_NAMESPACE);
|
|
1193
|
+
const assertion = assertionElements.at(0);
|
|
1194
|
+
if (assertionElements.length !== 1 || !assertion) throw assertionSignatureFailure();
|
|
1195
|
+
if (Array.from(assertion.childNodes).filter((node) => node.nodeType === 1 && node.localName === "Signature" && node.namespaceURI === XML_SIGNATURE_NAMESPACE).length !== 1) throw assertionSignatureFailure();
|
|
1196
|
+
for (const node of Array.from(response.childNodes)) if (node.nodeType === 1 && node.localName === "Signature") response.removeChild(node);
|
|
1197
|
+
const assertionId = assertion.getAttribute("ID");
|
|
1198
|
+
if (!assertionId) throw assertionSignatureFailure();
|
|
1199
|
+
const [verified, authenticatedAssertion] = saml.SamlLib.verifySignature(document.toString(), verificationOptions);
|
|
1200
|
+
if (!verified || !authenticatedAssertion) throw assertionSignatureFailure();
|
|
1201
|
+
const authenticatedRoot = new DOMParser().parseFromString(authenticatedAssertion, "text/xml").documentElement;
|
|
1202
|
+
if (!authenticatedRoot || authenticatedRoot.localName !== "Assertion" || authenticatedRoot.namespaceURI !== SAML_ASSERTION_NAMESPACE || authenticatedRoot.getAttribute("ID") !== assertionId) throw assertionSignatureFailure();
|
|
1203
|
+
} catch (error) {
|
|
1204
|
+
if (error instanceof APIError && error.body?.code === "SAML_ASSERTION_SIGNATURE_REQUIRED") throw error;
|
|
1205
|
+
throw assertionSignatureFailure();
|
|
1206
|
+
}
|
|
1207
|
+
}
|
|
1074
1208
|
//#endregion
|
|
1075
1209
|
//#region src/saml/error-codes.ts
|
|
1076
1210
|
const SAML_ERROR_CODES = defineErrorCodes({
|
|
@@ -1116,6 +1250,7 @@ function validateCertSources(config) {
|
|
|
1116
1250
|
//#region src/saml/response-binding.ts
|
|
1117
1251
|
const SAML_HTTP_POST_BINDING = "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST";
|
|
1118
1252
|
const SAML_BEARER_CONFIRMATION_METHOD = "urn:oasis:names:tc:SAML:2.0:cm:bearer";
|
|
1253
|
+
const SAML_METADATA_NAMESPACE = "urn:oasis:names:tc:SAML:2.0:metadata";
|
|
1119
1254
|
function toNode(value) {
|
|
1120
1255
|
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
|
|
1121
1256
|
return value;
|
|
@@ -1147,11 +1282,75 @@ function parseSAMLContent(samlContent) {
|
|
|
1147
1282
|
code: "SAML_RESPONSE_INVALID_XML"
|
|
1148
1283
|
});
|
|
1149
1284
|
}
|
|
1285
|
+
function directMetadataChildren(element, localName) {
|
|
1286
|
+
return Array.from(element.childNodes).filter((node) => node.nodeType === 1 && node.localName === localName && node.namespaceURI === SAML_METADATA_NAMESPACE);
|
|
1287
|
+
}
|
|
1288
|
+
function parseXMLSchemaBoolean(value) {
|
|
1289
|
+
if (value === null) return false;
|
|
1290
|
+
switch (value.trim()) {
|
|
1291
|
+
case "true":
|
|
1292
|
+
case "1": return true;
|
|
1293
|
+
case "false":
|
|
1294
|
+
case "0": return false;
|
|
1295
|
+
default: throw new Error("Invalid XML Schema boolean");
|
|
1296
|
+
}
|
|
1297
|
+
}
|
|
1298
|
+
function isAbsoluteHttpUrl(value) {
|
|
1299
|
+
if (value.includes("#")) return false;
|
|
1300
|
+
try {
|
|
1301
|
+
const url = new URL(value);
|
|
1302
|
+
return url.protocol === "http:" || url.protocol === "https:";
|
|
1303
|
+
} catch {
|
|
1304
|
+
return false;
|
|
1305
|
+
}
|
|
1306
|
+
}
|
|
1307
|
+
/**
|
|
1308
|
+
* Parses the security- and identity-relevant parts of SAML service-provider
|
|
1309
|
+
* metadata without relying on local-name-only XML matching.
|
|
1310
|
+
*/
|
|
1311
|
+
function parseSAMLServiceProviderMetadata(metadata) {
|
|
1312
|
+
const document = new DOMParser({ onError: (_level, message) => {
|
|
1313
|
+
throw new Error(message);
|
|
1314
|
+
} }).parseFromString(metadata, "text/xml");
|
|
1315
|
+
const entityDescriptor = document.documentElement;
|
|
1316
|
+
if (!entityDescriptor || entityDescriptor.localName !== "EntityDescriptor" || entityDescriptor.namespaceURI !== SAML_METADATA_NAMESPACE) throw new Error("Invalid SAML EntityDescriptor");
|
|
1317
|
+
const entityID = entityDescriptor.getAttribute("entityID")?.trim();
|
|
1318
|
+
if (!entityID) throw new Error("Missing SAML entityID");
|
|
1319
|
+
const serviceProviderDescriptors = directMetadataChildren(entityDescriptor, "SPSSODescriptor");
|
|
1320
|
+
if (serviceProviderDescriptors.length === 0) throw new Error("SAML metadata must contain an SPSSODescriptor");
|
|
1321
|
+
const acceptedServiceProviderDescriptors = new Set(serviceProviderDescriptors);
|
|
1322
|
+
for (const element of Array.from(document.getElementsByTagName("*"))) {
|
|
1323
|
+
if (element.localName === "EntityDescriptor" && element !== entityDescriptor) throw new Error("Invalid nested SAML EntityDescriptor");
|
|
1324
|
+
if (element.localName === "SPSSODescriptor" && !acceptedServiceProviderDescriptors.has(element)) throw new Error("Invalid SAML SPSSODescriptor namespace or position");
|
|
1325
|
+
if ((element.localName === "AssertionConsumerService" || element.localName === "NameIDFormat") && (element.namespaceURI !== SAML_METADATA_NAMESPACE || !element.parentNode || !acceptedServiceProviderDescriptors.has(element.parentNode))) throw new Error(`Invalid SAML ${element.localName} namespace or position`);
|
|
1326
|
+
}
|
|
1327
|
+
const postAssertionConsumerServiceUrls = [];
|
|
1328
|
+
const nameIDFormats = [];
|
|
1329
|
+
let wantAssertionsSigned = false;
|
|
1330
|
+
for (const descriptor of serviceProviderDescriptors) {
|
|
1331
|
+
wantAssertionsSigned = parseXMLSchemaBoolean(descriptor.getAttribute("WantAssertionsSigned")) || wantAssertionsSigned;
|
|
1332
|
+
for (const nameIDFormat of directMetadataChildren(descriptor, "NameIDFormat")) {
|
|
1333
|
+
const value = nameIDFormat.textContent?.trim();
|
|
1334
|
+
if (value) nameIDFormats.push(value);
|
|
1335
|
+
}
|
|
1336
|
+
for (const service of directMetadataChildren(descriptor, "AssertionConsumerService")) {
|
|
1337
|
+
if (service.getAttribute("Binding") !== "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST") continue;
|
|
1338
|
+
const location = service.getAttribute("Location")?.trim();
|
|
1339
|
+
if (!location || !isAbsoluteHttpUrl(location)) throw new Error("Invalid SAML POST AssertionConsumerService");
|
|
1340
|
+
postAssertionConsumerServiceUrls.push(location);
|
|
1341
|
+
}
|
|
1342
|
+
}
|
|
1343
|
+
return {
|
|
1344
|
+
entityID,
|
|
1345
|
+
nameIDFormats: [...new Set(nameIDFormats)],
|
|
1346
|
+
postAssertionConsumerServiceUrls: [...new Set(postAssertionConsumerServiceUrls)],
|
|
1347
|
+
wantAssertionsSigned
|
|
1348
|
+
};
|
|
1349
|
+
}
|
|
1150
1350
|
function getSAMLPostAssertionConsumerServiceUrls(metadata) {
|
|
1151
1351
|
if (!metadata) return [];
|
|
1152
1352
|
try {
|
|
1153
|
-
|
|
1154
|
-
return [...new Set(locations)];
|
|
1353
|
+
return parseSAMLServiceProviderMetadata(metadata).postAssertionConsumerServiceUrls;
|
|
1155
1354
|
} catch {
|
|
1156
1355
|
return [];
|
|
1157
1356
|
}
|
|
@@ -1251,7 +1450,7 @@ function errorRedirectUrl(base, error, description) {
|
|
|
1251
1450
|
* samlify's parsed output (not at the top level).
|
|
1252
1451
|
*/
|
|
1253
1452
|
async function validateInResponseTo(c, ctx) {
|
|
1254
|
-
if (ctx.options.enableInResponseToValidation === false) return;
|
|
1453
|
+
if (ctx.options.enableInResponseToValidation === false) return null;
|
|
1255
1454
|
const inResponseTo = ctx.extract.response?.inResponseTo;
|
|
1256
1455
|
const allowIdpInitiated = ctx.options.allowIdpInitiated ?? false;
|
|
1257
1456
|
if (inResponseTo) {
|
|
@@ -1277,10 +1476,23 @@ async function validateInResponseTo(c, ctx) {
|
|
|
1277
1476
|
});
|
|
1278
1477
|
throw c.redirect(errorRedirectUrl(ctx.redirectUrl, "invalid_saml_response", "Provider mismatch"));
|
|
1279
1478
|
}
|
|
1479
|
+
const providerReference = parseSSOProviderReference(storedRequest.providerReference);
|
|
1480
|
+
if (!providerReference) {
|
|
1481
|
+
c.context.logger.error("SAML InResponseTo validation failed: provider reference missing or invalid", {
|
|
1482
|
+
inResponseTo,
|
|
1483
|
+
providerId: ctx.providerId
|
|
1484
|
+
});
|
|
1485
|
+
throw c.redirect(errorRedirectUrl(ctx.redirectUrl, "invalid_state", "SSO provider reference missing or invalid"));
|
|
1486
|
+
}
|
|
1487
|
+
return {
|
|
1488
|
+
...storedRequest,
|
|
1489
|
+
providerReference
|
|
1490
|
+
};
|
|
1280
1491
|
} else if (!allowIdpInitiated) {
|
|
1281
1492
|
c.context.logger.error("SAML IdP-initiated SSO rejected: InResponseTo missing and allowIdpInitiated is false", { providerId: ctx.providerId });
|
|
1282
1493
|
throw c.redirect(errorRedirectUrl(ctx.redirectUrl, "unsolicited_response", "IdP-initiated SSO not allowed"));
|
|
1283
1494
|
}
|
|
1495
|
+
return null;
|
|
1284
1496
|
}
|
|
1285
1497
|
/**
|
|
1286
1498
|
* Validates the AudienceRestriction of a SAML assertion.
|
|
@@ -1310,7 +1522,93 @@ function validateAudience(c, ctx) {
|
|
|
1310
1522
|
throw c.redirect(errorRedirectUrl(ctx.redirectUrl, "invalid_saml_response", "Audience mismatch"));
|
|
1311
1523
|
}
|
|
1312
1524
|
}
|
|
1313
|
-
|
|
1525
|
+
//#endregion
|
|
1526
|
+
//#region src/user-resolution.ts
|
|
1527
|
+
const SSO_AUTHENTICATION_FAILURE = Symbol("SSO_AUTHENTICATION_FAILURE");
|
|
1528
|
+
function isRecord$1(value) {
|
|
1529
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
1530
|
+
}
|
|
1531
|
+
function isNonEmptyString(value) {
|
|
1532
|
+
return typeof value === "string" && value.trim().length > 0;
|
|
1533
|
+
}
|
|
1534
|
+
function isSSOUserResolution(value) {
|
|
1535
|
+
if (!isRecord$1(value)) return false;
|
|
1536
|
+
if (value.action === "continue") return true;
|
|
1537
|
+
if (value.action === "link") return isNonEmptyString(value.userId) && (value.profile === "preserve" || value.profile === "update");
|
|
1538
|
+
return value.action === "reject" && isNonEmptyString(value.code) && (value.message === void 0 || typeof value.message === "string");
|
|
1539
|
+
}
|
|
1540
|
+
function logFailure(logger, message) {
|
|
1541
|
+
try {
|
|
1542
|
+
logger.error(message);
|
|
1543
|
+
} catch {}
|
|
1544
|
+
}
|
|
1545
|
+
function resolutionFailure() {
|
|
1546
|
+
return new APIError("INTERNAL_SERVER_ERROR", {
|
|
1547
|
+
code: "SSO_USER_RESOLUTION_FAILED",
|
|
1548
|
+
message: "Unable to resolve the SSO user"
|
|
1549
|
+
});
|
|
1550
|
+
}
|
|
1551
|
+
async function resolveSSOUser(resolveUser, input, database, logger) {
|
|
1552
|
+
let resolution;
|
|
1553
|
+
try {
|
|
1554
|
+
resolution = await resolveUser(input, { database });
|
|
1555
|
+
} catch {
|
|
1556
|
+
logFailure(logger, "SSO user resolution failed");
|
|
1557
|
+
throw resolutionFailure();
|
|
1558
|
+
}
|
|
1559
|
+
if (!isSSOUserResolution(resolution)) {
|
|
1560
|
+
logFailure(logger, "SSO user resolver returned an invalid decision");
|
|
1561
|
+
throw resolutionFailure();
|
|
1562
|
+
}
|
|
1563
|
+
return resolution;
|
|
1564
|
+
}
|
|
1565
|
+
function assertSSOUserResolutionNativeTransactionSupport(adapter) {
|
|
1566
|
+
assertSSONativeTransactionSupport(adapter, {
|
|
1567
|
+
code: "SSO_USER_RESOLUTION_REQUIRES_NATIVE_TRANSACTIONS",
|
|
1568
|
+
message: "SSO user resolution requires a database adapter with native transaction support"
|
|
1569
|
+
});
|
|
1570
|
+
}
|
|
1571
|
+
function assertSSONativeTransactionSupport(adapter, error) {
|
|
1572
|
+
if (typeof adapter.options?.adapterConfig.transaction === "function") return;
|
|
1573
|
+
throw new APIError("NOT_IMPLEMENTED", {
|
|
1574
|
+
code: error.code,
|
|
1575
|
+
message: error.message
|
|
1576
|
+
});
|
|
1577
|
+
}
|
|
1578
|
+
async function assertSSOUserResolutionAsyncContextSupport(getStorage = getCurrentDBAdapterAsyncLocalStorage) {
|
|
1579
|
+
await assertSSOAsyncContextSupport({
|
|
1580
|
+
code: "SSO_USER_RESOLUTION_REQUIRES_ASYNC_CONTEXT",
|
|
1581
|
+
message: "SSO user resolution requires database transaction async context support"
|
|
1582
|
+
}, getStorage);
|
|
1583
|
+
}
|
|
1584
|
+
async function assertSSOAsyncContextSupport(error, getStorage = getCurrentDBAdapterAsyncLocalStorage) {
|
|
1585
|
+
try {
|
|
1586
|
+
await getStorage();
|
|
1587
|
+
} catch {
|
|
1588
|
+
throw new APIError("NOT_IMPLEMENTED", {
|
|
1589
|
+
code: error.code,
|
|
1590
|
+
message: error.message
|
|
1591
|
+
});
|
|
1592
|
+
}
|
|
1593
|
+
}
|
|
1594
|
+
function assertSSOUserResolutionSessionStorage(options) {
|
|
1595
|
+
if (!options.secondaryStorage || options.session?.storeSessionInDatabase === true && options.session.preserveSessionInDatabase !== true) return;
|
|
1596
|
+
throw new APIError("NOT_IMPLEMENTED", {
|
|
1597
|
+
code: "SSO_USER_RESOLUTION_REQUIRES_DATABASE_SESSIONS",
|
|
1598
|
+
message: "SSO user resolution requires database-backed sessions with database fallback"
|
|
1599
|
+
});
|
|
1600
|
+
}
|
|
1601
|
+
function requireSuccessfulSSOAuthentication(result) {
|
|
1602
|
+
if (!result.error) return result;
|
|
1603
|
+
throw Object.assign(/* @__PURE__ */ new Error("SSO authentication failed"), {
|
|
1604
|
+
[SSO_AUTHENTICATION_FAILURE]: true,
|
|
1605
|
+
result
|
|
1606
|
+
});
|
|
1607
|
+
}
|
|
1608
|
+
function getFailedSSOAuthenticationResult(error) {
|
|
1609
|
+
if (!(error instanceof Error) || !(SSO_AUTHENTICATION_FAILURE in error) || error[SSO_AUTHENTICATION_FAILURE] !== true || !("result" in error)) return;
|
|
1610
|
+
return error.result;
|
|
1611
|
+
}
|
|
1314
1612
|
//#endregion
|
|
1315
1613
|
//#region src/routes/helpers.ts
|
|
1316
1614
|
/**
|
|
@@ -1349,6 +1647,7 @@ function createSP(config, baseURL, providerId, opts) {
|
|
|
1349
1647
|
const sloLocation = `${baseURL}/sso/saml2/sp/slo/${providerId}`;
|
|
1350
1648
|
const acsUrl = `${baseURL}/sso/saml2/sp/acs/${providerId}`;
|
|
1351
1649
|
let metadata = spData?.metadata;
|
|
1650
|
+
assertSAMLServiceProviderMetadataPolicy(config);
|
|
1352
1651
|
if (!metadata) metadata = saml.SPMetadata({
|
|
1353
1652
|
entityID: spData?.entityID || config.issuer,
|
|
1354
1653
|
assertionConsumerService: [{
|
|
@@ -1362,7 +1661,7 @@ function createSP(config, baseURL, providerId, opts) {
|
|
|
1362
1661
|
Binding: "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect",
|
|
1363
1662
|
Location: sloLocation
|
|
1364
1663
|
}] : void 0,
|
|
1365
|
-
|
|
1664
|
+
wantAssertionsSigned: config.wantAssertionsSigned || false,
|
|
1366
1665
|
authnRequestsSigned: config.authnRequestsSigned || false,
|
|
1367
1666
|
nameIDFormat: config.identifierFormat ? [config.identifierFormat] : void 0
|
|
1368
1667
|
}).getMetadata() || "";
|
|
@@ -1380,6 +1679,42 @@ function createSP(config, baseURL, providerId, opts) {
|
|
|
1380
1679
|
clockDrifts: opts?.clockSkew && opts?.clockSkew !== 0 ? [-opts.clockSkew, opts.clockSkew] : void 0
|
|
1381
1680
|
});
|
|
1382
1681
|
}
|
|
1682
|
+
/**
|
|
1683
|
+
* Ensures custom SP metadata cannot weaken the configured assertion-signing
|
|
1684
|
+
* policy. This is safe to call before persisting a provider configuration.
|
|
1685
|
+
*/
|
|
1686
|
+
function assertSAMLServiceProviderMetadataPolicy(config) {
|
|
1687
|
+
const policy = deriveSAMLServiceProviderPolicy(config);
|
|
1688
|
+
if (config.wantAssertionsSigned !== true || policy.wantAssertionsSigned) return;
|
|
1689
|
+
throw new APIError("BAD_REQUEST", {
|
|
1690
|
+
code: "SAML_SP_METADATA_ASSERTION_SIGNATURE_MISMATCH",
|
|
1691
|
+
message: "SAML service provider metadata must require signed assertions"
|
|
1692
|
+
});
|
|
1693
|
+
}
|
|
1694
|
+
function assertSAMLMetadataSize(metadata, kind, maxMetadataSize) {
|
|
1695
|
+
if (metadata && new TextEncoder().encode(metadata).length > maxMetadataSize) throw new APIError("BAD_REQUEST", { message: `${kind} metadata exceeds maximum allowed size (${maxMetadataSize} bytes)` });
|
|
1696
|
+
}
|
|
1697
|
+
/**
|
|
1698
|
+
* Parses custom SP metadata and returns its effective verification policy.
|
|
1699
|
+
*
|
|
1700
|
+
* Configurations without custom metadata use the code-defined policy directly.
|
|
1701
|
+
* Invalid or unusable custom metadata throws an API error with the
|
|
1702
|
+
* `SAML_INVALID_SP_METADATA` code.
|
|
1703
|
+
*/
|
|
1704
|
+
function deriveSAMLServiceProviderPolicy(config) {
|
|
1705
|
+
const metadata = config.spMetadata?.metadata;
|
|
1706
|
+
if (!metadata) return { wantAssertionsSigned: config.wantAssertionsSigned === true };
|
|
1707
|
+
try {
|
|
1708
|
+
const parsedMetadata = parseSAMLServiceProviderMetadata(metadata);
|
|
1709
|
+
if (!parsedMetadata.postAssertionConsumerServiceUrls.length) throw new Error("Unusable SAML service provider metadata");
|
|
1710
|
+
return { wantAssertionsSigned: parsedMetadata.wantAssertionsSigned };
|
|
1711
|
+
} catch {
|
|
1712
|
+
throw new APIError("BAD_REQUEST", {
|
|
1713
|
+
code: "SAML_INVALID_SP_METADATA",
|
|
1714
|
+
message: "Invalid SAML service provider metadata"
|
|
1715
|
+
});
|
|
1716
|
+
}
|
|
1717
|
+
}
|
|
1383
1718
|
function assertSAMLIdentityProviderAuthority(config) {
|
|
1384
1719
|
if (config.idpMetadata?.metadata || config.idpMetadata?.entityID) return;
|
|
1385
1720
|
throw new APIError("BAD_REQUEST", { message: "SAML manual IdP configuration requires idpMetadata.entityID; issuer identifies the service provider and cannot identify the IdP" });
|
|
@@ -1409,6 +1744,13 @@ function createIdP(config) {
|
|
|
1409
1744
|
encPrivateKeyPass: idpData.encPrivateKeyPass
|
|
1410
1745
|
});
|
|
1411
1746
|
}
|
|
1747
|
+
/**
|
|
1748
|
+
* Derive the verified SAML identity-provider entity ID using the same metadata
|
|
1749
|
+
* parsing and manual-configuration validation as SAML authentication.
|
|
1750
|
+
*/
|
|
1751
|
+
function deriveSAMLIdentityProviderEntityID(config) {
|
|
1752
|
+
return createIdP(config).entityMeta.getEntityID();
|
|
1753
|
+
}
|
|
1412
1754
|
function escapeHtml(str) {
|
|
1413
1755
|
if (!str) return "";
|
|
1414
1756
|
return String(str).replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
@@ -1586,24 +1928,13 @@ const OIDC_IDENTITY_BOUNDARY_FIELDS = [
|
|
|
1586
1928
|
"tokenEndpoint",
|
|
1587
1929
|
"userInfoEndpoint"
|
|
1588
1930
|
];
|
|
1589
|
-
const SAML_IDENTITY_BOUNDARY_FIELDS = [
|
|
1590
|
-
|
|
1591
|
-
"callbackUrl",
|
|
1592
|
-
"entryPoint",
|
|
1593
|
-
"identifierFormat"
|
|
1594
|
-
];
|
|
1595
|
-
const SAML_IDP_BOUNDARY_FIELDS = [
|
|
1596
|
-
"metadata",
|
|
1597
|
-
"entityID",
|
|
1598
|
-
"singleSignOnService"
|
|
1599
|
-
];
|
|
1600
|
-
const SAML_SP_BOUNDARY_FIELDS = ["metadata", "entityID"];
|
|
1601
|
-
function isRecord$2(value) {
|
|
1931
|
+
const SAML_IDENTITY_BOUNDARY_FIELDS = ["audience", "callbackUrl"];
|
|
1932
|
+
function isRecord(value) {
|
|
1602
1933
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
1603
1934
|
}
|
|
1604
1935
|
function stableStringify(value) {
|
|
1605
1936
|
if (Array.isArray(value)) return `[${value.map(stableStringify).join(",")}]`;
|
|
1606
|
-
if (isRecord
|
|
1937
|
+
if (isRecord(value)) return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${stableStringify(value[key])}`).join(",")}}`;
|
|
1607
1938
|
return JSON.stringify(value) ?? String(value);
|
|
1608
1939
|
}
|
|
1609
1940
|
function identityValueChanged(current, updated) {
|
|
@@ -1616,7 +1947,34 @@ function oidcIdentityBoundaryChanged(current, updated) {
|
|
|
1616
1947
|
return hasChangedField(current, updated, OIDC_IDENTITY_BOUNDARY_FIELDS);
|
|
1617
1948
|
}
|
|
1618
1949
|
function samlIdentityBoundaryChanged(current, updated) {
|
|
1619
|
-
|
|
1950
|
+
const currentIdentityProvider = createIdP(current).entityMeta;
|
|
1951
|
+
const updatedIdentityProvider = createIdP(updated).entityMeta;
|
|
1952
|
+
let currentServiceProvider;
|
|
1953
|
+
let updatedServiceProvider;
|
|
1954
|
+
try {
|
|
1955
|
+
currentServiceProvider = current.spMetadata?.metadata ? parseSAMLServiceProviderMetadata(current.spMetadata.metadata) : null;
|
|
1956
|
+
updatedServiceProvider = updated.spMetadata?.metadata ? parseSAMLServiceProviderMetadata(updated.spMetadata.metadata) : null;
|
|
1957
|
+
} catch {
|
|
1958
|
+
return true;
|
|
1959
|
+
}
|
|
1960
|
+
const effectiveIdentityBoundary = (config) => {
|
|
1961
|
+
const identityProvider = config === current ? currentIdentityProvider : updatedIdentityProvider;
|
|
1962
|
+
const serviceProvider = config === current ? currentServiceProvider : updatedServiceProvider;
|
|
1963
|
+
return {
|
|
1964
|
+
idpEntityId: identityProvider.getEntityID(),
|
|
1965
|
+
idpRedirectService: identityProvider.getSingleSignOnService("redirect"),
|
|
1966
|
+
idpPostService: identityProvider.getSingleSignOnService("post"),
|
|
1967
|
+
spEntityId: serviceProvider?.entityID ?? config.spMetadata?.entityID ?? config.issuer,
|
|
1968
|
+
spNameIDFormat: serviceProvider?.nameIDFormats.at(0) ?? config.identifierFormat,
|
|
1969
|
+
spPostServices: serviceProvider ? getSAMLPostAssertionConsumerServiceUrls(config.spMetadata?.metadata) : config.callbackUrl ? [config.callbackUrl] : [],
|
|
1970
|
+
wantAssertionsSigned: serviceProvider?.wantAssertionsSigned ?? config.wantAssertionsSigned === true
|
|
1971
|
+
};
|
|
1972
|
+
};
|
|
1973
|
+
const trustAnchors = (identityProvider) => {
|
|
1974
|
+
const certificates = identityProvider.getX509Certificate("signing");
|
|
1975
|
+
return (Array.isArray(certificates) ? certificates : certificates ? [certificates] : []).map((certificate) => normalizePem(certificate) ?? certificate).sort();
|
|
1976
|
+
};
|
|
1977
|
+
return hasChangedField(current, updated, SAML_IDENTITY_BOUNDARY_FIELDS) || identityValueChanged(trustAnchors(currentIdentityProvider), trustAnchors(updatedIdentityProvider)) || identityValueChanged(effectiveIdentityBoundary(current), effectiveIdentityBoundary(updated));
|
|
1620
1978
|
}
|
|
1621
1979
|
function parseConfigSnapshot(config, configType) {
|
|
1622
1980
|
if (!config) return;
|
|
@@ -1632,19 +1990,67 @@ function ssoProviderIdentityBoundaryChanged(current, updated) {
|
|
|
1632
1990
|
const updatedOidcConfig = parseConfigSnapshot(updated.oidcConfig, "OIDC");
|
|
1633
1991
|
return Boolean(currentOidcConfig && (!updatedOidcConfig || oidcIdentityBoundaryChanged(currentOidcConfig, updatedOidcConfig)));
|
|
1634
1992
|
}
|
|
1635
|
-
async function lockSSOProviderRow(
|
|
1636
|
-
|
|
1993
|
+
async function lockSSOProviderRow(context, provider) {
|
|
1994
|
+
const where = [...provider.id ? [{
|
|
1995
|
+
field: "id",
|
|
1996
|
+
value: provider.id
|
|
1997
|
+
}] : [], {
|
|
1998
|
+
field: "providerId",
|
|
1999
|
+
value: provider.providerId
|
|
2000
|
+
}];
|
|
2001
|
+
return (await getCurrentAdapter(context.adapter)).update({
|
|
1637
2002
|
model: "ssoProvider",
|
|
1638
|
-
where
|
|
1639
|
-
|
|
1640
|
-
|
|
1641
|
-
|
|
1642
|
-
|
|
2003
|
+
where,
|
|
2004
|
+
update: { providerId: provider.providerId }
|
|
2005
|
+
});
|
|
2006
|
+
}
|
|
2007
|
+
async function guardSSOProviderMutation(options, mutation, provider, database, logger) {
|
|
2008
|
+
if (!options?.guardProviderMutation) return;
|
|
2009
|
+
const oidcConfig = provider.oidcConfig ? parseConfigSnapshot(provider.oidcConfig, "OIDC") : void 0;
|
|
2010
|
+
const samlConfig = provider.samlConfig ? parseConfigSnapshot(provider.samlConfig, "SAML") : void 0;
|
|
2011
|
+
const providerReference = await computeSSOProviderReference({
|
|
2012
|
+
...provider,
|
|
2013
|
+
organizationId: provider.organizationId ?? void 0,
|
|
2014
|
+
oidcConfig,
|
|
2015
|
+
samlConfig
|
|
2016
|
+
});
|
|
2017
|
+
try {
|
|
2018
|
+
await options.guardProviderMutation({
|
|
2019
|
+
...mutation,
|
|
2020
|
+
provider: {
|
|
2021
|
+
id: provider.id,
|
|
2022
|
+
providerId: provider.providerId,
|
|
2023
|
+
organizationId: provider.organizationId ?? null
|
|
2024
|
+
},
|
|
2025
|
+
providerReference
|
|
2026
|
+
}, { database });
|
|
2027
|
+
} catch {
|
|
2028
|
+
try {
|
|
2029
|
+
logger.error("SSO provider mutation guard rejected the mutation");
|
|
2030
|
+
} catch {}
|
|
2031
|
+
throw new APIError("CONFLICT", {
|
|
2032
|
+
code: "SSO_PROVIDER_MUTATION_REJECTED",
|
|
2033
|
+
message: "SSO provider mutation is not allowed"
|
|
2034
|
+
});
|
|
2035
|
+
}
|
|
2036
|
+
}
|
|
2037
|
+
async function assertProviderMutationGuardCapabilities(options, adapter) {
|
|
2038
|
+
if (!options?.guardProviderMutation) return;
|
|
2039
|
+
assertSSONativeTransactionSupport(adapter, {
|
|
2040
|
+
code: "SSO_PROVIDER_MUTATION_GUARD_REQUIRES_NATIVE_TRANSACTIONS",
|
|
2041
|
+
message: "SSO provider mutation guards require a database adapter with native transaction support"
|
|
2042
|
+
});
|
|
2043
|
+
await assertSSOAsyncContextSupport({
|
|
2044
|
+
code: "SSO_PROVIDER_MUTATION_GUARD_REQUIRES_ASYNC_CONTEXT",
|
|
2045
|
+
message: "SSO provider mutation guards require database transaction async context support"
|
|
1643
2046
|
});
|
|
1644
2047
|
}
|
|
1645
2048
|
async function lockSSOProviderForAccountLink(ctx, provider) {
|
|
1646
2049
|
if (typeof provider.id !== "string") return;
|
|
1647
|
-
const lockedProvider = await lockSSOProviderRow(ctx.context
|
|
2050
|
+
const lockedProvider = await lockSSOProviderRow(ctx.context, {
|
|
2051
|
+
id: provider.id,
|
|
2052
|
+
providerId: provider.providerId
|
|
2053
|
+
});
|
|
1648
2054
|
if (!lockedProvider) throw new APIError("CONFLICT", {
|
|
1649
2055
|
code: "SSO_PROVIDER_CHANGED",
|
|
1650
2056
|
message: "SSO provider changed while account linking was in progress"
|
|
@@ -1913,10 +2319,14 @@ const updateSSOProvider = (options) => {
|
|
|
1913
2319
|
const { issuer, domain, samlConfig, oidcConfig } = body;
|
|
1914
2320
|
const additionalFields = parseSSOProviderAdditionalFields(options, body, "update");
|
|
1915
2321
|
if (!issuer && !domain && !samlConfig && !oidcConfig && Object.keys(additionalFields).length === 0) throw new APIError("BAD_REQUEST", { message: "No fields provided for update" });
|
|
1916
|
-
await checkProviderAccess(ctx, providerId);
|
|
2322
|
+
const authorizedProvider = await checkProviderAccess(ctx, providerId);
|
|
2323
|
+
await assertProviderMutationGuardCapabilities(options, ctx.context.adapter);
|
|
1917
2324
|
const fullProvider = await runWithTransaction(ctx.context.adapter, async () => {
|
|
1918
2325
|
const trx = await getCurrentAdapter(ctx.context.adapter);
|
|
1919
|
-
const existingProvider = await lockSSOProviderRow(ctx.context
|
|
2326
|
+
const existingProvider = await lockSSOProviderRow(ctx.context, {
|
|
2327
|
+
id: authorizedProvider.id,
|
|
2328
|
+
providerId
|
|
2329
|
+
});
|
|
1920
2330
|
if (!existingProvider) throw new APIError("NOT_FOUND", { message: "Provider not found" });
|
|
1921
2331
|
const updateData = { ...additionalFields };
|
|
1922
2332
|
let providerIdentityBoundaryChanged = body.issuer !== void 0 && body.issuer !== existingProvider.issuer;
|
|
@@ -1926,10 +2336,9 @@ const updateSSOProvider = (options) => {
|
|
|
1926
2336
|
if (body.domain !== existingProvider.domain) updateData.domainVerified = false;
|
|
1927
2337
|
}
|
|
1928
2338
|
if (body.samlConfig) {
|
|
1929
|
-
|
|
1930
|
-
|
|
1931
|
-
|
|
1932
|
-
}
|
|
2339
|
+
const maxMetadataSize = options?.saml?.maxMetadataSize ?? 102400;
|
|
2340
|
+
assertSAMLMetadataSize(body.samlConfig.idpMetadata?.metadata, "IdP", maxMetadataSize);
|
|
2341
|
+
assertSAMLMetadataSize(body.samlConfig.spMetadata?.metadata, "SP", maxMetadataSize);
|
|
1933
2342
|
if (body.samlConfig.signatureAlgorithm !== void 0 || body.samlConfig.digestAlgorithm !== void 0) validateConfigAlgorithms({
|
|
1934
2343
|
signatureAlgorithm: body.samlConfig.signatureAlgorithm,
|
|
1935
2344
|
digestAlgorithm: body.samlConfig.digestAlgorithm
|
|
@@ -1938,6 +2347,7 @@ const updateSSOProvider = (options) => {
|
|
|
1938
2347
|
const updatedSamlConfig = mergeSAMLConfig(currentSamlConfig, body.samlConfig, updateData.issuer || currentSamlConfig.issuer || existingProvider.issuer);
|
|
1939
2348
|
validateCertSources(updatedSamlConfig);
|
|
1940
2349
|
assertSAMLIdentityProviderAuthority(updatedSamlConfig);
|
|
2350
|
+
assertSAMLServiceProviderMetadataPolicy(updatedSamlConfig);
|
|
1941
2351
|
if (samlIdentityBoundaryChanged(currentSamlConfig, updatedSamlConfig)) providerIdentityBoundaryChanged = true;
|
|
1942
2352
|
updateData.samlConfig = JSON.stringify(updatedSamlConfig);
|
|
1943
2353
|
}
|
|
@@ -1955,6 +2365,10 @@ const updateSSOProvider = (options) => {
|
|
|
1955
2365
|
if (oidcIdentityBoundaryChanged(currentOidcConfig, updatedOidcConfig)) providerIdentityBoundaryChanged = true;
|
|
1956
2366
|
updateData.oidcConfig = JSON.stringify(updatedOidcConfig);
|
|
1957
2367
|
}
|
|
2368
|
+
await guardSSOProviderMutation(options, {
|
|
2369
|
+
action: "update",
|
|
2370
|
+
isAuthenticationBoundaryChange: providerIdentityBoundaryChanged
|
|
2371
|
+
}, existingProvider, trx, ctx.context.logger);
|
|
1958
2372
|
if (providerIdentityBoundaryChanged) {
|
|
1959
2373
|
if (await trx.findOne({
|
|
1960
2374
|
model: "account",
|
|
@@ -1985,7 +2399,7 @@ const updateSSOProvider = (options) => {
|
|
|
1985
2399
|
return ctx.json(sanitizeProvider(fullProvider, ctx.context.baseURL, options));
|
|
1986
2400
|
});
|
|
1987
2401
|
};
|
|
1988
|
-
const deleteSSOProvider = () => {
|
|
2402
|
+
const deleteSSOProvider = (options) => {
|
|
1989
2403
|
return createAuthEndpoint("/sso/delete-provider", {
|
|
1990
2404
|
method: "POST",
|
|
1991
2405
|
use: [sessionMiddleware],
|
|
@@ -2002,9 +2416,16 @@ const deleteSSOProvider = () => {
|
|
|
2002
2416
|
} }
|
|
2003
2417
|
}, async (ctx) => {
|
|
2004
2418
|
const { providerId } = ctx.body;
|
|
2005
|
-
await checkProviderAccess(ctx, providerId);
|
|
2419
|
+
const authorizedProvider = await checkProviderAccess(ctx, providerId);
|
|
2420
|
+
await assertProviderMutationGuardCapabilities(options, ctx.context.adapter);
|
|
2006
2421
|
await runWithTransaction(ctx.context.adapter, async () => {
|
|
2007
2422
|
const trx = await getCurrentAdapter(ctx.context.adapter);
|
|
2423
|
+
const existingProvider = await lockSSOProviderRow(ctx.context, {
|
|
2424
|
+
id: authorizedProvider.id,
|
|
2425
|
+
providerId
|
|
2426
|
+
});
|
|
2427
|
+
if (!existingProvider) throw new APIError("NOT_FOUND", { message: "Provider not found" });
|
|
2428
|
+
await guardSSOProviderMutation(options, { action: "delete" }, existingProvider, trx, ctx.context.logger);
|
|
2008
2429
|
await trx.deleteMany({
|
|
2009
2430
|
model: "account",
|
|
2010
2431
|
where: [{
|
|
@@ -2015,6 +2436,9 @@ const deleteSSOProvider = () => {
|
|
|
2015
2436
|
await trx.delete({
|
|
2016
2437
|
model: "ssoProvider",
|
|
2017
2438
|
where: [{
|
|
2439
|
+
field: "id",
|
|
2440
|
+
value: existingProvider.id
|
|
2441
|
+
}, {
|
|
2018
2442
|
field: "providerId",
|
|
2019
2443
|
value: providerId
|
|
2020
2444
|
}]
|
|
@@ -2144,82 +2568,8 @@ const verifyDomain = (options) => {
|
|
|
2144
2568
|
});
|
|
2145
2569
|
};
|
|
2146
2570
|
//#endregion
|
|
2147
|
-
//#region src/provider-reference.ts
|
|
2148
|
-
const SSO_PROVIDER_STATE_KEY = "ssoProviderReference";
|
|
2149
|
-
function serializeCanonical(value) {
|
|
2150
|
-
if (value === null) return "null";
|
|
2151
|
-
if (Array.isArray(value)) return `[${value.map((entry) => entry === void 0 ? "null" : serializeCanonical(entry)).join(",")}]`;
|
|
2152
|
-
switch (typeof value) {
|
|
2153
|
-
case "boolean":
|
|
2154
|
-
case "number":
|
|
2155
|
-
case "string": return JSON.stringify(value);
|
|
2156
|
-
case "object": return `{${Object.entries(value).filter(([, entry]) => entry !== void 0).sort(([left], [right]) => left < right ? -1 : left > right ? 1 : 0).map(([key, entry]) => `${JSON.stringify(key)}:${serializeCanonical(entry)}`).join(",")}}`;
|
|
2157
|
-
default: throw new TypeError("SSO provider configuration must be JSON-serializable");
|
|
2158
|
-
}
|
|
2159
|
-
}
|
|
2160
|
-
function withoutOIDCSecret(configuration) {
|
|
2161
|
-
if (!configuration) return void 0;
|
|
2162
|
-
const result = { ...configuration };
|
|
2163
|
-
result.clientSecret = void 0;
|
|
2164
|
-
return result;
|
|
2165
|
-
}
|
|
2166
|
-
function getProviderSource(provider) {
|
|
2167
|
-
return typeof provider.id === "string" && provider.id.length > 0 ? {
|
|
2168
|
-
type: "persisted",
|
|
2169
|
-
recordId: provider.id
|
|
2170
|
-
} : { type: "configured" };
|
|
2171
|
-
}
|
|
2172
|
-
async function computeProviderAuthenticationFingerprint(provider) {
|
|
2173
|
-
const digest = await createHash("SHA-256").digest(serializeCanonical({
|
|
2174
|
-
domain: provider.domain,
|
|
2175
|
-
domainVerified: "domainVerified" in provider ? provider.domainVerified : void 0,
|
|
2176
|
-
issuer: provider.issuer,
|
|
2177
|
-
organizationId: provider.organizationId,
|
|
2178
|
-
oidcConfig: withoutOIDCSecret(provider.oidcConfig)
|
|
2179
|
-
}));
|
|
2180
|
-
return base64Url.encode(new Uint8Array(digest), { padding: false });
|
|
2181
|
-
}
|
|
2182
|
-
async function computeSSOProviderReference(provider) {
|
|
2183
|
-
return {
|
|
2184
|
-
providerId: provider.providerId,
|
|
2185
|
-
source: getProviderSource(provider),
|
|
2186
|
-
authenticationConfigurationFingerprint: await computeProviderAuthenticationFingerprint(provider)
|
|
2187
|
-
};
|
|
2188
|
-
}
|
|
2189
|
-
function isRecord$1(value) {
|
|
2190
|
-
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
2191
|
-
}
|
|
2192
|
-
function parseSSOProviderReference(value) {
|
|
2193
|
-
if (!isRecord$1(value) || !isRecord$1(value.source)) return null;
|
|
2194
|
-
const providerId = value.providerId;
|
|
2195
|
-
const authenticationConfigurationFingerprint = value.authenticationConfigurationFingerprint;
|
|
2196
|
-
const source = value.source;
|
|
2197
|
-
if (typeof providerId !== "string" || providerId.length === 0 || typeof authenticationConfigurationFingerprint !== "string" || authenticationConfigurationFingerprint.length === 0) return null;
|
|
2198
|
-
if (source.type === "configured") return {
|
|
2199
|
-
providerId,
|
|
2200
|
-
source: { type: "configured" },
|
|
2201
|
-
authenticationConfigurationFingerprint
|
|
2202
|
-
};
|
|
2203
|
-
if (source.type === "persisted" && typeof source.recordId === "string" && source.recordId.length > 0) return {
|
|
2204
|
-
providerId,
|
|
2205
|
-
source: {
|
|
2206
|
-
type: "persisted",
|
|
2207
|
-
recordId: source.recordId
|
|
2208
|
-
},
|
|
2209
|
-
authenticationConfigurationFingerprint
|
|
2210
|
-
};
|
|
2211
|
-
return null;
|
|
2212
|
-
}
|
|
2213
|
-
async function isCurrentSSOProviderReference(provider, reference) {
|
|
2214
|
-
if (!reference || reference.providerId !== provider.providerId) return false;
|
|
2215
|
-
const source = getProviderSource(provider);
|
|
2216
|
-
if (source.type !== reference.source.type) return false;
|
|
2217
|
-
if (source.type === "persisted" && (reference.source.type !== "persisted" || source.recordId !== reference.source.recordId)) return false;
|
|
2218
|
-
return reference.authenticationConfigurationFingerprint === await computeProviderAuthenticationFingerprint(provider);
|
|
2219
|
-
}
|
|
2220
|
-
//#endregion
|
|
2221
2571
|
//#region src/saml-state.ts
|
|
2222
|
-
async function generateRelayState(c, link) {
|
|
2572
|
+
async function generateRelayState(c, link, providerReference) {
|
|
2223
2573
|
const callbackURL = c.body.callbackURL;
|
|
2224
2574
|
if (!callbackURL) throw new APIError("BAD_REQUEST", { message: "callbackURL is required" });
|
|
2225
2575
|
const stateData = {
|
|
@@ -2232,7 +2582,8 @@ async function generateRelayState(c, link) {
|
|
|
2232
2582
|
* This is the actual expiry time of the state
|
|
2233
2583
|
*/
|
|
2234
2584
|
expiresAt: Date.now() + 600 * 1e3,
|
|
2235
|
-
requestSignUp: c.body.requestSignUp
|
|
2585
|
+
requestSignUp: c.body.requestSignUp,
|
|
2586
|
+
serverContext: providerReference ? { [SSO_PROVIDER_STATE_KEY]: providerReference } : void 0
|
|
2236
2587
|
};
|
|
2237
2588
|
try {
|
|
2238
2589
|
return generateGenericState(c, stateData, { cookieName: "relay_state" });
|
|
@@ -2268,81 +2619,6 @@ async function parseRelayState(c) {
|
|
|
2268
2619
|
return parsedData;
|
|
2269
2620
|
}
|
|
2270
2621
|
//#endregion
|
|
2271
|
-
//#region src/user-resolution.ts
|
|
2272
|
-
const SSO_AUTHENTICATION_FAILURE = Symbol("SSO_AUTHENTICATION_FAILURE");
|
|
2273
|
-
function isRecord(value) {
|
|
2274
|
-
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
2275
|
-
}
|
|
2276
|
-
function isNonEmptyString(value) {
|
|
2277
|
-
return typeof value === "string" && value.trim().length > 0;
|
|
2278
|
-
}
|
|
2279
|
-
function isSSOUserResolution(value) {
|
|
2280
|
-
if (!isRecord(value)) return false;
|
|
2281
|
-
if (value.action === "continue") return true;
|
|
2282
|
-
if (value.action === "link") return isNonEmptyString(value.userId) && (value.profile === "preserve" || value.profile === "update");
|
|
2283
|
-
return value.action === "reject" && isNonEmptyString(value.code) && (value.message === void 0 || typeof value.message === "string");
|
|
2284
|
-
}
|
|
2285
|
-
function logFailure(logger, message, error) {
|
|
2286
|
-
try {
|
|
2287
|
-
logger.error(message, ...error === void 0 ? [] : [error]);
|
|
2288
|
-
} catch {}
|
|
2289
|
-
}
|
|
2290
|
-
function resolutionFailure() {
|
|
2291
|
-
return new APIError("INTERNAL_SERVER_ERROR", {
|
|
2292
|
-
code: "SSO_USER_RESOLUTION_FAILED",
|
|
2293
|
-
message: "Unable to resolve the SSO user"
|
|
2294
|
-
});
|
|
2295
|
-
}
|
|
2296
|
-
async function resolveSSOUser(resolveUser, input, database, logger) {
|
|
2297
|
-
let resolution;
|
|
2298
|
-
try {
|
|
2299
|
-
resolution = await resolveUser(input, { database });
|
|
2300
|
-
} catch (error) {
|
|
2301
|
-
logFailure(logger, "SSO user resolution failed", error);
|
|
2302
|
-
throw resolutionFailure();
|
|
2303
|
-
}
|
|
2304
|
-
if (!isSSOUserResolution(resolution)) {
|
|
2305
|
-
logFailure(logger, "SSO user resolver returned an invalid decision");
|
|
2306
|
-
throw resolutionFailure();
|
|
2307
|
-
}
|
|
2308
|
-
return resolution;
|
|
2309
|
-
}
|
|
2310
|
-
function assertSSOUserResolutionNativeTransactionSupport(adapter) {
|
|
2311
|
-
if (typeof adapter.options?.adapterConfig.transaction === "function") return;
|
|
2312
|
-
throw new APIError("NOT_IMPLEMENTED", {
|
|
2313
|
-
code: "SSO_USER_RESOLUTION_REQUIRES_NATIVE_TRANSACTIONS",
|
|
2314
|
-
message: "SSO user resolution requires a database adapter with native transaction support"
|
|
2315
|
-
});
|
|
2316
|
-
}
|
|
2317
|
-
async function assertSSOUserResolutionAsyncContextSupport(getStorage = getCurrentDBAdapterAsyncLocalStorage) {
|
|
2318
|
-
try {
|
|
2319
|
-
await getStorage();
|
|
2320
|
-
} catch {
|
|
2321
|
-
throw new APIError("NOT_IMPLEMENTED", {
|
|
2322
|
-
code: "SSO_USER_RESOLUTION_REQUIRES_ASYNC_CONTEXT",
|
|
2323
|
-
message: "SSO user resolution requires database transaction async context support"
|
|
2324
|
-
});
|
|
2325
|
-
}
|
|
2326
|
-
}
|
|
2327
|
-
function assertSSOUserResolutionSessionStorage(options) {
|
|
2328
|
-
if (!options.secondaryStorage || options.session?.storeSessionInDatabase === true && options.session.preserveSessionInDatabase !== true) return;
|
|
2329
|
-
throw new APIError("NOT_IMPLEMENTED", {
|
|
2330
|
-
code: "SSO_USER_RESOLUTION_REQUIRES_DATABASE_SESSIONS",
|
|
2331
|
-
message: "SSO user resolution requires database-backed sessions with database fallback"
|
|
2332
|
-
});
|
|
2333
|
-
}
|
|
2334
|
-
function requireSuccessfulSSOAuthentication(result) {
|
|
2335
|
-
if (!result.error) return result;
|
|
2336
|
-
throw Object.assign(/* @__PURE__ */ new Error("SSO authentication failed"), {
|
|
2337
|
-
[SSO_AUTHENTICATION_FAILURE]: true,
|
|
2338
|
-
result
|
|
2339
|
-
});
|
|
2340
|
-
}
|
|
2341
|
-
function getFailedSSOAuthenticationResult(error) {
|
|
2342
|
-
if (!(error instanceof Error) || !(SSO_AUTHENTICATION_FAILURE in error) || error[SSO_AUTHENTICATION_FAILURE] !== true || !("result" in error)) return;
|
|
2343
|
-
return error.result;
|
|
2344
|
-
}
|
|
2345
|
-
//#endregion
|
|
2346
2622
|
//#region src/saml/timestamp.ts
|
|
2347
2623
|
/**
|
|
2348
2624
|
* Validates SAML assertion timestamp conditions (NotBefore/NotOnOrAfter).
|
|
@@ -2385,6 +2661,10 @@ function validateSAMLTimestamp(conditions, options = {}) {
|
|
|
2385
2661
|
}
|
|
2386
2662
|
//#endregion
|
|
2387
2663
|
//#region src/routes/saml-pipeline.ts
|
|
2664
|
+
function isSameSSOProviderReference(left, right) {
|
|
2665
|
+
if (left.providerId !== right.providerId || left.authenticationConfigurationFingerprint !== right.authenticationConfigurationFingerprint || left.source.type !== right.source.type) return false;
|
|
2666
|
+
return left.source.type === "configured" ? true : right.source.type === "persisted" && left.source.recordId === right.source.recordId;
|
|
2667
|
+
}
|
|
2388
2668
|
function getSafeRedirectCandidate(url, callbackPathname, appOrigin, isTrustedOrigin) {
|
|
2389
2669
|
if (!url) return;
|
|
2390
2670
|
if (url.startsWith("/")) {
|
|
@@ -2499,10 +2779,11 @@ async function processSAMLResponse(ctx, params, options) {
|
|
|
2499
2779
|
if (new TextEncoder().encode(params.SAMLResponse).length > maxResponseSize) throw new APIError("BAD_REQUEST", { message: `SAML response exceeds maximum allowed size (${maxResponseSize} bytes)` });
|
|
2500
2780
|
const SAMLResponse = params.SAMLResponse.replace(/\s+/g, "");
|
|
2501
2781
|
let relayState = null;
|
|
2782
|
+
let relayStateValidationFailed = false;
|
|
2502
2783
|
if (params.RelayState) try {
|
|
2503
2784
|
relayState = await parseRelayState(ctx);
|
|
2504
2785
|
} catch {
|
|
2505
|
-
|
|
2786
|
+
relayStateValidationFailed = true;
|
|
2506
2787
|
}
|
|
2507
2788
|
const provider = await findSAMLProvider(providerId, options, ctx.context.adapter);
|
|
2508
2789
|
if (!provider?.samlConfig) throw new APIError("NOT_FOUND", { message: "No SAML provider found" });
|
|
@@ -2515,6 +2796,7 @@ async function processSAMLResponse(ctx, params, options) {
|
|
|
2515
2796
|
const samlRedirectUrl = getSafeRedirectUrl(redirectCandidates, currentCallbackPath, appOrigin, (url, settings) => ctx.context.isTrustedOrigin(url, settings));
|
|
2516
2797
|
const samlErrorRedirectUrl = getSafeRedirectUrl([relayState?.errorURL, samlRedirectUrl], currentCallbackPath, appOrigin, (url, settings) => ctx.context.isTrustedOrigin(url, settings));
|
|
2517
2798
|
params.onErrorRedirectResolved?.(samlErrorRedirectUrl);
|
|
2799
|
+
const stateProviderReference = parseSSOProviderReference(relayState?.serverContext?.[SSO_PROVIDER_STATE_KEY]);
|
|
2518
2800
|
validateSingleAssertion(SAMLResponse);
|
|
2519
2801
|
let parsedResponse;
|
|
2520
2802
|
try {
|
|
@@ -2523,15 +2805,9 @@ async function processSAMLResponse(ctx, params, options) {
|
|
|
2523
2805
|
RelayState: params.RelayState || void 0
|
|
2524
2806
|
} });
|
|
2525
2807
|
if (!parsedResponse?.extract) throw new Error("Invalid SAML response structure");
|
|
2526
|
-
} catch
|
|
2527
|
-
ctx.context.logger.error("SAML response validation failed"
|
|
2528
|
-
|
|
2529
|
-
samlResponsePreview: SAMLResponse.slice(0, 200)
|
|
2530
|
-
});
|
|
2531
|
-
throw new APIError("BAD_REQUEST", {
|
|
2532
|
-
message: "Invalid SAML response",
|
|
2533
|
-
details: error instanceof Error ? error.message : String(error)
|
|
2534
|
-
});
|
|
2808
|
+
} catch {
|
|
2809
|
+
ctx.context.logger.error("SAML response validation failed");
|
|
2810
|
+
throw new APIError("BAD_REQUEST", { message: "Invalid SAML response" });
|
|
2535
2811
|
}
|
|
2536
2812
|
const { extract } = parsedResponse;
|
|
2537
2813
|
const samlContent = parsedResponse.samlContent;
|
|
@@ -2544,9 +2820,14 @@ async function processSAMLResponse(ctx, params, options) {
|
|
|
2544
2820
|
const expectedAudiences = [sp.entityMeta.getEntityID(), parsedSamlConfig.audience];
|
|
2545
2821
|
const assertionConsumerServiceUrl = sp.entityMeta.getAssertionConsumerService(SAML_HTTP_POST_BINDING);
|
|
2546
2822
|
const expectedRecipients = getExpectedSAMLRecipients(parsedSamlConfig, ctx.context.baseURL, providerId, currentCallbackPath, assertionConsumerServiceUrl);
|
|
2823
|
+
const serviceProviderPolicy = deriveSAMLServiceProviderPolicy(parsedSamlConfig);
|
|
2547
2824
|
let samlBindingContent;
|
|
2548
2825
|
try {
|
|
2549
2826
|
samlBindingContent = await getSAMLResponseBindingContent(sp, samlContent);
|
|
2827
|
+
if (serviceProviderPolicy.wantAssertionsSigned) verifySAMLAssertionSignature(samlBindingContent, {
|
|
2828
|
+
metadata: idp.entityMeta,
|
|
2829
|
+
signatureAlgorithm: idp.entitySetting.requestSignatureAlgorithm
|
|
2830
|
+
});
|
|
2550
2831
|
validateSAMLResponseBinding(samlBindingContent, {
|
|
2551
2832
|
expectedAudiences,
|
|
2552
2833
|
expectedRecipients
|
|
@@ -2555,27 +2836,20 @@ async function processSAMLResponse(ctx, params, options) {
|
|
|
2555
2836
|
if (isAPIError(error)) {
|
|
2556
2837
|
ctx.context.logger.error("SAML response binding validation failed", {
|
|
2557
2838
|
providerId,
|
|
2558
|
-
code: error.body?.code
|
|
2559
|
-
expectedAudiences: expectedAudiences.filter(Boolean),
|
|
2560
|
-
expectedRecipients: expectedRecipients.filter(Boolean)
|
|
2839
|
+
code: error.body?.code
|
|
2561
2840
|
});
|
|
2562
2841
|
throw ctx.redirect(buildSAMLRedirectUrl(samlErrorRedirectUrl, {
|
|
2563
2842
|
error: "invalid_saml_response",
|
|
2564
2843
|
error_description: error.body?.message || error.message || "Invalid SAML response"
|
|
2565
2844
|
}));
|
|
2566
2845
|
}
|
|
2567
|
-
ctx.context.logger.error("SAML response binding validation failed", {
|
|
2568
|
-
providerId,
|
|
2569
|
-
error,
|
|
2570
|
-
expectedAudiences: expectedAudiences.filter(Boolean),
|
|
2571
|
-
expectedRecipients: expectedRecipients.filter(Boolean)
|
|
2572
|
-
});
|
|
2846
|
+
ctx.context.logger.error("SAML response binding validation failed", { providerId });
|
|
2573
2847
|
throw ctx.redirect(buildSAMLRedirectUrl(samlErrorRedirectUrl, {
|
|
2574
2848
|
error: "invalid_saml_response",
|
|
2575
2849
|
error_description: "SAML response binding could not be validated"
|
|
2576
2850
|
}));
|
|
2577
2851
|
}
|
|
2578
|
-
await validateInResponseTo(ctx, {
|
|
2852
|
+
const requestProviderReference = (await validateInResponseTo(ctx, {
|
|
2579
2853
|
extract,
|
|
2580
2854
|
providerId,
|
|
2581
2855
|
options: {
|
|
@@ -2583,7 +2857,24 @@ async function processSAMLResponse(ctx, params, options) {
|
|
|
2583
2857
|
allowIdpInitiated: options?.saml?.allowIdpInitiated
|
|
2584
2858
|
},
|
|
2585
2859
|
redirectUrl: samlErrorRedirectUrl
|
|
2586
|
-
});
|
|
2860
|
+
}))?.providerReference;
|
|
2861
|
+
if (relayStateValidationFailed) throw ctx.redirect(buildSAMLRedirectUrl(samlErrorRedirectUrl, {
|
|
2862
|
+
error: "invalid_state",
|
|
2863
|
+
error_description: "invalid_or_expired_relay_state"
|
|
2864
|
+
}));
|
|
2865
|
+
if (relayState && !stateProviderReference) throw ctx.redirect(buildSAMLRedirectUrl(samlErrorRedirectUrl, {
|
|
2866
|
+
error: "invalid_state",
|
|
2867
|
+
error_description: "sso_provider_reference_missing_or_invalid"
|
|
2868
|
+
}));
|
|
2869
|
+
if (stateProviderReference && requestProviderReference && !isSameSSOProviderReference(stateProviderReference, requestProviderReference)) throw ctx.redirect(buildSAMLRedirectUrl(samlErrorRedirectUrl, {
|
|
2870
|
+
error: "invalid_state",
|
|
2871
|
+
error_description: "sso_provider_reference_mismatch"
|
|
2872
|
+
}));
|
|
2873
|
+
const providerReference = stateProviderReference ?? requestProviderReference ?? await computeSSOProviderReference(provider);
|
|
2874
|
+
if (!await isCurrentSSOProviderReference(provider, providerReference)) throw ctx.redirect(buildSAMLRedirectUrl(samlErrorRedirectUrl, {
|
|
2875
|
+
error: "invalid_state",
|
|
2876
|
+
error_description: "sso_provider_changed_during_authentication"
|
|
2877
|
+
}));
|
|
2587
2878
|
validateAudience(ctx, {
|
|
2588
2879
|
extract,
|
|
2589
2880
|
expectedAudience: parsedSamlConfig.audience || sp.entityMeta.getEntityID(),
|
|
@@ -2619,6 +2910,9 @@ async function processSAMLResponse(ctx, params, options) {
|
|
|
2619
2910
|
}
|
|
2620
2911
|
} else ctx.context.logger.warn("Could not extract assertion ID for replay protection", { providerId });
|
|
2621
2912
|
const attributes = extract.attributes || {};
|
|
2913
|
+
const providerAttributes = {};
|
|
2914
|
+
for (const [name, value] of Object.entries(attributes)) if (typeof value === "string") providerAttributes[name] = value;
|
|
2915
|
+
else if (Array.isArray(value) && value.every((entry) => typeof entry === "string")) providerAttributes[name] = value;
|
|
2622
2916
|
const mapping = parsedSamlConfig.mapping ?? {};
|
|
2623
2917
|
const attr = (key) => {
|
|
2624
2918
|
const value = attributes[key];
|
|
@@ -2633,31 +2927,63 @@ async function processSAMLResponse(ctx, params, options) {
|
|
|
2633
2927
|
};
|
|
2634
2928
|
if (!userInfo.id || !userInfo.email) {
|
|
2635
2929
|
ctx.context.logger.error("Missing essential user info from SAML response", {
|
|
2636
|
-
|
|
2637
|
-
|
|
2638
|
-
|
|
2639
|
-
|
|
2930
|
+
providerId,
|
|
2931
|
+
attributeNames: Object.keys(attributes),
|
|
2932
|
+
hasNameId: Boolean(userInfo.id),
|
|
2933
|
+
hasEmail: Boolean(userInfo.email)
|
|
2640
2934
|
});
|
|
2641
2935
|
throw new APIError("BAD_REQUEST", { message: "Unable to extract user ID or email from SAML response" });
|
|
2642
2936
|
}
|
|
2937
|
+
const providerUserAttributes = Object.fromEntries(Object.entries(userInfo).filter(([key]) => key !== "id"));
|
|
2938
|
+
const providerUser = {
|
|
2939
|
+
...providerUserAttributes,
|
|
2940
|
+
email: userInfo.email,
|
|
2941
|
+
name: userInfo.name || userInfo.email,
|
|
2942
|
+
image: typeof providerUserAttributes.image === "string" ? providerUserAttributes.image : void 0,
|
|
2943
|
+
emailVerified: userInfo.emailVerified
|
|
2944
|
+
};
|
|
2945
|
+
const accountKey = {
|
|
2946
|
+
issuer,
|
|
2947
|
+
providerAccountId: userInfo.id
|
|
2948
|
+
};
|
|
2643
2949
|
const isTrustedProvider = "domainVerified" in provider && !!provider.domainVerified && validateEmailDomain(userInfo.email, provider.domain);
|
|
2644
2950
|
const callbackUrl = redirectCandidates.some(Boolean) ? samlRedirectUrl : ctx.context.baseURL;
|
|
2645
2951
|
const errorUrl = samlErrorRedirectUrl;
|
|
2646
2952
|
let result;
|
|
2647
2953
|
try {
|
|
2954
|
+
if (options?.resolveUser) {
|
|
2955
|
+
assertSSOUserResolutionNativeTransactionSupport(ctx.context.adapter);
|
|
2956
|
+
assertSSOUserResolutionSessionStorage(ctx.context.options);
|
|
2957
|
+
await assertSSOUserResolutionAsyncContextSupport();
|
|
2958
|
+
}
|
|
2648
2959
|
result = await runWithTransaction(ctx.context.adapter, async () => {
|
|
2649
2960
|
await lockSSOProviderForAccountLink(ctx, provider);
|
|
2650
|
-
|
|
2961
|
+
const currentProvider = await findSAMLProvider(providerId, options, await getCurrentAdapter(ctx.context.adapter));
|
|
2962
|
+
if (!currentProvider || !await isCurrentSSOProviderReference(currentProvider, providerReference)) throw new APIError("CONFLICT", {
|
|
2963
|
+
code: "SSO_PROVIDER_CHANGED",
|
|
2964
|
+
message: "SSO provider changed while account linking was in progress"
|
|
2965
|
+
});
|
|
2966
|
+
const resolution = options?.resolveUser ? await resolveSSOUser(options.resolveUser, {
|
|
2967
|
+
protocol: "saml",
|
|
2968
|
+
providerId: provider.providerId,
|
|
2969
|
+
accountKey,
|
|
2970
|
+
providerUser,
|
|
2971
|
+
providerAttributes,
|
|
2972
|
+
providerReference
|
|
2973
|
+
}, await getCurrentAdapter(ctx.context.adapter), ctx.context.logger) : void 0;
|
|
2974
|
+
if (resolution?.action === "reject") throw new APIError("FORBIDDEN", {
|
|
2975
|
+
code: resolution.code,
|
|
2976
|
+
...resolution.message === void 0 ? {} : { message: resolution.message }
|
|
2977
|
+
});
|
|
2978
|
+
const authentication = await handleOAuthUserInfo(ctx, {
|
|
2651
2979
|
userInfo: {
|
|
2652
|
-
|
|
2653
|
-
|
|
2654
|
-
id: userInfo.id,
|
|
2655
|
-
emailVerified: userInfo.emailVerified
|
|
2980
|
+
...providerUser,
|
|
2981
|
+
id: userInfo.id
|
|
2656
2982
|
},
|
|
2657
2983
|
account: {
|
|
2658
2984
|
providerId,
|
|
2659
|
-
issuer,
|
|
2660
|
-
providerAccountId:
|
|
2985
|
+
issuer: accountKey.issuer,
|
|
2986
|
+
providerAccountId: accountKey.providerAccountId,
|
|
2661
2987
|
accessToken: "",
|
|
2662
2988
|
refreshToken: ""
|
|
2663
2989
|
},
|
|
@@ -2671,15 +2997,26 @@ async function processSAMLResponse(ctx, params, options) {
|
|
|
2671
2997
|
}
|
|
2672
2998
|
},
|
|
2673
2999
|
isTrustedProvider,
|
|
2674
|
-
trustProviderByName: false
|
|
3000
|
+
trustProviderByName: false,
|
|
3001
|
+
selectedUser: resolution?.action === "link" ? {
|
|
3002
|
+
userId: resolution.userId,
|
|
3003
|
+
profile: resolution.profile
|
|
3004
|
+
} : void 0,
|
|
3005
|
+
deferNonDatabaseWrites: !!options?.resolveUser,
|
|
3006
|
+
requireExactAccountBinding: !!options?.resolveUser
|
|
2675
3007
|
});
|
|
2676
|
-
|
|
3008
|
+
return options?.resolveUser ? requireSuccessfulSSOAuthentication(authentication) : authentication;
|
|
3009
|
+
}, { onAfterCommitHookError() {
|
|
3010
|
+
ctx.context.logger.error("Committed SSO authentication after-hook failed");
|
|
3011
|
+
} });
|
|
2677
3012
|
} catch (e) {
|
|
2678
|
-
|
|
3013
|
+
const failedAuthentication = getFailedSSOAuthenticationResult(e);
|
|
3014
|
+
if (failedAuthentication) result = failedAuthentication;
|
|
3015
|
+
else if (isAPIError(e) && e.body?.code) throw ctx.redirect(buildSAMLRedirectUrl(errorUrl, {
|
|
2679
3016
|
error: e.body.code,
|
|
2680
3017
|
...e.body.message ? { error_description: e.body.message } : {}
|
|
2681
3018
|
}));
|
|
2682
|
-
throw e;
|
|
3019
|
+
else throw e;
|
|
2683
3020
|
}
|
|
2684
3021
|
if (result.error) throw ctx.redirect(buildSAMLRedirectUrl(callbackUrl, { error: result.error.split(" ").join("_") }));
|
|
2685
3022
|
const { session, user } = result.data;
|
|
@@ -2701,6 +3038,7 @@ async function processSAMLResponse(ctx, params, options) {
|
|
|
2701
3038
|
provider,
|
|
2702
3039
|
provisioningOptions: options?.organizationProvisioning
|
|
2703
3040
|
});
|
|
3041
|
+
if ("accountCookie" in result && result.accountCookie) await setAccountCookie(ctx, result.accountCookie);
|
|
2704
3042
|
await setSessionCookie(ctx, {
|
|
2705
3043
|
session,
|
|
2706
3044
|
user
|
|
@@ -2953,9 +3291,10 @@ const registerSSOProvider = (options) => {
|
|
|
2953
3291
|
})).length >= limit) throw new APIError("FORBIDDEN", { message: "You have reached the maximum number of SSO providers" });
|
|
2954
3292
|
const body = ctx.body;
|
|
2955
3293
|
const additionalFields = parseSSOProviderAdditionalFields(options, body, "create");
|
|
2956
|
-
if (body.samlConfig
|
|
3294
|
+
if (body.samlConfig) {
|
|
2957
3295
|
const maxMetadataSize = options?.saml?.maxMetadataSize ?? 102400;
|
|
2958
|
-
|
|
3296
|
+
assertSAMLMetadataSize(body.samlConfig.idpMetadata?.metadata, "IdP", maxMetadataSize);
|
|
3297
|
+
assertSAMLMetadataSize(body.samlConfig.spMetadata?.metadata, "SP", maxMetadataSize);
|
|
2959
3298
|
}
|
|
2960
3299
|
if (ctx.body.organizationId) {
|
|
2961
3300
|
const member = await ctx.context.adapter.findOne({
|
|
@@ -2981,18 +3320,6 @@ const registerSSOProvider = (options) => {
|
|
|
2981
3320
|
ctx.context.logger.warn(`SSO provider registration rejected for reserved providerId: ${body.providerId}`);
|
|
2982
3321
|
throw new APIError("UNPROCESSABLE_ENTITY", { message: "This providerId is reserved and cannot be used for an SSO provider" });
|
|
2983
3322
|
}
|
|
2984
|
-
if (ctx.context.hasPlugin("scim")) {
|
|
2985
|
-
if (await ctx.context.adapter.findOne({
|
|
2986
|
-
model: "scimProvider",
|
|
2987
|
-
where: [{
|
|
2988
|
-
field: "providerId",
|
|
2989
|
-
value: body.providerId
|
|
2990
|
-
}]
|
|
2991
|
-
})) {
|
|
2992
|
-
ctx.context.logger.warn(`SSO provider registration rejected for SCIM providerId: ${body.providerId}`);
|
|
2993
|
-
throw new APIError("UNPROCESSABLE_ENTITY", { message: "This providerId is already used by a SCIM provider and cannot be used for an SSO provider" });
|
|
2994
|
-
}
|
|
2995
|
-
}
|
|
2996
3323
|
if (await ctx.context.adapter.findOne({
|
|
2997
3324
|
model: "ssoProvider",
|
|
2998
3325
|
where: [{
|
|
@@ -3072,6 +3399,10 @@ const registerSSOProvider = (options) => {
|
|
|
3072
3399
|
}, options?.saml?.algorithms);
|
|
3073
3400
|
validateCertSources(body.samlConfig);
|
|
3074
3401
|
assertSAMLIdentityProviderAuthority(body.samlConfig);
|
|
3402
|
+
assertSAMLServiceProviderMetadataPolicy({
|
|
3403
|
+
...body.samlConfig,
|
|
3404
|
+
issuer: body.issuer
|
|
3405
|
+
});
|
|
3075
3406
|
const hasIdpMetadata = body.samlConfig.idpMetadata?.metadata;
|
|
3076
3407
|
let hasEntryPoint = false;
|
|
3077
3408
|
if (body.samlConfig.entryPoint) try {
|
|
@@ -3344,7 +3675,8 @@ const signInSSO = (options) => {
|
|
|
3344
3675
|
const parsedSamlConfig = typeof provider.samlConfig === "object" ? provider.samlConfig : safeJsonParse(provider.samlConfig);
|
|
3345
3676
|
if (!parsedSamlConfig) throw new APIError("BAD_REQUEST", { message: "Invalid SAML configuration" });
|
|
3346
3677
|
if (parsedSamlConfig.authnRequestsSigned && !parsedSamlConfig.spMetadata?.privateKey && !parsedSamlConfig.privateKey) throw new APIError("BAD_REQUEST", { message: "authnRequestsSigned is enabled but no privateKey provided in spMetadata or samlConfig" });
|
|
3347
|
-
const
|
|
3678
|
+
const providerReference = await computeSSOProviderReference(provider);
|
|
3679
|
+
const { state: relayState } = await generateRelayState(ctx, void 0, providerReference);
|
|
3348
3680
|
const sp = createSP(parsedSamlConfig, ctx.context.baseURL, provider.providerId, { relayState });
|
|
3349
3681
|
const idp = createIdP(parsedSamlConfig);
|
|
3350
3682
|
const loginRequest = sp.createLoginRequest(idp, "redirect");
|
|
@@ -3354,6 +3686,7 @@ const signInSSO = (options) => {
|
|
|
3354
3686
|
const record = {
|
|
3355
3687
|
id: loginRequest.id,
|
|
3356
3688
|
providerId: provider.providerId,
|
|
3689
|
+
providerReference,
|
|
3357
3690
|
createdAt: Date.now(),
|
|
3358
3691
|
expiresAt: Date.now() + ttl
|
|
3359
3692
|
};
|
|
@@ -3422,7 +3755,8 @@ async function handleOIDCCallback(ctx, options, providerId, stateData, parsedPro
|
|
|
3422
3755
|
if (!code || error) redirectOIDCError(error || "invalid_request", error_description || (error ? error : "authorization_code_not_found"));
|
|
3423
3756
|
const provider = await resolveOIDCProvider(ctx, options, providerId);
|
|
3424
3757
|
if (!provider) throw ctx.redirect(`${errorURL || callbackURL}?error=invalid_provider&error_description=provider not found`);
|
|
3425
|
-
|
|
3758
|
+
const acceptedProviderReference = providerReference ?? redirectOIDCError("invalid_state", "missing_sso_provider_reference");
|
|
3759
|
+
if (!await isCurrentSSOProviderReference(provider, acceptedProviderReference)) redirectOIDCError("invalid_state", "sso_provider_changed_during_authentication");
|
|
3426
3760
|
if (options?.domainVerification?.enabled && !("domainVerified" in provider && provider.domainVerified)) throw new APIError("UNAUTHORIZED", { message: "Provider domain has not been verified" });
|
|
3427
3761
|
let config = provider.oidcConfig;
|
|
3428
3762
|
if (!config) throw ctx.redirect(`${errorURL || callbackURL}?error=invalid_provider&error_description=provider not found`);
|
|
@@ -3570,7 +3904,7 @@ async function handleOIDCCallback(ctx, options, providerId, stateData, parsedPro
|
|
|
3570
3904
|
linked = await runWithTransaction(ctx.context.adapter, async () => {
|
|
3571
3905
|
await lockSSOProviderForAccountLink(ctx, provider);
|
|
3572
3906
|
const currentProvider = await resolveOIDCProvider(ctx, options, providerId, await getCurrentAdapter(ctx.context.adapter));
|
|
3573
|
-
if (!currentProvider || !await isCurrentSSOProviderReference(currentProvider,
|
|
3907
|
+
if (!currentProvider || !await isCurrentSSOProviderReference(currentProvider, acceptedProviderReference)) throw new APIError("CONFLICT", {
|
|
3574
3908
|
code: "SSO_PROVIDER_CHANGED",
|
|
3575
3909
|
message: "SSO provider changed while account linking was in progress"
|
|
3576
3910
|
});
|
|
@@ -3579,7 +3913,9 @@ async function handleOIDCCallback(ctx, options, providerId, stateData, parsedPro
|
|
|
3579
3913
|
providerId: provider.providerId,
|
|
3580
3914
|
accountKey,
|
|
3581
3915
|
providerUser,
|
|
3582
|
-
providerClaims: rawProfile ?? {}
|
|
3916
|
+
providerClaims: rawProfile ?? {},
|
|
3917
|
+
verifiedIdTokenClaims: verifiedIdToken?.payload ?? {},
|
|
3918
|
+
providerReference: acceptedProviderReference
|
|
3583
3919
|
}, await getCurrentAdapter(ctx.context.adapter), ctx.context.logger) : void 0;
|
|
3584
3920
|
if (resolution?.action === "reject") throw new APIError("FORBIDDEN", {
|
|
3585
3921
|
code: resolution.code,
|
|
@@ -4097,7 +4433,7 @@ function sso(options) {
|
|
|
4097
4433
|
listSSOProviders: listSSOProviders(optionsWithStore),
|
|
4098
4434
|
getSSOProvider: getSSOProvider(optionsWithStore),
|
|
4099
4435
|
updateSSOProvider: updateSSOProvider(optionsWithStore),
|
|
4100
|
-
deleteSSOProvider: deleteSSOProvider()
|
|
4436
|
+
deleteSSOProvider: deleteSSOProvider(optionsWithStore)
|
|
4101
4437
|
};
|
|
4102
4438
|
if (options?.domainVerification?.enabled) {
|
|
4103
4439
|
const domainVerificationEndpoints = {
|
|
@@ -4206,4 +4542,4 @@ function sso(options) {
|
|
|
4206
4542
|
};
|
|
4207
4543
|
}
|
|
4208
4544
|
//#endregion
|
|
4209
|
-
export { DEFAULT_CLOCK_SKEW_MS, DEFAULT_MAX_SAML_METADATA_SIZE, DEFAULT_MAX_SAML_RESPONSE_SIZE, DataEncryptionAlgorithm, DigestAlgorithm, DiscoveryError, KeyEncryptionAlgorithm, REQUIRED_DISCOVERY_FIELDS, SignatureAlgorithm, computeDiscoveryUrl, discoverOIDCConfig, fetchDiscoveryDocument, needsRuntimeDiscovery, normalizeDiscoveryUrls, normalizeUrl, selectTokenEndpointAuthMethod, sso, validateDiscoveryDocument, validateDiscoveryUrl, validateSAMLTimestamp };
|
|
4545
|
+
export { DEFAULT_CLOCK_SKEW_MS, DEFAULT_MAX_SAML_METADATA_SIZE, DEFAULT_MAX_SAML_RESPONSE_SIZE, DataEncryptionAlgorithm, DigestAlgorithm, DiscoveryError, KeyEncryptionAlgorithm, REQUIRED_DISCOVERY_FIELDS, SignatureAlgorithm, computeDiscoveryUrl, deriveSAMLIdentityProviderEntityID, deriveSAMLServiceProviderPolicy, discoverOIDCConfig, fetchDiscoveryDocument, needsRuntimeDiscovery, normalizeDiscoveryUrls, normalizeUrl, selectTokenEndpointAuthMethod, sso, validateDiscoveryDocument, validateDiscoveryUrl, validateSAMLTimestamp };
|