@better-auth/sso 1.6.2 → 1.7.0-beta.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.mjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { t as PACKAGE_VERSION } from "./version-
|
|
1
|
+
import { t as PACKAGE_VERSION } from "./version-CzfTSPRz.mjs";
|
|
2
2
|
import { APIError, createAuthEndpoint, createAuthMiddleware, getSessionFromCtx, sessionMiddleware } from "better-auth/api";
|
|
3
3
|
import { XMLParser, XMLValidator } from "fast-xml-parser";
|
|
4
4
|
import * as saml from "samlify";
|
|
@@ -8,7 +8,7 @@ import { generateRandomString } from "better-auth/crypto";
|
|
|
8
8
|
import * as z from "zod";
|
|
9
9
|
import { base64 } from "@better-auth/utils/base64";
|
|
10
10
|
import { BetterFetchError, betterFetch } from "@better-fetch/fetch";
|
|
11
|
-
import { HIDE_METADATA, createAuthorizationURL, generateGenericState, generateState, parseGenericState, parseState, validateAuthorizationCode, validateToken } from "better-auth";
|
|
11
|
+
import { ASSERTION_SIGNING_ALGORITHMS, HIDE_METADATA, createAuthorizationURL, generateGenericState, generateState, parseGenericState, parseState, validateAuthorizationCode, validateToken } from "better-auth";
|
|
12
12
|
import { deleteSessionCookie, setSessionCookie } from "better-auth/cookies";
|
|
13
13
|
import { handleOAuthUserInfo } from "better-auth/oauth2";
|
|
14
14
|
import { decodeJwt } from "jose";
|
|
@@ -625,6 +625,91 @@ function validateSingleAssertion(samlResponse) {
|
|
|
625
625
|
});
|
|
626
626
|
}
|
|
627
627
|
//#endregion
|
|
628
|
+
//#region src/saml/response-validation.ts
|
|
629
|
+
function errorRedirectUrl(base, error, description) {
|
|
630
|
+
try {
|
|
631
|
+
const url = new URL(base);
|
|
632
|
+
url.searchParams.set("error", error);
|
|
633
|
+
url.searchParams.set("error_description", description);
|
|
634
|
+
return url.toString();
|
|
635
|
+
} catch {
|
|
636
|
+
const hashIdx = base.indexOf("#");
|
|
637
|
+
const path = hashIdx >= 0 ? base.slice(0, hashIdx) : base;
|
|
638
|
+
const hash = hashIdx >= 0 ? base.slice(hashIdx + 1) : void 0;
|
|
639
|
+
return `${path}${path.includes("?") ? "&" : "?"}${`error=${encodeURIComponent(error)}&error_description=${encodeURIComponent(description)}`}${hash ? `#${hash}` : ""}`;
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
/**
|
|
643
|
+
* Validates the InResponseTo attribute of a SAML Response.
|
|
644
|
+
*
|
|
645
|
+
* This binds the IdP's Response to a specific SP-initiated AuthnRequest,
|
|
646
|
+
* preventing replay attacks, unsolicited response injection, and
|
|
647
|
+
* cross-provider assertion swaps.
|
|
648
|
+
*
|
|
649
|
+
* The InResponseTo value lives at `extract.response.inResponseTo` in
|
|
650
|
+
* samlify's parsed output (not at the top level).
|
|
651
|
+
*/
|
|
652
|
+
async function validateInResponseTo(c, ctx) {
|
|
653
|
+
if (ctx.options.enableInResponseToValidation === false) return;
|
|
654
|
+
const inResponseTo = ctx.extract.response?.inResponseTo;
|
|
655
|
+
const allowIdpInitiated = ctx.options.allowIdpInitiated ?? false;
|
|
656
|
+
if (inResponseTo) {
|
|
657
|
+
let storedRequest = null;
|
|
658
|
+
const verification = await c.context.internalAdapter.findVerificationValue(`${AUTHN_REQUEST_KEY_PREFIX}${inResponseTo}`);
|
|
659
|
+
if (verification) try {
|
|
660
|
+
storedRequest = JSON.parse(verification.value);
|
|
661
|
+
if (storedRequest && storedRequest.expiresAt < Date.now()) storedRequest = null;
|
|
662
|
+
} catch {
|
|
663
|
+
storedRequest = null;
|
|
664
|
+
}
|
|
665
|
+
if (!storedRequest) {
|
|
666
|
+
c.context.logger.error("SAML InResponseTo validation failed: unknown or expired request ID", {
|
|
667
|
+
inResponseTo,
|
|
668
|
+
providerId: ctx.providerId
|
|
669
|
+
});
|
|
670
|
+
throw c.redirect(errorRedirectUrl(ctx.redirectUrl, "invalid_saml_response", "Unknown or expired request ID"));
|
|
671
|
+
}
|
|
672
|
+
if (storedRequest.providerId !== ctx.providerId) {
|
|
673
|
+
c.context.logger.error("SAML InResponseTo validation failed: provider mismatch", {
|
|
674
|
+
inResponseTo,
|
|
675
|
+
expectedProvider: storedRequest.providerId,
|
|
676
|
+
actualProvider: ctx.providerId
|
|
677
|
+
});
|
|
678
|
+
await c.context.internalAdapter.deleteVerificationByIdentifier(`${AUTHN_REQUEST_KEY_PREFIX}${inResponseTo}`);
|
|
679
|
+
throw c.redirect(errorRedirectUrl(ctx.redirectUrl, "invalid_saml_response", "Provider mismatch"));
|
|
680
|
+
}
|
|
681
|
+
await c.context.internalAdapter.deleteVerificationByIdentifier(`${AUTHN_REQUEST_KEY_PREFIX}${inResponseTo}`);
|
|
682
|
+
} else if (!allowIdpInitiated) {
|
|
683
|
+
c.context.logger.error("SAML IdP-initiated SSO rejected: InResponseTo missing and allowIdpInitiated is false", { providerId: ctx.providerId });
|
|
684
|
+
throw c.redirect(errorRedirectUrl(ctx.redirectUrl, "unsolicited_response", "IdP-initiated SSO not allowed"));
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
/**
|
|
688
|
+
* Validates the AudienceRestriction of a SAML assertion.
|
|
689
|
+
*
|
|
690
|
+
* Per SAML 2.0 Core §2.5.1, an assertion's Audience element specifies
|
|
691
|
+
* the intended recipient SP. Without this check, an assertion issued
|
|
692
|
+
* for a different SP (e.g., another application sharing the same IdP)
|
|
693
|
+
* could be accepted.
|
|
694
|
+
*/
|
|
695
|
+
function validateAudience(c, ctx) {
|
|
696
|
+
if (!ctx.expectedAudience) return;
|
|
697
|
+
const audience = ctx.extract.audience;
|
|
698
|
+
if (!audience) {
|
|
699
|
+
c.context.logger.error("SAML assertion missing AudienceRestriction but audience is configured — rejecting", { providerId: ctx.providerId });
|
|
700
|
+
throw c.redirect(errorRedirectUrl(ctx.redirectUrl, "invalid_saml_response", "Audience restriction missing"));
|
|
701
|
+
}
|
|
702
|
+
const audiences = Array.isArray(audience) ? audience : [audience];
|
|
703
|
+
if (!audiences.includes(ctx.expectedAudience)) {
|
|
704
|
+
c.context.logger.error("SAML audience mismatch: assertion was issued for a different service provider", {
|
|
705
|
+
expected: ctx.expectedAudience,
|
|
706
|
+
received: audiences,
|
|
707
|
+
providerId: ctx.providerId
|
|
708
|
+
});
|
|
709
|
+
throw c.redirect(errorRedirectUrl(ctx.redirectUrl, "invalid_saml_response", "Audience mismatch"));
|
|
710
|
+
}
|
|
711
|
+
}
|
|
712
|
+
//#endregion
|
|
628
713
|
//#region src/routes/schemas.ts
|
|
629
714
|
const oidcMappingSchema = z.object({
|
|
630
715
|
id: z.string().optional(),
|
|
@@ -649,7 +734,13 @@ const oidcConfigSchema = z.object({
|
|
|
649
734
|
authorizationEndpoint: z.string().url().optional(),
|
|
650
735
|
tokenEndpoint: z.string().url().optional(),
|
|
651
736
|
userInfoEndpoint: z.string().url().optional(),
|
|
652
|
-
tokenEndpointAuthentication: z.enum([
|
|
737
|
+
tokenEndpointAuthentication: z.enum([
|
|
738
|
+
"client_secret_post",
|
|
739
|
+
"client_secret_basic",
|
|
740
|
+
"private_key_jwt"
|
|
741
|
+
]).optional(),
|
|
742
|
+
privateKeyId: z.string().optional(),
|
|
743
|
+
privateKeyAlgorithm: z.string().optional(),
|
|
653
744
|
jwksEndpoint: z.string().url().optional(),
|
|
654
745
|
discoveryEndpoint: z.string().url().optional(),
|
|
655
746
|
scopes: z.array(z.string()).optional(),
|
|
@@ -900,7 +991,9 @@ function mergeOIDCConfig(current, updates, issuer) {
|
|
|
900
991
|
tokenEndpoint: updates.tokenEndpoint ?? current.tokenEndpoint,
|
|
901
992
|
userInfoEndpoint: updates.userInfoEndpoint ?? current.userInfoEndpoint,
|
|
902
993
|
jwksEndpoint: updates.jwksEndpoint ?? current.jwksEndpoint,
|
|
903
|
-
tokenEndpointAuthentication: updates.tokenEndpointAuthentication ?? current.tokenEndpointAuthentication
|
|
994
|
+
tokenEndpointAuthentication: updates.tokenEndpointAuthentication ?? current.tokenEndpointAuthentication,
|
|
995
|
+
privateKeyId: updates.privateKeyId ?? current.privateKeyId,
|
|
996
|
+
privateKeyAlgorithm: updates.privateKeyAlgorithm ?? current.privateKeyAlgorithm
|
|
904
997
|
};
|
|
905
998
|
}
|
|
906
999
|
const updateSSOProvider = (options) => {
|
|
@@ -945,6 +1038,8 @@ const updateSSOProvider = (options) => {
|
|
|
945
1038
|
if (body.oidcConfig) {
|
|
946
1039
|
const currentOidcConfig = parseAndValidateConfig(existingProvider.oidcConfig, "OIDC");
|
|
947
1040
|
const updatedOidcConfig = mergeOIDCConfig(currentOidcConfig, body.oidcConfig, updateData.issuer || currentOidcConfig.issuer || existingProvider.issuer);
|
|
1041
|
+
if (updatedOidcConfig.tokenEndpointAuthentication !== "private_key_jwt" && !updatedOidcConfig.clientSecret) throw new APIError("BAD_REQUEST", { message: "clientSecret is required when using client_secret_basic or client_secret_post authentication" });
|
|
1042
|
+
if (updatedOidcConfig.tokenEndpointAuthentication === "private_key_jwt" && !options?.resolvePrivateKey && !options?.defaultSSO?.some((p) => p.providerId === providerId && "privateKey" in p && p.privateKey)) throw new APIError("BAD_REQUEST", { message: "private_key_jwt authentication requires either a resolvePrivateKey callback or a privateKey in defaultSSO" });
|
|
948
1043
|
updateData.oidcConfig = JSON.stringify(updatedOidcConfig);
|
|
949
1044
|
}
|
|
950
1045
|
await ctx.context.adapter.update({
|
|
@@ -1242,11 +1337,13 @@ function parseURL(name, endpoint, base) {
|
|
|
1242
1337
|
* @returns The selected authentication method
|
|
1243
1338
|
*/
|
|
1244
1339
|
function selectTokenEndpointAuthMethod(doc, existing) {
|
|
1340
|
+
if (existing === "private_key_jwt") return existing;
|
|
1245
1341
|
if (existing) return existing;
|
|
1246
1342
|
const supported = doc.token_endpoint_auth_methods_supported;
|
|
1247
1343
|
if (!supported || supported.length === 0) return "client_secret_basic";
|
|
1248
1344
|
if (supported.includes("client_secret_basic")) return "client_secret_basic";
|
|
1249
1345
|
if (supported.includes("client_secret_post")) return "client_secret_post";
|
|
1346
|
+
if (supported.includes("private_key_jwt")) return "private_key_jwt";
|
|
1250
1347
|
return "client_secret_basic";
|
|
1251
1348
|
}
|
|
1252
1349
|
/**
|
|
@@ -1436,13 +1533,15 @@ async function findSAMLProvider(providerId, options, adapter) {
|
|
|
1436
1533
|
samlConfig: res.samlConfig ? safeJsonParse(res.samlConfig) || void 0 : void 0
|
|
1437
1534
|
};
|
|
1438
1535
|
}
|
|
1439
|
-
function createSP(config, baseURL, providerId,
|
|
1536
|
+
function createSP(config, baseURL, providerId, opts) {
|
|
1537
|
+
const spData = config.spMetadata;
|
|
1440
1538
|
const sloLocation = `${baseURL}/sso/saml2/sp/slo/${providerId}`;
|
|
1539
|
+
const acsUrl = config.callbackUrl || `${baseURL}/sso/saml2/sp/acs/${providerId}`;
|
|
1441
1540
|
return saml.ServiceProvider({
|
|
1442
|
-
entityID:
|
|
1443
|
-
assertionConsumerService: [{
|
|
1541
|
+
entityID: spData?.entityID || config.issuer,
|
|
1542
|
+
assertionConsumerService: spData?.metadata ? void 0 : [{
|
|
1444
1543
|
Binding: "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST",
|
|
1445
|
-
Location:
|
|
1544
|
+
Location: acsUrl
|
|
1446
1545
|
}],
|
|
1447
1546
|
singleLogoutService: [{
|
|
1448
1547
|
Binding: "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST",
|
|
@@ -1452,11 +1551,16 @@ function createSP(config, baseURL, providerId, sloOptions) {
|
|
|
1452
1551
|
Location: sloLocation
|
|
1453
1552
|
}],
|
|
1454
1553
|
wantMessageSigned: config.wantAssertionsSigned || false,
|
|
1455
|
-
wantLogoutRequestSigned: sloOptions?.wantLogoutRequestSigned ?? false,
|
|
1456
|
-
wantLogoutResponseSigned: sloOptions?.wantLogoutResponseSigned ?? false,
|
|
1457
|
-
metadata:
|
|
1458
|
-
privateKey:
|
|
1459
|
-
privateKeyPass:
|
|
1554
|
+
wantLogoutRequestSigned: opts?.sloOptions?.wantLogoutRequestSigned ?? false,
|
|
1555
|
+
wantLogoutResponseSigned: opts?.sloOptions?.wantLogoutResponseSigned ?? false,
|
|
1556
|
+
metadata: spData?.metadata,
|
|
1557
|
+
privateKey: spData?.privateKey || config.privateKey,
|
|
1558
|
+
privateKeyPass: spData?.privateKeyPass,
|
|
1559
|
+
isAssertionEncrypted: spData?.isAssertionEncrypted || false,
|
|
1560
|
+
encPrivateKey: spData?.encPrivateKey,
|
|
1561
|
+
encPrivateKeyPass: spData?.encPrivateKeyPass,
|
|
1562
|
+
nameIDFormat: config.identifierFormat ? [config.identifierFormat] : void 0,
|
|
1563
|
+
relayState: opts?.relayState
|
|
1460
1564
|
});
|
|
1461
1565
|
}
|
|
1462
1566
|
function createIdP(config) {
|
|
@@ -1465,6 +1569,7 @@ function createIdP(config) {
|
|
|
1465
1569
|
metadata: idpData.metadata,
|
|
1466
1570
|
privateKey: idpData.privateKey,
|
|
1467
1571
|
privateKeyPass: idpData.privateKeyPass,
|
|
1572
|
+
isAssertionEncrypted: idpData.isAssertionEncrypted,
|
|
1468
1573
|
encPrivateKey: idpData.encPrivateKey,
|
|
1469
1574
|
encPrivateKeyPass: idpData.encPrivateKeyPass
|
|
1470
1575
|
});
|
|
@@ -1475,7 +1580,11 @@ function createIdP(config) {
|
|
|
1475
1580
|
Location: config.entryPoint
|
|
1476
1581
|
}],
|
|
1477
1582
|
singleLogoutService: idpData?.singleLogoutService,
|
|
1478
|
-
signingCert: idpData?.cert || config.cert
|
|
1583
|
+
signingCert: idpData?.cert || config.cert,
|
|
1584
|
+
wantAuthnRequestsSigned: config.authnRequestsSigned || false,
|
|
1585
|
+
isAssertionEncrypted: idpData?.isAssertionEncrypted || false,
|
|
1586
|
+
encPrivateKey: idpData?.encPrivateKey,
|
|
1587
|
+
encPrivateKeyPass: idpData?.encPrivateKeyPass
|
|
1479
1588
|
});
|
|
1480
1589
|
}
|
|
1481
1590
|
function escapeHtml(str) {
|
|
@@ -1491,20 +1600,7 @@ function createSAMLPostForm(action, samlParam, samlValue, relayState) {
|
|
|
1491
1600
|
return new Response(html, { headers: { "Content-Type": "text/html" } });
|
|
1492
1601
|
}
|
|
1493
1602
|
//#endregion
|
|
1494
|
-
//#region src/
|
|
1495
|
-
/**
|
|
1496
|
-
* Builds the OIDC redirect URI. Uses the shared `redirectURI` option
|
|
1497
|
-
* when set, otherwise falls back to `/sso/callback/:providerId`.
|
|
1498
|
-
*/
|
|
1499
|
-
function getOIDCRedirectURI(baseURL, providerId, options) {
|
|
1500
|
-
if (options?.redirectURI?.trim()) try {
|
|
1501
|
-
new URL(options.redirectURI);
|
|
1502
|
-
return options.redirectURI;
|
|
1503
|
-
} catch {
|
|
1504
|
-
return `${baseURL}${options.redirectURI.startsWith("/") ? options.redirectURI : `/${options.redirectURI}`}`;
|
|
1505
|
-
}
|
|
1506
|
-
return `${baseURL}/sso/callback/${providerId}`;
|
|
1507
|
-
}
|
|
1603
|
+
//#region src/saml/timestamp.ts
|
|
1508
1604
|
/**
|
|
1509
1605
|
* Validates SAML assertion timestamp conditions (NotBefore/NotOnOrAfter).
|
|
1510
1606
|
* Prevents acceptance of expired or future-dated assertions.
|
|
@@ -1544,9 +1640,39 @@ function validateSAMLTimestamp(conditions, options = {}) {
|
|
|
1544
1640
|
});
|
|
1545
1641
|
}
|
|
1546
1642
|
}
|
|
1643
|
+
//#endregion
|
|
1644
|
+
//#region src/routes/saml-pipeline.ts
|
|
1645
|
+
/**
|
|
1646
|
+
* Validates and returns a safe redirect URL.
|
|
1647
|
+
* - Prevents open redirect attacks by validating against trusted origins
|
|
1648
|
+
* - Prevents redirect loops by checking if URL points to callback route
|
|
1649
|
+
* - Falls back to appOrigin if URL is invalid or unsafe
|
|
1650
|
+
*/
|
|
1651
|
+
function getSafeRedirectUrl(url, callbackPath, appOrigin, isTrustedOrigin) {
|
|
1652
|
+
if (!url) return appOrigin;
|
|
1653
|
+
if (url.startsWith("/") && !url.startsWith("//")) {
|
|
1654
|
+
try {
|
|
1655
|
+
const absoluteUrl = new URL(url, appOrigin);
|
|
1656
|
+
if (absoluteUrl.origin !== appOrigin) return appOrigin;
|
|
1657
|
+
const callbackPathname = new URL(callbackPath).pathname;
|
|
1658
|
+
if (absoluteUrl.pathname === callbackPathname) return appOrigin;
|
|
1659
|
+
} catch {
|
|
1660
|
+
return appOrigin;
|
|
1661
|
+
}
|
|
1662
|
+
return url;
|
|
1663
|
+
}
|
|
1664
|
+
if (!isTrustedOrigin(url, { allowRelativePaths: false })) return appOrigin;
|
|
1665
|
+
try {
|
|
1666
|
+
const callbackPathname = new URL(callbackPath).pathname;
|
|
1667
|
+
if (new URL(url).pathname === callbackPathname) return appOrigin;
|
|
1668
|
+
} catch {
|
|
1669
|
+
if (url === callbackPath || url.startsWith(`${callbackPath}?`)) return appOrigin;
|
|
1670
|
+
}
|
|
1671
|
+
return url;
|
|
1672
|
+
}
|
|
1547
1673
|
/**
|
|
1548
1674
|
* Extracts the Assertion ID from a SAML response XML.
|
|
1549
|
-
*
|
|
1675
|
+
* Used for replay protection per SAML 2.0 Core section 2.3.3.
|
|
1550
1676
|
*/
|
|
1551
1677
|
function extractAssertionId(samlContent) {
|
|
1552
1678
|
try {
|
|
@@ -1565,6 +1691,208 @@ function extractAssertionId(samlContent) {
|
|
|
1565
1691
|
return null;
|
|
1566
1692
|
}
|
|
1567
1693
|
}
|
|
1694
|
+
/**
|
|
1695
|
+
* Unified SAML response processing pipeline.
|
|
1696
|
+
*
|
|
1697
|
+
* Both `/sso/saml2/callback/:providerId` (POST) and `/sso/saml2/sp/acs/:providerId`
|
|
1698
|
+
* delegate to this function. It handles the full lifecycle: provider lookup,
|
|
1699
|
+
* SP/IdP construction, response validation, session creation, and redirect
|
|
1700
|
+
* URL computation.
|
|
1701
|
+
*/
|
|
1702
|
+
async function processSAMLResponse(ctx, params, options) {
|
|
1703
|
+
const { providerId, currentCallbackPath } = params;
|
|
1704
|
+
const appOrigin = new URL(ctx.context.baseURL).origin;
|
|
1705
|
+
const maxResponseSize = options?.saml?.maxResponseSize ?? 262144;
|
|
1706
|
+
if (new TextEncoder().encode(params.SAMLResponse).length > maxResponseSize) throw new APIError("BAD_REQUEST", { message: `SAML response exceeds maximum allowed size (${maxResponseSize} bytes)` });
|
|
1707
|
+
const SAMLResponse = params.SAMLResponse.replace(/\s+/g, "");
|
|
1708
|
+
let relayState = null;
|
|
1709
|
+
if (params.RelayState) try {
|
|
1710
|
+
relayState = await parseRelayState(ctx);
|
|
1711
|
+
} catch {
|
|
1712
|
+
relayState = null;
|
|
1713
|
+
}
|
|
1714
|
+
const provider = await findSAMLProvider(providerId, options, ctx.context.adapter);
|
|
1715
|
+
if (!provider?.samlConfig) throw new APIError("NOT_FOUND", { message: "No SAML provider found" });
|
|
1716
|
+
if (options?.domainVerification?.enabled && !("domainVerified" in provider && provider.domainVerified)) throw new APIError("UNAUTHORIZED", { message: "Provider domain has not been verified" });
|
|
1717
|
+
const parsedSamlConfig = typeof provider.samlConfig === "object" ? provider.samlConfig : safeJsonParse(provider.samlConfig);
|
|
1718
|
+
if (!parsedSamlConfig) throw new APIError("BAD_REQUEST", { message: "Invalid SAML configuration" });
|
|
1719
|
+
const sp = createSP(parsedSamlConfig, ctx.context.baseURL, providerId);
|
|
1720
|
+
const idp = createIdP(parsedSamlConfig);
|
|
1721
|
+
const samlRedirectUrl = getSafeRedirectUrl(relayState?.callbackURL || parsedSamlConfig.callbackUrl, params.currentCallbackPath, appOrigin, (url, settings) => ctx.context.isTrustedOrigin(url, settings));
|
|
1722
|
+
validateSingleAssertion(SAMLResponse);
|
|
1723
|
+
let parsedResponse;
|
|
1724
|
+
try {
|
|
1725
|
+
parsedResponse = await sp.parseLoginResponse(idp, "post", { body: {
|
|
1726
|
+
SAMLResponse,
|
|
1727
|
+
RelayState: params.RelayState || void 0
|
|
1728
|
+
} });
|
|
1729
|
+
if (!parsedResponse?.extract) throw new Error("Invalid SAML response structure");
|
|
1730
|
+
} catch (error) {
|
|
1731
|
+
ctx.context.logger.error("SAML response validation failed", {
|
|
1732
|
+
error,
|
|
1733
|
+
samlResponsePreview: SAMLResponse.slice(0, 200)
|
|
1734
|
+
});
|
|
1735
|
+
throw new APIError("BAD_REQUEST", {
|
|
1736
|
+
message: "Invalid SAML response",
|
|
1737
|
+
details: error instanceof Error ? error.message : String(error)
|
|
1738
|
+
});
|
|
1739
|
+
}
|
|
1740
|
+
const { extract } = parsedResponse;
|
|
1741
|
+
validateSAMLAlgorithms(parsedResponse, options?.saml?.algorithms);
|
|
1742
|
+
validateSAMLTimestamp(extract.conditions, {
|
|
1743
|
+
clockSkew: options?.saml?.clockSkew,
|
|
1744
|
+
requireTimestamps: options?.saml?.requireTimestamps,
|
|
1745
|
+
logger: ctx.context.logger
|
|
1746
|
+
});
|
|
1747
|
+
await validateInResponseTo(ctx, {
|
|
1748
|
+
extract,
|
|
1749
|
+
providerId,
|
|
1750
|
+
options: {
|
|
1751
|
+
enableInResponseToValidation: options?.saml?.enableInResponseToValidation,
|
|
1752
|
+
allowIdpInitiated: options?.saml?.allowIdpInitiated
|
|
1753
|
+
},
|
|
1754
|
+
redirectUrl: samlRedirectUrl
|
|
1755
|
+
});
|
|
1756
|
+
validateAudience(ctx, {
|
|
1757
|
+
extract,
|
|
1758
|
+
expectedAudience: parsedSamlConfig.audience,
|
|
1759
|
+
providerId,
|
|
1760
|
+
redirectUrl: samlRedirectUrl
|
|
1761
|
+
});
|
|
1762
|
+
const samlContent = parsedResponse.samlContent;
|
|
1763
|
+
const assertionId = samlContent ? extractAssertionId(samlContent) : null;
|
|
1764
|
+
if (assertionId) {
|
|
1765
|
+
const issuer = idp.entityMeta.getEntityID();
|
|
1766
|
+
const conditions = extract.conditions;
|
|
1767
|
+
const clockSkew = options?.saml?.clockSkew ?? 3e5;
|
|
1768
|
+
const expiresAt = conditions?.notOnOrAfter ? new Date(conditions.notOnOrAfter).getTime() + clockSkew : Date.now() + DEFAULT_ASSERTION_TTL_MS;
|
|
1769
|
+
const existingAssertion = await ctx.context.internalAdapter.findVerificationValue(`${USED_ASSERTION_KEY_PREFIX}${assertionId}`);
|
|
1770
|
+
let isReplay = false;
|
|
1771
|
+
if (existingAssertion) try {
|
|
1772
|
+
if (JSON.parse(existingAssertion.value).expiresAt >= Date.now()) isReplay = true;
|
|
1773
|
+
} catch (error) {
|
|
1774
|
+
ctx.context.logger.warn("Failed to parse stored assertion record", {
|
|
1775
|
+
assertionId,
|
|
1776
|
+
error
|
|
1777
|
+
});
|
|
1778
|
+
}
|
|
1779
|
+
if (isReplay) {
|
|
1780
|
+
ctx.context.logger.error("SAML assertion replay detected: assertion ID already used", {
|
|
1781
|
+
assertionId,
|
|
1782
|
+
issuer,
|
|
1783
|
+
providerId
|
|
1784
|
+
});
|
|
1785
|
+
throw ctx.redirect(`${samlRedirectUrl}?error=replay_detected&error_description=SAML+assertion+has+already+been+used`);
|
|
1786
|
+
}
|
|
1787
|
+
await ctx.context.internalAdapter.createVerificationValue({
|
|
1788
|
+
identifier: `${USED_ASSERTION_KEY_PREFIX}${assertionId}`,
|
|
1789
|
+
value: JSON.stringify({
|
|
1790
|
+
assertionId,
|
|
1791
|
+
issuer,
|
|
1792
|
+
providerId,
|
|
1793
|
+
usedAt: Date.now(),
|
|
1794
|
+
expiresAt
|
|
1795
|
+
}),
|
|
1796
|
+
expiresAt: new Date(expiresAt)
|
|
1797
|
+
});
|
|
1798
|
+
} else ctx.context.logger.warn("Could not extract assertion ID for replay protection", { providerId });
|
|
1799
|
+
const attributes = extract.attributes || {};
|
|
1800
|
+
const mapping = parsedSamlConfig.mapping ?? {};
|
|
1801
|
+
const userInfo = {
|
|
1802
|
+
...Object.fromEntries(Object.entries(mapping.extraFields || {}).map(([key, value]) => [key, attributes[value]])),
|
|
1803
|
+
id: attributes[mapping.id || "nameID"] || extract.nameID,
|
|
1804
|
+
email: (attributes[mapping.email || "email"] || extract.nameID || "").toLowerCase(),
|
|
1805
|
+
name: [attributes[mapping.firstName || "givenName"], attributes[mapping.lastName || "surname"]].filter(Boolean).join(" ") || attributes[mapping.name || "displayName"] || extract.nameID,
|
|
1806
|
+
emailVerified: options?.trustEmailVerified && mapping.emailVerified ? attributes[mapping.emailVerified] || false : false
|
|
1807
|
+
};
|
|
1808
|
+
if (!userInfo.id || !userInfo.email) {
|
|
1809
|
+
ctx.context.logger.error("Missing essential user info from SAML response", {
|
|
1810
|
+
attributes: Object.keys(attributes),
|
|
1811
|
+
mapping,
|
|
1812
|
+
extractedId: userInfo.id,
|
|
1813
|
+
extractedEmail: userInfo.email
|
|
1814
|
+
});
|
|
1815
|
+
throw new APIError("BAD_REQUEST", { message: "Unable to extract user ID or email from SAML response" });
|
|
1816
|
+
}
|
|
1817
|
+
const isTrustedProvider = ctx.context.trustedProviders.includes(providerId) || "domainVerified" in provider && !!provider.domainVerified && validateEmailDomain(userInfo.email, provider.domain);
|
|
1818
|
+
const callbackUrl = relayState?.callbackURL || parsedSamlConfig.callbackUrl || ctx.context.baseURL;
|
|
1819
|
+
const result = await handleOAuthUserInfo(ctx, {
|
|
1820
|
+
userInfo: {
|
|
1821
|
+
email: userInfo.email,
|
|
1822
|
+
name: userInfo.name || userInfo.email,
|
|
1823
|
+
id: userInfo.id,
|
|
1824
|
+
emailVerified: Boolean(userInfo.emailVerified)
|
|
1825
|
+
},
|
|
1826
|
+
account: {
|
|
1827
|
+
providerId,
|
|
1828
|
+
accountId: userInfo.id,
|
|
1829
|
+
accessToken: "",
|
|
1830
|
+
refreshToken: ""
|
|
1831
|
+
},
|
|
1832
|
+
callbackURL: callbackUrl,
|
|
1833
|
+
disableSignUp: options?.disableImplicitSignUp,
|
|
1834
|
+
isTrustedProvider
|
|
1835
|
+
});
|
|
1836
|
+
if (result.error) throw ctx.redirect(`${samlRedirectUrl}?error=${result.error.split(" ").join("_")}`);
|
|
1837
|
+
const { session, user } = result.data;
|
|
1838
|
+
if (options?.provisionUser && (result.isRegister || options.provisionUserOnEveryLogin)) await options.provisionUser({
|
|
1839
|
+
user,
|
|
1840
|
+
userInfo,
|
|
1841
|
+
provider
|
|
1842
|
+
});
|
|
1843
|
+
await assignOrganizationFromProvider(ctx, {
|
|
1844
|
+
user,
|
|
1845
|
+
profile: {
|
|
1846
|
+
providerType: "saml",
|
|
1847
|
+
providerId,
|
|
1848
|
+
accountId: userInfo.id,
|
|
1849
|
+
email: userInfo.email,
|
|
1850
|
+
emailVerified: Boolean(userInfo.emailVerified),
|
|
1851
|
+
rawAttributes: attributes
|
|
1852
|
+
},
|
|
1853
|
+
provider,
|
|
1854
|
+
provisioningOptions: options?.organizationProvisioning
|
|
1855
|
+
});
|
|
1856
|
+
await setSessionCookie(ctx, {
|
|
1857
|
+
session,
|
|
1858
|
+
user
|
|
1859
|
+
});
|
|
1860
|
+
if (options?.saml?.enableSingleLogout && extract.nameID) {
|
|
1861
|
+
const samlSessionKey = `${SAML_SESSION_KEY_PREFIX}${providerId}:${extract.nameID}`;
|
|
1862
|
+
const samlSessionData = {
|
|
1863
|
+
sessionId: session.id,
|
|
1864
|
+
providerId,
|
|
1865
|
+
nameID: extract.nameID,
|
|
1866
|
+
sessionIndex: extract.sessionIndex?.sessionIndex
|
|
1867
|
+
};
|
|
1868
|
+
await ctx.context.internalAdapter.createVerificationValue({
|
|
1869
|
+
identifier: samlSessionKey,
|
|
1870
|
+
value: JSON.stringify(samlSessionData),
|
|
1871
|
+
expiresAt: session.expiresAt
|
|
1872
|
+
}).catch((e) => ctx.context.logger.warn("Failed to create SAML session record", { error: e }));
|
|
1873
|
+
await ctx.context.internalAdapter.createVerificationValue({
|
|
1874
|
+
identifier: `${SAML_SESSION_BY_ID_PREFIX}${session.id}`,
|
|
1875
|
+
value: samlSessionKey,
|
|
1876
|
+
expiresAt: session.expiresAt
|
|
1877
|
+
}).catch((e) => ctx.context.logger.warn("Failed to create SAML session lookup record", e));
|
|
1878
|
+
}
|
|
1879
|
+
return getSafeRedirectUrl(relayState?.callbackURL || parsedSamlConfig.callbackUrl, currentCallbackPath, appOrigin, (url, settings) => ctx.context.isTrustedOrigin(url, settings));
|
|
1880
|
+
}
|
|
1881
|
+
//#endregion
|
|
1882
|
+
//#region src/routes/sso.ts
|
|
1883
|
+
/**
|
|
1884
|
+
* Builds the OIDC redirect URI. Uses the shared `redirectURI` option
|
|
1885
|
+
* when set, otherwise falls back to `/sso/callback/:providerId`.
|
|
1886
|
+
*/
|
|
1887
|
+
function getOIDCRedirectURI(baseURL, providerId, options) {
|
|
1888
|
+
if (options?.redirectURI?.trim()) try {
|
|
1889
|
+
new URL(options.redirectURI);
|
|
1890
|
+
return options.redirectURI;
|
|
1891
|
+
} catch {
|
|
1892
|
+
return `${baseURL}${options.redirectURI.startsWith("/") ? options.redirectURI : `/${options.redirectURI}`}`;
|
|
1893
|
+
}
|
|
1894
|
+
return `${baseURL}/sso/callback/${providerId}`;
|
|
1895
|
+
}
|
|
1568
1896
|
const spMetadataQuerySchema = z.object({
|
|
1569
1897
|
providerId: z.string(),
|
|
1570
1898
|
format: z.enum(["xml", "json"]).default("xml")
|
|
@@ -1602,7 +1930,7 @@ const spMetadata = (options) => {
|
|
|
1602
1930
|
entityID: parsedSamlConfig.spMetadata?.entityID || parsedSamlConfig.issuer,
|
|
1603
1931
|
assertionConsumerService: [{
|
|
1604
1932
|
Binding: "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST",
|
|
1605
|
-
Location: parsedSamlConfig.callbackUrl || `${ctx.context.baseURL}/sso/saml2/sp/acs/${
|
|
1933
|
+
Location: parsedSamlConfig.callbackUrl || `${ctx.context.baseURL}/sso/saml2/sp/acs/${ctx.query.providerId}`
|
|
1606
1934
|
}],
|
|
1607
1935
|
singleLogoutService,
|
|
1608
1936
|
wantMessageSigned: parsedSamlConfig.wantAssertionsSigned || false,
|
|
@@ -1618,11 +1946,17 @@ const ssoProviderBodySchema = z.object({
|
|
|
1618
1946
|
domain: z.string({}).meta({ description: "The domain(s) of the provider. For enterprise multi-domain SSO where a single IdP serves multiple email domains, use comma-separated values (e.g., 'company.com,subsidiary.com,acquired-company.com')" }),
|
|
1619
1947
|
oidcConfig: z.object({
|
|
1620
1948
|
clientId: z.string({}).meta({ description: "The client ID" }),
|
|
1621
|
-
clientSecret: z.string({}).meta({ description: "The client secret" }),
|
|
1949
|
+
clientSecret: z.string({}).optional().meta({ description: "The client secret. Required for client_secret_basic/client_secret_post. Optional for private_key_jwt." }),
|
|
1622
1950
|
authorizationEndpoint: z.string({}).meta({ description: "The authorization endpoint" }).optional(),
|
|
1623
1951
|
tokenEndpoint: z.string({}).meta({ description: "The token endpoint" }).optional(),
|
|
1624
1952
|
userInfoEndpoint: z.string({}).meta({ description: "The user info endpoint" }).optional(),
|
|
1625
|
-
tokenEndpointAuthentication: z.enum([
|
|
1953
|
+
tokenEndpointAuthentication: z.enum([
|
|
1954
|
+
"client_secret_post",
|
|
1955
|
+
"client_secret_basic",
|
|
1956
|
+
"private_key_jwt"
|
|
1957
|
+
]).optional(),
|
|
1958
|
+
privateKeyId: z.string().optional(),
|
|
1959
|
+
privateKeyAlgorithm: z.string().optional(),
|
|
1626
1960
|
jwksEndpoint: z.string({}).meta({ description: "The JWKS endpoint" }).optional(),
|
|
1627
1961
|
discoveryEndpoint: z.string().optional(),
|
|
1628
1962
|
skipDiscovery: z.boolean().meta({ description: "Skip OIDC discovery during registration. When true, you must provide authorizationEndpoint, tokenEndpoint, and jwksEndpoint manually." }).optional(),
|
|
@@ -1925,6 +2259,8 @@ const registerSSOProvider = (options) => {
|
|
|
1925
2259
|
authorizationEndpoint: body.oidcConfig.authorizationEndpoint,
|
|
1926
2260
|
tokenEndpoint: body.oidcConfig.tokenEndpoint,
|
|
1927
2261
|
tokenEndpointAuthentication: body.oidcConfig.tokenEndpointAuthentication || "client_secret_basic",
|
|
2262
|
+
privateKeyId: body.oidcConfig.privateKeyId,
|
|
2263
|
+
privateKeyAlgorithm: body.oidcConfig.privateKeyAlgorithm,
|
|
1928
2264
|
jwksEndpoint: body.oidcConfig.jwksEndpoint,
|
|
1929
2265
|
pkce: body.oidcConfig.pkce,
|
|
1930
2266
|
discoveryEndpoint: body.oidcConfig.discoveryEndpoint || `${body.issuer}/.well-known/openid-configuration`,
|
|
@@ -1941,6 +2277,8 @@ const registerSSOProvider = (options) => {
|
|
|
1941
2277
|
authorizationEndpoint: hydratedOIDCConfig.authorizationEndpoint,
|
|
1942
2278
|
tokenEndpoint: hydratedOIDCConfig.tokenEndpoint,
|
|
1943
2279
|
tokenEndpointAuthentication: hydratedOIDCConfig.tokenEndpointAuthentication,
|
|
2280
|
+
privateKeyId: body.oidcConfig.privateKeyId,
|
|
2281
|
+
privateKeyAlgorithm: body.oidcConfig.privateKeyAlgorithm,
|
|
1944
2282
|
jwksEndpoint: hydratedOIDCConfig.jwksEndpoint,
|
|
1945
2283
|
pkce: body.oidcConfig.pkce,
|
|
1946
2284
|
discoveryEndpoint: hydratedOIDCConfig.discoveryEndpoint,
|
|
@@ -1950,17 +2288,35 @@ const registerSSOProvider = (options) => {
|
|
|
1950
2288
|
overrideUserInfo: ctx.body.overrideUserInfo || options?.defaultOverrideUserInfo || false
|
|
1951
2289
|
});
|
|
1952
2290
|
};
|
|
1953
|
-
if (body.samlConfig)
|
|
1954
|
-
|
|
1955
|
-
|
|
1956
|
-
|
|
2291
|
+
if (body.samlConfig) {
|
|
2292
|
+
validateConfigAlgorithms({
|
|
2293
|
+
signatureAlgorithm: body.samlConfig.signatureAlgorithm,
|
|
2294
|
+
digestAlgorithm: body.samlConfig.digestAlgorithm
|
|
2295
|
+
}, options?.saml?.algorithms);
|
|
2296
|
+
const hasIdpMetadata = body.samlConfig.idpMetadata?.metadata;
|
|
2297
|
+
let hasEntryPoint = false;
|
|
2298
|
+
if (body.samlConfig.entryPoint) try {
|
|
2299
|
+
new URL(body.samlConfig.entryPoint);
|
|
2300
|
+
hasEntryPoint = true;
|
|
2301
|
+
} catch {}
|
|
2302
|
+
const hasSingleSignOnService = body.samlConfig.idpMetadata?.singleSignOnService?.length;
|
|
2303
|
+
if (!hasIdpMetadata && !hasEntryPoint && !hasSingleSignOnService) throw new APIError("BAD_REQUEST", { message: "SAML configuration requires either idpMetadata.metadata (IdP metadata XML), idpMetadata.singleSignOnService, or a valid entryPoint URL" });
|
|
2304
|
+
}
|
|
1957
2305
|
const provider = await ctx.context.adapter.create({
|
|
1958
2306
|
model: "ssoProvider",
|
|
1959
2307
|
data: {
|
|
1960
2308
|
issuer: body.issuer,
|
|
1961
2309
|
domain: body.domain,
|
|
1962
2310
|
domainVerified: false,
|
|
1963
|
-
oidcConfig:
|
|
2311
|
+
oidcConfig: (() => {
|
|
2312
|
+
const config = buildOIDCConfig();
|
|
2313
|
+
if (config) {
|
|
2314
|
+
const parsed = JSON.parse(config);
|
|
2315
|
+
if (parsed.tokenEndpointAuthentication !== "private_key_jwt" && !parsed.clientSecret) throw new APIError("BAD_REQUEST", { message: "clientSecret is required when using client_secret_basic or client_secret_post authentication" });
|
|
2316
|
+
if (parsed.tokenEndpointAuthentication === "private_key_jwt" && !options?.resolvePrivateKey && !options?.defaultSSO?.some((p) => p.providerId === body.providerId && "privateKey" in p && p.privateKey)) throw new APIError("BAD_REQUEST", { message: "private_key_jwt authentication requires either a resolvePrivateKey callback or a privateKey in defaultSSO" });
|
|
2317
|
+
}
|
|
2318
|
+
return config;
|
|
2319
|
+
})(),
|
|
1964
2320
|
samlConfig: body.samlConfig ? JSON.stringify({
|
|
1965
2321
|
issuer: body.issuer,
|
|
1966
2322
|
entryPoint: body.samlConfig.entryPoint,
|
|
@@ -2313,6 +2669,30 @@ async function handleOIDCCallback(ctx, options, providerId, stateData) {
|
|
|
2313
2669
|
]
|
|
2314
2670
|
};
|
|
2315
2671
|
if (!config.tokenEndpoint) throw ctx.redirect(`${errorURL || callbackURL}?error=invalid_provider&error_description=token_endpoint_not_found`);
|
|
2672
|
+
let authMethod = "basic";
|
|
2673
|
+
if (config.tokenEndpointAuthentication === "client_secret_post") authMethod = "post";
|
|
2674
|
+
else if (config.tokenEndpointAuthentication === "private_key_jwt") authMethod = "private_key_jwt";
|
|
2675
|
+
let clientAssertionConfig;
|
|
2676
|
+
if (authMethod === "private_key_jwt") {
|
|
2677
|
+
let resolved;
|
|
2678
|
+
const matchingDefault = options?.defaultSSO?.find((p) => p.providerId === provider.providerId && "privateKey" in p && p.privateKey);
|
|
2679
|
+
if (matchingDefault && "privateKey" in matchingDefault) resolved = matchingDefault.privateKey;
|
|
2680
|
+
if (!resolved && options?.resolvePrivateKey) resolved = await options.resolvePrivateKey({
|
|
2681
|
+
providerId: provider.providerId,
|
|
2682
|
+
keyId: config.privateKeyId,
|
|
2683
|
+
issuer: config.issuer
|
|
2684
|
+
});
|
|
2685
|
+
if (!resolved) throw ctx.redirect(`${errorURL || callbackURL}?error=invalid_provider&error_description=no_private_key_available`);
|
|
2686
|
+
const rawAlg = config.privateKeyAlgorithm ?? resolved.algorithm;
|
|
2687
|
+
const algorithm = rawAlg && ASSERTION_SIGNING_ALGORITHMS.includes(rawAlg) ? rawAlg : void 0;
|
|
2688
|
+
clientAssertionConfig = {
|
|
2689
|
+
privateKeyJwk: resolved.privateKeyJwk,
|
|
2690
|
+
privateKeyPem: resolved.privateKeyPem,
|
|
2691
|
+
kid: config.privateKeyId ?? resolved.kid,
|
|
2692
|
+
algorithm,
|
|
2693
|
+
tokenEndpoint: config.tokenEndpoint
|
|
2694
|
+
};
|
|
2695
|
+
}
|
|
2316
2696
|
const tokenResponse = await validateAuthorizationCode({
|
|
2317
2697
|
code,
|
|
2318
2698
|
codeVerifier: config.pkce ? stateData.codeVerifier : void 0,
|
|
@@ -2322,7 +2702,8 @@ async function handleOIDCCallback(ctx, options, providerId, stateData) {
|
|
|
2322
2702
|
clientSecret: config.clientSecret
|
|
2323
2703
|
},
|
|
2324
2704
|
tokenEndpoint: config.tokenEndpoint,
|
|
2325
|
-
authentication:
|
|
2705
|
+
authentication: authMethod,
|
|
2706
|
+
clientAssertion: clientAssertionConfig
|
|
2326
2707
|
}).catch((e) => {
|
|
2327
2708
|
ctx.context.logger.error("Error validating authorization code", e);
|
|
2328
2709
|
if (e instanceof BetterFetchError) throw ctx.redirect(`${errorURL || callbackURL}?error=invalid_provider&error_description=${e.message}`);
|
|
@@ -2476,34 +2857,6 @@ const callbackSSOSAMLBodySchema = z.object({
|
|
|
2476
2857
|
SAMLResponse: z.string(),
|
|
2477
2858
|
RelayState: z.string().optional()
|
|
2478
2859
|
});
|
|
2479
|
-
/**
|
|
2480
|
-
* Validates and returns a safe redirect URL.
|
|
2481
|
-
* - Prevents open redirect attacks by validating against trusted origins
|
|
2482
|
-
* - Prevents redirect loops by checking if URL points to callback route
|
|
2483
|
-
* - Falls back to appOrigin if URL is invalid or unsafe
|
|
2484
|
-
*/
|
|
2485
|
-
const getSafeRedirectUrl = (url, callbackPath, appOrigin, isTrustedOrigin) => {
|
|
2486
|
-
if (!url) return appOrigin;
|
|
2487
|
-
if (url.startsWith("/") && !url.startsWith("//")) {
|
|
2488
|
-
try {
|
|
2489
|
-
const absoluteUrl = new URL(url, appOrigin);
|
|
2490
|
-
if (absoluteUrl.origin !== appOrigin) return appOrigin;
|
|
2491
|
-
const callbackPathname = new URL(callbackPath).pathname;
|
|
2492
|
-
if (absoluteUrl.pathname === callbackPathname) return appOrigin;
|
|
2493
|
-
} catch {
|
|
2494
|
-
return appOrigin;
|
|
2495
|
-
}
|
|
2496
|
-
return url;
|
|
2497
|
-
}
|
|
2498
|
-
if (!isTrustedOrigin(url, { allowRelativePaths: false })) return appOrigin;
|
|
2499
|
-
try {
|
|
2500
|
-
const callbackPathname = new URL(callbackPath).pathname;
|
|
2501
|
-
if (new URL(url).pathname === callbackPathname) return appOrigin;
|
|
2502
|
-
} catch {
|
|
2503
|
-
if (url === callbackPath || url.startsWith(`${callbackPath}?`)) return appOrigin;
|
|
2504
|
-
}
|
|
2505
|
-
return url;
|
|
2506
|
-
};
|
|
2507
2860
|
const callbackSSOSAML = (options) => {
|
|
2508
2861
|
return createAuthEndpoint("/sso/saml2/callback/:providerId", {
|
|
2509
2862
|
method: ["GET", "POST"],
|
|
@@ -2535,261 +2888,12 @@ const callbackSSOSAML = (options) => {
|
|
|
2535
2888
|
throw ctx.redirect(safeRedirectUrl);
|
|
2536
2889
|
}
|
|
2537
2890
|
if (!ctx.body?.SAMLResponse) throw new APIError("BAD_REQUEST", { message: "SAMLResponse is required for POST requests" });
|
|
2538
|
-
const
|
|
2539
|
-
|
|
2540
|
-
|
|
2541
|
-
|
|
2542
|
-
|
|
2543
|
-
|
|
2544
|
-
} catch {
|
|
2545
|
-
relayState = null;
|
|
2546
|
-
}
|
|
2547
|
-
let provider = null;
|
|
2548
|
-
if (options?.defaultSSO?.length) {
|
|
2549
|
-
const matchingDefault = options.defaultSSO.find((defaultProvider) => defaultProvider.providerId === providerId);
|
|
2550
|
-
if (matchingDefault) provider = {
|
|
2551
|
-
...matchingDefault,
|
|
2552
|
-
userId: "default",
|
|
2553
|
-
issuer: matchingDefault.samlConfig?.issuer || "",
|
|
2554
|
-
...options.domainVerification?.enabled ? { domainVerified: true } : {}
|
|
2555
|
-
};
|
|
2556
|
-
}
|
|
2557
|
-
if (!provider) provider = await ctx.context.adapter.findOne({
|
|
2558
|
-
model: "ssoProvider",
|
|
2559
|
-
where: [{
|
|
2560
|
-
field: "providerId",
|
|
2561
|
-
value: providerId
|
|
2562
|
-
}]
|
|
2563
|
-
}).then((res) => {
|
|
2564
|
-
if (!res) return null;
|
|
2565
|
-
return {
|
|
2566
|
-
...res,
|
|
2567
|
-
samlConfig: res.samlConfig ? safeJsonParse(res.samlConfig) || void 0 : void 0
|
|
2568
|
-
};
|
|
2569
|
-
});
|
|
2570
|
-
if (!provider) throw new APIError("NOT_FOUND", { message: "No provider found for the given providerId" });
|
|
2571
|
-
if (options?.domainVerification?.enabled && !("domainVerified" in provider && provider.domainVerified)) throw new APIError("UNAUTHORIZED", { message: "Provider domain has not been verified" });
|
|
2572
|
-
const parsedSamlConfig = safeJsonParse(provider.samlConfig);
|
|
2573
|
-
if (!parsedSamlConfig) throw new APIError("BAD_REQUEST", { message: "Invalid SAML configuration" });
|
|
2574
|
-
const idpData = parsedSamlConfig.idpMetadata;
|
|
2575
|
-
let idp = null;
|
|
2576
|
-
if (!idpData?.metadata) idp = saml.IdentityProvider({
|
|
2577
|
-
entityID: idpData?.entityID || parsedSamlConfig.issuer,
|
|
2578
|
-
singleSignOnService: idpData?.singleSignOnService || [{
|
|
2579
|
-
Binding: "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect",
|
|
2580
|
-
Location: parsedSamlConfig.entryPoint
|
|
2581
|
-
}],
|
|
2582
|
-
signingCert: idpData?.cert || parsedSamlConfig.cert,
|
|
2583
|
-
wantAuthnRequestsSigned: parsedSamlConfig.authnRequestsSigned || false,
|
|
2584
|
-
isAssertionEncrypted: idpData?.isAssertionEncrypted || false,
|
|
2585
|
-
encPrivateKey: idpData?.encPrivateKey,
|
|
2586
|
-
encPrivateKeyPass: idpData?.encPrivateKeyPass
|
|
2587
|
-
});
|
|
2588
|
-
else idp = saml.IdentityProvider({
|
|
2589
|
-
metadata: idpData.metadata,
|
|
2590
|
-
privateKey: idpData.privateKey,
|
|
2591
|
-
privateKeyPass: idpData.privateKeyPass,
|
|
2592
|
-
isAssertionEncrypted: idpData.isAssertionEncrypted,
|
|
2593
|
-
encPrivateKey: idpData.encPrivateKey,
|
|
2594
|
-
encPrivateKeyPass: idpData.encPrivateKeyPass
|
|
2595
|
-
});
|
|
2596
|
-
const spData = parsedSamlConfig.spMetadata;
|
|
2597
|
-
const sp = saml.ServiceProvider({
|
|
2598
|
-
metadata: spData?.metadata,
|
|
2599
|
-
entityID: spData?.entityID || parsedSamlConfig.issuer,
|
|
2600
|
-
assertionConsumerService: spData?.metadata ? void 0 : [{
|
|
2601
|
-
Binding: "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST",
|
|
2602
|
-
Location: parsedSamlConfig.callbackUrl
|
|
2603
|
-
}],
|
|
2604
|
-
privateKey: spData?.privateKey || parsedSamlConfig.privateKey,
|
|
2605
|
-
privateKeyPass: spData?.privateKeyPass,
|
|
2606
|
-
isAssertionEncrypted: spData?.isAssertionEncrypted || false,
|
|
2607
|
-
encPrivateKey: spData?.encPrivateKey,
|
|
2608
|
-
encPrivateKeyPass: spData?.encPrivateKeyPass,
|
|
2609
|
-
wantMessageSigned: parsedSamlConfig.wantAssertionsSigned || false,
|
|
2610
|
-
nameIDFormat: parsedSamlConfig.identifierFormat ? [parsedSamlConfig.identifierFormat] : void 0
|
|
2611
|
-
});
|
|
2612
|
-
validateSingleAssertion(SAMLResponse);
|
|
2613
|
-
let parsedResponse;
|
|
2614
|
-
try {
|
|
2615
|
-
parsedResponse = await sp.parseLoginResponse(idp, "post", { body: {
|
|
2616
|
-
SAMLResponse,
|
|
2617
|
-
RelayState: ctx.body.RelayState || void 0
|
|
2618
|
-
} });
|
|
2619
|
-
if (!parsedResponse?.extract) throw new Error("Invalid SAML response structure");
|
|
2620
|
-
} catch (error) {
|
|
2621
|
-
ctx.context.logger.error("SAML response validation failed", {
|
|
2622
|
-
error,
|
|
2623
|
-
decodedResponse: Buffer.from(SAMLResponse, "base64").toString("utf-8")
|
|
2624
|
-
});
|
|
2625
|
-
throw new APIError("BAD_REQUEST", {
|
|
2626
|
-
message: "Invalid SAML response",
|
|
2627
|
-
details: error instanceof Error ? error.message : String(error)
|
|
2628
|
-
});
|
|
2629
|
-
}
|
|
2630
|
-
const { extract } = parsedResponse;
|
|
2631
|
-
validateSAMLAlgorithms(parsedResponse, options?.saml?.algorithms);
|
|
2632
|
-
validateSAMLTimestamp(extract.conditions, {
|
|
2633
|
-
clockSkew: options?.saml?.clockSkew,
|
|
2634
|
-
requireTimestamps: options?.saml?.requireTimestamps,
|
|
2635
|
-
logger: ctx.context.logger
|
|
2636
|
-
});
|
|
2637
|
-
const inResponseTo = extract.inResponseTo;
|
|
2638
|
-
if (options?.saml?.enableInResponseToValidation !== false) {
|
|
2639
|
-
const allowIdpInitiated = options?.saml?.allowIdpInitiated !== false;
|
|
2640
|
-
if (inResponseTo) {
|
|
2641
|
-
let storedRequest = null;
|
|
2642
|
-
const verification = await ctx.context.internalAdapter.findVerificationValue(`${AUTHN_REQUEST_KEY_PREFIX}${inResponseTo}`);
|
|
2643
|
-
if (verification) try {
|
|
2644
|
-
storedRequest = JSON.parse(verification.value);
|
|
2645
|
-
if (storedRequest && storedRequest.expiresAt < Date.now()) storedRequest = null;
|
|
2646
|
-
} catch {
|
|
2647
|
-
storedRequest = null;
|
|
2648
|
-
}
|
|
2649
|
-
if (!storedRequest) {
|
|
2650
|
-
ctx.context.logger.error("SAML InResponseTo validation failed: unknown or expired request ID", {
|
|
2651
|
-
inResponseTo,
|
|
2652
|
-
providerId: provider.providerId
|
|
2653
|
-
});
|
|
2654
|
-
const redirectUrl = relayState?.callbackURL || parsedSamlConfig.callbackUrl || ctx.context.baseURL;
|
|
2655
|
-
throw ctx.redirect(`${redirectUrl}?error=invalid_saml_response&error_description=Unknown+or+expired+request+ID`);
|
|
2656
|
-
}
|
|
2657
|
-
if (storedRequest.providerId !== provider.providerId) {
|
|
2658
|
-
ctx.context.logger.error("SAML InResponseTo validation failed: provider mismatch", {
|
|
2659
|
-
inResponseTo,
|
|
2660
|
-
expectedProvider: storedRequest.providerId,
|
|
2661
|
-
actualProvider: provider.providerId
|
|
2662
|
-
});
|
|
2663
|
-
await ctx.context.internalAdapter.deleteVerificationByIdentifier(`${AUTHN_REQUEST_KEY_PREFIX}${inResponseTo}`);
|
|
2664
|
-
const redirectUrl = relayState?.callbackURL || parsedSamlConfig.callbackUrl || ctx.context.baseURL;
|
|
2665
|
-
throw ctx.redirect(`${redirectUrl}?error=invalid_saml_response&error_description=Provider+mismatch`);
|
|
2666
|
-
}
|
|
2667
|
-
await ctx.context.internalAdapter.deleteVerificationByIdentifier(`${AUTHN_REQUEST_KEY_PREFIX}${inResponseTo}`);
|
|
2668
|
-
} else if (!allowIdpInitiated) {
|
|
2669
|
-
ctx.context.logger.error("SAML IdP-initiated SSO rejected: InResponseTo missing and allowIdpInitiated is false", { providerId: provider.providerId });
|
|
2670
|
-
const redirectUrl = relayState?.callbackURL || parsedSamlConfig.callbackUrl || ctx.context.baseURL;
|
|
2671
|
-
throw ctx.redirect(`${redirectUrl}?error=unsolicited_response&error_description=IdP-initiated+SSO+not+allowed`);
|
|
2672
|
-
}
|
|
2673
|
-
}
|
|
2674
|
-
const samlContent = parsedResponse.samlContent;
|
|
2675
|
-
const assertionId = samlContent ? extractAssertionId(samlContent) : null;
|
|
2676
|
-
if (assertionId) {
|
|
2677
|
-
const issuer = idp.entityMeta.getEntityID();
|
|
2678
|
-
const conditions = extract.conditions;
|
|
2679
|
-
const clockSkew = options?.saml?.clockSkew ?? 3e5;
|
|
2680
|
-
const expiresAt = conditions?.notOnOrAfter ? new Date(conditions.notOnOrAfter).getTime() + clockSkew : Date.now() + DEFAULT_ASSERTION_TTL_MS;
|
|
2681
|
-
const existingAssertion = await ctx.context.internalAdapter.findVerificationValue(`${USED_ASSERTION_KEY_PREFIX}${assertionId}`);
|
|
2682
|
-
let isReplay = false;
|
|
2683
|
-
if (existingAssertion) try {
|
|
2684
|
-
if (JSON.parse(existingAssertion.value).expiresAt >= Date.now()) isReplay = true;
|
|
2685
|
-
} catch (error) {
|
|
2686
|
-
ctx.context.logger.warn("Failed to parse stored assertion record", {
|
|
2687
|
-
assertionId,
|
|
2688
|
-
error
|
|
2689
|
-
});
|
|
2690
|
-
}
|
|
2691
|
-
if (isReplay) {
|
|
2692
|
-
ctx.context.logger.error("SAML assertion replay detected: assertion ID already used", {
|
|
2693
|
-
assertionId,
|
|
2694
|
-
issuer,
|
|
2695
|
-
providerId: provider.providerId
|
|
2696
|
-
});
|
|
2697
|
-
const redirectUrl = relayState?.callbackURL || parsedSamlConfig.callbackUrl || ctx.context.baseURL;
|
|
2698
|
-
throw ctx.redirect(`${redirectUrl}?error=replay_detected&error_description=SAML+assertion+has+already+been+used`);
|
|
2699
|
-
}
|
|
2700
|
-
await ctx.context.internalAdapter.createVerificationValue({
|
|
2701
|
-
identifier: `${USED_ASSERTION_KEY_PREFIX}${assertionId}`,
|
|
2702
|
-
value: JSON.stringify({
|
|
2703
|
-
assertionId,
|
|
2704
|
-
issuer,
|
|
2705
|
-
providerId: provider.providerId,
|
|
2706
|
-
usedAt: Date.now(),
|
|
2707
|
-
expiresAt
|
|
2708
|
-
}),
|
|
2709
|
-
expiresAt: new Date(expiresAt)
|
|
2710
|
-
});
|
|
2711
|
-
} else ctx.context.logger.warn("Could not extract assertion ID for replay protection", { providerId: provider.providerId });
|
|
2712
|
-
const attributes = extract.attributes || {};
|
|
2713
|
-
const mapping = parsedSamlConfig.mapping ?? {};
|
|
2714
|
-
const userInfo = {
|
|
2715
|
-
...Object.fromEntries(Object.entries(mapping.extraFields || {}).map(([key, value]) => [key, attributes[value]])),
|
|
2716
|
-
id: attributes[mapping.id || "nameID"] || extract.nameID,
|
|
2717
|
-
email: (attributes[mapping.email || "email"] || extract.nameID).toLowerCase(),
|
|
2718
|
-
name: [attributes[mapping.firstName || "givenName"], attributes[mapping.lastName || "surname"]].filter(Boolean).join(" ") || attributes[mapping.name || "displayName"] || extract.nameID,
|
|
2719
|
-
emailVerified: options?.trustEmailVerified && mapping.emailVerified ? attributes[mapping.emailVerified] || false : false
|
|
2720
|
-
};
|
|
2721
|
-
if (!userInfo.id || !userInfo.email) {
|
|
2722
|
-
ctx.context.logger.error("Missing essential user info from SAML response", {
|
|
2723
|
-
attributes: Object.keys(attributes),
|
|
2724
|
-
mapping,
|
|
2725
|
-
extractedId: userInfo.id,
|
|
2726
|
-
extractedEmail: userInfo.email
|
|
2727
|
-
});
|
|
2728
|
-
throw new APIError("BAD_REQUEST", { message: "Unable to extract user ID or email from SAML response" });
|
|
2729
|
-
}
|
|
2730
|
-
const isTrustedProvider = ctx.context.trustedProviders.includes(provider.providerId) || "domainVerified" in provider && !!provider.domainVerified && validateEmailDomain(userInfo.email, provider.domain);
|
|
2731
|
-
const callbackUrl = relayState?.callbackURL || parsedSamlConfig.callbackUrl || ctx.context.baseURL;
|
|
2732
|
-
const result = await handleOAuthUserInfo(ctx, {
|
|
2733
|
-
userInfo: {
|
|
2734
|
-
email: userInfo.email,
|
|
2735
|
-
name: userInfo.name || userInfo.email,
|
|
2736
|
-
id: userInfo.id,
|
|
2737
|
-
emailVerified: Boolean(userInfo.emailVerified)
|
|
2738
|
-
},
|
|
2739
|
-
account: {
|
|
2740
|
-
providerId: provider.providerId,
|
|
2741
|
-
accountId: userInfo.id,
|
|
2742
|
-
accessToken: "",
|
|
2743
|
-
refreshToken: ""
|
|
2744
|
-
},
|
|
2745
|
-
callbackURL: callbackUrl,
|
|
2746
|
-
disableSignUp: options?.disableImplicitSignUp,
|
|
2747
|
-
isTrustedProvider
|
|
2748
|
-
});
|
|
2749
|
-
if (result.error) throw ctx.redirect(`${callbackUrl}?error=${result.error.split(" ").join("_")}`);
|
|
2750
|
-
const { session, user } = result.data;
|
|
2751
|
-
if (options?.provisionUser && (result.isRegister || options.provisionUserOnEveryLogin)) await options.provisionUser({
|
|
2752
|
-
user,
|
|
2753
|
-
userInfo,
|
|
2754
|
-
provider
|
|
2755
|
-
});
|
|
2756
|
-
await assignOrganizationFromProvider(ctx, {
|
|
2757
|
-
user,
|
|
2758
|
-
profile: {
|
|
2759
|
-
providerType: "saml",
|
|
2760
|
-
providerId: provider.providerId,
|
|
2761
|
-
accountId: userInfo.id,
|
|
2762
|
-
email: userInfo.email,
|
|
2763
|
-
emailVerified: Boolean(userInfo.emailVerified),
|
|
2764
|
-
rawAttributes: attributes
|
|
2765
|
-
},
|
|
2766
|
-
provider,
|
|
2767
|
-
provisioningOptions: options?.organizationProvisioning
|
|
2768
|
-
});
|
|
2769
|
-
await setSessionCookie(ctx, {
|
|
2770
|
-
session,
|
|
2771
|
-
user
|
|
2772
|
-
});
|
|
2773
|
-
if (options?.saml?.enableSingleLogout && extract.nameID) {
|
|
2774
|
-
const samlSessionKey = `${SAML_SESSION_KEY_PREFIX}${provider.providerId}:${extract.nameID}`;
|
|
2775
|
-
const samlSessionData = {
|
|
2776
|
-
sessionId: session.id,
|
|
2777
|
-
providerId: provider.providerId,
|
|
2778
|
-
nameID: extract.nameID,
|
|
2779
|
-
sessionIndex: extract.sessionIndex
|
|
2780
|
-
};
|
|
2781
|
-
await ctx.context.internalAdapter.createVerificationValue({
|
|
2782
|
-
identifier: samlSessionKey,
|
|
2783
|
-
value: JSON.stringify(samlSessionData),
|
|
2784
|
-
expiresAt: session.expiresAt
|
|
2785
|
-
}).catch((e) => ctx.context.logger.warn("Failed to create SAML session record", { error: e }));
|
|
2786
|
-
await ctx.context.internalAdapter.createVerificationValue({
|
|
2787
|
-
identifier: `${SAML_SESSION_BY_ID_PREFIX}${session.id}`,
|
|
2788
|
-
value: samlSessionKey,
|
|
2789
|
-
expiresAt: session.expiresAt
|
|
2790
|
-
}).catch((e) => ctx.context.logger.warn("Failed to create SAML session lookup record", e));
|
|
2791
|
-
}
|
|
2792
|
-
const safeRedirectUrl = getSafeRedirectUrl(relayState?.callbackURL || parsedSamlConfig.callbackUrl, currentCallbackPath, appOrigin, (url, settings) => ctx.context.isTrustedOrigin(url, settings));
|
|
2891
|
+
const safeRedirectUrl = await processSAMLResponse(ctx, {
|
|
2892
|
+
SAMLResponse: ctx.body.SAMLResponse,
|
|
2893
|
+
RelayState: ctx.body.RelayState,
|
|
2894
|
+
providerId,
|
|
2895
|
+
currentCallbackPath
|
|
2896
|
+
}, options);
|
|
2793
2897
|
throw ctx.redirect(safeRedirectUrl);
|
|
2794
2898
|
});
|
|
2795
2899
|
};
|
|
@@ -2815,253 +2919,24 @@ const acsEndpoint = (options) => {
|
|
|
2815
2919
|
const { providerId } = ctx.params;
|
|
2816
2920
|
const currentCallbackPath = `${ctx.context.baseURL}/sso/saml2/sp/acs/${providerId}`;
|
|
2817
2921
|
const appOrigin = new URL(ctx.context.baseURL).origin;
|
|
2818
|
-
const maxResponseSize = options?.saml?.maxResponseSize ?? 262144;
|
|
2819
|
-
if (new TextEncoder().encode(ctx.body.SAMLResponse).length > maxResponseSize) throw new APIError("BAD_REQUEST", { message: `SAML response exceeds maximum allowed size (${maxResponseSize} bytes)` });
|
|
2820
|
-
const SAMLResponse = ctx.body.SAMLResponse.replace(/\s+/g, "");
|
|
2821
|
-
let relayState = null;
|
|
2822
|
-
if (ctx.body.RelayState) try {
|
|
2823
|
-
relayState = await parseRelayState(ctx);
|
|
2824
|
-
} catch {
|
|
2825
|
-
relayState = null;
|
|
2826
|
-
}
|
|
2827
|
-
let provider = null;
|
|
2828
|
-
if (options?.defaultSSO?.length) {
|
|
2829
|
-
const matchingDefault = providerId ? options.defaultSSO.find((defaultProvider) => defaultProvider.providerId === providerId) : options.defaultSSO[0];
|
|
2830
|
-
if (matchingDefault) provider = {
|
|
2831
|
-
issuer: matchingDefault.samlConfig?.issuer || "",
|
|
2832
|
-
providerId: matchingDefault.providerId,
|
|
2833
|
-
userId: "default",
|
|
2834
|
-
samlConfig: matchingDefault.samlConfig,
|
|
2835
|
-
domain: matchingDefault.domain,
|
|
2836
|
-
...options.domainVerification?.enabled ? { domainVerified: true } : {}
|
|
2837
|
-
};
|
|
2838
|
-
} else provider = await ctx.context.adapter.findOne({
|
|
2839
|
-
model: "ssoProvider",
|
|
2840
|
-
where: [{
|
|
2841
|
-
field: "providerId",
|
|
2842
|
-
value: providerId
|
|
2843
|
-
}]
|
|
2844
|
-
}).then((res) => {
|
|
2845
|
-
if (!res) return null;
|
|
2846
|
-
return {
|
|
2847
|
-
...res,
|
|
2848
|
-
samlConfig: res.samlConfig ? safeJsonParse(res.samlConfig) || void 0 : void 0
|
|
2849
|
-
};
|
|
2850
|
-
});
|
|
2851
|
-
if (!provider?.samlConfig) throw new APIError("NOT_FOUND", { message: "No SAML provider found" });
|
|
2852
|
-
if (options?.domainVerification?.enabled && !("domainVerified" in provider && provider.domainVerified)) throw new APIError("UNAUTHORIZED", { message: "Provider domain has not been verified" });
|
|
2853
|
-
const parsedSamlConfig = provider.samlConfig;
|
|
2854
|
-
const sp = saml.ServiceProvider({
|
|
2855
|
-
entityID: parsedSamlConfig.spMetadata?.entityID || parsedSamlConfig.issuer,
|
|
2856
|
-
assertionConsumerService: [{
|
|
2857
|
-
Binding: "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST",
|
|
2858
|
-
Location: parsedSamlConfig.callbackUrl || `${ctx.context.baseURL}/sso/saml2/sp/acs/${providerId}`
|
|
2859
|
-
}],
|
|
2860
|
-
wantMessageSigned: parsedSamlConfig.wantAssertionsSigned || false,
|
|
2861
|
-
metadata: parsedSamlConfig.spMetadata?.metadata,
|
|
2862
|
-
privateKey: parsedSamlConfig.spMetadata?.privateKey || parsedSamlConfig.privateKey,
|
|
2863
|
-
privateKeyPass: parsedSamlConfig.spMetadata?.privateKeyPass,
|
|
2864
|
-
nameIDFormat: parsedSamlConfig.identifierFormat ? [parsedSamlConfig.identifierFormat] : void 0
|
|
2865
|
-
});
|
|
2866
|
-
const idpData = parsedSamlConfig.idpMetadata;
|
|
2867
|
-
const idp = !idpData?.metadata ? saml.IdentityProvider({
|
|
2868
|
-
entityID: idpData?.entityID || parsedSamlConfig.issuer,
|
|
2869
|
-
singleSignOnService: idpData?.singleSignOnService || [{
|
|
2870
|
-
Binding: "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect",
|
|
2871
|
-
Location: parsedSamlConfig.entryPoint
|
|
2872
|
-
}],
|
|
2873
|
-
signingCert: idpData?.cert || parsedSamlConfig.cert
|
|
2874
|
-
}) : saml.IdentityProvider({ metadata: idpData.metadata });
|
|
2875
2922
|
try {
|
|
2876
|
-
|
|
2923
|
+
const safeRedirectUrl = await processSAMLResponse(ctx, {
|
|
2924
|
+
SAMLResponse: ctx.body.SAMLResponse,
|
|
2925
|
+
RelayState: ctx.body.RelayState,
|
|
2926
|
+
providerId,
|
|
2927
|
+
currentCallbackPath
|
|
2928
|
+
}, options);
|
|
2929
|
+
throw ctx.redirect(safeRedirectUrl);
|
|
2877
2930
|
} catch (error) {
|
|
2878
|
-
if (error instanceof
|
|
2879
|
-
|
|
2880
|
-
const
|
|
2881
|
-
|
|
2931
|
+
if (error instanceof Response || error && typeof error === "object" && "status" in error && error.status === 302) throw error;
|
|
2932
|
+
if (error instanceof APIError && error.statusCode === 400) {
|
|
2933
|
+
const internalCode = error.body?.code || "";
|
|
2934
|
+
const errorCode = internalCode === "SAML_MULTIPLE_ASSERTIONS" ? "multiple_assertions" : internalCode === "SAML_NO_ASSERTION" ? "no_assertion" : internalCode.toLowerCase() || "saml_error";
|
|
2935
|
+
const redirectUrl = getSafeRedirectUrl(ctx.body.RelayState || void 0, currentCallbackPath, appOrigin, (url, settings) => ctx.context.isTrustedOrigin(url, settings));
|
|
2936
|
+
throw ctx.redirect(`${redirectUrl}${redirectUrl.includes("?") ? "&" : "?"}error=${encodeURIComponent(errorCode)}&error_description=${encodeURIComponent(error.message)}`);
|
|
2882
2937
|
}
|
|
2883
2938
|
throw error;
|
|
2884
2939
|
}
|
|
2885
|
-
let parsedResponse;
|
|
2886
|
-
try {
|
|
2887
|
-
parsedResponse = await sp.parseLoginResponse(idp, "post", { body: {
|
|
2888
|
-
SAMLResponse,
|
|
2889
|
-
RelayState: ctx.body.RelayState || void 0
|
|
2890
|
-
} });
|
|
2891
|
-
if (!parsedResponse?.extract) throw new Error("Invalid SAML response structure");
|
|
2892
|
-
} catch (error) {
|
|
2893
|
-
ctx.context.logger.error("SAML response validation failed", {
|
|
2894
|
-
error,
|
|
2895
|
-
decodedResponse: Buffer.from(SAMLResponse, "base64").toString("utf-8")
|
|
2896
|
-
});
|
|
2897
|
-
throw new APIError("BAD_REQUEST", {
|
|
2898
|
-
message: "Invalid SAML response",
|
|
2899
|
-
details: error instanceof Error ? error.message : String(error)
|
|
2900
|
-
});
|
|
2901
|
-
}
|
|
2902
|
-
const { extract } = parsedResponse;
|
|
2903
|
-
validateSAMLAlgorithms(parsedResponse, options?.saml?.algorithms);
|
|
2904
|
-
validateSAMLTimestamp(extract.conditions, {
|
|
2905
|
-
clockSkew: options?.saml?.clockSkew,
|
|
2906
|
-
requireTimestamps: options?.saml?.requireTimestamps,
|
|
2907
|
-
logger: ctx.context.logger
|
|
2908
|
-
});
|
|
2909
|
-
const inResponseToAcs = extract.inResponseTo;
|
|
2910
|
-
if (options?.saml?.enableInResponseToValidation !== false) {
|
|
2911
|
-
const allowIdpInitiated = options?.saml?.allowIdpInitiated !== false;
|
|
2912
|
-
if (inResponseToAcs) {
|
|
2913
|
-
let storedRequest = null;
|
|
2914
|
-
const verification = await ctx.context.internalAdapter.findVerificationValue(`${AUTHN_REQUEST_KEY_PREFIX}${inResponseToAcs}`);
|
|
2915
|
-
if (verification) try {
|
|
2916
|
-
storedRequest = JSON.parse(verification.value);
|
|
2917
|
-
if (storedRequest && storedRequest.expiresAt < Date.now()) storedRequest = null;
|
|
2918
|
-
} catch {
|
|
2919
|
-
storedRequest = null;
|
|
2920
|
-
}
|
|
2921
|
-
if (!storedRequest) {
|
|
2922
|
-
ctx.context.logger.error("SAML InResponseTo validation failed: unknown or expired request ID", {
|
|
2923
|
-
inResponseTo: inResponseToAcs,
|
|
2924
|
-
providerId
|
|
2925
|
-
});
|
|
2926
|
-
const redirectUrl = relayState?.callbackURL || parsedSamlConfig.callbackUrl || ctx.context.baseURL;
|
|
2927
|
-
throw ctx.redirect(`${redirectUrl}?error=invalid_saml_response&error_description=Unknown+or+expired+request+ID`);
|
|
2928
|
-
}
|
|
2929
|
-
if (storedRequest.providerId !== providerId) {
|
|
2930
|
-
ctx.context.logger.error("SAML InResponseTo validation failed: provider mismatch", {
|
|
2931
|
-
inResponseTo: inResponseToAcs,
|
|
2932
|
-
expectedProvider: storedRequest.providerId,
|
|
2933
|
-
actualProvider: providerId
|
|
2934
|
-
});
|
|
2935
|
-
await ctx.context.internalAdapter.deleteVerificationByIdentifier(`${AUTHN_REQUEST_KEY_PREFIX}${inResponseToAcs}`);
|
|
2936
|
-
const redirectUrl = relayState?.callbackURL || parsedSamlConfig.callbackUrl || ctx.context.baseURL;
|
|
2937
|
-
throw ctx.redirect(`${redirectUrl}?error=invalid_saml_response&error_description=Provider+mismatch`);
|
|
2938
|
-
}
|
|
2939
|
-
await ctx.context.internalAdapter.deleteVerificationByIdentifier(`${AUTHN_REQUEST_KEY_PREFIX}${inResponseToAcs}`);
|
|
2940
|
-
} else if (!allowIdpInitiated) {
|
|
2941
|
-
ctx.context.logger.error("SAML IdP-initiated SSO rejected: InResponseTo missing and allowIdpInitiated is false", { providerId });
|
|
2942
|
-
const redirectUrl = relayState?.callbackURL || parsedSamlConfig.callbackUrl || ctx.context.baseURL;
|
|
2943
|
-
throw ctx.redirect(`${redirectUrl}?error=unsolicited_response&error_description=IdP-initiated+SSO+not+allowed`);
|
|
2944
|
-
}
|
|
2945
|
-
}
|
|
2946
|
-
const assertionIdAcs = extractAssertionId(Buffer.from(SAMLResponse, "base64").toString("utf-8"));
|
|
2947
|
-
if (assertionIdAcs) {
|
|
2948
|
-
const issuer = idp.entityMeta.getEntityID();
|
|
2949
|
-
const conditions = extract.conditions;
|
|
2950
|
-
const clockSkew = options?.saml?.clockSkew ?? 3e5;
|
|
2951
|
-
const expiresAt = conditions?.notOnOrAfter ? new Date(conditions.notOnOrAfter).getTime() + clockSkew : Date.now() + DEFAULT_ASSERTION_TTL_MS;
|
|
2952
|
-
const existingAssertion = await ctx.context.internalAdapter.findVerificationValue(`${USED_ASSERTION_KEY_PREFIX}${assertionIdAcs}`);
|
|
2953
|
-
let isReplay = false;
|
|
2954
|
-
if (existingAssertion) try {
|
|
2955
|
-
if (JSON.parse(existingAssertion.value).expiresAt >= Date.now()) isReplay = true;
|
|
2956
|
-
} catch (error) {
|
|
2957
|
-
ctx.context.logger.warn("Failed to parse stored assertion record", {
|
|
2958
|
-
assertionId: assertionIdAcs,
|
|
2959
|
-
error
|
|
2960
|
-
});
|
|
2961
|
-
}
|
|
2962
|
-
if (isReplay) {
|
|
2963
|
-
ctx.context.logger.error("SAML assertion replay detected: assertion ID already used", {
|
|
2964
|
-
assertionId: assertionIdAcs,
|
|
2965
|
-
issuer,
|
|
2966
|
-
providerId
|
|
2967
|
-
});
|
|
2968
|
-
const redirectUrl = relayState?.callbackURL || parsedSamlConfig.callbackUrl || ctx.context.baseURL;
|
|
2969
|
-
throw ctx.redirect(`${redirectUrl}?error=replay_detected&error_description=SAML+assertion+has+already+been+used`);
|
|
2970
|
-
}
|
|
2971
|
-
await ctx.context.internalAdapter.createVerificationValue({
|
|
2972
|
-
identifier: `${USED_ASSERTION_KEY_PREFIX}${assertionIdAcs}`,
|
|
2973
|
-
value: JSON.stringify({
|
|
2974
|
-
assertionId: assertionIdAcs,
|
|
2975
|
-
issuer,
|
|
2976
|
-
providerId,
|
|
2977
|
-
usedAt: Date.now(),
|
|
2978
|
-
expiresAt
|
|
2979
|
-
}),
|
|
2980
|
-
expiresAt: new Date(expiresAt)
|
|
2981
|
-
});
|
|
2982
|
-
} else ctx.context.logger.warn("Could not extract assertion ID for replay protection", { providerId });
|
|
2983
|
-
const attributes = extract.attributes || {};
|
|
2984
|
-
const mapping = parsedSamlConfig.mapping ?? {};
|
|
2985
|
-
const userInfo = {
|
|
2986
|
-
...Object.fromEntries(Object.entries(mapping.extraFields || {}).map(([key, value]) => [key, attributes[value]])),
|
|
2987
|
-
id: attributes[mapping.id || "nameID"] || extract.nameID,
|
|
2988
|
-
email: (attributes[mapping.email || "email"] || extract.nameID).toLowerCase(),
|
|
2989
|
-
name: [attributes[mapping.firstName || "givenName"], attributes[mapping.lastName || "surname"]].filter(Boolean).join(" ") || attributes[mapping.name || "displayName"] || extract.nameID,
|
|
2990
|
-
emailVerified: options?.trustEmailVerified && mapping.emailVerified ? attributes[mapping.emailVerified] || false : false
|
|
2991
|
-
};
|
|
2992
|
-
if (!userInfo.id || !userInfo.email) {
|
|
2993
|
-
ctx.context.logger.error("Missing essential user info from SAML response", {
|
|
2994
|
-
attributes: Object.keys(attributes),
|
|
2995
|
-
mapping,
|
|
2996
|
-
extractedId: userInfo.id,
|
|
2997
|
-
extractedEmail: userInfo.email
|
|
2998
|
-
});
|
|
2999
|
-
throw new APIError("BAD_REQUEST", { message: "Unable to extract user ID or email from SAML response" });
|
|
3000
|
-
}
|
|
3001
|
-
const isTrustedProvider = ctx.context.trustedProviders.includes(provider.providerId) || "domainVerified" in provider && !!provider.domainVerified && validateEmailDomain(userInfo.email, provider.domain);
|
|
3002
|
-
const callbackUrl = relayState?.callbackURL || parsedSamlConfig.callbackUrl || ctx.context.baseURL;
|
|
3003
|
-
const result = await handleOAuthUserInfo(ctx, {
|
|
3004
|
-
userInfo: {
|
|
3005
|
-
email: userInfo.email,
|
|
3006
|
-
name: userInfo.name || userInfo.email,
|
|
3007
|
-
id: userInfo.id,
|
|
3008
|
-
emailVerified: Boolean(userInfo.emailVerified)
|
|
3009
|
-
},
|
|
3010
|
-
account: {
|
|
3011
|
-
providerId: provider.providerId,
|
|
3012
|
-
accountId: userInfo.id,
|
|
3013
|
-
accessToken: "",
|
|
3014
|
-
refreshToken: ""
|
|
3015
|
-
},
|
|
3016
|
-
callbackURL: callbackUrl,
|
|
3017
|
-
disableSignUp: options?.disableImplicitSignUp,
|
|
3018
|
-
isTrustedProvider
|
|
3019
|
-
});
|
|
3020
|
-
if (result.error) throw ctx.redirect(`${callbackUrl}?error=${result.error.split(" ").join("_")}`);
|
|
3021
|
-
const { session, user } = result.data;
|
|
3022
|
-
if (options?.provisionUser && (result.isRegister || options.provisionUserOnEveryLogin)) await options.provisionUser({
|
|
3023
|
-
user,
|
|
3024
|
-
userInfo,
|
|
3025
|
-
provider
|
|
3026
|
-
});
|
|
3027
|
-
await assignOrganizationFromProvider(ctx, {
|
|
3028
|
-
user,
|
|
3029
|
-
profile: {
|
|
3030
|
-
providerType: "saml",
|
|
3031
|
-
providerId: provider.providerId,
|
|
3032
|
-
accountId: userInfo.id,
|
|
3033
|
-
email: userInfo.email,
|
|
3034
|
-
emailVerified: Boolean(userInfo.emailVerified),
|
|
3035
|
-
rawAttributes: attributes
|
|
3036
|
-
},
|
|
3037
|
-
provider,
|
|
3038
|
-
provisioningOptions: options?.organizationProvisioning
|
|
3039
|
-
});
|
|
3040
|
-
await setSessionCookie(ctx, {
|
|
3041
|
-
session,
|
|
3042
|
-
user
|
|
3043
|
-
});
|
|
3044
|
-
if (options?.saml?.enableSingleLogout && extract.nameID) {
|
|
3045
|
-
const samlSessionKey = `${SAML_SESSION_KEY_PREFIX}${providerId}:${extract.nameID}`;
|
|
3046
|
-
const samlSessionData = {
|
|
3047
|
-
sessionId: session.id,
|
|
3048
|
-
providerId,
|
|
3049
|
-
nameID: extract.nameID,
|
|
3050
|
-
sessionIndex: extract.sessionIndex
|
|
3051
|
-
};
|
|
3052
|
-
await ctx.context.internalAdapter.createVerificationValue({
|
|
3053
|
-
identifier: samlSessionKey,
|
|
3054
|
-
value: JSON.stringify(samlSessionData),
|
|
3055
|
-
expiresAt: session.expiresAt
|
|
3056
|
-
}).catch((e) => ctx.context.logger.warn("Failed to create SAML session record", { error: e }));
|
|
3057
|
-
await ctx.context.internalAdapter.createVerificationValue({
|
|
3058
|
-
identifier: `${SAML_SESSION_BY_ID_PREFIX}${session.id}`,
|
|
3059
|
-
value: samlSessionKey,
|
|
3060
|
-
expiresAt: session.expiresAt
|
|
3061
|
-
}).catch((e) => ctx.context.logger.warn("Failed to create SAML session lookup record", e));
|
|
3062
|
-
}
|
|
3063
|
-
const safeRedirectUrl = getSafeRedirectUrl(relayState?.callbackURL || parsedSamlConfig.callbackUrl, currentCallbackPath, appOrigin, (url, settings) => ctx.context.isTrustedOrigin(url, settings));
|
|
3064
|
-
throw ctx.redirect(safeRedirectUrl);
|
|
3065
2940
|
});
|
|
3066
2941
|
};
|
|
3067
2942
|
const sloSchema = z.object({
|
|
@@ -3092,10 +2967,10 @@ const sloEndpoint = (options) => {
|
|
|
3092
2967
|
const provider = await findSAMLProvider(providerId, options, ctx.context.adapter);
|
|
3093
2968
|
if (!provider?.samlConfig) throw APIError.from("NOT_FOUND", SAML_ERROR_CODES.SAML_PROVIDER_NOT_FOUND);
|
|
3094
2969
|
const config = provider.samlConfig;
|
|
3095
|
-
const sp = createSP(config, ctx.context.baseURL, providerId, {
|
|
2970
|
+
const sp = createSP(config, ctx.context.baseURL, providerId, { sloOptions: {
|
|
3096
2971
|
wantLogoutRequestSigned: options?.saml?.wantLogoutRequestSigned,
|
|
3097
2972
|
wantLogoutResponseSigned: options?.saml?.wantLogoutResponseSigned
|
|
3098
|
-
});
|
|
2973
|
+
} });
|
|
3099
2974
|
const idp = createIdP(config);
|
|
3100
2975
|
if (samlResponse) return handleLogoutResponse(ctx, sp, idp, relayState, providerId);
|
|
3101
2976
|
return handleLogoutRequest(ctx, sp, idp, relayState, providerId);
|
|
@@ -3181,10 +3056,10 @@ const initiateSLO = (options) => {
|
|
|
3181
3056
|
if (!provider?.samlConfig) throw APIError.from("NOT_FOUND", SAML_ERROR_CODES.SAML_PROVIDER_NOT_FOUND);
|
|
3182
3057
|
const config = provider.samlConfig;
|
|
3183
3058
|
if (!(config.idpMetadata?.singleLogoutService?.length || config.idpMetadata?.metadata && config.idpMetadata.metadata.includes("SingleLogoutService"))) throw APIError.from("BAD_REQUEST", SAML_ERROR_CODES.IDP_SLO_NOT_SUPPORTED);
|
|
3184
|
-
const sp = createSP(config, ctx.context.baseURL, providerId, {
|
|
3059
|
+
const sp = createSP(config, ctx.context.baseURL, providerId, { sloOptions: {
|
|
3185
3060
|
wantLogoutRequestSigned: options?.saml?.wantLogoutRequestSigned,
|
|
3186
3061
|
wantLogoutResponseSigned: options?.saml?.wantLogoutResponseSigned
|
|
3187
|
-
});
|
|
3062
|
+
} });
|
|
3188
3063
|
const idp = createIdP(config);
|
|
3189
3064
|
const session = ctx.context.session;
|
|
3190
3065
|
const sessionLookupKey = `${SAML_SESSION_BY_ID_PREFIX}${session.session.id}`;
|