@better-auth/sso 1.6.2 → 1.6.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/client.d.mts
CHANGED
package/dist/client.mjs
CHANGED
|
@@ -892,7 +892,7 @@ declare const deleteSSOProvider: () => better_call0.StrictEndpoint<"/sso/delete-
|
|
|
892
892
|
success: boolean;
|
|
893
893
|
}>;
|
|
894
894
|
//#endregion
|
|
895
|
-
//#region src/
|
|
895
|
+
//#region src/saml/timestamp.d.ts
|
|
896
896
|
interface TimestampValidationOptions {
|
|
897
897
|
clockSkew?: number;
|
|
898
898
|
requireTimestamps?: boolean;
|
|
@@ -911,6 +911,8 @@ interface SAMLConditions {
|
|
|
911
911
|
* @throws {APIError} If timestamps are invalid, expired, or not yet valid
|
|
912
912
|
*/
|
|
913
913
|
declare function validateSAMLTimestamp(conditions: SAMLConditions | undefined, options?: TimestampValidationOptions): void;
|
|
914
|
+
//#endregion
|
|
915
|
+
//#region src/routes/sso.d.ts
|
|
914
916
|
declare const spMetadata: (options?: SSOOptions) => better_call0.StrictEndpoint<"/sso/saml2/sp/metadata", {
|
|
915
917
|
method: "GET";
|
|
916
918
|
query: z.ZodObject<{
|
package/dist/index.d.mts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
|
-
import { A as DataEncryptionAlgorithm, C as TimestampValidationOptions, D as SSOOptions, E as SAMLConfig, M as DigestAlgorithm, N as KeyEncryptionAlgorithm, O as SSOProvider, P as SignatureAlgorithm, S as SAMLConditions, T as OIDCConfig, _ as REQUIRED_DISCOVERY_FIELDS, a as fetchDiscoveryDocument, b as DEFAULT_MAX_SAML_METADATA_SIZE, c as normalizeUrl, d as validateDiscoveryUrl, f as DiscoverOIDCConfigParams, g as OIDCDiscoveryDocument, h as HydratedOIDCConfig, i as discoverOIDCConfig, j as DeprecatedAlgorithmBehavior, k as AlgorithmValidationOptions, l as selectTokenEndpointAuthMethod, m as DiscoveryErrorCode, n as sso, o as needsRuntimeDiscovery, p as DiscoveryError, r as computeDiscoveryUrl, s as normalizeDiscoveryUrls, t as SSOPlugin, u as validateDiscoveryDocument, v as RequiredDiscoveryField, w as validateSAMLTimestamp, x as DEFAULT_MAX_SAML_RESPONSE_SIZE, y as DEFAULT_CLOCK_SKEW_MS } from "./index-
|
|
1
|
+
import { A as DataEncryptionAlgorithm, C as TimestampValidationOptions, D as SSOOptions, E as SAMLConfig, M as DigestAlgorithm, N as KeyEncryptionAlgorithm, O as SSOProvider, P as SignatureAlgorithm, S as SAMLConditions, T as OIDCConfig, _ as REQUIRED_DISCOVERY_FIELDS, a as fetchDiscoveryDocument, b as DEFAULT_MAX_SAML_METADATA_SIZE, c as normalizeUrl, d as validateDiscoveryUrl, f as DiscoverOIDCConfigParams, g as OIDCDiscoveryDocument, h as HydratedOIDCConfig, i as discoverOIDCConfig, j as DeprecatedAlgorithmBehavior, k as AlgorithmValidationOptions, l as selectTokenEndpointAuthMethod, m as DiscoveryErrorCode, n as sso, o as needsRuntimeDiscovery, p as DiscoveryError, r as computeDiscoveryUrl, s as normalizeDiscoveryUrls, t as SSOPlugin, u as validateDiscoveryDocument, v as RequiredDiscoveryField, w as validateSAMLTimestamp, x as DEFAULT_MAX_SAML_RESPONSE_SIZE, y as DEFAULT_CLOCK_SKEW_MS } from "./index-DyoL-0jp.mjs";
|
|
2
2
|
export { AlgorithmValidationOptions, DEFAULT_CLOCK_SKEW_MS, DEFAULT_MAX_SAML_METADATA_SIZE, DEFAULT_MAX_SAML_RESPONSE_SIZE, DataEncryptionAlgorithm, DeprecatedAlgorithmBehavior, DigestAlgorithm, DiscoverOIDCConfigParams, DiscoveryError, DiscoveryErrorCode, HydratedOIDCConfig, KeyEncryptionAlgorithm, OIDCConfig, OIDCDiscoveryDocument, REQUIRED_DISCOVERY_FIELDS, RequiredDiscoveryField, SAMLConditions, SAMLConfig, SSOOptions, SSOPlugin, SSOProvider, SignatureAlgorithm, TimestampValidationOptions, computeDiscoveryUrl, discoverOIDCConfig, fetchDiscoveryDocument, needsRuntimeDiscovery, normalizeDiscoveryUrls, normalizeUrl, selectTokenEndpointAuthMethod, sso, validateDiscoveryDocument, validateDiscoveryUrl, validateSAMLTimestamp };
|
package/dist/index.mjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { t as PACKAGE_VERSION } from "./version-
|
|
1
|
+
import { t as PACKAGE_VERSION } from "./version-1sp6DKT-.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";
|
|
@@ -1436,13 +1436,15 @@ async function findSAMLProvider(providerId, options, adapter) {
|
|
|
1436
1436
|
samlConfig: res.samlConfig ? safeJsonParse(res.samlConfig) || void 0 : void 0
|
|
1437
1437
|
};
|
|
1438
1438
|
}
|
|
1439
|
-
function createSP(config, baseURL, providerId,
|
|
1439
|
+
function createSP(config, baseURL, providerId, opts) {
|
|
1440
|
+
const spData = config.spMetadata;
|
|
1440
1441
|
const sloLocation = `${baseURL}/sso/saml2/sp/slo/${providerId}`;
|
|
1442
|
+
const acsUrl = config.callbackUrl || `${baseURL}/sso/saml2/sp/acs/${providerId}`;
|
|
1441
1443
|
return saml.ServiceProvider({
|
|
1442
|
-
entityID:
|
|
1443
|
-
assertionConsumerService: [{
|
|
1444
|
+
entityID: spData?.entityID || config.issuer,
|
|
1445
|
+
assertionConsumerService: spData?.metadata ? void 0 : [{
|
|
1444
1446
|
Binding: "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST",
|
|
1445
|
-
Location:
|
|
1447
|
+
Location: acsUrl
|
|
1446
1448
|
}],
|
|
1447
1449
|
singleLogoutService: [{
|
|
1448
1450
|
Binding: "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST",
|
|
@@ -1452,11 +1454,16 @@ function createSP(config, baseURL, providerId, sloOptions) {
|
|
|
1452
1454
|
Location: sloLocation
|
|
1453
1455
|
}],
|
|
1454
1456
|
wantMessageSigned: config.wantAssertionsSigned || false,
|
|
1455
|
-
wantLogoutRequestSigned: sloOptions?.wantLogoutRequestSigned ?? false,
|
|
1456
|
-
wantLogoutResponseSigned: sloOptions?.wantLogoutResponseSigned ?? false,
|
|
1457
|
-
metadata:
|
|
1458
|
-
privateKey:
|
|
1459
|
-
privateKeyPass:
|
|
1457
|
+
wantLogoutRequestSigned: opts?.sloOptions?.wantLogoutRequestSigned ?? false,
|
|
1458
|
+
wantLogoutResponseSigned: opts?.sloOptions?.wantLogoutResponseSigned ?? false,
|
|
1459
|
+
metadata: spData?.metadata,
|
|
1460
|
+
privateKey: spData?.privateKey || config.privateKey,
|
|
1461
|
+
privateKeyPass: spData?.privateKeyPass,
|
|
1462
|
+
isAssertionEncrypted: spData?.isAssertionEncrypted || false,
|
|
1463
|
+
encPrivateKey: spData?.encPrivateKey,
|
|
1464
|
+
encPrivateKeyPass: spData?.encPrivateKeyPass,
|
|
1465
|
+
nameIDFormat: config.identifierFormat ? [config.identifierFormat] : void 0,
|
|
1466
|
+
relayState: opts?.relayState
|
|
1460
1467
|
});
|
|
1461
1468
|
}
|
|
1462
1469
|
function createIdP(config) {
|
|
@@ -1465,6 +1472,7 @@ function createIdP(config) {
|
|
|
1465
1472
|
metadata: idpData.metadata,
|
|
1466
1473
|
privateKey: idpData.privateKey,
|
|
1467
1474
|
privateKeyPass: idpData.privateKeyPass,
|
|
1475
|
+
isAssertionEncrypted: idpData.isAssertionEncrypted,
|
|
1468
1476
|
encPrivateKey: idpData.encPrivateKey,
|
|
1469
1477
|
encPrivateKeyPass: idpData.encPrivateKeyPass
|
|
1470
1478
|
});
|
|
@@ -1475,7 +1483,11 @@ function createIdP(config) {
|
|
|
1475
1483
|
Location: config.entryPoint
|
|
1476
1484
|
}],
|
|
1477
1485
|
singleLogoutService: idpData?.singleLogoutService,
|
|
1478
|
-
signingCert: idpData?.cert || config.cert
|
|
1486
|
+
signingCert: idpData?.cert || config.cert,
|
|
1487
|
+
wantAuthnRequestsSigned: config.authnRequestsSigned || false,
|
|
1488
|
+
isAssertionEncrypted: idpData?.isAssertionEncrypted || false,
|
|
1489
|
+
encPrivateKey: idpData?.encPrivateKey,
|
|
1490
|
+
encPrivateKeyPass: idpData?.encPrivateKeyPass
|
|
1479
1491
|
});
|
|
1480
1492
|
}
|
|
1481
1493
|
function escapeHtml(str) {
|
|
@@ -1491,20 +1503,7 @@ function createSAMLPostForm(action, samlParam, samlValue, relayState) {
|
|
|
1491
1503
|
return new Response(html, { headers: { "Content-Type": "text/html" } });
|
|
1492
1504
|
}
|
|
1493
1505
|
//#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
|
-
}
|
|
1506
|
+
//#region src/saml/timestamp.ts
|
|
1508
1507
|
/**
|
|
1509
1508
|
* Validates SAML assertion timestamp conditions (NotBefore/NotOnOrAfter).
|
|
1510
1509
|
* Prevents acceptance of expired or future-dated assertions.
|
|
@@ -1544,9 +1543,39 @@ function validateSAMLTimestamp(conditions, options = {}) {
|
|
|
1544
1543
|
});
|
|
1545
1544
|
}
|
|
1546
1545
|
}
|
|
1546
|
+
//#endregion
|
|
1547
|
+
//#region src/routes/saml-pipeline.ts
|
|
1548
|
+
/**
|
|
1549
|
+
* Validates and returns a safe redirect URL.
|
|
1550
|
+
* - Prevents open redirect attacks by validating against trusted origins
|
|
1551
|
+
* - Prevents redirect loops by checking if URL points to callback route
|
|
1552
|
+
* - Falls back to appOrigin if URL is invalid or unsafe
|
|
1553
|
+
*/
|
|
1554
|
+
function getSafeRedirectUrl(url, callbackPath, appOrigin, isTrustedOrigin) {
|
|
1555
|
+
if (!url) return appOrigin;
|
|
1556
|
+
if (url.startsWith("/") && !url.startsWith("//")) {
|
|
1557
|
+
try {
|
|
1558
|
+
const absoluteUrl = new URL(url, appOrigin);
|
|
1559
|
+
if (absoluteUrl.origin !== appOrigin) return appOrigin;
|
|
1560
|
+
const callbackPathname = new URL(callbackPath).pathname;
|
|
1561
|
+
if (absoluteUrl.pathname === callbackPathname) return appOrigin;
|
|
1562
|
+
} catch {
|
|
1563
|
+
return appOrigin;
|
|
1564
|
+
}
|
|
1565
|
+
return url;
|
|
1566
|
+
}
|
|
1567
|
+
if (!isTrustedOrigin(url, { allowRelativePaths: false })) return appOrigin;
|
|
1568
|
+
try {
|
|
1569
|
+
const callbackPathname = new URL(callbackPath).pathname;
|
|
1570
|
+
if (new URL(url).pathname === callbackPathname) return appOrigin;
|
|
1571
|
+
} catch {
|
|
1572
|
+
if (url === callbackPath || url.startsWith(`${callbackPath}?`)) return appOrigin;
|
|
1573
|
+
}
|
|
1574
|
+
return url;
|
|
1575
|
+
}
|
|
1547
1576
|
/**
|
|
1548
1577
|
* Extracts the Assertion ID from a SAML response XML.
|
|
1549
|
-
*
|
|
1578
|
+
* Used for replay protection per SAML 2.0 Core section 2.3.3.
|
|
1550
1579
|
*/
|
|
1551
1580
|
function extractAssertionId(samlContent) {
|
|
1552
1581
|
try {
|
|
@@ -1565,6 +1594,227 @@ function extractAssertionId(samlContent) {
|
|
|
1565
1594
|
return null;
|
|
1566
1595
|
}
|
|
1567
1596
|
}
|
|
1597
|
+
/**
|
|
1598
|
+
* Unified SAML response processing pipeline.
|
|
1599
|
+
*
|
|
1600
|
+
* Both `/sso/saml2/callback/:providerId` (POST) and `/sso/saml2/sp/acs/:providerId`
|
|
1601
|
+
* delegate to this function. It handles the full lifecycle: provider lookup,
|
|
1602
|
+
* SP/IdP construction, response validation, session creation, and redirect
|
|
1603
|
+
* URL computation.
|
|
1604
|
+
*/
|
|
1605
|
+
async function processSAMLResponse(ctx, params, options) {
|
|
1606
|
+
const { providerId, currentCallbackPath } = params;
|
|
1607
|
+
const appOrigin = new URL(ctx.context.baseURL).origin;
|
|
1608
|
+
const maxResponseSize = options?.saml?.maxResponseSize ?? 262144;
|
|
1609
|
+
if (new TextEncoder().encode(params.SAMLResponse).length > maxResponseSize) throw new APIError("BAD_REQUEST", { message: `SAML response exceeds maximum allowed size (${maxResponseSize} bytes)` });
|
|
1610
|
+
const SAMLResponse = params.SAMLResponse.replace(/\s+/g, "");
|
|
1611
|
+
let relayState = null;
|
|
1612
|
+
if (params.RelayState) try {
|
|
1613
|
+
relayState = await parseRelayState(ctx);
|
|
1614
|
+
} catch {
|
|
1615
|
+
relayState = null;
|
|
1616
|
+
}
|
|
1617
|
+
const provider = await findSAMLProvider(providerId, options, ctx.context.adapter);
|
|
1618
|
+
if (!provider?.samlConfig) throw new APIError("NOT_FOUND", { message: "No SAML provider found" });
|
|
1619
|
+
if (options?.domainVerification?.enabled && !("domainVerified" in provider && provider.domainVerified)) throw new APIError("UNAUTHORIZED", { message: "Provider domain has not been verified" });
|
|
1620
|
+
const parsedSamlConfig = typeof provider.samlConfig === "object" ? provider.samlConfig : safeJsonParse(provider.samlConfig);
|
|
1621
|
+
if (!parsedSamlConfig) throw new APIError("BAD_REQUEST", { message: "Invalid SAML configuration" });
|
|
1622
|
+
const sp = createSP(parsedSamlConfig, ctx.context.baseURL, providerId);
|
|
1623
|
+
const idp = createIdP(parsedSamlConfig);
|
|
1624
|
+
const samlRedirectUrl = getSafeRedirectUrl(relayState?.callbackURL || parsedSamlConfig.callbackUrl, params.currentCallbackPath, appOrigin, (url, settings) => ctx.context.isTrustedOrigin(url, settings));
|
|
1625
|
+
validateSingleAssertion(SAMLResponse);
|
|
1626
|
+
let parsedResponse;
|
|
1627
|
+
try {
|
|
1628
|
+
parsedResponse = await sp.parseLoginResponse(idp, "post", { body: {
|
|
1629
|
+
SAMLResponse,
|
|
1630
|
+
RelayState: params.RelayState || void 0
|
|
1631
|
+
} });
|
|
1632
|
+
if (!parsedResponse?.extract) throw new Error("Invalid SAML response structure");
|
|
1633
|
+
} catch (error) {
|
|
1634
|
+
ctx.context.logger.error("SAML response validation failed", {
|
|
1635
|
+
error,
|
|
1636
|
+
samlResponsePreview: SAMLResponse.slice(0, 200)
|
|
1637
|
+
});
|
|
1638
|
+
throw new APIError("BAD_REQUEST", {
|
|
1639
|
+
message: "Invalid SAML response",
|
|
1640
|
+
details: error instanceof Error ? error.message : String(error)
|
|
1641
|
+
});
|
|
1642
|
+
}
|
|
1643
|
+
const { extract } = parsedResponse;
|
|
1644
|
+
validateSAMLAlgorithms(parsedResponse, options?.saml?.algorithms);
|
|
1645
|
+
validateSAMLTimestamp(extract.conditions, {
|
|
1646
|
+
clockSkew: options?.saml?.clockSkew,
|
|
1647
|
+
requireTimestamps: options?.saml?.requireTimestamps,
|
|
1648
|
+
logger: ctx.context.logger
|
|
1649
|
+
});
|
|
1650
|
+
const inResponseTo = extract.inResponseTo;
|
|
1651
|
+
if (options?.saml?.enableInResponseToValidation !== false) {
|
|
1652
|
+
const allowIdpInitiated = options?.saml?.allowIdpInitiated !== false;
|
|
1653
|
+
if (inResponseTo) {
|
|
1654
|
+
let storedRequest = null;
|
|
1655
|
+
const verification = await ctx.context.internalAdapter.findVerificationValue(`${AUTHN_REQUEST_KEY_PREFIX}${inResponseTo}`);
|
|
1656
|
+
if (verification) try {
|
|
1657
|
+
storedRequest = JSON.parse(verification.value);
|
|
1658
|
+
if (storedRequest && storedRequest.expiresAt < Date.now()) storedRequest = null;
|
|
1659
|
+
} catch {
|
|
1660
|
+
storedRequest = null;
|
|
1661
|
+
}
|
|
1662
|
+
if (!storedRequest) {
|
|
1663
|
+
ctx.context.logger.error("SAML InResponseTo validation failed: unknown or expired request ID", {
|
|
1664
|
+
inResponseTo,
|
|
1665
|
+
providerId
|
|
1666
|
+
});
|
|
1667
|
+
throw ctx.redirect(`${samlRedirectUrl}?error=invalid_saml_response&error_description=Unknown+or+expired+request+ID`);
|
|
1668
|
+
}
|
|
1669
|
+
if (storedRequest.providerId !== providerId) {
|
|
1670
|
+
ctx.context.logger.error("SAML InResponseTo validation failed: provider mismatch", {
|
|
1671
|
+
inResponseTo,
|
|
1672
|
+
expectedProvider: storedRequest.providerId,
|
|
1673
|
+
actualProvider: providerId
|
|
1674
|
+
});
|
|
1675
|
+
await ctx.context.internalAdapter.deleteVerificationByIdentifier(`${AUTHN_REQUEST_KEY_PREFIX}${inResponseTo}`);
|
|
1676
|
+
throw ctx.redirect(`${samlRedirectUrl}?error=invalid_saml_response&error_description=Provider+mismatch`);
|
|
1677
|
+
}
|
|
1678
|
+
await ctx.context.internalAdapter.deleteVerificationByIdentifier(`${AUTHN_REQUEST_KEY_PREFIX}${inResponseTo}`);
|
|
1679
|
+
} else if (!allowIdpInitiated) {
|
|
1680
|
+
ctx.context.logger.error("SAML IdP-initiated SSO rejected: InResponseTo missing and allowIdpInitiated is false", { providerId });
|
|
1681
|
+
throw ctx.redirect(`${samlRedirectUrl}?error=unsolicited_response&error_description=IdP-initiated+SSO+not+allowed`);
|
|
1682
|
+
}
|
|
1683
|
+
}
|
|
1684
|
+
const samlContent = parsedResponse.samlContent;
|
|
1685
|
+
const assertionId = samlContent ? extractAssertionId(samlContent) : null;
|
|
1686
|
+
if (assertionId) {
|
|
1687
|
+
const issuer = idp.entityMeta.getEntityID();
|
|
1688
|
+
const conditions = extract.conditions;
|
|
1689
|
+
const clockSkew = options?.saml?.clockSkew ?? 3e5;
|
|
1690
|
+
const expiresAt = conditions?.notOnOrAfter ? new Date(conditions.notOnOrAfter).getTime() + clockSkew : Date.now() + DEFAULT_ASSERTION_TTL_MS;
|
|
1691
|
+
const existingAssertion = await ctx.context.internalAdapter.findVerificationValue(`${USED_ASSERTION_KEY_PREFIX}${assertionId}`);
|
|
1692
|
+
let isReplay = false;
|
|
1693
|
+
if (existingAssertion) try {
|
|
1694
|
+
if (JSON.parse(existingAssertion.value).expiresAt >= Date.now()) isReplay = true;
|
|
1695
|
+
} catch (error) {
|
|
1696
|
+
ctx.context.logger.warn("Failed to parse stored assertion record", {
|
|
1697
|
+
assertionId,
|
|
1698
|
+
error
|
|
1699
|
+
});
|
|
1700
|
+
}
|
|
1701
|
+
if (isReplay) {
|
|
1702
|
+
ctx.context.logger.error("SAML assertion replay detected: assertion ID already used", {
|
|
1703
|
+
assertionId,
|
|
1704
|
+
issuer,
|
|
1705
|
+
providerId
|
|
1706
|
+
});
|
|
1707
|
+
throw ctx.redirect(`${samlRedirectUrl}?error=replay_detected&error_description=SAML+assertion+has+already+been+used`);
|
|
1708
|
+
}
|
|
1709
|
+
await ctx.context.internalAdapter.createVerificationValue({
|
|
1710
|
+
identifier: `${USED_ASSERTION_KEY_PREFIX}${assertionId}`,
|
|
1711
|
+
value: JSON.stringify({
|
|
1712
|
+
assertionId,
|
|
1713
|
+
issuer,
|
|
1714
|
+
providerId,
|
|
1715
|
+
usedAt: Date.now(),
|
|
1716
|
+
expiresAt
|
|
1717
|
+
}),
|
|
1718
|
+
expiresAt: new Date(expiresAt)
|
|
1719
|
+
});
|
|
1720
|
+
} else ctx.context.logger.warn("Could not extract assertion ID for replay protection", { providerId });
|
|
1721
|
+
const attributes = extract.attributes || {};
|
|
1722
|
+
const mapping = parsedSamlConfig.mapping ?? {};
|
|
1723
|
+
const userInfo = {
|
|
1724
|
+
...Object.fromEntries(Object.entries(mapping.extraFields || {}).map(([key, value]) => [key, attributes[value]])),
|
|
1725
|
+
id: attributes[mapping.id || "nameID"] || extract.nameID,
|
|
1726
|
+
email: (attributes[mapping.email || "email"] || extract.nameID).toLowerCase(),
|
|
1727
|
+
name: [attributes[mapping.firstName || "givenName"], attributes[mapping.lastName || "surname"]].filter(Boolean).join(" ") || attributes[mapping.name || "displayName"] || extract.nameID,
|
|
1728
|
+
emailVerified: options?.trustEmailVerified && mapping.emailVerified ? attributes[mapping.emailVerified] || false : false
|
|
1729
|
+
};
|
|
1730
|
+
if (!userInfo.id || !userInfo.email) {
|
|
1731
|
+
ctx.context.logger.error("Missing essential user info from SAML response", {
|
|
1732
|
+
attributes: Object.keys(attributes),
|
|
1733
|
+
mapping,
|
|
1734
|
+
extractedId: userInfo.id,
|
|
1735
|
+
extractedEmail: userInfo.email
|
|
1736
|
+
});
|
|
1737
|
+
throw new APIError("BAD_REQUEST", { message: "Unable to extract user ID or email from SAML response" });
|
|
1738
|
+
}
|
|
1739
|
+
const isTrustedProvider = ctx.context.trustedProviders.includes(providerId) || "domainVerified" in provider && !!provider.domainVerified && validateEmailDomain(userInfo.email, provider.domain);
|
|
1740
|
+
const callbackUrl = relayState?.callbackURL || parsedSamlConfig.callbackUrl || ctx.context.baseURL;
|
|
1741
|
+
const result = await handleOAuthUserInfo(ctx, {
|
|
1742
|
+
userInfo: {
|
|
1743
|
+
email: userInfo.email,
|
|
1744
|
+
name: userInfo.name || userInfo.email,
|
|
1745
|
+
id: userInfo.id,
|
|
1746
|
+
emailVerified: Boolean(userInfo.emailVerified)
|
|
1747
|
+
},
|
|
1748
|
+
account: {
|
|
1749
|
+
providerId,
|
|
1750
|
+
accountId: userInfo.id,
|
|
1751
|
+
accessToken: "",
|
|
1752
|
+
refreshToken: ""
|
|
1753
|
+
},
|
|
1754
|
+
callbackURL: callbackUrl,
|
|
1755
|
+
disableSignUp: options?.disableImplicitSignUp,
|
|
1756
|
+
isTrustedProvider
|
|
1757
|
+
});
|
|
1758
|
+
if (result.error) throw ctx.redirect(`${callbackUrl}?error=${result.error.split(" ").join("_")}`);
|
|
1759
|
+
const { session, user } = result.data;
|
|
1760
|
+
if (options?.provisionUser && (result.isRegister || options.provisionUserOnEveryLogin)) await options.provisionUser({
|
|
1761
|
+
user,
|
|
1762
|
+
userInfo,
|
|
1763
|
+
provider
|
|
1764
|
+
});
|
|
1765
|
+
await assignOrganizationFromProvider(ctx, {
|
|
1766
|
+
user,
|
|
1767
|
+
profile: {
|
|
1768
|
+
providerType: "saml",
|
|
1769
|
+
providerId,
|
|
1770
|
+
accountId: userInfo.id,
|
|
1771
|
+
email: userInfo.email,
|
|
1772
|
+
emailVerified: Boolean(userInfo.emailVerified),
|
|
1773
|
+
rawAttributes: attributes
|
|
1774
|
+
},
|
|
1775
|
+
provider,
|
|
1776
|
+
provisioningOptions: options?.organizationProvisioning
|
|
1777
|
+
});
|
|
1778
|
+
await setSessionCookie(ctx, {
|
|
1779
|
+
session,
|
|
1780
|
+
user
|
|
1781
|
+
});
|
|
1782
|
+
if (options?.saml?.enableSingleLogout && extract.nameID) {
|
|
1783
|
+
const samlSessionKey = `${SAML_SESSION_KEY_PREFIX}${providerId}:${extract.nameID}`;
|
|
1784
|
+
const samlSessionData = {
|
|
1785
|
+
sessionId: session.id,
|
|
1786
|
+
providerId,
|
|
1787
|
+
nameID: extract.nameID,
|
|
1788
|
+
sessionIndex: extract.sessionIndex
|
|
1789
|
+
};
|
|
1790
|
+
await ctx.context.internalAdapter.createVerificationValue({
|
|
1791
|
+
identifier: samlSessionKey,
|
|
1792
|
+
value: JSON.stringify(samlSessionData),
|
|
1793
|
+
expiresAt: session.expiresAt
|
|
1794
|
+
}).catch((e) => ctx.context.logger.warn("Failed to create SAML session record", { error: e }));
|
|
1795
|
+
await ctx.context.internalAdapter.createVerificationValue({
|
|
1796
|
+
identifier: `${SAML_SESSION_BY_ID_PREFIX}${session.id}`,
|
|
1797
|
+
value: samlSessionKey,
|
|
1798
|
+
expiresAt: session.expiresAt
|
|
1799
|
+
}).catch((e) => ctx.context.logger.warn("Failed to create SAML session lookup record", e));
|
|
1800
|
+
}
|
|
1801
|
+
return getSafeRedirectUrl(relayState?.callbackURL || parsedSamlConfig.callbackUrl, currentCallbackPath, appOrigin, (url, settings) => ctx.context.isTrustedOrigin(url, settings));
|
|
1802
|
+
}
|
|
1803
|
+
//#endregion
|
|
1804
|
+
//#region src/routes/sso.ts
|
|
1805
|
+
/**
|
|
1806
|
+
* Builds the OIDC redirect URI. Uses the shared `redirectURI` option
|
|
1807
|
+
* when set, otherwise falls back to `/sso/callback/:providerId`.
|
|
1808
|
+
*/
|
|
1809
|
+
function getOIDCRedirectURI(baseURL, providerId, options) {
|
|
1810
|
+
if (options?.redirectURI?.trim()) try {
|
|
1811
|
+
new URL(options.redirectURI);
|
|
1812
|
+
return options.redirectURI;
|
|
1813
|
+
} catch {
|
|
1814
|
+
return `${baseURL}${options.redirectURI.startsWith("/") ? options.redirectURI : `/${options.redirectURI}`}`;
|
|
1815
|
+
}
|
|
1816
|
+
return `${baseURL}/sso/callback/${providerId}`;
|
|
1817
|
+
}
|
|
1568
1818
|
const spMetadataQuerySchema = z.object({
|
|
1569
1819
|
providerId: z.string(),
|
|
1570
1820
|
format: z.enum(["xml", "json"]).default("xml")
|
|
@@ -1602,7 +1852,7 @@ const spMetadata = (options) => {
|
|
|
1602
1852
|
entityID: parsedSamlConfig.spMetadata?.entityID || parsedSamlConfig.issuer,
|
|
1603
1853
|
assertionConsumerService: [{
|
|
1604
1854
|
Binding: "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST",
|
|
1605
|
-
Location: parsedSamlConfig.callbackUrl || `${ctx.context.baseURL}/sso/saml2/sp/acs/${
|
|
1855
|
+
Location: parsedSamlConfig.callbackUrl || `${ctx.context.baseURL}/sso/saml2/sp/acs/${ctx.query.providerId}`
|
|
1606
1856
|
}],
|
|
1607
1857
|
singleLogoutService,
|
|
1608
1858
|
wantMessageSigned: parsedSamlConfig.wantAssertionsSigned || false,
|
|
@@ -1950,10 +2200,20 @@ const registerSSOProvider = (options) => {
|
|
|
1950
2200
|
overrideUserInfo: ctx.body.overrideUserInfo || options?.defaultOverrideUserInfo || false
|
|
1951
2201
|
});
|
|
1952
2202
|
};
|
|
1953
|
-
if (body.samlConfig)
|
|
1954
|
-
|
|
1955
|
-
|
|
1956
|
-
|
|
2203
|
+
if (body.samlConfig) {
|
|
2204
|
+
validateConfigAlgorithms({
|
|
2205
|
+
signatureAlgorithm: body.samlConfig.signatureAlgorithm,
|
|
2206
|
+
digestAlgorithm: body.samlConfig.digestAlgorithm
|
|
2207
|
+
}, options?.saml?.algorithms);
|
|
2208
|
+
const hasIdpMetadata = body.samlConfig.idpMetadata?.metadata;
|
|
2209
|
+
let hasEntryPoint = false;
|
|
2210
|
+
if (body.samlConfig.entryPoint) try {
|
|
2211
|
+
new URL(body.samlConfig.entryPoint);
|
|
2212
|
+
hasEntryPoint = true;
|
|
2213
|
+
} catch {}
|
|
2214
|
+
const hasSingleSignOnService = body.samlConfig.idpMetadata?.singleSignOnService?.length;
|
|
2215
|
+
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" });
|
|
2216
|
+
}
|
|
1957
2217
|
const provider = await ctx.context.adapter.create({
|
|
1958
2218
|
model: "ssoProvider",
|
|
1959
2219
|
data: {
|
|
@@ -2476,34 +2736,6 @@ const callbackSSOSAMLBodySchema = z.object({
|
|
|
2476
2736
|
SAMLResponse: z.string(),
|
|
2477
2737
|
RelayState: z.string().optional()
|
|
2478
2738
|
});
|
|
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
2739
|
const callbackSSOSAML = (options) => {
|
|
2508
2740
|
return createAuthEndpoint("/sso/saml2/callback/:providerId", {
|
|
2509
2741
|
method: ["GET", "POST"],
|
|
@@ -2535,261 +2767,12 @@ const callbackSSOSAML = (options) => {
|
|
|
2535
2767
|
throw ctx.redirect(safeRedirectUrl);
|
|
2536
2768
|
}
|
|
2537
2769
|
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));
|
|
2770
|
+
const safeRedirectUrl = await processSAMLResponse(ctx, {
|
|
2771
|
+
SAMLResponse: ctx.body.SAMLResponse,
|
|
2772
|
+
RelayState: ctx.body.RelayState,
|
|
2773
|
+
providerId,
|
|
2774
|
+
currentCallbackPath
|
|
2775
|
+
}, options);
|
|
2793
2776
|
throw ctx.redirect(safeRedirectUrl);
|
|
2794
2777
|
});
|
|
2795
2778
|
};
|
|
@@ -2815,253 +2798,24 @@ const acsEndpoint = (options) => {
|
|
|
2815
2798
|
const { providerId } = ctx.params;
|
|
2816
2799
|
const currentCallbackPath = `${ctx.context.baseURL}/sso/saml2/sp/acs/${providerId}`;
|
|
2817
2800
|
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
2801
|
try {
|
|
2876
|
-
|
|
2802
|
+
const safeRedirectUrl = await processSAMLResponse(ctx, {
|
|
2803
|
+
SAMLResponse: ctx.body.SAMLResponse,
|
|
2804
|
+
RelayState: ctx.body.RelayState,
|
|
2805
|
+
providerId,
|
|
2806
|
+
currentCallbackPath
|
|
2807
|
+
}, options);
|
|
2808
|
+
throw ctx.redirect(safeRedirectUrl);
|
|
2877
2809
|
} catch (error) {
|
|
2878
|
-
if (error instanceof
|
|
2879
|
-
|
|
2880
|
-
const
|
|
2881
|
-
|
|
2810
|
+
if (error instanceof Response || error && typeof error === "object" && "status" in error && error.status === 302) throw error;
|
|
2811
|
+
if (error instanceof APIError && error.statusCode === 400) {
|
|
2812
|
+
const internalCode = error.body?.code || "";
|
|
2813
|
+
const errorCode = internalCode === "SAML_MULTIPLE_ASSERTIONS" ? "multiple_assertions" : internalCode === "SAML_NO_ASSERTION" ? "no_assertion" : internalCode.toLowerCase() || "saml_error";
|
|
2814
|
+
const redirectUrl = getSafeRedirectUrl(ctx.body.RelayState || void 0, currentCallbackPath, appOrigin, (url, settings) => ctx.context.isTrustedOrigin(url, settings));
|
|
2815
|
+
throw ctx.redirect(`${redirectUrl}${redirectUrl.includes("?") ? "&" : "?"}error=${encodeURIComponent(errorCode)}&error_description=${encodeURIComponent(error.message)}`);
|
|
2882
2816
|
}
|
|
2883
2817
|
throw error;
|
|
2884
2818
|
}
|
|
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
2819
|
});
|
|
3066
2820
|
};
|
|
3067
2821
|
const sloSchema = z.object({
|
|
@@ -3092,10 +2846,10 @@ const sloEndpoint = (options) => {
|
|
|
3092
2846
|
const provider = await findSAMLProvider(providerId, options, ctx.context.adapter);
|
|
3093
2847
|
if (!provider?.samlConfig) throw APIError.from("NOT_FOUND", SAML_ERROR_CODES.SAML_PROVIDER_NOT_FOUND);
|
|
3094
2848
|
const config = provider.samlConfig;
|
|
3095
|
-
const sp = createSP(config, ctx.context.baseURL, providerId, {
|
|
2849
|
+
const sp = createSP(config, ctx.context.baseURL, providerId, { sloOptions: {
|
|
3096
2850
|
wantLogoutRequestSigned: options?.saml?.wantLogoutRequestSigned,
|
|
3097
2851
|
wantLogoutResponseSigned: options?.saml?.wantLogoutResponseSigned
|
|
3098
|
-
});
|
|
2852
|
+
} });
|
|
3099
2853
|
const idp = createIdP(config);
|
|
3100
2854
|
if (samlResponse) return handleLogoutResponse(ctx, sp, idp, relayState, providerId);
|
|
3101
2855
|
return handleLogoutRequest(ctx, sp, idp, relayState, providerId);
|
|
@@ -3181,10 +2935,10 @@ const initiateSLO = (options) => {
|
|
|
3181
2935
|
if (!provider?.samlConfig) throw APIError.from("NOT_FOUND", SAML_ERROR_CODES.SAML_PROVIDER_NOT_FOUND);
|
|
3182
2936
|
const config = provider.samlConfig;
|
|
3183
2937
|
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, {
|
|
2938
|
+
const sp = createSP(config, ctx.context.baseURL, providerId, { sloOptions: {
|
|
3185
2939
|
wantLogoutRequestSigned: options?.saml?.wantLogoutRequestSigned,
|
|
3186
2940
|
wantLogoutResponseSigned: options?.saml?.wantLogoutResponseSigned
|
|
3187
|
-
});
|
|
2941
|
+
} });
|
|
3188
2942
|
const idp = createIdP(config);
|
|
3189
2943
|
const session = ctx.context.session;
|
|
3190
2944
|
const sessionLookupKey = `${SAML_SESSION_BY_ID_PREFIX}${session.session.id}`;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@better-auth/sso",
|
|
3
|
-
"version": "1.6.
|
|
3
|
+
"version": "1.6.3",
|
|
4
4
|
"description": "SSO plugin for Better Auth",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -70,15 +70,15 @@
|
|
|
70
70
|
"express": "^5.2.1",
|
|
71
71
|
"oauth2-mock-server": "^8.2.2",
|
|
72
72
|
"tsdown": "0.21.1",
|
|
73
|
-
"@better-auth/core": "1.6.
|
|
74
|
-
"better-auth": "1.6.
|
|
73
|
+
"@better-auth/core": "1.6.3",
|
|
74
|
+
"better-auth": "1.6.3"
|
|
75
75
|
},
|
|
76
76
|
"peerDependencies": {
|
|
77
77
|
"@better-auth/utils": "0.4.0",
|
|
78
78
|
"@better-fetch/fetch": "1.1.21",
|
|
79
79
|
"better-call": "1.3.5",
|
|
80
|
-
"@better-auth/core": "^1.6.
|
|
81
|
-
"better-auth": "^1.6.
|
|
80
|
+
"@better-auth/core": "^1.6.3",
|
|
81
|
+
"better-auth": "^1.6.3"
|
|
82
82
|
},
|
|
83
83
|
"scripts": {
|
|
84
84
|
"build": "tsdown",
|