@better-auth/sso 1.7.0-rc.1 → 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,24 +1,26 @@
|
|
|
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";
|
|
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
|
+
import { getCurrentAdapter, getCurrentDBAdapterAsyncLocalStorage, runWithTransaction } from "@better-auth/core/context";
|
|
9
9
|
import { filterOutputFields } from "@better-auth/core/utils/db";
|
|
10
10
|
import { classifyHost, isPublicRoutableHost } from "@better-auth/core/utils/host";
|
|
11
11
|
import { betterFetch } from "@better-fetch/fetch";
|
|
12
|
-
import { createRemoteJWKSet, customFetch,
|
|
13
|
-
import { base64 } from "@better-auth/utils/base64";
|
|
12
|
+
import { createRemoteJWKSet, customFetch, jwtVerify } from "jose";
|
|
13
|
+
import { base64, base64Url } from "@better-auth/utils/base64";
|
|
14
|
+
import { createHash } from "@better-auth/utils/hash";
|
|
15
|
+
import { DOMParser } from "@xmldom/xmldom";
|
|
16
|
+
import * as samlifyNamespace from "samlify";
|
|
17
|
+
import samlifyDefault from "samlify";
|
|
14
18
|
import { defineErrorCodes } from "@better-auth/core/utils/error-codes";
|
|
15
19
|
import { parseInputData, toZodSchema } from "better-auth/db";
|
|
16
20
|
import { isAPIError } from "@better-auth/core/utils/is-api-error";
|
|
17
21
|
import { HIDE_METADATA, PRIVATE_KEY_JWT_SIGNING_ALGORITHMS, authorizationCodeRequest, createAuthorizationURL, createPrivateKeyJwtClientAssertionGetter, generateGenericState, generateState, getOAuth2Tokens, parseGenericState, parseState } from "better-auth";
|
|
18
|
-
import { deleteSessionCookie, setSessionCookie } from "better-auth/cookies";
|
|
22
|
+
import { deleteSessionCookie, setAccountCookie, setSessionCookie } from "better-auth/cookies";
|
|
19
23
|
import { additionalAuthorizationParamsSchema, handleOAuthUserInfo } from "better-auth/oauth2";
|
|
20
|
-
import * as samlifyNamespace from "samlify";
|
|
21
|
-
import samlifyDefault from "samlify";
|
|
22
24
|
//#region src/constants.ts
|
|
23
25
|
/**
|
|
24
26
|
* SAML Constants
|
|
@@ -60,6 +62,8 @@ const DEFAULT_MAX_SAML_RESPONSE_SIZE = 256 * 1024;
|
|
|
60
62
|
const DEFAULT_MAX_SAML_METADATA_SIZE = 100 * 1024;
|
|
61
63
|
//#endregion
|
|
62
64
|
//#region src/utils.ts
|
|
65
|
+
const unsafeSAMLRedirectPathPrefix = /^\/(?:\/|\\|%2f|%5c)/i;
|
|
66
|
+
const isSafeSAMLRedirectPath = (url) => url.startsWith("/") && !unsafeSAMLRedirectPathPrefix.test(url);
|
|
63
67
|
/**
|
|
64
68
|
* Safely parses a value that might be a JSON string or already a parsed object.
|
|
65
69
|
* This handles cases where ORMs like Drizzle might return already parsed objects
|
|
@@ -515,15 +519,21 @@ async function fetchOIDCEndpointResponse(name, endpoint, init, isTrustedOrigin)
|
|
|
515
519
|
if (isHttpRedirectStatus(response.status)) throwRedirectError(name, endpoint, response.status, response.headers.get("location"));
|
|
516
520
|
return response;
|
|
517
521
|
}
|
|
522
|
+
/** Enforces the OpenID Connect authorized-party rules for ID Tokens. */
|
|
523
|
+
function assertOIDCAuthorizedParty(payload, clientId) {
|
|
524
|
+
if (Array.isArray(payload.aud) && payload.aud.length > 1 && payload.azp === void 0 || payload.azp !== void 0 && payload.azp !== clientId) throw new Error("OIDC ID token authorized party does not match the client");
|
|
525
|
+
}
|
|
518
526
|
/**
|
|
519
527
|
* Validate an OIDC ID token using the same endpoint fetch policy as the rest of
|
|
520
528
|
* the SSO OIDC flow.
|
|
521
529
|
*/
|
|
522
530
|
async function validateOIDCIdToken(token, jwksEndpoint, options, isTrustedOrigin) {
|
|
523
|
-
|
|
531
|
+
const verified = await jwtVerify(token, createRemoteJWKSet(new URL(jwksEndpoint), { [customFetch]: (url, init) => fetchOIDCEndpointResponse("jwksEndpoint", url, init, isTrustedOrigin) }), {
|
|
524
532
|
audience: options.audience,
|
|
525
533
|
issuer: options.issuer
|
|
526
534
|
});
|
|
535
|
+
if (typeof options.audience === "string") assertOIDCAuthorizedParty(verified.payload, options.audience);
|
|
536
|
+
return verified;
|
|
527
537
|
}
|
|
528
538
|
/**
|
|
529
539
|
* Fetch the OIDC discovery document from the IdP.
|
|
@@ -808,6 +818,102 @@ function mapDiscoveryErrorToAPIError(error) {
|
|
|
808
818
|
}
|
|
809
819
|
}
|
|
810
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
|
|
811
917
|
//#region src/saml/parser.ts
|
|
812
918
|
const xmlParser = new XMLParser({
|
|
813
919
|
ignoreAttributes: false,
|
|
@@ -1021,8 +1127,12 @@ function validateConfigAlgorithms(config, options = {}) {
|
|
|
1021
1127
|
});
|
|
1022
1128
|
}
|
|
1023
1129
|
}
|
|
1130
|
+
const saml = typeof samlifyNamespace.SPMetadata === "function" && typeof samlifyNamespace.setSchemaValidator === "function" ? samlifyNamespace : samlifyDefault ?? samlifyNamespace;
|
|
1024
1131
|
//#endregion
|
|
1025
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#";
|
|
1026
1136
|
function countAssertions(xml) {
|
|
1027
1137
|
let parsed;
|
|
1028
1138
|
try {
|
|
@@ -1062,6 +1172,39 @@ function validateSingleAssertion(samlResponse) {
|
|
|
1062
1172
|
code: "SAML_MULTIPLE_ASSERTIONS"
|
|
1063
1173
|
});
|
|
1064
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
|
+
}
|
|
1065
1208
|
//#endregion
|
|
1066
1209
|
//#region src/saml/error-codes.ts
|
|
1067
1210
|
const SAML_ERROR_CODES = defineErrorCodes({
|
|
@@ -1107,6 +1250,7 @@ function validateCertSources(config) {
|
|
|
1107
1250
|
//#region src/saml/response-binding.ts
|
|
1108
1251
|
const SAML_HTTP_POST_BINDING = "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST";
|
|
1109
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";
|
|
1110
1254
|
function toNode(value) {
|
|
1111
1255
|
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
|
|
1112
1256
|
return value;
|
|
@@ -1138,11 +1282,75 @@ function parseSAMLContent(samlContent) {
|
|
|
1138
1282
|
code: "SAML_RESPONSE_INVALID_XML"
|
|
1139
1283
|
});
|
|
1140
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
|
+
}
|
|
1141
1350
|
function getSAMLPostAssertionConsumerServiceUrls(metadata) {
|
|
1142
1351
|
if (!metadata) return [];
|
|
1143
1352
|
try {
|
|
1144
|
-
|
|
1145
|
-
return [...new Set(locations)];
|
|
1353
|
+
return parseSAMLServiceProviderMetadata(metadata).postAssertionConsumerServiceUrls;
|
|
1146
1354
|
} catch {
|
|
1147
1355
|
return [];
|
|
1148
1356
|
}
|
|
@@ -1242,7 +1450,7 @@ function errorRedirectUrl(base, error, description) {
|
|
|
1242
1450
|
* samlify's parsed output (not at the top level).
|
|
1243
1451
|
*/
|
|
1244
1452
|
async function validateInResponseTo(c, ctx) {
|
|
1245
|
-
if (ctx.options.enableInResponseToValidation === false) return;
|
|
1453
|
+
if (ctx.options.enableInResponseToValidation === false) return null;
|
|
1246
1454
|
const inResponseTo = ctx.extract.response?.inResponseTo;
|
|
1247
1455
|
const allowIdpInitiated = ctx.options.allowIdpInitiated ?? false;
|
|
1248
1456
|
if (inResponseTo) {
|
|
@@ -1268,10 +1476,23 @@ async function validateInResponseTo(c, ctx) {
|
|
|
1268
1476
|
});
|
|
1269
1477
|
throw c.redirect(errorRedirectUrl(ctx.redirectUrl, "invalid_saml_response", "Provider mismatch"));
|
|
1270
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
|
+
};
|
|
1271
1491
|
} else if (!allowIdpInitiated) {
|
|
1272
1492
|
c.context.logger.error("SAML IdP-initiated SSO rejected: InResponseTo missing and allowIdpInitiated is false", { providerId: ctx.providerId });
|
|
1273
1493
|
throw c.redirect(errorRedirectUrl(ctx.redirectUrl, "unsolicited_response", "IdP-initiated SSO not allowed"));
|
|
1274
1494
|
}
|
|
1495
|
+
return null;
|
|
1275
1496
|
}
|
|
1276
1497
|
/**
|
|
1277
1498
|
* Validates the AudienceRestriction of a SAML assertion.
|
|
@@ -1302,7 +1523,260 @@ function validateAudience(c, ctx) {
|
|
|
1302
1523
|
}
|
|
1303
1524
|
}
|
|
1304
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
|
+
}
|
|
1612
|
+
//#endregion
|
|
1613
|
+
//#region src/routes/helpers.ts
|
|
1614
|
+
/**
|
|
1615
|
+
* Same as `normalizePem`, but applied across the resolved list of IdP signing
|
|
1616
|
+
* certificates so multi-cert rotation configs survive the line-trim step.
|
|
1617
|
+
*/
|
|
1618
|
+
function normalizePemList(certs) {
|
|
1619
|
+
if (!certs) return certs;
|
|
1620
|
+
return certs.map((pem) => normalizePem(pem) ?? pem);
|
|
1621
|
+
}
|
|
1622
|
+
async function findSAMLProvider(providerId, options, adapter) {
|
|
1623
|
+
if (options?.defaultSSO?.length) {
|
|
1624
|
+
const match = options.defaultSSO.find((p) => p.providerId === providerId);
|
|
1625
|
+
if (match) return {
|
|
1626
|
+
...match,
|
|
1627
|
+
userId: "default",
|
|
1628
|
+
issuer: match.samlConfig?.issuer || "",
|
|
1629
|
+
...options.domainVerification?.enabled ? { domainVerified: true } : {}
|
|
1630
|
+
};
|
|
1631
|
+
}
|
|
1632
|
+
const res = await adapter.findOne({
|
|
1633
|
+
model: "ssoProvider",
|
|
1634
|
+
where: [{
|
|
1635
|
+
field: "providerId",
|
|
1636
|
+
value: providerId
|
|
1637
|
+
}]
|
|
1638
|
+
});
|
|
1639
|
+
if (!res) return null;
|
|
1640
|
+
return {
|
|
1641
|
+
...res,
|
|
1642
|
+
samlConfig: res.samlConfig ? safeJsonParse(res.samlConfig) || void 0 : void 0
|
|
1643
|
+
};
|
|
1644
|
+
}
|
|
1645
|
+
function createSP(config, baseURL, providerId, opts) {
|
|
1646
|
+
const spData = config.spMetadata;
|
|
1647
|
+
const sloLocation = `${baseURL}/sso/saml2/sp/slo/${providerId}`;
|
|
1648
|
+
const acsUrl = `${baseURL}/sso/saml2/sp/acs/${providerId}`;
|
|
1649
|
+
let metadata = spData?.metadata;
|
|
1650
|
+
assertSAMLServiceProviderMetadataPolicy(config);
|
|
1651
|
+
if (!metadata) metadata = saml.SPMetadata({
|
|
1652
|
+
entityID: spData?.entityID || config.issuer,
|
|
1653
|
+
assertionConsumerService: [{
|
|
1654
|
+
Binding: "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST",
|
|
1655
|
+
Location: acsUrl
|
|
1656
|
+
}],
|
|
1657
|
+
singleLogoutService: opts?.sloOptions ? [{
|
|
1658
|
+
Binding: "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST",
|
|
1659
|
+
Location: sloLocation
|
|
1660
|
+
}, {
|
|
1661
|
+
Binding: "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect",
|
|
1662
|
+
Location: sloLocation
|
|
1663
|
+
}] : void 0,
|
|
1664
|
+
wantAssertionsSigned: config.wantAssertionsSigned || false,
|
|
1665
|
+
authnRequestsSigned: config.authnRequestsSigned || false,
|
|
1666
|
+
nameIDFormat: config.identifierFormat ? [config.identifierFormat] : void 0
|
|
1667
|
+
}).getMetadata() || "";
|
|
1668
|
+
return saml.ServiceProvider({
|
|
1669
|
+
metadata,
|
|
1670
|
+
allowCreate: true,
|
|
1671
|
+
wantLogoutRequestSigned: opts?.sloOptions?.wantLogoutRequestSigned ?? false,
|
|
1672
|
+
wantLogoutResponseSigned: opts?.sloOptions?.wantLogoutResponseSigned ?? false,
|
|
1673
|
+
privateKey: normalizePem(spData?.privateKey || config.privateKey),
|
|
1674
|
+
privateKeyPass: spData?.privateKeyPass,
|
|
1675
|
+
isAssertionEncrypted: spData?.isAssertionEncrypted || false,
|
|
1676
|
+
encPrivateKey: normalizePem(spData?.encPrivateKey),
|
|
1677
|
+
encPrivateKeyPass: spData?.encPrivateKeyPass,
|
|
1678
|
+
relayState: opts?.relayState,
|
|
1679
|
+
clockDrifts: opts?.clockSkew && opts?.clockSkew !== 0 ? [-opts.clockSkew, opts.clockSkew] : void 0
|
|
1680
|
+
});
|
|
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
|
+
}
|
|
1718
|
+
function assertSAMLIdentityProviderAuthority(config) {
|
|
1719
|
+
if (config.idpMetadata?.metadata || config.idpMetadata?.entityID) return;
|
|
1720
|
+
throw new APIError("BAD_REQUEST", { message: "SAML manual IdP configuration requires idpMetadata.entityID; issuer identifies the service provider and cannot identify the IdP" });
|
|
1721
|
+
}
|
|
1722
|
+
function createIdP(config) {
|
|
1723
|
+
assertSAMLIdentityProviderAuthority(config);
|
|
1724
|
+
const idpData = config.idpMetadata;
|
|
1725
|
+
if (idpData?.metadata) return saml.IdentityProvider({
|
|
1726
|
+
metadata: idpData.metadata,
|
|
1727
|
+
privateKey: normalizePem(idpData.privateKey),
|
|
1728
|
+
privateKeyPass: idpData.privateKeyPass,
|
|
1729
|
+
isAssertionEncrypted: idpData.isAssertionEncrypted,
|
|
1730
|
+
encPrivateKey: normalizePem(idpData.encPrivateKey),
|
|
1731
|
+
encPrivateKeyPass: idpData.encPrivateKeyPass
|
|
1732
|
+
});
|
|
1733
|
+
return saml.IdentityProvider({
|
|
1734
|
+
entityID: idpData.entityID,
|
|
1735
|
+
singleSignOnService: idpData.singleSignOnService || [{
|
|
1736
|
+
Binding: "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect",
|
|
1737
|
+
Location: config.entryPoint
|
|
1738
|
+
}],
|
|
1739
|
+
singleLogoutService: idpData.singleLogoutService,
|
|
1740
|
+
signingCert: normalizePemList(resolveSigningCerts(config)),
|
|
1741
|
+
wantAuthnRequestsSigned: config.authnRequestsSigned || false,
|
|
1742
|
+
isAssertionEncrypted: idpData.isAssertionEncrypted || false,
|
|
1743
|
+
encPrivateKey: normalizePem(idpData.encPrivateKey),
|
|
1744
|
+
encPrivateKeyPass: idpData.encPrivateKeyPass
|
|
1745
|
+
});
|
|
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
|
+
}
|
|
1754
|
+
function escapeHtml(str) {
|
|
1755
|
+
if (!str) return "";
|
|
1756
|
+
return String(str).replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
1757
|
+
}
|
|
1758
|
+
function isSAMLPostBindingLocation(value) {
|
|
1759
|
+
let url;
|
|
1760
|
+
try {
|
|
1761
|
+
url = new URL(value);
|
|
1762
|
+
} catch {
|
|
1763
|
+
return false;
|
|
1764
|
+
}
|
|
1765
|
+
return url.protocol === "http:" || url.protocol === "https:";
|
|
1766
|
+
}
|
|
1767
|
+
function createSAMLPostForm(action, samlParam, samlValue, relayState) {
|
|
1768
|
+
if (!isSAMLPostBindingLocation(action)) throw new APIError("BAD_REQUEST", { message: "SAML POST binding location must be an absolute http or https URL" });
|
|
1769
|
+
const safeAction = escapeHtml(action);
|
|
1770
|
+
const safeSamlParam = escapeHtml(samlParam);
|
|
1771
|
+
const safeSamlValue = escapeHtml(samlValue);
|
|
1772
|
+
const safeRelayState = relayState ? escapeHtml(relayState) : void 0;
|
|
1773
|
+
const html = `<!DOCTYPE html><html><body onload="document.forms[0].submit();"><form method="POST" action="${safeAction}"><input type="hidden" name="${safeSamlParam}" value="${safeSamlValue}" />${safeRelayState ? `<input type="hidden" name="RelayState" value="${safeRelayState}" />` : ""}<noscript><input type="submit" value="Continue" /></noscript></form></body></html>`;
|
|
1774
|
+
return new Response(html, { headers: { "Content-Type": "text/html" } });
|
|
1775
|
+
}
|
|
1776
|
+
//#endregion
|
|
1305
1777
|
//#region src/routes/schemas.ts
|
|
1778
|
+
const absoluteUrlSchema = z.url();
|
|
1779
|
+
const samlRedirectUrlSchema = z.string().refine((url) => isSafeSAMLRedirectPath(url) || absoluteUrlSchema.safeParse(url).success, { message: "Expected an absolute URL or a relative path starting with /" });
|
|
1306
1780
|
function getSSOProviderAdditionalFields$1(options) {
|
|
1307
1781
|
return options?.schema?.ssoProvider?.additionalFields ?? {};
|
|
1308
1782
|
}
|
|
@@ -1327,16 +1801,14 @@ function parseSSOProviderAdditionalFields(options, data, action) {
|
|
|
1327
1801
|
action
|
|
1328
1802
|
});
|
|
1329
1803
|
}
|
|
1330
|
-
const oidcMappingSchema = z.
|
|
1331
|
-
id: z.string().meta({ description: "Field mapping for user ID (defaults to 'sub')" }),
|
|
1804
|
+
const oidcMappingSchema = z.strictObject({
|
|
1332
1805
|
email: z.string().meta({ description: "Field mapping for email (defaults to 'email')" }),
|
|
1333
1806
|
emailVerified: z.string().meta({ description: "Field mapping for email verification (defaults to 'email_verified')" }).optional(),
|
|
1334
1807
|
name: z.string().meta({ description: "Field mapping for name (defaults to 'name')" }),
|
|
1335
1808
|
image: z.string().meta({ description: "Field mapping for image (defaults to 'picture')" }).optional(),
|
|
1336
1809
|
extraFields: z.record(z.string(), z.any()).optional()
|
|
1337
1810
|
}).optional();
|
|
1338
|
-
const samlMappingSchema = z.
|
|
1339
|
-
id: z.string().meta({ description: "Field mapping for user ID (defaults to 'nameID')" }),
|
|
1811
|
+
const samlMappingSchema = z.strictObject({
|
|
1340
1812
|
email: z.string().meta({ description: "Field mapping for email (defaults to 'email')" }),
|
|
1341
1813
|
emailVerified: z.string().meta({ description: "Field mapping for email verification" }).optional(),
|
|
1342
1814
|
name: z.string().meta({ description: "Field mapping for name (defaults to 'displayName')" }),
|
|
@@ -1366,29 +1838,40 @@ const oidcConfigSchema = z.object({
|
|
|
1366
1838
|
overrideUserInfo: z.boolean().optional(),
|
|
1367
1839
|
mapping: oidcMappingSchema
|
|
1368
1840
|
});
|
|
1841
|
+
const samlIdentityProviderConfigSchema = z.object({
|
|
1842
|
+
cert: signingCertSchema.meta({ description: "IdP signing certificate(s). Pass a single PEM string or an array for rolling rotation. Takes precedence over the top-level `cert`." }).optional(),
|
|
1843
|
+
privateKey: z.string().optional(),
|
|
1844
|
+
privateKeyPass: z.string().optional(),
|
|
1845
|
+
isAssertionEncrypted: z.boolean().optional(),
|
|
1846
|
+
encPrivateKey: z.string().optional(),
|
|
1847
|
+
encPrivateKeyPass: z.string().optional(),
|
|
1848
|
+
singleSignOnService: z.array(z.object({
|
|
1849
|
+
Binding: z.string().meta({ description: "The binding type for the SSO service" }),
|
|
1850
|
+
Location: z.string().url().meta({ description: "The URL for the SSO service" })
|
|
1851
|
+
})).meta({ description: "Single Sign-On service configuration" }).optional(),
|
|
1852
|
+
singleLogoutService: z.array(z.object({
|
|
1853
|
+
Binding: z.string(),
|
|
1854
|
+
Location: z.string().url()
|
|
1855
|
+
})).optional()
|
|
1856
|
+
});
|
|
1857
|
+
const samlIdentityProviderMetadataSchema = z.union([samlIdentityProviderConfigSchema.extend({
|
|
1858
|
+
metadata: z.string().min(1),
|
|
1859
|
+
entityID: z.string().optional()
|
|
1860
|
+
}), samlIdentityProviderConfigSchema.extend({
|
|
1861
|
+
metadata: z.undefined().optional(),
|
|
1862
|
+
entityID: z.string().min(1)
|
|
1863
|
+
})], "idpMetadata.entityID is required when IdP metadata XML is not provided");
|
|
1864
|
+
const samlIdentityProviderMetadataUpdateSchema = samlIdentityProviderConfigSchema.extend({
|
|
1865
|
+
metadata: z.string().min(1).optional(),
|
|
1866
|
+
entityID: z.string().min(1).optional()
|
|
1867
|
+
});
|
|
1369
1868
|
const samlConfigSchema = z.object({
|
|
1370
1869
|
entryPoint: z.string().url().meta({ description: "The IdP SSO URL (entry point)" }),
|
|
1371
1870
|
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(),
|
|
1372
1871
|
audience: z.string().optional(),
|
|
1373
1872
|
callbackUrl: z.string().refine((url) => !url.includes("#"), { message: "callbackUrl must not contain a fragment" }).optional(),
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
entityID: z.string().optional(),
|
|
1377
|
-
cert: signingCertSchema.meta({ description: "IdP signing certificate(s). Pass a single PEM string or an array for rolling rotation. Takes precedence over the top-level `cert`." }).optional(),
|
|
1378
|
-
privateKey: z.string().optional(),
|
|
1379
|
-
privateKeyPass: z.string().optional(),
|
|
1380
|
-
isAssertionEncrypted: z.boolean().optional(),
|
|
1381
|
-
encPrivateKey: z.string().optional(),
|
|
1382
|
-
encPrivateKeyPass: z.string().optional(),
|
|
1383
|
-
singleSignOnService: z.array(z.object({
|
|
1384
|
-
Binding: z.string().meta({ description: "The binding type for the SSO service" }),
|
|
1385
|
-
Location: z.string().url().meta({ description: "The URL for the SSO service" })
|
|
1386
|
-
})).meta({ description: "Single Sign-On service configuration" }).optional(),
|
|
1387
|
-
singleLogoutService: z.array(z.object({
|
|
1388
|
-
Binding: z.string(),
|
|
1389
|
-
Location: z.string().url()
|
|
1390
|
-
})).optional()
|
|
1391
|
-
}).optional(),
|
|
1873
|
+
idpInitiatedCallbackUrl: samlRedirectUrlSchema.optional(),
|
|
1874
|
+
idpMetadata: samlIdentityProviderMetadataSchema,
|
|
1392
1875
|
spMetadata: z.object({
|
|
1393
1876
|
metadata: z.string().optional(),
|
|
1394
1877
|
entityID: z.string().optional(),
|
|
@@ -1423,7 +1906,10 @@ const updateSSOProviderBodySchema = z.object({
|
|
|
1423
1906
|
issuer: z.string().url().optional(),
|
|
1424
1907
|
domain: z.string().optional(),
|
|
1425
1908
|
oidcConfig: oidcConfigSchema.partial().optional(),
|
|
1426
|
-
samlConfig: samlConfigSchema.partial().
|
|
1909
|
+
samlConfig: samlConfigSchema.omit({ idpMetadata: true }).partial().extend({
|
|
1910
|
+
idpInitiatedCallbackUrl: samlRedirectUrlSchema.nullable().optional(),
|
|
1911
|
+
idpMetadata: samlIdentityProviderMetadataUpdateSchema.optional()
|
|
1912
|
+
}).optional()
|
|
1427
1913
|
});
|
|
1428
1914
|
function getUpdateSSOProviderBodySchema(options) {
|
|
1429
1915
|
return updateSSOProviderBodySchema.extend({
|
|
@@ -1442,18 +1928,7 @@ const OIDC_IDENTITY_BOUNDARY_FIELDS = [
|
|
|
1442
1928
|
"tokenEndpoint",
|
|
1443
1929
|
"userInfoEndpoint"
|
|
1444
1930
|
];
|
|
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"];
|
|
1931
|
+
const SAML_IDENTITY_BOUNDARY_FIELDS = ["audience", "callbackUrl"];
|
|
1457
1932
|
function isRecord(value) {
|
|
1458
1933
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
1459
1934
|
}
|
|
@@ -1469,10 +1944,37 @@ function hasChangedField(current, updated, fields) {
|
|
|
1469
1944
|
return fields.some((field) => identityValueChanged(current?.[field], updated?.[field]));
|
|
1470
1945
|
}
|
|
1471
1946
|
function oidcIdentityBoundaryChanged(current, updated) {
|
|
1472
|
-
return hasChangedField(current, updated, OIDC_IDENTITY_BOUNDARY_FIELDS)
|
|
1947
|
+
return hasChangedField(current, updated, OIDC_IDENTITY_BOUNDARY_FIELDS);
|
|
1473
1948
|
}
|
|
1474
1949
|
function samlIdentityBoundaryChanged(current, updated) {
|
|
1475
|
-
|
|
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));
|
|
1476
1978
|
}
|
|
1477
1979
|
function parseConfigSnapshot(config, configType) {
|
|
1478
1980
|
if (!config) return;
|
|
@@ -1488,25 +1990,71 @@ function ssoProviderIdentityBoundaryChanged(current, updated) {
|
|
|
1488
1990
|
const updatedOidcConfig = parseConfigSnapshot(updated.oidcConfig, "OIDC");
|
|
1489
1991
|
return Boolean(currentOidcConfig && (!updatedOidcConfig || oidcIdentityBoundaryChanged(currentOidcConfig, updatedOidcConfig)));
|
|
1490
1992
|
}
|
|
1491
|
-
async function lockSSOProviderRow(
|
|
1492
|
-
|
|
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({
|
|
1493
2002
|
model: "ssoProvider",
|
|
1494
|
-
where
|
|
1495
|
-
|
|
1496
|
-
value: providerId
|
|
1497
|
-
}],
|
|
1498
|
-
update: { providerId }
|
|
2003
|
+
where,
|
|
2004
|
+
update: { providerId: provider.providerId }
|
|
1499
2005
|
});
|
|
1500
2006
|
}
|
|
1501
|
-
async function
|
|
1502
|
-
|
|
1503
|
-
|
|
1504
|
-
|
|
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 {}
|
|
1505
2031
|
throw new APIError("CONFLICT", {
|
|
1506
|
-
code: "
|
|
1507
|
-
message: "SSO provider
|
|
2032
|
+
code: "SSO_PROVIDER_MUTATION_REJECTED",
|
|
2033
|
+
message: "SSO provider mutation is not allowed"
|
|
1508
2034
|
});
|
|
1509
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"
|
|
2046
|
+
});
|
|
2047
|
+
}
|
|
2048
|
+
async function lockSSOProviderForAccountLink(ctx, provider) {
|
|
2049
|
+
if (typeof provider.id !== "string") return;
|
|
2050
|
+
const lockedProvider = await lockSSOProviderRow(ctx.context, {
|
|
2051
|
+
id: provider.id,
|
|
2052
|
+
providerId: provider.providerId
|
|
2053
|
+
});
|
|
2054
|
+
if (!lockedProvider) throw new APIError("CONFLICT", {
|
|
2055
|
+
code: "SSO_PROVIDER_CHANGED",
|
|
2056
|
+
message: "SSO provider changed while account linking was in progress"
|
|
2057
|
+
});
|
|
1510
2058
|
if (ssoProviderIdentityBoundaryChanged(provider, lockedProvider)) throw new APIError("CONFLICT", {
|
|
1511
2059
|
code: "SSO_PROVIDER_CHANGED",
|
|
1512
2060
|
message: "SSO provider changed while account linking was in progress"
|
|
@@ -1607,6 +2155,8 @@ function sanitizeProvider(provider, baseURL, options) {
|
|
|
1607
2155
|
} : void 0,
|
|
1608
2156
|
samlConfig: samlConfig ? {
|
|
1609
2157
|
entryPoint: samlConfig.entryPoint,
|
|
2158
|
+
callbackUrl: samlConfig.callbackUrl,
|
|
2159
|
+
idpInitiatedCallbackUrl: samlConfig.idpInitiatedCallbackUrl,
|
|
1610
2160
|
audience: samlConfig.audience,
|
|
1611
2161
|
wantAssertionsSigned: samlConfig.wantAssertionsSigned,
|
|
1612
2162
|
authnRequestsSigned: samlConfig.authnRequestsSigned,
|
|
@@ -1696,6 +2246,15 @@ function parseAndValidateConfig(configString, configType) {
|
|
|
1696
2246
|
if (!config) throw new APIError("BAD_REQUEST", { message: `Cannot update ${configType} config for a provider that doesn't have ${configType} configured` });
|
|
1697
2247
|
return config;
|
|
1698
2248
|
}
|
|
2249
|
+
function mergeSAMLIdentityProviderMetadata(current, updates) {
|
|
2250
|
+
if (!updates) return current;
|
|
2251
|
+
const config = { idpMetadata: {
|
|
2252
|
+
...current,
|
|
2253
|
+
...updates
|
|
2254
|
+
} };
|
|
2255
|
+
assertSAMLIdentityProviderAuthority(config);
|
|
2256
|
+
return config.idpMetadata;
|
|
2257
|
+
}
|
|
1699
2258
|
function mergeSAMLConfig(current, updates, issuer) {
|
|
1700
2259
|
return {
|
|
1701
2260
|
...current,
|
|
@@ -1703,11 +2262,15 @@ function mergeSAMLConfig(current, updates, issuer) {
|
|
|
1703
2262
|
issuer,
|
|
1704
2263
|
entryPoint: updates.entryPoint ?? current.entryPoint,
|
|
1705
2264
|
cert: updates.cert ?? current.cert,
|
|
1706
|
-
spMetadata: updates.spMetadata
|
|
1707
|
-
|
|
2265
|
+
spMetadata: updates.spMetadata ? {
|
|
2266
|
+
...current.spMetadata,
|
|
2267
|
+
...updates.spMetadata
|
|
2268
|
+
} : current.spMetadata,
|
|
2269
|
+
idpMetadata: mergeSAMLIdentityProviderMetadata(current.idpMetadata, updates.idpMetadata),
|
|
1708
2270
|
mapping: updates.mapping ?? current.mapping,
|
|
1709
2271
|
audience: updates.audience ?? current.audience,
|
|
1710
2272
|
callbackUrl: updates.callbackUrl ?? current.callbackUrl,
|
|
2273
|
+
idpInitiatedCallbackUrl: updates.idpInitiatedCallbackUrl === null ? void 0 : updates.idpInitiatedCallbackUrl ?? current.idpInitiatedCallbackUrl,
|
|
1711
2274
|
wantAssertionsSigned: updates.wantAssertionsSigned ?? current.wantAssertionsSigned,
|
|
1712
2275
|
authnRequestsSigned: updates.authnRequestsSigned ?? current.authnRequestsSigned,
|
|
1713
2276
|
identifierFormat: updates.identifierFormat ?? current.identifierFormat,
|
|
@@ -1756,10 +2319,14 @@ const updateSSOProvider = (options) => {
|
|
|
1756
2319
|
const { issuer, domain, samlConfig, oidcConfig } = body;
|
|
1757
2320
|
const additionalFields = parseSSOProviderAdditionalFields(options, body, "update");
|
|
1758
2321
|
if (!issuer && !domain && !samlConfig && !oidcConfig && Object.keys(additionalFields).length === 0) throw new APIError("BAD_REQUEST", { message: "No fields provided for update" });
|
|
1759
|
-
await checkProviderAccess(ctx, providerId);
|
|
2322
|
+
const authorizedProvider = await checkProviderAccess(ctx, providerId);
|
|
2323
|
+
await assertProviderMutationGuardCapabilities(options, ctx.context.adapter);
|
|
1760
2324
|
const fullProvider = await runWithTransaction(ctx.context.adapter, async () => {
|
|
1761
2325
|
const trx = await getCurrentAdapter(ctx.context.adapter);
|
|
1762
|
-
const existingProvider = await lockSSOProviderRow(ctx.context
|
|
2326
|
+
const existingProvider = await lockSSOProviderRow(ctx.context, {
|
|
2327
|
+
id: authorizedProvider.id,
|
|
2328
|
+
providerId
|
|
2329
|
+
});
|
|
1763
2330
|
if (!existingProvider) throw new APIError("NOT_FOUND", { message: "Provider not found" });
|
|
1764
2331
|
const updateData = { ...additionalFields };
|
|
1765
2332
|
let providerIdentityBoundaryChanged = body.issuer !== void 0 && body.issuer !== existingProvider.issuer;
|
|
@@ -1769,10 +2336,9 @@ const updateSSOProvider = (options) => {
|
|
|
1769
2336
|
if (body.domain !== existingProvider.domain) updateData.domainVerified = false;
|
|
1770
2337
|
}
|
|
1771
2338
|
if (body.samlConfig) {
|
|
1772
|
-
|
|
1773
|
-
|
|
1774
|
-
|
|
1775
|
-
}
|
|
2339
|
+
const maxMetadataSize = options?.saml?.maxMetadataSize ?? 102400;
|
|
2340
|
+
assertSAMLMetadataSize(body.samlConfig.idpMetadata?.metadata, "IdP", maxMetadataSize);
|
|
2341
|
+
assertSAMLMetadataSize(body.samlConfig.spMetadata?.metadata, "SP", maxMetadataSize);
|
|
1776
2342
|
if (body.samlConfig.signatureAlgorithm !== void 0 || body.samlConfig.digestAlgorithm !== void 0) validateConfigAlgorithms({
|
|
1777
2343
|
signatureAlgorithm: body.samlConfig.signatureAlgorithm,
|
|
1778
2344
|
digestAlgorithm: body.samlConfig.digestAlgorithm
|
|
@@ -1780,6 +2346,8 @@ const updateSSOProvider = (options) => {
|
|
|
1780
2346
|
const currentSamlConfig = parseAndValidateConfig(existingProvider.samlConfig, "SAML");
|
|
1781
2347
|
const updatedSamlConfig = mergeSAMLConfig(currentSamlConfig, body.samlConfig, updateData.issuer || currentSamlConfig.issuer || existingProvider.issuer);
|
|
1782
2348
|
validateCertSources(updatedSamlConfig);
|
|
2349
|
+
assertSAMLIdentityProviderAuthority(updatedSamlConfig);
|
|
2350
|
+
assertSAMLServiceProviderMetadataPolicy(updatedSamlConfig);
|
|
1783
2351
|
if (samlIdentityBoundaryChanged(currentSamlConfig, updatedSamlConfig)) providerIdentityBoundaryChanged = true;
|
|
1784
2352
|
updateData.samlConfig = JSON.stringify(updatedSamlConfig);
|
|
1785
2353
|
}
|
|
@@ -1797,6 +2365,10 @@ const updateSSOProvider = (options) => {
|
|
|
1797
2365
|
if (oidcIdentityBoundaryChanged(currentOidcConfig, updatedOidcConfig)) providerIdentityBoundaryChanged = true;
|
|
1798
2366
|
updateData.oidcConfig = JSON.stringify(updatedOidcConfig);
|
|
1799
2367
|
}
|
|
2368
|
+
await guardSSOProviderMutation(options, {
|
|
2369
|
+
action: "update",
|
|
2370
|
+
isAuthenticationBoundaryChange: providerIdentityBoundaryChanged
|
|
2371
|
+
}, existingProvider, trx, ctx.context.logger);
|
|
1800
2372
|
if (providerIdentityBoundaryChanged) {
|
|
1801
2373
|
if (await trx.findOne({
|
|
1802
2374
|
model: "account",
|
|
@@ -1827,7 +2399,7 @@ const updateSSOProvider = (options) => {
|
|
|
1827
2399
|
return ctx.json(sanitizeProvider(fullProvider, ctx.context.baseURL, options));
|
|
1828
2400
|
});
|
|
1829
2401
|
};
|
|
1830
|
-
const deleteSSOProvider = () => {
|
|
2402
|
+
const deleteSSOProvider = (options) => {
|
|
1831
2403
|
return createAuthEndpoint("/sso/delete-provider", {
|
|
1832
2404
|
method: "POST",
|
|
1833
2405
|
use: [sessionMiddleware],
|
|
@@ -1844,9 +2416,16 @@ const deleteSSOProvider = () => {
|
|
|
1844
2416
|
} }
|
|
1845
2417
|
}, async (ctx) => {
|
|
1846
2418
|
const { providerId } = ctx.body;
|
|
1847
|
-
await checkProviderAccess(ctx, providerId);
|
|
2419
|
+
const authorizedProvider = await checkProviderAccess(ctx, providerId);
|
|
2420
|
+
await assertProviderMutationGuardCapabilities(options, ctx.context.adapter);
|
|
1848
2421
|
await runWithTransaction(ctx.context.adapter, async () => {
|
|
1849
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);
|
|
1850
2429
|
await trx.deleteMany({
|
|
1851
2430
|
model: "account",
|
|
1852
2431
|
where: [{
|
|
@@ -1857,6 +2436,9 @@ const deleteSSOProvider = () => {
|
|
|
1857
2436
|
await trx.delete({
|
|
1858
2437
|
model: "ssoProvider",
|
|
1859
2438
|
where: [{
|
|
2439
|
+
field: "id",
|
|
2440
|
+
value: existingProvider.id
|
|
2441
|
+
}, {
|
|
1860
2442
|
field: "providerId",
|
|
1861
2443
|
value: providerId
|
|
1862
2444
|
}]
|
|
@@ -1987,7 +2569,7 @@ const verifyDomain = (options) => {
|
|
|
1987
2569
|
};
|
|
1988
2570
|
//#endregion
|
|
1989
2571
|
//#region src/saml-state.ts
|
|
1990
|
-
async function generateRelayState(c, link) {
|
|
2572
|
+
async function generateRelayState(c, link, providerReference) {
|
|
1991
2573
|
const callbackURL = c.body.callbackURL;
|
|
1992
2574
|
if (!callbackURL) throw new APIError("BAD_REQUEST", { message: "callbackURL is required" });
|
|
1993
2575
|
const stateData = {
|
|
@@ -1996,8 +2578,12 @@ async function generateRelayState(c, link) {
|
|
|
1996
2578
|
errorURL: c.body.errorCallbackURL,
|
|
1997
2579
|
newUserURL: c.body.newUserCallbackURL,
|
|
1998
2580
|
link,
|
|
2581
|
+
/**
|
|
2582
|
+
* This is the actual expiry time of the state
|
|
2583
|
+
*/
|
|
1999
2584
|
expiresAt: Date.now() + 600 * 1e3,
|
|
2000
|
-
requestSignUp: c.body.requestSignUp
|
|
2585
|
+
requestSignUp: c.body.requestSignUp,
|
|
2586
|
+
serverContext: providerReference ? { [SSO_PROVIDER_STATE_KEY]: providerReference } : void 0
|
|
2001
2587
|
};
|
|
2002
2588
|
try {
|
|
2003
2589
|
return generateGenericState(c, stateData, { cookieName: "relay_state" });
|
|
@@ -2016,6 +2602,10 @@ async function parseRelayState(c) {
|
|
|
2016
2602
|
try {
|
|
2017
2603
|
parsedData = await parseGenericState(c, state, {
|
|
2018
2604
|
cookieName: "relay_state",
|
|
2605
|
+
/**
|
|
2606
|
+
* SAML ACS receives a POST from the IdP, which is typically cross-origin.
|
|
2607
|
+
* SameSite=Lax (default) cookies are not sent on cross-site POST requests.
|
|
2608
|
+
*/
|
|
2019
2609
|
skipStateCookieCheck: true
|
|
2020
2610
|
});
|
|
2021
2611
|
} catch (error) {
|
|
@@ -2028,122 +2618,6 @@ async function parseRelayState(c) {
|
|
|
2028
2618
|
if (!parsedData.errorURL) parsedData.errorURL = errorURL;
|
|
2029
2619
|
return parsedData;
|
|
2030
2620
|
}
|
|
2031
|
-
const saml = typeof samlifyNamespace.SPMetadata === "function" && typeof samlifyNamespace.setSchemaValidator === "function" ? samlifyNamespace : samlifyDefault ?? samlifyNamespace;
|
|
2032
|
-
//#endregion
|
|
2033
|
-
//#region src/routes/helpers.ts
|
|
2034
|
-
/**
|
|
2035
|
-
* Same as `normalizePem`, but applied across the resolved list of IdP signing
|
|
2036
|
-
* certificates so multi-cert rotation configs survive the line-trim step.
|
|
2037
|
-
*/
|
|
2038
|
-
function normalizePemList(certs) {
|
|
2039
|
-
if (!certs) return certs;
|
|
2040
|
-
return certs.map((pem) => normalizePem(pem) ?? pem);
|
|
2041
|
-
}
|
|
2042
|
-
async function findSAMLProvider(providerId, options, adapter) {
|
|
2043
|
-
if (options?.defaultSSO?.length) {
|
|
2044
|
-
const match = options.defaultSSO.find((p) => p.providerId === providerId);
|
|
2045
|
-
if (match) return {
|
|
2046
|
-
...match,
|
|
2047
|
-
userId: "default",
|
|
2048
|
-
issuer: match.samlConfig?.issuer || "",
|
|
2049
|
-
...options.domainVerification?.enabled ? { domainVerified: true } : {}
|
|
2050
|
-
};
|
|
2051
|
-
}
|
|
2052
|
-
const res = await adapter.findOne({
|
|
2053
|
-
model: "ssoProvider",
|
|
2054
|
-
where: [{
|
|
2055
|
-
field: "providerId",
|
|
2056
|
-
value: providerId
|
|
2057
|
-
}]
|
|
2058
|
-
});
|
|
2059
|
-
if (!res) return null;
|
|
2060
|
-
return {
|
|
2061
|
-
...res,
|
|
2062
|
-
samlConfig: res.samlConfig ? safeJsonParse(res.samlConfig) || void 0 : void 0
|
|
2063
|
-
};
|
|
2064
|
-
}
|
|
2065
|
-
function createSP(config, baseURL, providerId, opts) {
|
|
2066
|
-
const spData = config.spMetadata;
|
|
2067
|
-
const sloLocation = `${baseURL}/sso/saml2/sp/slo/${providerId}`;
|
|
2068
|
-
const acsUrl = `${baseURL}/sso/saml2/sp/acs/${providerId}`;
|
|
2069
|
-
let metadata = spData?.metadata;
|
|
2070
|
-
if (!metadata) metadata = saml.SPMetadata({
|
|
2071
|
-
entityID: spData?.entityID || config.issuer,
|
|
2072
|
-
assertionConsumerService: [{
|
|
2073
|
-
Binding: "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST",
|
|
2074
|
-
Location: acsUrl
|
|
2075
|
-
}],
|
|
2076
|
-
singleLogoutService: opts?.sloOptions ? [{
|
|
2077
|
-
Binding: "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST",
|
|
2078
|
-
Location: sloLocation
|
|
2079
|
-
}, {
|
|
2080
|
-
Binding: "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect",
|
|
2081
|
-
Location: sloLocation
|
|
2082
|
-
}] : void 0,
|
|
2083
|
-
wantMessageSigned: config.wantAssertionsSigned || false,
|
|
2084
|
-
authnRequestsSigned: config.authnRequestsSigned || false,
|
|
2085
|
-
nameIDFormat: config.identifierFormat ? [config.identifierFormat] : void 0
|
|
2086
|
-
}).getMetadata() || "";
|
|
2087
|
-
return saml.ServiceProvider({
|
|
2088
|
-
metadata,
|
|
2089
|
-
allowCreate: true,
|
|
2090
|
-
wantLogoutRequestSigned: opts?.sloOptions?.wantLogoutRequestSigned ?? false,
|
|
2091
|
-
wantLogoutResponseSigned: opts?.sloOptions?.wantLogoutResponseSigned ?? false,
|
|
2092
|
-
privateKey: normalizePem(spData?.privateKey || config.privateKey),
|
|
2093
|
-
privateKeyPass: spData?.privateKeyPass,
|
|
2094
|
-
isAssertionEncrypted: spData?.isAssertionEncrypted || false,
|
|
2095
|
-
encPrivateKey: normalizePem(spData?.encPrivateKey),
|
|
2096
|
-
encPrivateKeyPass: spData?.encPrivateKeyPass,
|
|
2097
|
-
relayState: opts?.relayState,
|
|
2098
|
-
clockDrifts: opts?.clockSkew && opts?.clockSkew !== 0 ? [-opts.clockSkew, opts.clockSkew] : void 0
|
|
2099
|
-
});
|
|
2100
|
-
}
|
|
2101
|
-
function createIdP(config) {
|
|
2102
|
-
const idpData = config.idpMetadata;
|
|
2103
|
-
if (idpData?.metadata) return saml.IdentityProvider({
|
|
2104
|
-
metadata: idpData.metadata,
|
|
2105
|
-
privateKey: normalizePem(idpData.privateKey),
|
|
2106
|
-
privateKeyPass: idpData.privateKeyPass,
|
|
2107
|
-
isAssertionEncrypted: idpData.isAssertionEncrypted,
|
|
2108
|
-
encPrivateKey: normalizePem(idpData.encPrivateKey),
|
|
2109
|
-
encPrivateKeyPass: idpData.encPrivateKeyPass
|
|
2110
|
-
});
|
|
2111
|
-
return saml.IdentityProvider({
|
|
2112
|
-
entityID: idpData?.entityID || config.issuer,
|
|
2113
|
-
singleSignOnService: idpData?.singleSignOnService || [{
|
|
2114
|
-
Binding: "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect",
|
|
2115
|
-
Location: config.entryPoint
|
|
2116
|
-
}],
|
|
2117
|
-
singleLogoutService: idpData?.singleLogoutService,
|
|
2118
|
-
signingCert: normalizePemList(resolveSigningCerts(config)),
|
|
2119
|
-
wantAuthnRequestsSigned: config.authnRequestsSigned || false,
|
|
2120
|
-
isAssertionEncrypted: idpData?.isAssertionEncrypted || false,
|
|
2121
|
-
encPrivateKey: normalizePem(idpData?.encPrivateKey),
|
|
2122
|
-
encPrivateKeyPass: idpData?.encPrivateKeyPass
|
|
2123
|
-
});
|
|
2124
|
-
}
|
|
2125
|
-
function escapeHtml(str) {
|
|
2126
|
-
if (!str) return "";
|
|
2127
|
-
return String(str).replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """).replace(/'/g, "'");
|
|
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
|
-
}
|
|
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" });
|
|
2140
|
-
const safeAction = escapeHtml(action);
|
|
2141
|
-
const safeSamlParam = escapeHtml(samlParam);
|
|
2142
|
-
const safeSamlValue = escapeHtml(samlValue);
|
|
2143
|
-
const safeRelayState = relayState ? escapeHtml(relayState) : void 0;
|
|
2144
|
-
const html = `<!DOCTYPE html><html><body onload="document.forms[0].submit();"><form method="POST" action="${safeAction}"><input type="hidden" name="${safeSamlParam}" value="${safeSamlValue}" />${safeRelayState ? `<input type="hidden" name="RelayState" value="${safeRelayState}" />` : ""}<noscript><input type="submit" value="Continue" /></noscript></form></body></html>`;
|
|
2145
|
-
return new Response(html, { headers: { "Content-Type": "text/html" } });
|
|
2146
|
-
}
|
|
2147
2621
|
//#endregion
|
|
2148
2622
|
//#region src/saml/timestamp.ts
|
|
2149
2623
|
/**
|
|
@@ -2187,42 +2661,47 @@ function validateSAMLTimestamp(conditions, options = {}) {
|
|
|
2187
2661
|
}
|
|
2188
2662
|
//#endregion
|
|
2189
2663
|
//#region src/routes/saml-pipeline.ts
|
|
2190
|
-
|
|
2191
|
-
|
|
2192
|
-
|
|
2193
|
-
|
|
2194
|
-
|
|
2195
|
-
|
|
2196
|
-
|
|
2197
|
-
|
|
2198
|
-
if (url.startsWith("/") && !url.startsWith("//")) {
|
|
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
|
+
}
|
|
2668
|
+
function getSafeRedirectCandidate(url, callbackPathname, appOrigin, isTrustedOrigin) {
|
|
2669
|
+
if (!url) return;
|
|
2670
|
+
if (url.startsWith("/")) {
|
|
2671
|
+
if (!isSafeSAMLRedirectPath(url)) return;
|
|
2199
2672
|
try {
|
|
2200
2673
|
const absoluteUrl = new URL(url, appOrigin);
|
|
2201
|
-
if (absoluteUrl.origin !== appOrigin) return
|
|
2202
|
-
|
|
2203
|
-
if (absoluteUrl.pathname === callbackPathname) return appOrigin;
|
|
2674
|
+
if (absoluteUrl.origin !== appOrigin || absoluteUrl.pathname === callbackPathname) return;
|
|
2675
|
+
return url;
|
|
2204
2676
|
} catch {
|
|
2205
|
-
return
|
|
2677
|
+
return;
|
|
2206
2678
|
}
|
|
2207
|
-
return url;
|
|
2208
2679
|
}
|
|
2680
|
+
let absoluteUrl;
|
|
2209
2681
|
try {
|
|
2210
|
-
|
|
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 {}
|
|
2217
|
-
if (!isTrustedOrigin(url, { allowRelativePaths: false })) return appOrigin;
|
|
2218
|
-
try {
|
|
2219
|
-
const callbackPathname = new URL(callbackPath).pathname;
|
|
2220
|
-
if (new URL(url).pathname === callbackPathname) return appOrigin;
|
|
2682
|
+
absoluteUrl = new URL(url);
|
|
2221
2683
|
} catch {
|
|
2222
|
-
|
|
2684
|
+
return;
|
|
2223
2685
|
}
|
|
2686
|
+
if (absoluteUrl.origin !== appOrigin && !isTrustedOrigin(url, { allowRelativePaths: false })) return;
|
|
2687
|
+
if (absoluteUrl.origin === appOrigin && absoluteUrl.pathname === callbackPathname) return;
|
|
2224
2688
|
return url;
|
|
2225
2689
|
}
|
|
2690
|
+
/**
|
|
2691
|
+
* Returns the first safe redirect URL from an ordered list of candidates.
|
|
2692
|
+
* - Prevents open redirect attacks by validating against trusted origins
|
|
2693
|
+
* - Prevents redirect loops by checking if URL points to callback route
|
|
2694
|
+
* - Tries the next candidate when a URL is invalid or unsafe
|
|
2695
|
+
* - Falls back to appOrigin when no candidate is safe
|
|
2696
|
+
*/
|
|
2697
|
+
function getSafeRedirectUrl(candidates, callbackPath, appOrigin, isTrustedOrigin) {
|
|
2698
|
+
const callbackPathname = new URL(callbackPath).pathname;
|
|
2699
|
+
for (const candidate of candidates) {
|
|
2700
|
+
const safeCandidate = getSafeRedirectCandidate(candidate, callbackPathname, appOrigin, isTrustedOrigin);
|
|
2701
|
+
if (safeCandidate) return safeCandidate;
|
|
2702
|
+
}
|
|
2703
|
+
return appOrigin;
|
|
2704
|
+
}
|
|
2226
2705
|
function buildSAMLRedirectUrl(url, params) {
|
|
2227
2706
|
const searchParams = new URLSearchParams(params);
|
|
2228
2707
|
try {
|
|
@@ -2238,6 +2717,14 @@ function buildSAMLRedirectUrl(url, params) {
|
|
|
2238
2717
|
return `${urlWithoutFragment}${urlWithoutFragment.includes("?") ? "&" : "?"}${searchParams.toString()}${fragment}`;
|
|
2239
2718
|
}
|
|
2240
2719
|
}
|
|
2720
|
+
function getSAMLRedirectCandidates(relayStateCallbackUrl, samlConfig, samlOptions) {
|
|
2721
|
+
return [
|
|
2722
|
+
relayStateCallbackUrl,
|
|
2723
|
+
samlConfig?.idpInitiatedCallbackUrl,
|
|
2724
|
+
samlOptions?.idpInitiatedCallbackUrl,
|
|
2725
|
+
samlConfig?.callbackUrl
|
|
2726
|
+
];
|
|
2727
|
+
}
|
|
2241
2728
|
function toArray(value) {
|
|
2242
2729
|
if (Array.isArray(value)) return value;
|
|
2243
2730
|
return value ? [value] : [];
|
|
@@ -2292,10 +2779,11 @@ async function processSAMLResponse(ctx, params, options) {
|
|
|
2292
2779
|
if (new TextEncoder().encode(params.SAMLResponse).length > maxResponseSize) throw new APIError("BAD_REQUEST", { message: `SAML response exceeds maximum allowed size (${maxResponseSize} bytes)` });
|
|
2293
2780
|
const SAMLResponse = params.SAMLResponse.replace(/\s+/g, "");
|
|
2294
2781
|
let relayState = null;
|
|
2782
|
+
let relayStateValidationFailed = false;
|
|
2295
2783
|
if (params.RelayState) try {
|
|
2296
2784
|
relayState = await parseRelayState(ctx);
|
|
2297
2785
|
} catch {
|
|
2298
|
-
|
|
2786
|
+
relayStateValidationFailed = true;
|
|
2299
2787
|
}
|
|
2300
2788
|
const provider = await findSAMLProvider(providerId, options, ctx.context.adapter);
|
|
2301
2789
|
if (!provider?.samlConfig) throw new APIError("NOT_FOUND", { message: "No SAML provider found" });
|
|
@@ -2304,7 +2792,11 @@ async function processSAMLResponse(ctx, params, options) {
|
|
|
2304
2792
|
if (!parsedSamlConfig) throw new APIError("BAD_REQUEST", { message: "Invalid SAML configuration" });
|
|
2305
2793
|
const sp = createSP(parsedSamlConfig, ctx.context.baseURL, providerId, { clockSkew: options?.saml?.clockSkew });
|
|
2306
2794
|
const idp = createIdP(parsedSamlConfig);
|
|
2307
|
-
const
|
|
2795
|
+
const redirectCandidates = getSAMLRedirectCandidates(relayState?.callbackURL, parsedSamlConfig, options?.saml);
|
|
2796
|
+
const samlRedirectUrl = getSafeRedirectUrl(redirectCandidates, currentCallbackPath, appOrigin, (url, settings) => ctx.context.isTrustedOrigin(url, settings));
|
|
2797
|
+
const samlErrorRedirectUrl = getSafeRedirectUrl([relayState?.errorURL, samlRedirectUrl], currentCallbackPath, appOrigin, (url, settings) => ctx.context.isTrustedOrigin(url, settings));
|
|
2798
|
+
params.onErrorRedirectResolved?.(samlErrorRedirectUrl);
|
|
2799
|
+
const stateProviderReference = parseSSOProviderReference(relayState?.serverContext?.[SSO_PROVIDER_STATE_KEY]);
|
|
2308
2800
|
validateSingleAssertion(SAMLResponse);
|
|
2309
2801
|
let parsedResponse;
|
|
2310
2802
|
try {
|
|
@@ -2313,15 +2805,9 @@ async function processSAMLResponse(ctx, params, options) {
|
|
|
2313
2805
|
RelayState: params.RelayState || void 0
|
|
2314
2806
|
} });
|
|
2315
2807
|
if (!parsedResponse?.extract) throw new Error("Invalid SAML response structure");
|
|
2316
|
-
} catch
|
|
2317
|
-
ctx.context.logger.error("SAML response validation failed"
|
|
2318
|
-
|
|
2319
|
-
samlResponsePreview: SAMLResponse.slice(0, 200)
|
|
2320
|
-
});
|
|
2321
|
-
throw new APIError("BAD_REQUEST", {
|
|
2322
|
-
message: "Invalid SAML response",
|
|
2323
|
-
details: error instanceof Error ? error.message : String(error)
|
|
2324
|
-
});
|
|
2808
|
+
} catch {
|
|
2809
|
+
ctx.context.logger.error("SAML response validation failed");
|
|
2810
|
+
throw new APIError("BAD_REQUEST", { message: "Invalid SAML response" });
|
|
2325
2811
|
}
|
|
2326
2812
|
const { extract } = parsedResponse;
|
|
2327
2813
|
const samlContent = parsedResponse.samlContent;
|
|
@@ -2334,9 +2820,14 @@ async function processSAMLResponse(ctx, params, options) {
|
|
|
2334
2820
|
const expectedAudiences = [sp.entityMeta.getEntityID(), parsedSamlConfig.audience];
|
|
2335
2821
|
const assertionConsumerServiceUrl = sp.entityMeta.getAssertionConsumerService(SAML_HTTP_POST_BINDING);
|
|
2336
2822
|
const expectedRecipients = getExpectedSAMLRecipients(parsedSamlConfig, ctx.context.baseURL, providerId, currentCallbackPath, assertionConsumerServiceUrl);
|
|
2823
|
+
const serviceProviderPolicy = deriveSAMLServiceProviderPolicy(parsedSamlConfig);
|
|
2337
2824
|
let samlBindingContent;
|
|
2338
2825
|
try {
|
|
2339
2826
|
samlBindingContent = await getSAMLResponseBindingContent(sp, samlContent);
|
|
2827
|
+
if (serviceProviderPolicy.wantAssertionsSigned) verifySAMLAssertionSignature(samlBindingContent, {
|
|
2828
|
+
metadata: idp.entityMeta,
|
|
2829
|
+
signatureAlgorithm: idp.entitySetting.requestSignatureAlgorithm
|
|
2830
|
+
});
|
|
2340
2831
|
validateSAMLResponseBinding(samlBindingContent, {
|
|
2341
2832
|
expectedAudiences,
|
|
2342
2833
|
expectedRecipients
|
|
@@ -2345,44 +2836,54 @@ async function processSAMLResponse(ctx, params, options) {
|
|
|
2345
2836
|
if (isAPIError(error)) {
|
|
2346
2837
|
ctx.context.logger.error("SAML response binding validation failed", {
|
|
2347
2838
|
providerId,
|
|
2348
|
-
code: error.body?.code
|
|
2349
|
-
expectedAudiences: expectedAudiences.filter(Boolean),
|
|
2350
|
-
expectedRecipients: expectedRecipients.filter(Boolean)
|
|
2839
|
+
code: error.body?.code
|
|
2351
2840
|
});
|
|
2352
|
-
throw ctx.redirect(buildSAMLRedirectUrl(
|
|
2841
|
+
throw ctx.redirect(buildSAMLRedirectUrl(samlErrorRedirectUrl, {
|
|
2353
2842
|
error: "invalid_saml_response",
|
|
2354
2843
|
error_description: error.body?.message || error.message || "Invalid SAML response"
|
|
2355
2844
|
}));
|
|
2356
2845
|
}
|
|
2357
|
-
ctx.context.logger.error("SAML response binding validation failed", {
|
|
2358
|
-
|
|
2359
|
-
error,
|
|
2360
|
-
expectedAudiences: expectedAudiences.filter(Boolean),
|
|
2361
|
-
expectedRecipients: expectedRecipients.filter(Boolean)
|
|
2362
|
-
});
|
|
2363
|
-
throw ctx.redirect(buildSAMLRedirectUrl(samlRedirectUrl, {
|
|
2846
|
+
ctx.context.logger.error("SAML response binding validation failed", { providerId });
|
|
2847
|
+
throw ctx.redirect(buildSAMLRedirectUrl(samlErrorRedirectUrl, {
|
|
2364
2848
|
error: "invalid_saml_response",
|
|
2365
2849
|
error_description: "SAML response binding could not be validated"
|
|
2366
2850
|
}));
|
|
2367
2851
|
}
|
|
2368
|
-
await validateInResponseTo(ctx, {
|
|
2852
|
+
const requestProviderReference = (await validateInResponseTo(ctx, {
|
|
2369
2853
|
extract,
|
|
2370
2854
|
providerId,
|
|
2371
2855
|
options: {
|
|
2372
2856
|
enableInResponseToValidation: options?.saml?.enableInResponseToValidation,
|
|
2373
2857
|
allowIdpInitiated: options?.saml?.allowIdpInitiated
|
|
2374
2858
|
},
|
|
2375
|
-
redirectUrl:
|
|
2376
|
-
});
|
|
2859
|
+
redirectUrl: samlErrorRedirectUrl
|
|
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
|
+
}));
|
|
2377
2878
|
validateAudience(ctx, {
|
|
2378
2879
|
extract,
|
|
2379
2880
|
expectedAudience: parsedSamlConfig.audience || sp.entityMeta.getEntityID(),
|
|
2380
2881
|
providerId,
|
|
2381
|
-
redirectUrl:
|
|
2882
|
+
redirectUrl: samlErrorRedirectUrl
|
|
2382
2883
|
});
|
|
2884
|
+
const issuer = idp.entityMeta.getEntityID();
|
|
2383
2885
|
const assertionId = extractAssertionId(samlBindingContent);
|
|
2384
2886
|
if (assertionId) {
|
|
2385
|
-
const issuer = idp.entityMeta.getEntityID();
|
|
2386
2887
|
const conditions = extract.conditions;
|
|
2387
2888
|
const clockSkew = options?.saml?.clockSkew ?? 3e5;
|
|
2388
2889
|
const expiresAt = conditions?.notOnOrAfter ? new Date(conditions.notOnOrAfter).getTime() + clockSkew : Date.now() + DEFAULT_ASSERTION_TTL_MS;
|
|
@@ -2402,13 +2903,16 @@ async function processSAMLResponse(ctx, params, options) {
|
|
|
2402
2903
|
issuer,
|
|
2403
2904
|
providerId
|
|
2404
2905
|
});
|
|
2405
|
-
throw ctx.redirect(buildSAMLRedirectUrl(
|
|
2906
|
+
throw ctx.redirect(buildSAMLRedirectUrl(samlErrorRedirectUrl, {
|
|
2406
2907
|
error: "replay_detected",
|
|
2407
2908
|
error_description: "SAML assertion has already been used"
|
|
2408
2909
|
}));
|
|
2409
2910
|
}
|
|
2410
2911
|
} else ctx.context.logger.warn("Could not extract assertion ID for replay protection", { providerId });
|
|
2411
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;
|
|
2412
2916
|
const mapping = parsedSamlConfig.mapping ?? {};
|
|
2413
2917
|
const attr = (key) => {
|
|
2414
2918
|
const value = attributes[key];
|
|
@@ -2416,37 +2920,70 @@ async function processSAMLResponse(ctx, params, options) {
|
|
|
2416
2920
|
};
|
|
2417
2921
|
const userInfo = {
|
|
2418
2922
|
...Object.fromEntries(Object.entries(mapping.extraFields || {}).map(([key, value]) => [key, attributes[value]])),
|
|
2419
|
-
id:
|
|
2923
|
+
id: extract.nameID,
|
|
2420
2924
|
email: (attr(mapping.email || "email") || extract.nameID || "").toLowerCase(),
|
|
2421
2925
|
name: [attr(mapping.firstName || "givenName"), attr(mapping.lastName || "surname")].filter(Boolean).join(" ") || attr(mapping.name || "displayName") || extract.nameID,
|
|
2422
2926
|
emailVerified: options?.trustEmailVerified && mapping.emailVerified ? parseProviderEmailVerified(attr(mapping.emailVerified)) : false
|
|
2423
2927
|
};
|
|
2424
2928
|
if (!userInfo.id || !userInfo.email) {
|
|
2425
2929
|
ctx.context.logger.error("Missing essential user info from SAML response", {
|
|
2426
|
-
|
|
2427
|
-
|
|
2428
|
-
|
|
2429
|
-
|
|
2930
|
+
providerId,
|
|
2931
|
+
attributeNames: Object.keys(attributes),
|
|
2932
|
+
hasNameId: Boolean(userInfo.id),
|
|
2933
|
+
hasEmail: Boolean(userInfo.email)
|
|
2430
2934
|
});
|
|
2431
2935
|
throw new APIError("BAD_REQUEST", { message: "Unable to extract user ID or email from SAML response" });
|
|
2432
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
|
+
};
|
|
2433
2949
|
const isTrustedProvider = "domainVerified" in provider && !!provider.domainVerified && validateEmailDomain(userInfo.email, provider.domain);
|
|
2434
|
-
const callbackUrl =
|
|
2435
|
-
const errorUrl =
|
|
2950
|
+
const callbackUrl = redirectCandidates.some(Boolean) ? samlRedirectUrl : ctx.context.baseURL;
|
|
2951
|
+
const errorUrl = samlErrorRedirectUrl;
|
|
2436
2952
|
let result;
|
|
2437
2953
|
try {
|
|
2954
|
+
if (options?.resolveUser) {
|
|
2955
|
+
assertSSOUserResolutionNativeTransactionSupport(ctx.context.adapter);
|
|
2956
|
+
assertSSOUserResolutionSessionStorage(ctx.context.options);
|
|
2957
|
+
await assertSSOUserResolutionAsyncContextSupport();
|
|
2958
|
+
}
|
|
2438
2959
|
result = await runWithTransaction(ctx.context.adapter, async () => {
|
|
2439
2960
|
await lockSSOProviderForAccountLink(ctx, provider);
|
|
2440
|
-
|
|
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, {
|
|
2441
2979
|
userInfo: {
|
|
2442
|
-
|
|
2443
|
-
|
|
2444
|
-
id: userInfo.id,
|
|
2445
|
-
emailVerified: userInfo.emailVerified
|
|
2980
|
+
...providerUser,
|
|
2981
|
+
id: userInfo.id
|
|
2446
2982
|
},
|
|
2447
2983
|
account: {
|
|
2448
2984
|
providerId,
|
|
2449
|
-
|
|
2985
|
+
issuer: accountKey.issuer,
|
|
2986
|
+
providerAccountId: accountKey.providerAccountId,
|
|
2450
2987
|
accessToken: "",
|
|
2451
2988
|
refreshToken: ""
|
|
2452
2989
|
},
|
|
@@ -2460,15 +2997,26 @@ async function processSAMLResponse(ctx, params, options) {
|
|
|
2460
2997
|
}
|
|
2461
2998
|
},
|
|
2462
2999
|
isTrustedProvider,
|
|
2463
|
-
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
|
|
2464
3007
|
});
|
|
2465
|
-
|
|
3008
|
+
return options?.resolveUser ? requireSuccessfulSSOAuthentication(authentication) : authentication;
|
|
3009
|
+
}, { onAfterCommitHookError() {
|
|
3010
|
+
ctx.context.logger.error("Committed SSO authentication after-hook failed");
|
|
3011
|
+
} });
|
|
2466
3012
|
} catch (e) {
|
|
2467
|
-
|
|
3013
|
+
const failedAuthentication = getFailedSSOAuthenticationResult(e);
|
|
3014
|
+
if (failedAuthentication) result = failedAuthentication;
|
|
3015
|
+
else if (isAPIError(e) && e.body?.code) throw ctx.redirect(buildSAMLRedirectUrl(errorUrl, {
|
|
2468
3016
|
error: e.body.code,
|
|
2469
3017
|
...e.body.message ? { error_description: e.body.message } : {}
|
|
2470
3018
|
}));
|
|
2471
|
-
throw e;
|
|
3019
|
+
else throw e;
|
|
2472
3020
|
}
|
|
2473
3021
|
if (result.error) throw ctx.redirect(buildSAMLRedirectUrl(callbackUrl, { error: result.error.split(" ").join("_") }));
|
|
2474
3022
|
const { session, user } = result.data;
|
|
@@ -2482,7 +3030,7 @@ async function processSAMLResponse(ctx, params, options) {
|
|
|
2482
3030
|
profile: {
|
|
2483
3031
|
providerType: "saml",
|
|
2484
3032
|
providerId,
|
|
2485
|
-
|
|
3033
|
+
providerAccountId: userInfo.id,
|
|
2486
3034
|
email: userInfo.email,
|
|
2487
3035
|
emailVerified: userInfo.emailVerified,
|
|
2488
3036
|
rawAttributes: attributes
|
|
@@ -2490,6 +3038,7 @@ async function processSAMLResponse(ctx, params, options) {
|
|
|
2490
3038
|
provider,
|
|
2491
3039
|
provisioningOptions: options?.organizationProvisioning
|
|
2492
3040
|
});
|
|
3041
|
+
if ("accountCookie" in result && result.accountCookie) await setAccountCookie(ctx, result.accountCookie);
|
|
2493
3042
|
await setSessionCookie(ctx, {
|
|
2494
3043
|
session,
|
|
2495
3044
|
user
|
|
@@ -2514,7 +3063,7 @@ async function processSAMLResponse(ctx, params, options) {
|
|
|
2514
3063
|
expiresAt: session.expiresAt
|
|
2515
3064
|
}).catch((e) => ctx.context.logger.warn("Failed to create SAML session lookup record", e));
|
|
2516
3065
|
}
|
|
2517
|
-
return
|
|
3066
|
+
return samlRedirectUrl;
|
|
2518
3067
|
}
|
|
2519
3068
|
//#endregion
|
|
2520
3069
|
//#region src/routes/sso.ts
|
|
@@ -2660,10 +3209,6 @@ const registerSSOProvider = (options) => {
|
|
|
2660
3209
|
type: "object",
|
|
2661
3210
|
nullable: true,
|
|
2662
3211
|
properties: {
|
|
2663
|
-
id: {
|
|
2664
|
-
type: "string",
|
|
2665
|
-
description: "Field mapping for user ID (defaults to 'sub')"
|
|
2666
|
-
},
|
|
2667
3212
|
email: {
|
|
2668
3213
|
type: "string",
|
|
2669
3214
|
description: "Field mapping for email (defaults to 'email')"
|
|
@@ -2689,11 +3234,7 @@ const registerSSOProvider = (options) => {
|
|
|
2689
3234
|
description: "Additional field mappings"
|
|
2690
3235
|
}
|
|
2691
3236
|
},
|
|
2692
|
-
required: [
|
|
2693
|
-
"id",
|
|
2694
|
-
"email",
|
|
2695
|
-
"name"
|
|
2696
|
-
]
|
|
3237
|
+
required: ["email", "name"]
|
|
2697
3238
|
}
|
|
2698
3239
|
},
|
|
2699
3240
|
required: [
|
|
@@ -2750,9 +3291,10 @@ const registerSSOProvider = (options) => {
|
|
|
2750
3291
|
})).length >= limit) throw new APIError("FORBIDDEN", { message: "You have reached the maximum number of SSO providers" });
|
|
2751
3292
|
const body = ctx.body;
|
|
2752
3293
|
const additionalFields = parseSSOProviderAdditionalFields(options, body, "create");
|
|
2753
|
-
if (body.samlConfig
|
|
3294
|
+
if (body.samlConfig) {
|
|
2754
3295
|
const maxMetadataSize = options?.saml?.maxMetadataSize ?? 102400;
|
|
2755
|
-
|
|
3296
|
+
assertSAMLMetadataSize(body.samlConfig.idpMetadata?.metadata, "IdP", maxMetadataSize);
|
|
3297
|
+
assertSAMLMetadataSize(body.samlConfig.spMetadata?.metadata, "SP", maxMetadataSize);
|
|
2756
3298
|
}
|
|
2757
3299
|
if (ctx.body.organizationId) {
|
|
2758
3300
|
const member = await ctx.context.adapter.findOne({
|
|
@@ -2768,28 +3310,16 @@ const registerSSOProvider = (options) => {
|
|
|
2768
3310
|
if (!member) throw new APIError("BAD_REQUEST", { message: "You are not a member of the organization" });
|
|
2769
3311
|
if (ctx.context.hasPlugin("organization") && !hasOrgAdminRole(member)) throw new APIError("FORBIDDEN", { message: "You must be an organization owner or admin to register SSO providers" });
|
|
2770
3312
|
}
|
|
2771
|
-
if (new Set([
|
|
3313
|
+
if ((/* @__PURE__ */ new Set([
|
|
2772
3314
|
...BUILT_IN_ACCOUNT_PROVIDER_IDS,
|
|
2773
3315
|
...Object.keys(ctx.context.options.socialProviders ?? {}),
|
|
2774
3316
|
...ctx.context.socialProviders.map((p) => p.id),
|
|
2775
3317
|
...ctx.context.trustedProviders,
|
|
2776
3318
|
...options?.defaultSSO?.map((p) => p.providerId) ?? []
|
|
2777
|
-
]).has(body.providerId)) {
|
|
3319
|
+
])).has(body.providerId)) {
|
|
2778
3320
|
ctx.context.logger.warn(`SSO provider registration rejected for reserved providerId: ${body.providerId}`);
|
|
2779
3321
|
throw new APIError("UNPROCESSABLE_ENTITY", { message: "This providerId is reserved and cannot be used for an SSO provider" });
|
|
2780
3322
|
}
|
|
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
|
-
}
|
|
2793
3323
|
if (await ctx.context.adapter.findOne({
|
|
2794
3324
|
model: "ssoProvider",
|
|
2795
3325
|
where: [{
|
|
@@ -2868,6 +3398,11 @@ const registerSSOProvider = (options) => {
|
|
|
2868
3398
|
digestAlgorithm: body.samlConfig.digestAlgorithm
|
|
2869
3399
|
}, options?.saml?.algorithms);
|
|
2870
3400
|
validateCertSources(body.samlConfig);
|
|
3401
|
+
assertSAMLIdentityProviderAuthority(body.samlConfig);
|
|
3402
|
+
assertSAMLServiceProviderMetadataPolicy({
|
|
3403
|
+
...body.samlConfig,
|
|
3404
|
+
issuer: body.issuer
|
|
3405
|
+
});
|
|
2871
3406
|
const hasIdpMetadata = body.samlConfig.idpMetadata?.metadata;
|
|
2872
3407
|
let hasEntryPoint = false;
|
|
2873
3408
|
if (body.samlConfig.entryPoint) try {
|
|
@@ -2899,6 +3434,7 @@ const registerSSOProvider = (options) => {
|
|
|
2899
3434
|
cert: body.samlConfig.cert,
|
|
2900
3435
|
audience: body.samlConfig.audience,
|
|
2901
3436
|
callbackUrl: body.samlConfig.callbackUrl,
|
|
3437
|
+
idpInitiatedCallbackUrl: body.samlConfig.idpInitiatedCallbackUrl,
|
|
2902
3438
|
idpMetadata: body.samlConfig.idpMetadata,
|
|
2903
3439
|
spMetadata: body.samlConfig.spMetadata,
|
|
2904
3440
|
wantAssertionsSigned: body.samlConfig.wantAssertionsSigned,
|
|
@@ -3107,7 +3643,7 @@ const signInSSO = (options) => {
|
|
|
3107
3643
|
throw error;
|
|
3108
3644
|
}
|
|
3109
3645
|
if (!config.authorizationEndpoint) throw new APIError("BAD_REQUEST", { message: "Invalid OIDC configuration. Authorization URL not found." });
|
|
3110
|
-
|
|
3646
|
+
await addOAuthServerContext({ [SSO_PROVIDER_STATE_KEY]: await computeSSOProviderReference(provider) });
|
|
3111
3647
|
const state = await generateState(ctx);
|
|
3112
3648
|
const redirectURI = getOIDCRedirectURI(ctx.context.baseURL, provider.providerId, options);
|
|
3113
3649
|
const authorizationURL = await createAuthorizationURL({
|
|
@@ -3139,7 +3675,8 @@ const signInSSO = (options) => {
|
|
|
3139
3675
|
const parsedSamlConfig = typeof provider.samlConfig === "object" ? provider.samlConfig : safeJsonParse(provider.samlConfig);
|
|
3140
3676
|
if (!parsedSamlConfig) throw new APIError("BAD_REQUEST", { message: "Invalid SAML configuration" });
|
|
3141
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" });
|
|
3142
|
-
const
|
|
3678
|
+
const providerReference = await computeSSOProviderReference(provider);
|
|
3679
|
+
const { state: relayState } = await generateRelayState(ctx, void 0, providerReference);
|
|
3143
3680
|
const sp = createSP(parsedSamlConfig, ctx.context.baseURL, provider.providerId, { relayState });
|
|
3144
3681
|
const idp = createIdP(parsedSamlConfig);
|
|
3145
3682
|
const loginRequest = sp.createLoginRequest(idp, "redirect");
|
|
@@ -3149,6 +3686,7 @@ const signInSSO = (options) => {
|
|
|
3149
3686
|
const record = {
|
|
3150
3687
|
id: loginRequest.id,
|
|
3151
3688
|
providerId: provider.providerId,
|
|
3689
|
+
providerReference,
|
|
3152
3690
|
createdAt: Date.now(),
|
|
3153
3691
|
expiresAt: Date.now() + ttl
|
|
3154
3692
|
};
|
|
@@ -3196,13 +3734,14 @@ function getOIDCErrorDescription(error, fallback) {
|
|
|
3196
3734
|
* @param stateData - Pre-parsed state data. If not provided, it will be
|
|
3197
3735
|
* parsed from the request context.
|
|
3198
3736
|
*/
|
|
3199
|
-
async function handleOIDCCallback(ctx, options, providerId, stateData) {
|
|
3737
|
+
async function handleOIDCCallback(ctx, options, providerId, stateData, parsedProviderReference) {
|
|
3200
3738
|
const { code, error, error_description } = ctx.query;
|
|
3201
3739
|
if (!stateData) stateData = await parseState(ctx);
|
|
3202
3740
|
if (!stateData) {
|
|
3203
3741
|
const errorURL = ctx.context.options.onAPIError?.errorURL || `${ctx.context.baseURL}/error`;
|
|
3204
3742
|
throw ctx.redirect(`${errorURL}?error=invalid_state`);
|
|
3205
3743
|
}
|
|
3744
|
+
const providerReference = parsedProviderReference ?? parseSSOProviderReference(stateData.serverContext?.["ssoProviderReference"]);
|
|
3206
3745
|
const { callbackURL, errorURL, newUserURL, requestSignUp } = stateData;
|
|
3207
3746
|
const redirectOIDCError = (error, description) => {
|
|
3208
3747
|
const baseURL = errorURL || callbackURL;
|
|
@@ -3216,6 +3755,8 @@ async function handleOIDCCallback(ctx, options, providerId, stateData) {
|
|
|
3216
3755
|
if (!code || error) redirectOIDCError(error || "invalid_request", error_description || (error ? error : "authorization_code_not_found"));
|
|
3217
3756
|
const provider = await resolveOIDCProvider(ctx, options, providerId);
|
|
3218
3757
|
if (!provider) throw ctx.redirect(`${errorURL || callbackURL}?error=invalid_provider&error_description=provider not found`);
|
|
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");
|
|
3219
3760
|
if (options?.domainVerification?.enabled && !("domainVerified" in provider && provider.domainVerified)) throw new APIError("UNAUTHORIZED", { message: "Provider domain has not been verified" });
|
|
3220
3761
|
let config = provider.oidcConfig;
|
|
3221
3762
|
if (!config) throw ctx.redirect(`${errorURL || callbackURL}?error=invalid_provider&error_description=provider not found`);
|
|
@@ -3287,7 +3828,28 @@ async function handleOIDCCallback(ctx, options, providerId, stateData) {
|
|
|
3287
3828
|
if (!tokenResponse) throw ctx.redirect(`${errorURL || callbackURL}?error=invalid_provider&error_description=token_response_not_found`);
|
|
3288
3829
|
let userInfo = null;
|
|
3289
3830
|
const mapping = config.mapping || {};
|
|
3831
|
+
const readStringClaim = (claims, claim) => {
|
|
3832
|
+
const value = claims[claim];
|
|
3833
|
+
return typeof value === "string" && value.length > 0 ? value : void 0;
|
|
3834
|
+
};
|
|
3290
3835
|
let rawProfile;
|
|
3836
|
+
let verifiedIdToken = null;
|
|
3837
|
+
if (tokenResponse.idToken) {
|
|
3838
|
+
const jwksEndpoint = config.jwksEndpoint;
|
|
3839
|
+
if (!jwksEndpoint) redirectOIDCError("invalid_provider", "jwks_endpoint_not_found");
|
|
3840
|
+
const verified = await validateOIDCIdToken(tokenResponse.idToken, jwksEndpoint, {
|
|
3841
|
+
audience: config.clientId,
|
|
3842
|
+
issuer: provider.issuer
|
|
3843
|
+
}, (url) => ctx.context.isTrustedOrigin(url)).catch((error) => {
|
|
3844
|
+
if (error instanceof DiscoveryError) redirectOIDCError("invalid_provider", error.message);
|
|
3845
|
+
ctx.context.logger.error(error);
|
|
3846
|
+
return null;
|
|
3847
|
+
});
|
|
3848
|
+
if (!verified) redirectOIDCError("invalid_provider", "token_not_verified");
|
|
3849
|
+
if (!readStringClaim(verified.payload, "sub")) redirectOIDCError("invalid_provider", "id_token_subject_missing");
|
|
3850
|
+
verifiedIdToken = verified;
|
|
3851
|
+
}
|
|
3852
|
+
if (options?.resolveUser && !verifiedIdToken) redirectOIDCError("invalid_provider", "id_token_required_for_user_resolution");
|
|
3291
3853
|
if (config.userInfoEndpoint) {
|
|
3292
3854
|
const userInfoResponse = await fetchOIDCEndpoint("userInfoEndpoint", config.userInfoEndpoint, { headers: { Authorization: `Bearer ${tokenResponse.accessToken}` } }, (url) => ctx.context.isTrustedOrigin(url)).catch((e) => {
|
|
3293
3855
|
if (e instanceof DiscoveryError) redirectOIDCError("invalid_provider", e.message);
|
|
@@ -3295,58 +3857,85 @@ async function handleOIDCCallback(ctx, options, providerId, stateData) {
|
|
|
3295
3857
|
});
|
|
3296
3858
|
if (userInfoResponse.error) redirectOIDCError("invalid_provider", userInfoResponse.error.message || userInfoResponse.error.statusText || "userinfo_response_error");
|
|
3297
3859
|
const rawUserInfo = userInfoResponse.data ?? redirectOIDCError("invalid_provider", "userinfo_response_not_found");
|
|
3860
|
+
if (verifiedIdToken && rawUserInfo.sub !== verifiedIdToken.payload.sub) redirectOIDCError("invalid_provider", "id_token_userinfo_subject_mismatch");
|
|
3298
3861
|
rawProfile = rawUserInfo;
|
|
3299
3862
|
userInfo = {
|
|
3300
3863
|
...Object.fromEntries(Object.entries(mapping.extraFields || {}).map(([key, value]) => [key, rawUserInfo[value]])),
|
|
3301
|
-
id: rawUserInfo
|
|
3302
|
-
email: rawUserInfo
|
|
3864
|
+
id: readStringClaim(rawUserInfo, "sub"),
|
|
3865
|
+
email: readStringClaim(rawUserInfo, mapping.email || "email"),
|
|
3303
3866
|
emailVerified: options?.trustEmailVerified ? parseProviderEmailVerified(rawUserInfo[mapping.emailVerified || "email_verified"]) : false,
|
|
3304
|
-
name: rawUserInfo
|
|
3305
|
-
image: rawUserInfo
|
|
3867
|
+
name: readStringClaim(rawUserInfo, mapping.name || "name"),
|
|
3868
|
+
image: readStringClaim(rawUserInfo, mapping.image || "picture")
|
|
3306
3869
|
};
|
|
3307
|
-
} else if (
|
|
3308
|
-
const idToken =
|
|
3870
|
+
} else if (verifiedIdToken) {
|
|
3871
|
+
const idToken = verifiedIdToken.payload;
|
|
3309
3872
|
rawProfile = idToken;
|
|
3310
|
-
if (!config.jwksEndpoint) throw ctx.redirect(`${errorURL || callbackURL}?error=invalid_provider&error_description=jwks_endpoint_not_found`);
|
|
3311
|
-
const verified = await validateOIDCIdToken(tokenResponse.idToken, config.jwksEndpoint, {
|
|
3312
|
-
audience: config.clientId,
|
|
3313
|
-
issuer: provider.issuer
|
|
3314
|
-
}, (url) => ctx.context.isTrustedOrigin(url)).catch((e) => {
|
|
3315
|
-
if (e instanceof DiscoveryError) redirectOIDCError("invalid_provider", e.message);
|
|
3316
|
-
ctx.context.logger.error(e);
|
|
3317
|
-
return null;
|
|
3318
|
-
});
|
|
3319
|
-
if (!verified) throw ctx.redirect(`${errorURL || callbackURL}?error=invalid_provider&error_description=token_not_verified`);
|
|
3320
3873
|
userInfo = {
|
|
3321
|
-
...Object.fromEntries(Object.entries(mapping.extraFields || {}).map(([key, value]) => [key,
|
|
3322
|
-
id: idToken
|
|
3323
|
-
email: idToken
|
|
3874
|
+
...Object.fromEntries(Object.entries(mapping.extraFields || {}).map(([key, value]) => [key, idToken[value]])),
|
|
3875
|
+
id: idToken.sub,
|
|
3876
|
+
email: readStringClaim(idToken, mapping.email || "email"),
|
|
3324
3877
|
emailVerified: options?.trustEmailVerified ? parseProviderEmailVerified(idToken[mapping.emailVerified || "email_verified"]) : false,
|
|
3325
|
-
name: idToken
|
|
3326
|
-
image: idToken
|
|
3878
|
+
name: readStringClaim(idToken, mapping.name || "name"),
|
|
3879
|
+
image: readStringClaim(idToken, mapping.image || "picture")
|
|
3327
3880
|
};
|
|
3328
3881
|
} else throw ctx.redirect(`${errorURL || callbackURL}?error=invalid_provider&error_description=user_info_endpoint_not_found`);
|
|
3329
3882
|
if (!userInfo.email || !userInfo.id) throw ctx.redirect(`${errorURL || callbackURL}?error=invalid_provider&error_description=missing_user_info`);
|
|
3330
3883
|
const userInfoEmail = userInfo.email;
|
|
3331
3884
|
const userInfoId = userInfo.id;
|
|
3885
|
+
const providerUser = {
|
|
3886
|
+
...Object.fromEntries(Object.entries(userInfo).filter(([key]) => key !== "id")),
|
|
3887
|
+
email: userInfoEmail,
|
|
3888
|
+
name: typeof userInfo.name === "string" ? userInfo.name : "",
|
|
3889
|
+
image: typeof userInfo.image === "string" ? userInfo.image : void 0,
|
|
3890
|
+
emailVerified: options?.trustEmailVerified ? userInfo.emailVerified === true : false
|
|
3891
|
+
};
|
|
3892
|
+
const accountKey = {
|
|
3893
|
+
issuer: verifiedIdToken && readStringClaim(verifiedIdToken.payload, "iss") || provider.issuer,
|
|
3894
|
+
providerAccountId: userInfoId
|
|
3895
|
+
};
|
|
3332
3896
|
const isTrustedProvider = "domainVerified" in provider && provider.domainVerified === true && validateEmailDomain(userInfoEmail, provider.domain);
|
|
3333
3897
|
let linked;
|
|
3334
3898
|
try {
|
|
3899
|
+
if (options?.resolveUser) {
|
|
3900
|
+
assertSSOUserResolutionNativeTransactionSupport(ctx.context.adapter);
|
|
3901
|
+
assertSSOUserResolutionSessionStorage(ctx.context.options);
|
|
3902
|
+
await assertSSOUserResolutionAsyncContextSupport();
|
|
3903
|
+
}
|
|
3335
3904
|
linked = await runWithTransaction(ctx.context.adapter, async () => {
|
|
3336
3905
|
await lockSSOProviderForAccountLink(ctx, provider);
|
|
3337
|
-
|
|
3906
|
+
const currentProvider = await resolveOIDCProvider(ctx, options, providerId, await getCurrentAdapter(ctx.context.adapter));
|
|
3907
|
+
if (!currentProvider || !await isCurrentSSOProviderReference(currentProvider, acceptedProviderReference)) throw new APIError("CONFLICT", {
|
|
3908
|
+
code: "SSO_PROVIDER_CHANGED",
|
|
3909
|
+
message: "SSO provider changed while account linking was in progress"
|
|
3910
|
+
});
|
|
3911
|
+
const resolution = options?.resolveUser ? await resolveSSOUser(options.resolveUser, {
|
|
3912
|
+
protocol: "oidc",
|
|
3913
|
+
providerId: provider.providerId,
|
|
3914
|
+
accountKey,
|
|
3915
|
+
providerUser,
|
|
3916
|
+
providerClaims: rawProfile ?? {},
|
|
3917
|
+
verifiedIdTokenClaims: verifiedIdToken?.payload ?? {},
|
|
3918
|
+
providerReference: acceptedProviderReference
|
|
3919
|
+
}, await getCurrentAdapter(ctx.context.adapter), ctx.context.logger) : void 0;
|
|
3920
|
+
if (resolution?.action === "reject") throw new APIError("FORBIDDEN", {
|
|
3921
|
+
code: resolution.code,
|
|
3922
|
+
...resolution.message === void 0 ? {} : { message: resolution.message }
|
|
3923
|
+
});
|
|
3924
|
+
const authentication = await handleOAuthUserInfo(ctx, {
|
|
3338
3925
|
userInfo: {
|
|
3339
|
-
|
|
3340
|
-
|
|
3926
|
+
...providerUser,
|
|
3927
|
+
email: providerUser.email,
|
|
3928
|
+
name: providerUser.name,
|
|
3341
3929
|
id: userInfoId,
|
|
3342
|
-
image:
|
|
3343
|
-
emailVerified:
|
|
3930
|
+
image: providerUser.image,
|
|
3931
|
+
emailVerified: providerUser.emailVerified
|
|
3344
3932
|
},
|
|
3345
3933
|
account: {
|
|
3346
3934
|
idToken: tokenResponse.idToken,
|
|
3347
3935
|
accessToken: tokenResponse.accessToken,
|
|
3348
3936
|
refreshToken: tokenResponse.refreshToken,
|
|
3349
|
-
|
|
3937
|
+
issuer: accountKey.issuer,
|
|
3938
|
+
providerAccountId: userInfoId,
|
|
3350
3939
|
providerId: provider.providerId,
|
|
3351
3940
|
accessTokenExpiresAt: tokenResponse.accessTokenExpiresAt,
|
|
3352
3941
|
refreshTokenExpiresAt: tokenResponse.refreshTokenExpiresAt,
|
|
@@ -3363,18 +3952,28 @@ async function handleOIDCCallback(ctx, options, providerId, stateData) {
|
|
|
3363
3952
|
}
|
|
3364
3953
|
},
|
|
3365
3954
|
isTrustedProvider,
|
|
3366
|
-
trustProviderByName: false
|
|
3955
|
+
trustProviderByName: false,
|
|
3956
|
+
selectedUser: resolution?.action === "link" ? {
|
|
3957
|
+
userId: resolution.userId,
|
|
3958
|
+
profile: resolution.profile
|
|
3959
|
+
} : void 0,
|
|
3960
|
+
deferNonDatabaseWrites: !!options?.resolveUser,
|
|
3961
|
+
requireExactAccountBinding: !!options?.resolveUser
|
|
3367
3962
|
});
|
|
3368
|
-
|
|
3963
|
+
return options?.resolveUser ? requireSuccessfulSSOAuthentication(authentication) : authentication;
|
|
3964
|
+
}, { onAfterCommitHookError(error) {
|
|
3965
|
+
ctx.context.logger.error("Committed SSO authentication after-hook failed", error);
|
|
3966
|
+
} });
|
|
3369
3967
|
} catch (e) {
|
|
3370
|
-
|
|
3968
|
+
const failedAuthentication = getFailedSSOAuthenticationResult(e);
|
|
3969
|
+
if (failedAuthentication) linked = failedAuthentication;
|
|
3970
|
+
else if (isAPIError(e) && e.body?.code) {
|
|
3371
3971
|
const baseURL = errorURL || callbackURL;
|
|
3372
3972
|
const params = new URLSearchParams({ error: e.body.code });
|
|
3373
3973
|
if (e.body.message) params.set("error_description", e.body.message);
|
|
3374
3974
|
const sep = baseURL.includes("?") ? "&" : "?";
|
|
3375
3975
|
throw ctx.redirect(`${baseURL}${sep}${params.toString()}`);
|
|
3376
|
-
}
|
|
3377
|
-
throw e;
|
|
3976
|
+
} else throw e;
|
|
3378
3977
|
}
|
|
3379
3978
|
if (linked.error) {
|
|
3380
3979
|
const baseURL = errorURL || callbackURL;
|
|
@@ -3394,7 +3993,7 @@ async function handleOIDCCallback(ctx, options, providerId, stateData) {
|
|
|
3394
3993
|
profile: {
|
|
3395
3994
|
providerType: "oidc",
|
|
3396
3995
|
providerId: provider.providerId,
|
|
3397
|
-
|
|
3996
|
+
providerAccountId: userInfoId,
|
|
3398
3997
|
email: userInfoEmail,
|
|
3399
3998
|
emailVerified: Boolean(userInfo.emailVerified),
|
|
3400
3999
|
rawAttributes: userInfo
|
|
@@ -3403,6 +4002,7 @@ async function handleOIDCCallback(ctx, options, providerId, stateData) {
|
|
|
3403
4002
|
token: tokenResponse,
|
|
3404
4003
|
provisioningOptions: options?.organizationProvisioning
|
|
3405
4004
|
});
|
|
4005
|
+
if ("accountCookie" in linked && linked.accountCookie) await setAccountCookie(ctx, linked.accountCookie);
|
|
3406
4006
|
await setSessionCookie(ctx, {
|
|
3407
4007
|
session,
|
|
3408
4008
|
user
|
|
@@ -3434,7 +4034,7 @@ const callbackSSOEndpointConfig = {
|
|
|
3434
4034
|
* and falling back to the `ssoProvider` table. Returns `null` when no match is
|
|
3435
4035
|
* found so the caller can decide how to react (redirect, silently skip, etc.).
|
|
3436
4036
|
*/
|
|
3437
|
-
async function resolveOIDCProvider(ctx, options, providerId) {
|
|
4037
|
+
async function resolveOIDCProvider(ctx, options, providerId, adapter = ctx.context.adapter) {
|
|
3438
4038
|
const matchingDefault = options?.defaultSSO?.find((defaultProvider) => defaultProvider.providerId === providerId);
|
|
3439
4039
|
if (matchingDefault) return {
|
|
3440
4040
|
...matchingDefault,
|
|
@@ -3442,7 +4042,7 @@ async function resolveOIDCProvider(ctx, options, providerId) {
|
|
|
3442
4042
|
userId: "default",
|
|
3443
4043
|
...options?.domainVerification?.enabled ? { domainVerified: true } : {}
|
|
3444
4044
|
};
|
|
3445
|
-
return
|
|
4045
|
+
return adapter.findOne({
|
|
3446
4046
|
model: "ssoProvider",
|
|
3447
4047
|
where: [{
|
|
3448
4048
|
field: "providerId",
|
|
@@ -3482,7 +4082,7 @@ async function bounceIfIdpInitiated(ctx, options, providerId) {
|
|
|
3482
4082
|
});
|
|
3483
4083
|
return;
|
|
3484
4084
|
}
|
|
3485
|
-
|
|
4085
|
+
await addOAuthServerContext({ [SSO_PROVIDER_STATE_KEY]: await computeSSOProviderReference(provider) });
|
|
3486
4086
|
const state = await generateState(ctx);
|
|
3487
4087
|
const redirectURI = getOIDCRedirectURI(ctx.context.baseURL, provider.providerId, options);
|
|
3488
4088
|
const authorizationURL = await createAuthorizationURL({
|
|
@@ -3534,12 +4134,12 @@ const callbackSSOShared = (options) => {
|
|
|
3534
4134
|
const errorURL = ctx.context.options.onAPIError?.errorURL || `${ctx.context.baseURL}/error`;
|
|
3535
4135
|
throw ctx.redirect(`${errorURL}?error=invalid_state`);
|
|
3536
4136
|
}
|
|
3537
|
-
const
|
|
3538
|
-
if (!
|
|
4137
|
+
const providerReference = parseSSOProviderReference(stateData.serverContext?.[SSO_PROVIDER_STATE_KEY]);
|
|
4138
|
+
if (!providerReference) {
|
|
3539
4139
|
const errorURL = stateData.errorURL || stateData.callbackURL;
|
|
3540
|
-
throw ctx.redirect(`${errorURL}?error=invalid_state&error_description=
|
|
4140
|
+
throw ctx.redirect(`${errorURL}?error=invalid_state&error_description=missing_sso_provider_reference`);
|
|
3541
4141
|
}
|
|
3542
|
-
return handleOIDCCallback(ctx, options, providerId, stateData);
|
|
4142
|
+
return handleOIDCCallback(ctx, options, providerReference.providerId, stateData, providerReference);
|
|
3543
4143
|
});
|
|
3544
4144
|
};
|
|
3545
4145
|
const acsEndpointBodySchema = z.object({
|
|
@@ -3569,13 +4169,14 @@ const acsEndpoint = (options) => {
|
|
|
3569
4169
|
const { providerId } = ctx.params;
|
|
3570
4170
|
const currentCallbackPath = `${ctx.context.baseURL}/sso/saml2/sp/acs/${providerId}`;
|
|
3571
4171
|
const appOrigin = new URL(ctx.context.baseURL).origin;
|
|
4172
|
+
let resolvedErrorRedirectUrl;
|
|
3572
4173
|
if (ctx.method === "GET" && !ctx.body?.SAMLResponse) {
|
|
3573
4174
|
if (!(await getSessionFromCtx(ctx))?.session) {
|
|
3574
4175
|
const errorURL = ctx.context.options.onAPIError?.errorURL || `${appOrigin}/error`;
|
|
3575
4176
|
throw ctx.redirect(`${errorURL}?error=invalid_request`);
|
|
3576
4177
|
}
|
|
3577
4178
|
const relayState = ctx.query?.RelayState;
|
|
3578
|
-
throw ctx.redirect(getSafeRedirectUrl(relayState, currentCallbackPath, appOrigin, (url, settings) => ctx.context.isTrustedOrigin(url, settings)));
|
|
4179
|
+
throw ctx.redirect(getSafeRedirectUrl([relayState], currentCallbackPath, appOrigin, (url, settings) => ctx.context.isTrustedOrigin(url, settings)));
|
|
3579
4180
|
}
|
|
3580
4181
|
if (!ctx.body?.SAMLResponse) throw new APIError("BAD_REQUEST", { message: "SAMLResponse is required for POST requests" });
|
|
3581
4182
|
try {
|
|
@@ -3583,15 +4184,33 @@ const acsEndpoint = (options) => {
|
|
|
3583
4184
|
SAMLResponse: ctx.body.SAMLResponse,
|
|
3584
4185
|
RelayState: ctx.body.RelayState,
|
|
3585
4186
|
providerId,
|
|
3586
|
-
currentCallbackPath
|
|
4187
|
+
currentCallbackPath,
|
|
4188
|
+
onErrorRedirectResolved: (url) => {
|
|
4189
|
+
resolvedErrorRedirectUrl = url;
|
|
4190
|
+
}
|
|
3587
4191
|
}, options);
|
|
3588
4192
|
throw ctx.redirect(safeRedirectUrl);
|
|
3589
4193
|
} catch (error) {
|
|
3590
4194
|
if (error instanceof Response || error && typeof error === "object" && "status" in error && error.status === 302) throw error;
|
|
3591
4195
|
if (error instanceof APIError && error.statusCode === 400) {
|
|
3592
4196
|
const errorCode = (error.body?.code || "saml_error").toLowerCase();
|
|
3593
|
-
|
|
3594
|
-
|
|
4197
|
+
let redirectUrl = resolvedErrorRedirectUrl;
|
|
4198
|
+
if (!redirectUrl) {
|
|
4199
|
+
let parsedSamlConfig;
|
|
4200
|
+
try {
|
|
4201
|
+
parsedSamlConfig = (await findSAMLProvider(providerId, options, ctx.context.adapter))?.samlConfig;
|
|
4202
|
+
} catch (providerLookupError) {
|
|
4203
|
+
ctx.context.logger.warn("Failed to resolve SAML provider for error redirect", {
|
|
4204
|
+
providerId,
|
|
4205
|
+
error: providerLookupError
|
|
4206
|
+
});
|
|
4207
|
+
}
|
|
4208
|
+
redirectUrl = getSafeRedirectUrl(getSAMLRedirectCandidates(void 0, parsedSamlConfig, options?.saml), currentCallbackPath, appOrigin, (url, settings) => ctx.context.isTrustedOrigin(url, settings));
|
|
4209
|
+
}
|
|
4210
|
+
throw ctx.redirect(buildSAMLRedirectUrl(redirectUrl, {
|
|
4211
|
+
error: errorCode,
|
|
4212
|
+
error_description: error.message
|
|
4213
|
+
}));
|
|
3595
4214
|
}
|
|
3596
4215
|
throw error;
|
|
3597
4216
|
}
|
|
@@ -3620,7 +4239,7 @@ const sloEndpoint = (options) => {
|
|
|
3620
4239
|
const samlResponse = ctx.body?.SAMLResponse || ctx.query?.SAMLResponse;
|
|
3621
4240
|
const relayState = ctx.body?.RelayState || ctx.query?.RelayState;
|
|
3622
4241
|
const appOrigin = new URL(ctx.context.baseURL).origin;
|
|
3623
|
-
const safeErrorURL = getSafeRedirectUrl(relayState, `${appOrigin}/sso/saml2/sp/slo/${providerId}`, appOrigin, (url, settings) => ctx.context.isTrustedOrigin(url, settings));
|
|
4242
|
+
const safeErrorURL = getSafeRedirectUrl([relayState], `${appOrigin}/sso/saml2/sp/slo/${providerId}`, appOrigin, (url, settings) => ctx.context.isTrustedOrigin(url, settings));
|
|
3624
4243
|
if (!samlRequest && !samlResponse) throw ctx.redirect(`${safeErrorURL}?error=invalid_request&error_description=missing_logout_data`);
|
|
3625
4244
|
const provider = await findSAMLProvider(providerId, options, ctx.context.adapter);
|
|
3626
4245
|
if (!provider?.samlConfig) throw APIError.from("NOT_FOUND", SAML_ERROR_CODES.SAML_PROVIDER_NOT_FOUND);
|
|
@@ -3660,7 +4279,7 @@ async function handleLogoutResponse(ctx, sp, idp, relayState, providerId) {
|
|
|
3660
4279
|
}
|
|
3661
4280
|
deleteSessionCookie(ctx);
|
|
3662
4281
|
const appOrigin = new URL(ctx.context.baseURL).origin;
|
|
3663
|
-
const safeRedirectUrl = getSafeRedirectUrl(relayState, `${appOrigin}/sso/saml2/sp/slo/${providerId}`, appOrigin, (url, settings) => ctx.context.isTrustedOrigin(url, settings));
|
|
4282
|
+
const safeRedirectUrl = getSafeRedirectUrl([relayState], `${appOrigin}/sso/saml2/sp/slo/${providerId}`, appOrigin, (url, settings) => ctx.context.isTrustedOrigin(url, settings));
|
|
3664
4283
|
throw ctx.redirect(safeRedirectUrl);
|
|
3665
4284
|
}
|
|
3666
4285
|
async function handleLogoutRequest(ctx, sp, idp, relayState, providerId) {
|
|
@@ -3814,7 +4433,7 @@ function sso(options) {
|
|
|
3814
4433
|
listSSOProviders: listSSOProviders(optionsWithStore),
|
|
3815
4434
|
getSSOProvider: getSSOProvider(optionsWithStore),
|
|
3816
4435
|
updateSSOProvider: updateSSOProvider(optionsWithStore),
|
|
3817
|
-
deleteSSOProvider: deleteSSOProvider()
|
|
4436
|
+
deleteSSOProvider: deleteSSOProvider(optionsWithStore)
|
|
3818
4437
|
};
|
|
3819
4438
|
if (options?.domainVerification?.enabled) {
|
|
3820
4439
|
const domainVerificationEndpoints = {
|
|
@@ -3923,4 +4542,4 @@ function sso(options) {
|
|
|
3923
4542
|
};
|
|
3924
4543
|
}
|
|
3925
4544
|
//#endregion
|
|
3926
|
-
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 };
|