@better-auth/sso 1.6.1 → 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";
|
|
@@ -606,7 +606,7 @@ function countAssertions(xml) {
|
|
|
606
606
|
function validateSingleAssertion(samlResponse) {
|
|
607
607
|
let xml;
|
|
608
608
|
try {
|
|
609
|
-
xml = new TextDecoder().decode(base64.decode(samlResponse));
|
|
609
|
+
xml = new TextDecoder().decode(base64.decode(samlResponse.replace(/\s+/g, "")));
|
|
610
610
|
if (!xml.includes("<")) throw new Error("Not XML");
|
|
611
611
|
} catch {
|
|
612
612
|
throw new APIError("BAD_REQUEST", {
|
|
@@ -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: {
|
|
@@ -2181,7 +2441,8 @@ const signInSSO = (options) => {
|
|
|
2181
2441
|
if (provider.samlConfig) {
|
|
2182
2442
|
const parsedSamlConfig = typeof provider.samlConfig === "object" ? provider.samlConfig : safeJsonParse(provider.samlConfig);
|
|
2183
2443
|
if (!parsedSamlConfig) throw new APIError("BAD_REQUEST", { message: "Invalid SAML configuration" });
|
|
2184
|
-
if (parsedSamlConfig.authnRequestsSigned && !parsedSamlConfig.spMetadata?.privateKey && !parsedSamlConfig.privateKey)
|
|
2444
|
+
if (parsedSamlConfig.authnRequestsSigned && !parsedSamlConfig.spMetadata?.privateKey && !parsedSamlConfig.privateKey) throw new APIError("BAD_REQUEST", { message: "authnRequestsSigned is enabled but no privateKey provided in spMetadata or samlConfig" });
|
|
2445
|
+
const { state: relayState } = await generateRelayState(ctx, void 0, false);
|
|
2185
2446
|
let metadata = parsedSamlConfig.spMetadata.metadata;
|
|
2186
2447
|
if (!metadata) metadata = saml.SPMetadata({
|
|
2187
2448
|
entityID: parsedSamlConfig.spMetadata?.entityID || parsedSamlConfig.issuer,
|
|
@@ -2197,7 +2458,8 @@ const signInSSO = (options) => {
|
|
|
2197
2458
|
metadata,
|
|
2198
2459
|
allowCreate: true,
|
|
2199
2460
|
privateKey: parsedSamlConfig.spMetadata?.privateKey || parsedSamlConfig.privateKey,
|
|
2200
|
-
privateKeyPass: parsedSamlConfig.spMetadata?.privateKeyPass
|
|
2461
|
+
privateKeyPass: parsedSamlConfig.spMetadata?.privateKeyPass,
|
|
2462
|
+
relayState
|
|
2201
2463
|
});
|
|
2202
2464
|
const idpData = parsedSamlConfig.idpMetadata;
|
|
2203
2465
|
let idp;
|
|
@@ -2223,7 +2485,6 @@ const signInSSO = (options) => {
|
|
|
2223
2485
|
});
|
|
2224
2486
|
const loginRequest = sp.createLoginRequest(idp, "redirect");
|
|
2225
2487
|
if (!loginRequest) throw new APIError("BAD_REQUEST", { message: "Invalid SAML request" });
|
|
2226
|
-
const { state: relayState } = await generateRelayState(ctx, void 0, false);
|
|
2227
2488
|
if (loginRequest.id && options?.saml?.enableInResponseToValidation !== false) {
|
|
2228
2489
|
const ttl = options?.saml?.requestTTL ?? 3e5;
|
|
2229
2490
|
const record = {
|
|
@@ -2239,7 +2500,7 @@ const signInSSO = (options) => {
|
|
|
2239
2500
|
});
|
|
2240
2501
|
}
|
|
2241
2502
|
return ctx.json({
|
|
2242
|
-
url:
|
|
2503
|
+
url: loginRequest.context,
|
|
2243
2504
|
redirect: true
|
|
2244
2505
|
});
|
|
2245
2506
|
}
|
|
@@ -2475,34 +2736,6 @@ const callbackSSOSAMLBodySchema = z.object({
|
|
|
2475
2736
|
SAMLResponse: z.string(),
|
|
2476
2737
|
RelayState: z.string().optional()
|
|
2477
2738
|
});
|
|
2478
|
-
/**
|
|
2479
|
-
* Validates and returns a safe redirect URL.
|
|
2480
|
-
* - Prevents open redirect attacks by validating against trusted origins
|
|
2481
|
-
* - Prevents redirect loops by checking if URL points to callback route
|
|
2482
|
-
* - Falls back to appOrigin if URL is invalid or unsafe
|
|
2483
|
-
*/
|
|
2484
|
-
const getSafeRedirectUrl = (url, callbackPath, appOrigin, isTrustedOrigin) => {
|
|
2485
|
-
if (!url) return appOrigin;
|
|
2486
|
-
if (url.startsWith("/") && !url.startsWith("//")) {
|
|
2487
|
-
try {
|
|
2488
|
-
const absoluteUrl = new URL(url, appOrigin);
|
|
2489
|
-
if (absoluteUrl.origin !== appOrigin) return appOrigin;
|
|
2490
|
-
const callbackPathname = new URL(callbackPath).pathname;
|
|
2491
|
-
if (absoluteUrl.pathname === callbackPathname) return appOrigin;
|
|
2492
|
-
} catch {
|
|
2493
|
-
return appOrigin;
|
|
2494
|
-
}
|
|
2495
|
-
return url;
|
|
2496
|
-
}
|
|
2497
|
-
if (!isTrustedOrigin(url, { allowRelativePaths: false })) return appOrigin;
|
|
2498
|
-
try {
|
|
2499
|
-
const callbackPathname = new URL(callbackPath).pathname;
|
|
2500
|
-
if (new URL(url).pathname === callbackPathname) return appOrigin;
|
|
2501
|
-
} catch {
|
|
2502
|
-
if (url === callbackPath || url.startsWith(`${callbackPath}?`)) return appOrigin;
|
|
2503
|
-
}
|
|
2504
|
-
return url;
|
|
2505
|
-
};
|
|
2506
2739
|
const callbackSSOSAML = (options) => {
|
|
2507
2740
|
return createAuthEndpoint("/sso/saml2/callback/:providerId", {
|
|
2508
2741
|
method: ["GET", "POST"],
|
|
@@ -2534,261 +2767,12 @@ const callbackSSOSAML = (options) => {
|
|
|
2534
2767
|
throw ctx.redirect(safeRedirectUrl);
|
|
2535
2768
|
}
|
|
2536
2769
|
if (!ctx.body?.SAMLResponse) throw new APIError("BAD_REQUEST", { message: "SAMLResponse is required for POST requests" });
|
|
2537
|
-
const
|
|
2538
|
-
|
|
2539
|
-
|
|
2540
|
-
|
|
2541
|
-
|
|
2542
|
-
|
|
2543
|
-
} catch {
|
|
2544
|
-
relayState = null;
|
|
2545
|
-
}
|
|
2546
|
-
let provider = null;
|
|
2547
|
-
if (options?.defaultSSO?.length) {
|
|
2548
|
-
const matchingDefault = options.defaultSSO.find((defaultProvider) => defaultProvider.providerId === providerId);
|
|
2549
|
-
if (matchingDefault) provider = {
|
|
2550
|
-
...matchingDefault,
|
|
2551
|
-
userId: "default",
|
|
2552
|
-
issuer: matchingDefault.samlConfig?.issuer || "",
|
|
2553
|
-
...options.domainVerification?.enabled ? { domainVerified: true } : {}
|
|
2554
|
-
};
|
|
2555
|
-
}
|
|
2556
|
-
if (!provider) provider = await ctx.context.adapter.findOne({
|
|
2557
|
-
model: "ssoProvider",
|
|
2558
|
-
where: [{
|
|
2559
|
-
field: "providerId",
|
|
2560
|
-
value: providerId
|
|
2561
|
-
}]
|
|
2562
|
-
}).then((res) => {
|
|
2563
|
-
if (!res) return null;
|
|
2564
|
-
return {
|
|
2565
|
-
...res,
|
|
2566
|
-
samlConfig: res.samlConfig ? safeJsonParse(res.samlConfig) || void 0 : void 0
|
|
2567
|
-
};
|
|
2568
|
-
});
|
|
2569
|
-
if (!provider) throw new APIError("NOT_FOUND", { message: "No provider found for the given providerId" });
|
|
2570
|
-
if (options?.domainVerification?.enabled && !("domainVerified" in provider && provider.domainVerified)) throw new APIError("UNAUTHORIZED", { message: "Provider domain has not been verified" });
|
|
2571
|
-
const parsedSamlConfig = safeJsonParse(provider.samlConfig);
|
|
2572
|
-
if (!parsedSamlConfig) throw new APIError("BAD_REQUEST", { message: "Invalid SAML configuration" });
|
|
2573
|
-
const idpData = parsedSamlConfig.idpMetadata;
|
|
2574
|
-
let idp = null;
|
|
2575
|
-
if (!idpData?.metadata) idp = saml.IdentityProvider({
|
|
2576
|
-
entityID: idpData?.entityID || parsedSamlConfig.issuer,
|
|
2577
|
-
singleSignOnService: idpData?.singleSignOnService || [{
|
|
2578
|
-
Binding: "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect",
|
|
2579
|
-
Location: parsedSamlConfig.entryPoint
|
|
2580
|
-
}],
|
|
2581
|
-
signingCert: idpData?.cert || parsedSamlConfig.cert,
|
|
2582
|
-
wantAuthnRequestsSigned: parsedSamlConfig.authnRequestsSigned || false,
|
|
2583
|
-
isAssertionEncrypted: idpData?.isAssertionEncrypted || false,
|
|
2584
|
-
encPrivateKey: idpData?.encPrivateKey,
|
|
2585
|
-
encPrivateKeyPass: idpData?.encPrivateKeyPass
|
|
2586
|
-
});
|
|
2587
|
-
else idp = saml.IdentityProvider({
|
|
2588
|
-
metadata: idpData.metadata,
|
|
2589
|
-
privateKey: idpData.privateKey,
|
|
2590
|
-
privateKeyPass: idpData.privateKeyPass,
|
|
2591
|
-
isAssertionEncrypted: idpData.isAssertionEncrypted,
|
|
2592
|
-
encPrivateKey: idpData.encPrivateKey,
|
|
2593
|
-
encPrivateKeyPass: idpData.encPrivateKeyPass
|
|
2594
|
-
});
|
|
2595
|
-
const spData = parsedSamlConfig.spMetadata;
|
|
2596
|
-
const sp = saml.ServiceProvider({
|
|
2597
|
-
metadata: spData?.metadata,
|
|
2598
|
-
entityID: spData?.entityID || parsedSamlConfig.issuer,
|
|
2599
|
-
assertionConsumerService: spData?.metadata ? void 0 : [{
|
|
2600
|
-
Binding: "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST",
|
|
2601
|
-
Location: parsedSamlConfig.callbackUrl
|
|
2602
|
-
}],
|
|
2603
|
-
privateKey: spData?.privateKey || parsedSamlConfig.privateKey,
|
|
2604
|
-
privateKeyPass: spData?.privateKeyPass,
|
|
2605
|
-
isAssertionEncrypted: spData?.isAssertionEncrypted || false,
|
|
2606
|
-
encPrivateKey: spData?.encPrivateKey,
|
|
2607
|
-
encPrivateKeyPass: spData?.encPrivateKeyPass,
|
|
2608
|
-
wantMessageSigned: parsedSamlConfig.wantAssertionsSigned || false,
|
|
2609
|
-
nameIDFormat: parsedSamlConfig.identifierFormat ? [parsedSamlConfig.identifierFormat] : void 0
|
|
2610
|
-
});
|
|
2611
|
-
validateSingleAssertion(SAMLResponse);
|
|
2612
|
-
let parsedResponse;
|
|
2613
|
-
try {
|
|
2614
|
-
parsedResponse = await sp.parseLoginResponse(idp, "post", { body: {
|
|
2615
|
-
SAMLResponse,
|
|
2616
|
-
RelayState: ctx.body.RelayState || void 0
|
|
2617
|
-
} });
|
|
2618
|
-
if (!parsedResponse?.extract) throw new Error("Invalid SAML response structure");
|
|
2619
|
-
} catch (error) {
|
|
2620
|
-
ctx.context.logger.error("SAML response validation failed", {
|
|
2621
|
-
error,
|
|
2622
|
-
decodedResponse: Buffer.from(SAMLResponse, "base64").toString("utf-8")
|
|
2623
|
-
});
|
|
2624
|
-
throw new APIError("BAD_REQUEST", {
|
|
2625
|
-
message: "Invalid SAML response",
|
|
2626
|
-
details: error instanceof Error ? error.message : String(error)
|
|
2627
|
-
});
|
|
2628
|
-
}
|
|
2629
|
-
const { extract } = parsedResponse;
|
|
2630
|
-
validateSAMLAlgorithms(parsedResponse, options?.saml?.algorithms);
|
|
2631
|
-
validateSAMLTimestamp(extract.conditions, {
|
|
2632
|
-
clockSkew: options?.saml?.clockSkew,
|
|
2633
|
-
requireTimestamps: options?.saml?.requireTimestamps,
|
|
2634
|
-
logger: ctx.context.logger
|
|
2635
|
-
});
|
|
2636
|
-
const inResponseTo = extract.inResponseTo;
|
|
2637
|
-
if (options?.saml?.enableInResponseToValidation !== false) {
|
|
2638
|
-
const allowIdpInitiated = options?.saml?.allowIdpInitiated !== false;
|
|
2639
|
-
if (inResponseTo) {
|
|
2640
|
-
let storedRequest = null;
|
|
2641
|
-
const verification = await ctx.context.internalAdapter.findVerificationValue(`${AUTHN_REQUEST_KEY_PREFIX}${inResponseTo}`);
|
|
2642
|
-
if (verification) try {
|
|
2643
|
-
storedRequest = JSON.parse(verification.value);
|
|
2644
|
-
if (storedRequest && storedRequest.expiresAt < Date.now()) storedRequest = null;
|
|
2645
|
-
} catch {
|
|
2646
|
-
storedRequest = null;
|
|
2647
|
-
}
|
|
2648
|
-
if (!storedRequest) {
|
|
2649
|
-
ctx.context.logger.error("SAML InResponseTo validation failed: unknown or expired request ID", {
|
|
2650
|
-
inResponseTo,
|
|
2651
|
-
providerId: provider.providerId
|
|
2652
|
-
});
|
|
2653
|
-
const redirectUrl = relayState?.callbackURL || parsedSamlConfig.callbackUrl || ctx.context.baseURL;
|
|
2654
|
-
throw ctx.redirect(`${redirectUrl}?error=invalid_saml_response&error_description=Unknown+or+expired+request+ID`);
|
|
2655
|
-
}
|
|
2656
|
-
if (storedRequest.providerId !== provider.providerId) {
|
|
2657
|
-
ctx.context.logger.error("SAML InResponseTo validation failed: provider mismatch", {
|
|
2658
|
-
inResponseTo,
|
|
2659
|
-
expectedProvider: storedRequest.providerId,
|
|
2660
|
-
actualProvider: provider.providerId
|
|
2661
|
-
});
|
|
2662
|
-
await ctx.context.internalAdapter.deleteVerificationByIdentifier(`${AUTHN_REQUEST_KEY_PREFIX}${inResponseTo}`);
|
|
2663
|
-
const redirectUrl = relayState?.callbackURL || parsedSamlConfig.callbackUrl || ctx.context.baseURL;
|
|
2664
|
-
throw ctx.redirect(`${redirectUrl}?error=invalid_saml_response&error_description=Provider+mismatch`);
|
|
2665
|
-
}
|
|
2666
|
-
await ctx.context.internalAdapter.deleteVerificationByIdentifier(`${AUTHN_REQUEST_KEY_PREFIX}${inResponseTo}`);
|
|
2667
|
-
} else if (!allowIdpInitiated) {
|
|
2668
|
-
ctx.context.logger.error("SAML IdP-initiated SSO rejected: InResponseTo missing and allowIdpInitiated is false", { providerId: provider.providerId });
|
|
2669
|
-
const redirectUrl = relayState?.callbackURL || parsedSamlConfig.callbackUrl || ctx.context.baseURL;
|
|
2670
|
-
throw ctx.redirect(`${redirectUrl}?error=unsolicited_response&error_description=IdP-initiated+SSO+not+allowed`);
|
|
2671
|
-
}
|
|
2672
|
-
}
|
|
2673
|
-
const samlContent = parsedResponse.samlContent;
|
|
2674
|
-
const assertionId = samlContent ? extractAssertionId(samlContent) : null;
|
|
2675
|
-
if (assertionId) {
|
|
2676
|
-
const issuer = idp.entityMeta.getEntityID();
|
|
2677
|
-
const conditions = extract.conditions;
|
|
2678
|
-
const clockSkew = options?.saml?.clockSkew ?? 3e5;
|
|
2679
|
-
const expiresAt = conditions?.notOnOrAfter ? new Date(conditions.notOnOrAfter).getTime() + clockSkew : Date.now() + DEFAULT_ASSERTION_TTL_MS;
|
|
2680
|
-
const existingAssertion = await ctx.context.internalAdapter.findVerificationValue(`${USED_ASSERTION_KEY_PREFIX}${assertionId}`);
|
|
2681
|
-
let isReplay = false;
|
|
2682
|
-
if (existingAssertion) try {
|
|
2683
|
-
if (JSON.parse(existingAssertion.value).expiresAt >= Date.now()) isReplay = true;
|
|
2684
|
-
} catch (error) {
|
|
2685
|
-
ctx.context.logger.warn("Failed to parse stored assertion record", {
|
|
2686
|
-
assertionId,
|
|
2687
|
-
error
|
|
2688
|
-
});
|
|
2689
|
-
}
|
|
2690
|
-
if (isReplay) {
|
|
2691
|
-
ctx.context.logger.error("SAML assertion replay detected: assertion ID already used", {
|
|
2692
|
-
assertionId,
|
|
2693
|
-
issuer,
|
|
2694
|
-
providerId: provider.providerId
|
|
2695
|
-
});
|
|
2696
|
-
const redirectUrl = relayState?.callbackURL || parsedSamlConfig.callbackUrl || ctx.context.baseURL;
|
|
2697
|
-
throw ctx.redirect(`${redirectUrl}?error=replay_detected&error_description=SAML+assertion+has+already+been+used`);
|
|
2698
|
-
}
|
|
2699
|
-
await ctx.context.internalAdapter.createVerificationValue({
|
|
2700
|
-
identifier: `${USED_ASSERTION_KEY_PREFIX}${assertionId}`,
|
|
2701
|
-
value: JSON.stringify({
|
|
2702
|
-
assertionId,
|
|
2703
|
-
issuer,
|
|
2704
|
-
providerId: provider.providerId,
|
|
2705
|
-
usedAt: Date.now(),
|
|
2706
|
-
expiresAt
|
|
2707
|
-
}),
|
|
2708
|
-
expiresAt: new Date(expiresAt)
|
|
2709
|
-
});
|
|
2710
|
-
} else ctx.context.logger.warn("Could not extract assertion ID for replay protection", { providerId: provider.providerId });
|
|
2711
|
-
const attributes = extract.attributes || {};
|
|
2712
|
-
const mapping = parsedSamlConfig.mapping ?? {};
|
|
2713
|
-
const userInfo = {
|
|
2714
|
-
...Object.fromEntries(Object.entries(mapping.extraFields || {}).map(([key, value]) => [key, attributes[value]])),
|
|
2715
|
-
id: attributes[mapping.id || "nameID"] || extract.nameID,
|
|
2716
|
-
email: (attributes[mapping.email || "email"] || extract.nameID).toLowerCase(),
|
|
2717
|
-
name: [attributes[mapping.firstName || "givenName"], attributes[mapping.lastName || "surname"]].filter(Boolean).join(" ") || attributes[mapping.name || "displayName"] || extract.nameID,
|
|
2718
|
-
emailVerified: options?.trustEmailVerified && mapping.emailVerified ? attributes[mapping.emailVerified] || false : false
|
|
2719
|
-
};
|
|
2720
|
-
if (!userInfo.id || !userInfo.email) {
|
|
2721
|
-
ctx.context.logger.error("Missing essential user info from SAML response", {
|
|
2722
|
-
attributes: Object.keys(attributes),
|
|
2723
|
-
mapping,
|
|
2724
|
-
extractedId: userInfo.id,
|
|
2725
|
-
extractedEmail: userInfo.email
|
|
2726
|
-
});
|
|
2727
|
-
throw new APIError("BAD_REQUEST", { message: "Unable to extract user ID or email from SAML response" });
|
|
2728
|
-
}
|
|
2729
|
-
const isTrustedProvider = ctx.context.trustedProviders.includes(provider.providerId) || "domainVerified" in provider && !!provider.domainVerified && validateEmailDomain(userInfo.email, provider.domain);
|
|
2730
|
-
const callbackUrl = relayState?.callbackURL || parsedSamlConfig.callbackUrl || ctx.context.baseURL;
|
|
2731
|
-
const result = await handleOAuthUserInfo(ctx, {
|
|
2732
|
-
userInfo: {
|
|
2733
|
-
email: userInfo.email,
|
|
2734
|
-
name: userInfo.name || userInfo.email,
|
|
2735
|
-
id: userInfo.id,
|
|
2736
|
-
emailVerified: Boolean(userInfo.emailVerified)
|
|
2737
|
-
},
|
|
2738
|
-
account: {
|
|
2739
|
-
providerId: provider.providerId,
|
|
2740
|
-
accountId: userInfo.id,
|
|
2741
|
-
accessToken: "",
|
|
2742
|
-
refreshToken: ""
|
|
2743
|
-
},
|
|
2744
|
-
callbackURL: callbackUrl,
|
|
2745
|
-
disableSignUp: options?.disableImplicitSignUp,
|
|
2746
|
-
isTrustedProvider
|
|
2747
|
-
});
|
|
2748
|
-
if (result.error) throw ctx.redirect(`${callbackUrl}?error=${result.error.split(" ").join("_")}`);
|
|
2749
|
-
const { session, user } = result.data;
|
|
2750
|
-
if (options?.provisionUser && (result.isRegister || options.provisionUserOnEveryLogin)) await options.provisionUser({
|
|
2751
|
-
user,
|
|
2752
|
-
userInfo,
|
|
2753
|
-
provider
|
|
2754
|
-
});
|
|
2755
|
-
await assignOrganizationFromProvider(ctx, {
|
|
2756
|
-
user,
|
|
2757
|
-
profile: {
|
|
2758
|
-
providerType: "saml",
|
|
2759
|
-
providerId: provider.providerId,
|
|
2760
|
-
accountId: userInfo.id,
|
|
2761
|
-
email: userInfo.email,
|
|
2762
|
-
emailVerified: Boolean(userInfo.emailVerified),
|
|
2763
|
-
rawAttributes: attributes
|
|
2764
|
-
},
|
|
2765
|
-
provider,
|
|
2766
|
-
provisioningOptions: options?.organizationProvisioning
|
|
2767
|
-
});
|
|
2768
|
-
await setSessionCookie(ctx, {
|
|
2769
|
-
session,
|
|
2770
|
-
user
|
|
2771
|
-
});
|
|
2772
|
-
if (options?.saml?.enableSingleLogout && extract.nameID) {
|
|
2773
|
-
const samlSessionKey = `${SAML_SESSION_KEY_PREFIX}${provider.providerId}:${extract.nameID}`;
|
|
2774
|
-
const samlSessionData = {
|
|
2775
|
-
sessionId: session.id,
|
|
2776
|
-
providerId: provider.providerId,
|
|
2777
|
-
nameID: extract.nameID,
|
|
2778
|
-
sessionIndex: extract.sessionIndex
|
|
2779
|
-
};
|
|
2780
|
-
await ctx.context.internalAdapter.createVerificationValue({
|
|
2781
|
-
identifier: samlSessionKey,
|
|
2782
|
-
value: JSON.stringify(samlSessionData),
|
|
2783
|
-
expiresAt: session.expiresAt
|
|
2784
|
-
}).catch((e) => ctx.context.logger.warn("Failed to create SAML session record", { error: e }));
|
|
2785
|
-
await ctx.context.internalAdapter.createVerificationValue({
|
|
2786
|
-
identifier: `${SAML_SESSION_BY_ID_PREFIX}${session.id}`,
|
|
2787
|
-
value: samlSessionKey,
|
|
2788
|
-
expiresAt: session.expiresAt
|
|
2789
|
-
}).catch((e) => ctx.context.logger.warn("Failed to create SAML session lookup record", e));
|
|
2790
|
-
}
|
|
2791
|
-
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);
|
|
2792
2776
|
throw ctx.redirect(safeRedirectUrl);
|
|
2793
2777
|
});
|
|
2794
2778
|
};
|
|
@@ -2811,256 +2795,27 @@ const acsEndpoint = (options) => {
|
|
|
2811
2795
|
}
|
|
2812
2796
|
}
|
|
2813
2797
|
}, async (ctx) => {
|
|
2814
|
-
const { SAMLResponse } = ctx.body;
|
|
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(SAMLResponse).length > maxResponseSize) throw new APIError("BAD_REQUEST", { message: `SAML response exceeds maximum allowed size (${maxResponseSize} bytes)` });
|
|
2820
|
-
let relayState = null;
|
|
2821
|
-
if (ctx.body.RelayState) try {
|
|
2822
|
-
relayState = await parseRelayState(ctx);
|
|
2823
|
-
} catch {
|
|
2824
|
-
relayState = null;
|
|
2825
|
-
}
|
|
2826
|
-
let provider = null;
|
|
2827
|
-
if (options?.defaultSSO?.length) {
|
|
2828
|
-
const matchingDefault = providerId ? options.defaultSSO.find((defaultProvider) => defaultProvider.providerId === providerId) : options.defaultSSO[0];
|
|
2829
|
-
if (matchingDefault) provider = {
|
|
2830
|
-
issuer: matchingDefault.samlConfig?.issuer || "",
|
|
2831
|
-
providerId: matchingDefault.providerId,
|
|
2832
|
-
userId: "default",
|
|
2833
|
-
samlConfig: matchingDefault.samlConfig,
|
|
2834
|
-
domain: matchingDefault.domain,
|
|
2835
|
-
...options.domainVerification?.enabled ? { domainVerified: true } : {}
|
|
2836
|
-
};
|
|
2837
|
-
} else provider = await ctx.context.adapter.findOne({
|
|
2838
|
-
model: "ssoProvider",
|
|
2839
|
-
where: [{
|
|
2840
|
-
field: "providerId",
|
|
2841
|
-
value: providerId
|
|
2842
|
-
}]
|
|
2843
|
-
}).then((res) => {
|
|
2844
|
-
if (!res) return null;
|
|
2845
|
-
return {
|
|
2846
|
-
...res,
|
|
2847
|
-
samlConfig: res.samlConfig ? safeJsonParse(res.samlConfig) || void 0 : void 0
|
|
2848
|
-
};
|
|
2849
|
-
});
|
|
2850
|
-
if (!provider?.samlConfig) throw new APIError("NOT_FOUND", { message: "No SAML provider found" });
|
|
2851
|
-
if (options?.domainVerification?.enabled && !("domainVerified" in provider && provider.domainVerified)) throw new APIError("UNAUTHORIZED", { message: "Provider domain has not been verified" });
|
|
2852
|
-
const parsedSamlConfig = provider.samlConfig;
|
|
2853
|
-
const sp = saml.ServiceProvider({
|
|
2854
|
-
entityID: parsedSamlConfig.spMetadata?.entityID || parsedSamlConfig.issuer,
|
|
2855
|
-
assertionConsumerService: [{
|
|
2856
|
-
Binding: "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST",
|
|
2857
|
-
Location: parsedSamlConfig.callbackUrl || `${ctx.context.baseURL}/sso/saml2/sp/acs/${providerId}`
|
|
2858
|
-
}],
|
|
2859
|
-
wantMessageSigned: parsedSamlConfig.wantAssertionsSigned || false,
|
|
2860
|
-
metadata: parsedSamlConfig.spMetadata?.metadata,
|
|
2861
|
-
privateKey: parsedSamlConfig.spMetadata?.privateKey || parsedSamlConfig.privateKey,
|
|
2862
|
-
privateKeyPass: parsedSamlConfig.spMetadata?.privateKeyPass,
|
|
2863
|
-
nameIDFormat: parsedSamlConfig.identifierFormat ? [parsedSamlConfig.identifierFormat] : void 0
|
|
2864
|
-
});
|
|
2865
|
-
const idpData = parsedSamlConfig.idpMetadata;
|
|
2866
|
-
const idp = !idpData?.metadata ? saml.IdentityProvider({
|
|
2867
|
-
entityID: idpData?.entityID || parsedSamlConfig.issuer,
|
|
2868
|
-
singleSignOnService: idpData?.singleSignOnService || [{
|
|
2869
|
-
Binding: "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect",
|
|
2870
|
-
Location: parsedSamlConfig.entryPoint
|
|
2871
|
-
}],
|
|
2872
|
-
signingCert: idpData?.cert || parsedSamlConfig.cert
|
|
2873
|
-
}) : saml.IdentityProvider({ metadata: idpData.metadata });
|
|
2874
2801
|
try {
|
|
2875
|
-
|
|
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);
|
|
2876
2809
|
} catch (error) {
|
|
2877
|
-
if (error instanceof
|
|
2878
|
-
|
|
2879
|
-
const
|
|
2880
|
-
|
|
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)}`);
|
|
2881
2816
|
}
|
|
2882
2817
|
throw error;
|
|
2883
2818
|
}
|
|
2884
|
-
let parsedResponse;
|
|
2885
|
-
try {
|
|
2886
|
-
parsedResponse = await sp.parseLoginResponse(idp, "post", { body: {
|
|
2887
|
-
SAMLResponse,
|
|
2888
|
-
RelayState: ctx.body.RelayState || void 0
|
|
2889
|
-
} });
|
|
2890
|
-
if (!parsedResponse?.extract) throw new Error("Invalid SAML response structure");
|
|
2891
|
-
} catch (error) {
|
|
2892
|
-
ctx.context.logger.error("SAML response validation failed", {
|
|
2893
|
-
error,
|
|
2894
|
-
decodedResponse: Buffer.from(SAMLResponse, "base64").toString("utf-8")
|
|
2895
|
-
});
|
|
2896
|
-
throw new APIError("BAD_REQUEST", {
|
|
2897
|
-
message: "Invalid SAML response",
|
|
2898
|
-
details: error instanceof Error ? error.message : String(error)
|
|
2899
|
-
});
|
|
2900
|
-
}
|
|
2901
|
-
const { extract } = parsedResponse;
|
|
2902
|
-
validateSAMLAlgorithms(parsedResponse, options?.saml?.algorithms);
|
|
2903
|
-
validateSAMLTimestamp(extract.conditions, {
|
|
2904
|
-
clockSkew: options?.saml?.clockSkew,
|
|
2905
|
-
requireTimestamps: options?.saml?.requireTimestamps,
|
|
2906
|
-
logger: ctx.context.logger
|
|
2907
|
-
});
|
|
2908
|
-
const inResponseToAcs = extract.inResponseTo;
|
|
2909
|
-
if (options?.saml?.enableInResponseToValidation !== false) {
|
|
2910
|
-
const allowIdpInitiated = options?.saml?.allowIdpInitiated !== false;
|
|
2911
|
-
if (inResponseToAcs) {
|
|
2912
|
-
let storedRequest = null;
|
|
2913
|
-
const verification = await ctx.context.internalAdapter.findVerificationValue(`${AUTHN_REQUEST_KEY_PREFIX}${inResponseToAcs}`);
|
|
2914
|
-
if (verification) try {
|
|
2915
|
-
storedRequest = JSON.parse(verification.value);
|
|
2916
|
-
if (storedRequest && storedRequest.expiresAt < Date.now()) storedRequest = null;
|
|
2917
|
-
} catch {
|
|
2918
|
-
storedRequest = null;
|
|
2919
|
-
}
|
|
2920
|
-
if (!storedRequest) {
|
|
2921
|
-
ctx.context.logger.error("SAML InResponseTo validation failed: unknown or expired request ID", {
|
|
2922
|
-
inResponseTo: inResponseToAcs,
|
|
2923
|
-
providerId
|
|
2924
|
-
});
|
|
2925
|
-
const redirectUrl = relayState?.callbackURL || parsedSamlConfig.callbackUrl || ctx.context.baseURL;
|
|
2926
|
-
throw ctx.redirect(`${redirectUrl}?error=invalid_saml_response&error_description=Unknown+or+expired+request+ID`);
|
|
2927
|
-
}
|
|
2928
|
-
if (storedRequest.providerId !== providerId) {
|
|
2929
|
-
ctx.context.logger.error("SAML InResponseTo validation failed: provider mismatch", {
|
|
2930
|
-
inResponseTo: inResponseToAcs,
|
|
2931
|
-
expectedProvider: storedRequest.providerId,
|
|
2932
|
-
actualProvider: providerId
|
|
2933
|
-
});
|
|
2934
|
-
await ctx.context.internalAdapter.deleteVerificationByIdentifier(`${AUTHN_REQUEST_KEY_PREFIX}${inResponseToAcs}`);
|
|
2935
|
-
const redirectUrl = relayState?.callbackURL || parsedSamlConfig.callbackUrl || ctx.context.baseURL;
|
|
2936
|
-
throw ctx.redirect(`${redirectUrl}?error=invalid_saml_response&error_description=Provider+mismatch`);
|
|
2937
|
-
}
|
|
2938
|
-
await ctx.context.internalAdapter.deleteVerificationByIdentifier(`${AUTHN_REQUEST_KEY_PREFIX}${inResponseToAcs}`);
|
|
2939
|
-
} else if (!allowIdpInitiated) {
|
|
2940
|
-
ctx.context.logger.error("SAML IdP-initiated SSO rejected: InResponseTo missing and allowIdpInitiated is false", { providerId });
|
|
2941
|
-
const redirectUrl = relayState?.callbackURL || parsedSamlConfig.callbackUrl || ctx.context.baseURL;
|
|
2942
|
-
throw ctx.redirect(`${redirectUrl}?error=unsolicited_response&error_description=IdP-initiated+SSO+not+allowed`);
|
|
2943
|
-
}
|
|
2944
|
-
}
|
|
2945
|
-
const assertionIdAcs = extractAssertionId(Buffer.from(SAMLResponse, "base64").toString("utf-8"));
|
|
2946
|
-
if (assertionIdAcs) {
|
|
2947
|
-
const issuer = idp.entityMeta.getEntityID();
|
|
2948
|
-
const conditions = extract.conditions;
|
|
2949
|
-
const clockSkew = options?.saml?.clockSkew ?? 3e5;
|
|
2950
|
-
const expiresAt = conditions?.notOnOrAfter ? new Date(conditions.notOnOrAfter).getTime() + clockSkew : Date.now() + DEFAULT_ASSERTION_TTL_MS;
|
|
2951
|
-
const existingAssertion = await ctx.context.internalAdapter.findVerificationValue(`${USED_ASSERTION_KEY_PREFIX}${assertionIdAcs}`);
|
|
2952
|
-
let isReplay = false;
|
|
2953
|
-
if (existingAssertion) try {
|
|
2954
|
-
if (JSON.parse(existingAssertion.value).expiresAt >= Date.now()) isReplay = true;
|
|
2955
|
-
} catch (error) {
|
|
2956
|
-
ctx.context.logger.warn("Failed to parse stored assertion record", {
|
|
2957
|
-
assertionId: assertionIdAcs,
|
|
2958
|
-
error
|
|
2959
|
-
});
|
|
2960
|
-
}
|
|
2961
|
-
if (isReplay) {
|
|
2962
|
-
ctx.context.logger.error("SAML assertion replay detected: assertion ID already used", {
|
|
2963
|
-
assertionId: assertionIdAcs,
|
|
2964
|
-
issuer,
|
|
2965
|
-
providerId
|
|
2966
|
-
});
|
|
2967
|
-
const redirectUrl = relayState?.callbackURL || parsedSamlConfig.callbackUrl || ctx.context.baseURL;
|
|
2968
|
-
throw ctx.redirect(`${redirectUrl}?error=replay_detected&error_description=SAML+assertion+has+already+been+used`);
|
|
2969
|
-
}
|
|
2970
|
-
await ctx.context.internalAdapter.createVerificationValue({
|
|
2971
|
-
identifier: `${USED_ASSERTION_KEY_PREFIX}${assertionIdAcs}`,
|
|
2972
|
-
value: JSON.stringify({
|
|
2973
|
-
assertionId: assertionIdAcs,
|
|
2974
|
-
issuer,
|
|
2975
|
-
providerId,
|
|
2976
|
-
usedAt: Date.now(),
|
|
2977
|
-
expiresAt
|
|
2978
|
-
}),
|
|
2979
|
-
expiresAt: new Date(expiresAt)
|
|
2980
|
-
});
|
|
2981
|
-
} else ctx.context.logger.warn("Could not extract assertion ID for replay protection", { providerId });
|
|
2982
|
-
const attributes = extract.attributes || {};
|
|
2983
|
-
const mapping = parsedSamlConfig.mapping ?? {};
|
|
2984
|
-
const userInfo = {
|
|
2985
|
-
...Object.fromEntries(Object.entries(mapping.extraFields || {}).map(([key, value]) => [key, attributes[value]])),
|
|
2986
|
-
id: attributes[mapping.id || "nameID"] || extract.nameID,
|
|
2987
|
-
email: (attributes[mapping.email || "email"] || extract.nameID).toLowerCase(),
|
|
2988
|
-
name: [attributes[mapping.firstName || "givenName"], attributes[mapping.lastName || "surname"]].filter(Boolean).join(" ") || attributes[mapping.name || "displayName"] || extract.nameID,
|
|
2989
|
-
emailVerified: options?.trustEmailVerified && mapping.emailVerified ? attributes[mapping.emailVerified] || false : false
|
|
2990
|
-
};
|
|
2991
|
-
if (!userInfo.id || !userInfo.email) {
|
|
2992
|
-
ctx.context.logger.error("Missing essential user info from SAML response", {
|
|
2993
|
-
attributes: Object.keys(attributes),
|
|
2994
|
-
mapping,
|
|
2995
|
-
extractedId: userInfo.id,
|
|
2996
|
-
extractedEmail: userInfo.email
|
|
2997
|
-
});
|
|
2998
|
-
throw new APIError("BAD_REQUEST", { message: "Unable to extract user ID or email from SAML response" });
|
|
2999
|
-
}
|
|
3000
|
-
const isTrustedProvider = ctx.context.trustedProviders.includes(provider.providerId) || "domainVerified" in provider && !!provider.domainVerified && validateEmailDomain(userInfo.email, provider.domain);
|
|
3001
|
-
const callbackUrl = relayState?.callbackURL || parsedSamlConfig.callbackUrl || ctx.context.baseURL;
|
|
3002
|
-
const result = await handleOAuthUserInfo(ctx, {
|
|
3003
|
-
userInfo: {
|
|
3004
|
-
email: userInfo.email,
|
|
3005
|
-
name: userInfo.name || userInfo.email,
|
|
3006
|
-
id: userInfo.id,
|
|
3007
|
-
emailVerified: Boolean(userInfo.emailVerified)
|
|
3008
|
-
},
|
|
3009
|
-
account: {
|
|
3010
|
-
providerId: provider.providerId,
|
|
3011
|
-
accountId: userInfo.id,
|
|
3012
|
-
accessToken: "",
|
|
3013
|
-
refreshToken: ""
|
|
3014
|
-
},
|
|
3015
|
-
callbackURL: callbackUrl,
|
|
3016
|
-
disableSignUp: options?.disableImplicitSignUp,
|
|
3017
|
-
isTrustedProvider
|
|
3018
|
-
});
|
|
3019
|
-
if (result.error) throw ctx.redirect(`${callbackUrl}?error=${result.error.split(" ").join("_")}`);
|
|
3020
|
-
const { session, user } = result.data;
|
|
3021
|
-
if (options?.provisionUser && (result.isRegister || options.provisionUserOnEveryLogin)) await options.provisionUser({
|
|
3022
|
-
user,
|
|
3023
|
-
userInfo,
|
|
3024
|
-
provider
|
|
3025
|
-
});
|
|
3026
|
-
await assignOrganizationFromProvider(ctx, {
|
|
3027
|
-
user,
|
|
3028
|
-
profile: {
|
|
3029
|
-
providerType: "saml",
|
|
3030
|
-
providerId: provider.providerId,
|
|
3031
|
-
accountId: userInfo.id,
|
|
3032
|
-
email: userInfo.email,
|
|
3033
|
-
emailVerified: Boolean(userInfo.emailVerified),
|
|
3034
|
-
rawAttributes: attributes
|
|
3035
|
-
},
|
|
3036
|
-
provider,
|
|
3037
|
-
provisioningOptions: options?.organizationProvisioning
|
|
3038
|
-
});
|
|
3039
|
-
await setSessionCookie(ctx, {
|
|
3040
|
-
session,
|
|
3041
|
-
user
|
|
3042
|
-
});
|
|
3043
|
-
if (options?.saml?.enableSingleLogout && extract.nameID) {
|
|
3044
|
-
const samlSessionKey = `${SAML_SESSION_KEY_PREFIX}${providerId}:${extract.nameID}`;
|
|
3045
|
-
const samlSessionData = {
|
|
3046
|
-
sessionId: session.id,
|
|
3047
|
-
providerId,
|
|
3048
|
-
nameID: extract.nameID,
|
|
3049
|
-
sessionIndex: extract.sessionIndex
|
|
3050
|
-
};
|
|
3051
|
-
await ctx.context.internalAdapter.createVerificationValue({
|
|
3052
|
-
identifier: samlSessionKey,
|
|
3053
|
-
value: JSON.stringify(samlSessionData),
|
|
3054
|
-
expiresAt: session.expiresAt
|
|
3055
|
-
}).catch((e) => ctx.context.logger.warn("Failed to create SAML session record", { error: e }));
|
|
3056
|
-
await ctx.context.internalAdapter.createVerificationValue({
|
|
3057
|
-
identifier: `${SAML_SESSION_BY_ID_PREFIX}${session.id}`,
|
|
3058
|
-
value: samlSessionKey,
|
|
3059
|
-
expiresAt: session.expiresAt
|
|
3060
|
-
}).catch((e) => ctx.context.logger.warn("Failed to create SAML session lookup record", e));
|
|
3061
|
-
}
|
|
3062
|
-
const safeRedirectUrl = getSafeRedirectUrl(relayState?.callbackURL || parsedSamlConfig.callbackUrl, currentCallbackPath, appOrigin, (url, settings) => ctx.context.isTrustedOrigin(url, settings));
|
|
3063
|
-
throw ctx.redirect(safeRedirectUrl);
|
|
3064
2819
|
});
|
|
3065
2820
|
};
|
|
3066
2821
|
const sloSchema = z.object({
|
|
@@ -3091,10 +2846,10 @@ const sloEndpoint = (options) => {
|
|
|
3091
2846
|
const provider = await findSAMLProvider(providerId, options, ctx.context.adapter);
|
|
3092
2847
|
if (!provider?.samlConfig) throw APIError.from("NOT_FOUND", SAML_ERROR_CODES.SAML_PROVIDER_NOT_FOUND);
|
|
3093
2848
|
const config = provider.samlConfig;
|
|
3094
|
-
const sp = createSP(config, ctx.context.baseURL, providerId, {
|
|
2849
|
+
const sp = createSP(config, ctx.context.baseURL, providerId, { sloOptions: {
|
|
3095
2850
|
wantLogoutRequestSigned: options?.saml?.wantLogoutRequestSigned,
|
|
3096
2851
|
wantLogoutResponseSigned: options?.saml?.wantLogoutResponseSigned
|
|
3097
|
-
});
|
|
2852
|
+
} });
|
|
3098
2853
|
const idp = createIdP(config);
|
|
3099
2854
|
if (samlResponse) return handleLogoutResponse(ctx, sp, idp, relayState, providerId);
|
|
3100
2855
|
return handleLogoutRequest(ctx, sp, idp, relayState, providerId);
|
|
@@ -3180,10 +2935,10 @@ const initiateSLO = (options) => {
|
|
|
3180
2935
|
if (!provider?.samlConfig) throw APIError.from("NOT_FOUND", SAML_ERROR_CODES.SAML_PROVIDER_NOT_FOUND);
|
|
3181
2936
|
const config = provider.samlConfig;
|
|
3182
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);
|
|
3183
|
-
const sp = createSP(config, ctx.context.baseURL, providerId, {
|
|
2938
|
+
const sp = createSP(config, ctx.context.baseURL, providerId, { sloOptions: {
|
|
3184
2939
|
wantLogoutRequestSigned: options?.saml?.wantLogoutRequestSigned,
|
|
3185
2940
|
wantLogoutResponseSigned: options?.saml?.wantLogoutResponseSigned
|
|
3186
|
-
});
|
|
2941
|
+
} });
|
|
3187
2942
|
const idp = createIdP(config);
|
|
3188
2943
|
const session = ctx.context.session;
|
|
3189
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",
|