@better-auth/sso 1.6.1 → 1.7.0-beta.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.mjs CHANGED
@@ -1,4 +1,4 @@
1
- import { t as PACKAGE_VERSION } from "./version-CfoAqE_A.mjs";
1
+ import { t as PACKAGE_VERSION } from "./version-CzfTSPRz.mjs";
2
2
  import { APIError, createAuthEndpoint, createAuthMiddleware, getSessionFromCtx, sessionMiddleware } from "better-auth/api";
3
3
  import { XMLParser, XMLValidator } from "fast-xml-parser";
4
4
  import * as saml from "samlify";
@@ -8,7 +8,7 @@ import { generateRandomString } from "better-auth/crypto";
8
8
  import * as z from "zod";
9
9
  import { base64 } from "@better-auth/utils/base64";
10
10
  import { BetterFetchError, betterFetch } from "@better-fetch/fetch";
11
- import { HIDE_METADATA, createAuthorizationURL, generateGenericState, generateState, parseGenericState, parseState, validateAuthorizationCode, validateToken } from "better-auth";
11
+ import { ASSERTION_SIGNING_ALGORITHMS, HIDE_METADATA, createAuthorizationURL, generateGenericState, generateState, parseGenericState, parseState, validateAuthorizationCode, validateToken } from "better-auth";
12
12
  import { deleteSessionCookie, setSessionCookie } from "better-auth/cookies";
13
13
  import { handleOAuthUserInfo } from "better-auth/oauth2";
14
14
  import { decodeJwt } from "jose";
@@ -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", {
@@ -625,6 +625,91 @@ function validateSingleAssertion(samlResponse) {
625
625
  });
626
626
  }
627
627
  //#endregion
628
+ //#region src/saml/response-validation.ts
629
+ function errorRedirectUrl(base, error, description) {
630
+ try {
631
+ const url = new URL(base);
632
+ url.searchParams.set("error", error);
633
+ url.searchParams.set("error_description", description);
634
+ return url.toString();
635
+ } catch {
636
+ const hashIdx = base.indexOf("#");
637
+ const path = hashIdx >= 0 ? base.slice(0, hashIdx) : base;
638
+ const hash = hashIdx >= 0 ? base.slice(hashIdx + 1) : void 0;
639
+ return `${path}${path.includes("?") ? "&" : "?"}${`error=${encodeURIComponent(error)}&error_description=${encodeURIComponent(description)}`}${hash ? `#${hash}` : ""}`;
640
+ }
641
+ }
642
+ /**
643
+ * Validates the InResponseTo attribute of a SAML Response.
644
+ *
645
+ * This binds the IdP's Response to a specific SP-initiated AuthnRequest,
646
+ * preventing replay attacks, unsolicited response injection, and
647
+ * cross-provider assertion swaps.
648
+ *
649
+ * The InResponseTo value lives at `extract.response.inResponseTo` in
650
+ * samlify's parsed output (not at the top level).
651
+ */
652
+ async function validateInResponseTo(c, ctx) {
653
+ if (ctx.options.enableInResponseToValidation === false) return;
654
+ const inResponseTo = ctx.extract.response?.inResponseTo;
655
+ const allowIdpInitiated = ctx.options.allowIdpInitiated ?? false;
656
+ if (inResponseTo) {
657
+ let storedRequest = null;
658
+ const verification = await c.context.internalAdapter.findVerificationValue(`${AUTHN_REQUEST_KEY_PREFIX}${inResponseTo}`);
659
+ if (verification) try {
660
+ storedRequest = JSON.parse(verification.value);
661
+ if (storedRequest && storedRequest.expiresAt < Date.now()) storedRequest = null;
662
+ } catch {
663
+ storedRequest = null;
664
+ }
665
+ if (!storedRequest) {
666
+ c.context.logger.error("SAML InResponseTo validation failed: unknown or expired request ID", {
667
+ inResponseTo,
668
+ providerId: ctx.providerId
669
+ });
670
+ throw c.redirect(errorRedirectUrl(ctx.redirectUrl, "invalid_saml_response", "Unknown or expired request ID"));
671
+ }
672
+ if (storedRequest.providerId !== ctx.providerId) {
673
+ c.context.logger.error("SAML InResponseTo validation failed: provider mismatch", {
674
+ inResponseTo,
675
+ expectedProvider: storedRequest.providerId,
676
+ actualProvider: ctx.providerId
677
+ });
678
+ await c.context.internalAdapter.deleteVerificationByIdentifier(`${AUTHN_REQUEST_KEY_PREFIX}${inResponseTo}`);
679
+ throw c.redirect(errorRedirectUrl(ctx.redirectUrl, "invalid_saml_response", "Provider mismatch"));
680
+ }
681
+ await c.context.internalAdapter.deleteVerificationByIdentifier(`${AUTHN_REQUEST_KEY_PREFIX}${inResponseTo}`);
682
+ } else if (!allowIdpInitiated) {
683
+ c.context.logger.error("SAML IdP-initiated SSO rejected: InResponseTo missing and allowIdpInitiated is false", { providerId: ctx.providerId });
684
+ throw c.redirect(errorRedirectUrl(ctx.redirectUrl, "unsolicited_response", "IdP-initiated SSO not allowed"));
685
+ }
686
+ }
687
+ /**
688
+ * Validates the AudienceRestriction of a SAML assertion.
689
+ *
690
+ * Per SAML 2.0 Core §2.5.1, an assertion's Audience element specifies
691
+ * the intended recipient SP. Without this check, an assertion issued
692
+ * for a different SP (e.g., another application sharing the same IdP)
693
+ * could be accepted.
694
+ */
695
+ function validateAudience(c, ctx) {
696
+ if (!ctx.expectedAudience) return;
697
+ const audience = ctx.extract.audience;
698
+ if (!audience) {
699
+ c.context.logger.error("SAML assertion missing AudienceRestriction but audience is configured — rejecting", { providerId: ctx.providerId });
700
+ throw c.redirect(errorRedirectUrl(ctx.redirectUrl, "invalid_saml_response", "Audience restriction missing"));
701
+ }
702
+ const audiences = Array.isArray(audience) ? audience : [audience];
703
+ if (!audiences.includes(ctx.expectedAudience)) {
704
+ c.context.logger.error("SAML audience mismatch: assertion was issued for a different service provider", {
705
+ expected: ctx.expectedAudience,
706
+ received: audiences,
707
+ providerId: ctx.providerId
708
+ });
709
+ throw c.redirect(errorRedirectUrl(ctx.redirectUrl, "invalid_saml_response", "Audience mismatch"));
710
+ }
711
+ }
712
+ //#endregion
628
713
  //#region src/routes/schemas.ts
629
714
  const oidcMappingSchema = z.object({
630
715
  id: z.string().optional(),
@@ -649,7 +734,13 @@ const oidcConfigSchema = z.object({
649
734
  authorizationEndpoint: z.string().url().optional(),
650
735
  tokenEndpoint: z.string().url().optional(),
651
736
  userInfoEndpoint: z.string().url().optional(),
652
- tokenEndpointAuthentication: z.enum(["client_secret_post", "client_secret_basic"]).optional(),
737
+ tokenEndpointAuthentication: z.enum([
738
+ "client_secret_post",
739
+ "client_secret_basic",
740
+ "private_key_jwt"
741
+ ]).optional(),
742
+ privateKeyId: z.string().optional(),
743
+ privateKeyAlgorithm: z.string().optional(),
653
744
  jwksEndpoint: z.string().url().optional(),
654
745
  discoveryEndpoint: z.string().url().optional(),
655
746
  scopes: z.array(z.string()).optional(),
@@ -900,7 +991,9 @@ function mergeOIDCConfig(current, updates, issuer) {
900
991
  tokenEndpoint: updates.tokenEndpoint ?? current.tokenEndpoint,
901
992
  userInfoEndpoint: updates.userInfoEndpoint ?? current.userInfoEndpoint,
902
993
  jwksEndpoint: updates.jwksEndpoint ?? current.jwksEndpoint,
903
- tokenEndpointAuthentication: updates.tokenEndpointAuthentication ?? current.tokenEndpointAuthentication
994
+ tokenEndpointAuthentication: updates.tokenEndpointAuthentication ?? current.tokenEndpointAuthentication,
995
+ privateKeyId: updates.privateKeyId ?? current.privateKeyId,
996
+ privateKeyAlgorithm: updates.privateKeyAlgorithm ?? current.privateKeyAlgorithm
904
997
  };
905
998
  }
906
999
  const updateSSOProvider = (options) => {
@@ -945,6 +1038,8 @@ const updateSSOProvider = (options) => {
945
1038
  if (body.oidcConfig) {
946
1039
  const currentOidcConfig = parseAndValidateConfig(existingProvider.oidcConfig, "OIDC");
947
1040
  const updatedOidcConfig = mergeOIDCConfig(currentOidcConfig, body.oidcConfig, updateData.issuer || currentOidcConfig.issuer || existingProvider.issuer);
1041
+ if (updatedOidcConfig.tokenEndpointAuthentication !== "private_key_jwt" && !updatedOidcConfig.clientSecret) throw new APIError("BAD_REQUEST", { message: "clientSecret is required when using client_secret_basic or client_secret_post authentication" });
1042
+ if (updatedOidcConfig.tokenEndpointAuthentication === "private_key_jwt" && !options?.resolvePrivateKey && !options?.defaultSSO?.some((p) => p.providerId === providerId && "privateKey" in p && p.privateKey)) throw new APIError("BAD_REQUEST", { message: "private_key_jwt authentication requires either a resolvePrivateKey callback or a privateKey in defaultSSO" });
948
1043
  updateData.oidcConfig = JSON.stringify(updatedOidcConfig);
949
1044
  }
950
1045
  await ctx.context.adapter.update({
@@ -1242,11 +1337,13 @@ function parseURL(name, endpoint, base) {
1242
1337
  * @returns The selected authentication method
1243
1338
  */
1244
1339
  function selectTokenEndpointAuthMethod(doc, existing) {
1340
+ if (existing === "private_key_jwt") return existing;
1245
1341
  if (existing) return existing;
1246
1342
  const supported = doc.token_endpoint_auth_methods_supported;
1247
1343
  if (!supported || supported.length === 0) return "client_secret_basic";
1248
1344
  if (supported.includes("client_secret_basic")) return "client_secret_basic";
1249
1345
  if (supported.includes("client_secret_post")) return "client_secret_post";
1346
+ if (supported.includes("private_key_jwt")) return "private_key_jwt";
1250
1347
  return "client_secret_basic";
1251
1348
  }
1252
1349
  /**
@@ -1436,13 +1533,15 @@ async function findSAMLProvider(providerId, options, adapter) {
1436
1533
  samlConfig: res.samlConfig ? safeJsonParse(res.samlConfig) || void 0 : void 0
1437
1534
  };
1438
1535
  }
1439
- function createSP(config, baseURL, providerId, sloOptions) {
1536
+ function createSP(config, baseURL, providerId, opts) {
1537
+ const spData = config.spMetadata;
1440
1538
  const sloLocation = `${baseURL}/sso/saml2/sp/slo/${providerId}`;
1539
+ const acsUrl = config.callbackUrl || `${baseURL}/sso/saml2/sp/acs/${providerId}`;
1441
1540
  return saml.ServiceProvider({
1442
- entityID: config.spMetadata?.entityID || config.issuer,
1443
- assertionConsumerService: [{
1541
+ entityID: spData?.entityID || config.issuer,
1542
+ assertionConsumerService: spData?.metadata ? void 0 : [{
1444
1543
  Binding: "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST",
1445
- Location: config.callbackUrl || `${baseURL}/sso/saml2/sp/acs/${providerId}`
1544
+ Location: acsUrl
1446
1545
  }],
1447
1546
  singleLogoutService: [{
1448
1547
  Binding: "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST",
@@ -1452,11 +1551,16 @@ function createSP(config, baseURL, providerId, sloOptions) {
1452
1551
  Location: sloLocation
1453
1552
  }],
1454
1553
  wantMessageSigned: config.wantAssertionsSigned || false,
1455
- wantLogoutRequestSigned: sloOptions?.wantLogoutRequestSigned ?? false,
1456
- wantLogoutResponseSigned: sloOptions?.wantLogoutResponseSigned ?? false,
1457
- metadata: config.spMetadata?.metadata,
1458
- privateKey: config.spMetadata?.privateKey || config.privateKey,
1459
- privateKeyPass: config.spMetadata?.privateKeyPass
1554
+ wantLogoutRequestSigned: opts?.sloOptions?.wantLogoutRequestSigned ?? false,
1555
+ wantLogoutResponseSigned: opts?.sloOptions?.wantLogoutResponseSigned ?? false,
1556
+ metadata: spData?.metadata,
1557
+ privateKey: spData?.privateKey || config.privateKey,
1558
+ privateKeyPass: spData?.privateKeyPass,
1559
+ isAssertionEncrypted: spData?.isAssertionEncrypted || false,
1560
+ encPrivateKey: spData?.encPrivateKey,
1561
+ encPrivateKeyPass: spData?.encPrivateKeyPass,
1562
+ nameIDFormat: config.identifierFormat ? [config.identifierFormat] : void 0,
1563
+ relayState: opts?.relayState
1460
1564
  });
1461
1565
  }
1462
1566
  function createIdP(config) {
@@ -1465,6 +1569,7 @@ function createIdP(config) {
1465
1569
  metadata: idpData.metadata,
1466
1570
  privateKey: idpData.privateKey,
1467
1571
  privateKeyPass: idpData.privateKeyPass,
1572
+ isAssertionEncrypted: idpData.isAssertionEncrypted,
1468
1573
  encPrivateKey: idpData.encPrivateKey,
1469
1574
  encPrivateKeyPass: idpData.encPrivateKeyPass
1470
1575
  });
@@ -1475,7 +1580,11 @@ function createIdP(config) {
1475
1580
  Location: config.entryPoint
1476
1581
  }],
1477
1582
  singleLogoutService: idpData?.singleLogoutService,
1478
- signingCert: idpData?.cert || config.cert
1583
+ signingCert: idpData?.cert || config.cert,
1584
+ wantAuthnRequestsSigned: config.authnRequestsSigned || false,
1585
+ isAssertionEncrypted: idpData?.isAssertionEncrypted || false,
1586
+ encPrivateKey: idpData?.encPrivateKey,
1587
+ encPrivateKeyPass: idpData?.encPrivateKeyPass
1479
1588
  });
1480
1589
  }
1481
1590
  function escapeHtml(str) {
@@ -1491,20 +1600,7 @@ function createSAMLPostForm(action, samlParam, samlValue, relayState) {
1491
1600
  return new Response(html, { headers: { "Content-Type": "text/html" } });
1492
1601
  }
1493
1602
  //#endregion
1494
- //#region src/routes/sso.ts
1495
- /**
1496
- * Builds the OIDC redirect URI. Uses the shared `redirectURI` option
1497
- * when set, otherwise falls back to `/sso/callback/:providerId`.
1498
- */
1499
- function getOIDCRedirectURI(baseURL, providerId, options) {
1500
- if (options?.redirectURI?.trim()) try {
1501
- new URL(options.redirectURI);
1502
- return options.redirectURI;
1503
- } catch {
1504
- return `${baseURL}${options.redirectURI.startsWith("/") ? options.redirectURI : `/${options.redirectURI}`}`;
1505
- }
1506
- return `${baseURL}/sso/callback/${providerId}`;
1507
- }
1603
+ //#region src/saml/timestamp.ts
1508
1604
  /**
1509
1605
  * Validates SAML assertion timestamp conditions (NotBefore/NotOnOrAfter).
1510
1606
  * Prevents acceptance of expired or future-dated assertions.
@@ -1544,9 +1640,39 @@ function validateSAMLTimestamp(conditions, options = {}) {
1544
1640
  });
1545
1641
  }
1546
1642
  }
1643
+ //#endregion
1644
+ //#region src/routes/saml-pipeline.ts
1645
+ /**
1646
+ * Validates and returns a safe redirect URL.
1647
+ * - Prevents open redirect attacks by validating against trusted origins
1648
+ * - Prevents redirect loops by checking if URL points to callback route
1649
+ * - Falls back to appOrigin if URL is invalid or unsafe
1650
+ */
1651
+ function getSafeRedirectUrl(url, callbackPath, appOrigin, isTrustedOrigin) {
1652
+ if (!url) return appOrigin;
1653
+ if (url.startsWith("/") && !url.startsWith("//")) {
1654
+ try {
1655
+ const absoluteUrl = new URL(url, appOrigin);
1656
+ if (absoluteUrl.origin !== appOrigin) return appOrigin;
1657
+ const callbackPathname = new URL(callbackPath).pathname;
1658
+ if (absoluteUrl.pathname === callbackPathname) return appOrigin;
1659
+ } catch {
1660
+ return appOrigin;
1661
+ }
1662
+ return url;
1663
+ }
1664
+ if (!isTrustedOrigin(url, { allowRelativePaths: false })) return appOrigin;
1665
+ try {
1666
+ const callbackPathname = new URL(callbackPath).pathname;
1667
+ if (new URL(url).pathname === callbackPathname) return appOrigin;
1668
+ } catch {
1669
+ if (url === callbackPath || url.startsWith(`${callbackPath}?`)) return appOrigin;
1670
+ }
1671
+ return url;
1672
+ }
1547
1673
  /**
1548
1674
  * Extracts the Assertion ID from a SAML response XML.
1549
- * Returns null if the assertion ID cannot be found.
1675
+ * Used for replay protection per SAML 2.0 Core section 2.3.3.
1550
1676
  */
1551
1677
  function extractAssertionId(samlContent) {
1552
1678
  try {
@@ -1565,6 +1691,208 @@ function extractAssertionId(samlContent) {
1565
1691
  return null;
1566
1692
  }
1567
1693
  }
1694
+ /**
1695
+ * Unified SAML response processing pipeline.
1696
+ *
1697
+ * Both `/sso/saml2/callback/:providerId` (POST) and `/sso/saml2/sp/acs/:providerId`
1698
+ * delegate to this function. It handles the full lifecycle: provider lookup,
1699
+ * SP/IdP construction, response validation, session creation, and redirect
1700
+ * URL computation.
1701
+ */
1702
+ async function processSAMLResponse(ctx, params, options) {
1703
+ const { providerId, currentCallbackPath } = params;
1704
+ const appOrigin = new URL(ctx.context.baseURL).origin;
1705
+ const maxResponseSize = options?.saml?.maxResponseSize ?? 262144;
1706
+ if (new TextEncoder().encode(params.SAMLResponse).length > maxResponseSize) throw new APIError("BAD_REQUEST", { message: `SAML response exceeds maximum allowed size (${maxResponseSize} bytes)` });
1707
+ const SAMLResponse = params.SAMLResponse.replace(/\s+/g, "");
1708
+ let relayState = null;
1709
+ if (params.RelayState) try {
1710
+ relayState = await parseRelayState(ctx);
1711
+ } catch {
1712
+ relayState = null;
1713
+ }
1714
+ const provider = await findSAMLProvider(providerId, options, ctx.context.adapter);
1715
+ if (!provider?.samlConfig) throw new APIError("NOT_FOUND", { message: "No SAML provider found" });
1716
+ if (options?.domainVerification?.enabled && !("domainVerified" in provider && provider.domainVerified)) throw new APIError("UNAUTHORIZED", { message: "Provider domain has not been verified" });
1717
+ const parsedSamlConfig = typeof provider.samlConfig === "object" ? provider.samlConfig : safeJsonParse(provider.samlConfig);
1718
+ if (!parsedSamlConfig) throw new APIError("BAD_REQUEST", { message: "Invalid SAML configuration" });
1719
+ const sp = createSP(parsedSamlConfig, ctx.context.baseURL, providerId);
1720
+ const idp = createIdP(parsedSamlConfig);
1721
+ const samlRedirectUrl = getSafeRedirectUrl(relayState?.callbackURL || parsedSamlConfig.callbackUrl, params.currentCallbackPath, appOrigin, (url, settings) => ctx.context.isTrustedOrigin(url, settings));
1722
+ validateSingleAssertion(SAMLResponse);
1723
+ let parsedResponse;
1724
+ try {
1725
+ parsedResponse = await sp.parseLoginResponse(idp, "post", { body: {
1726
+ SAMLResponse,
1727
+ RelayState: params.RelayState || void 0
1728
+ } });
1729
+ if (!parsedResponse?.extract) throw new Error("Invalid SAML response structure");
1730
+ } catch (error) {
1731
+ ctx.context.logger.error("SAML response validation failed", {
1732
+ error,
1733
+ samlResponsePreview: SAMLResponse.slice(0, 200)
1734
+ });
1735
+ throw new APIError("BAD_REQUEST", {
1736
+ message: "Invalid SAML response",
1737
+ details: error instanceof Error ? error.message : String(error)
1738
+ });
1739
+ }
1740
+ const { extract } = parsedResponse;
1741
+ validateSAMLAlgorithms(parsedResponse, options?.saml?.algorithms);
1742
+ validateSAMLTimestamp(extract.conditions, {
1743
+ clockSkew: options?.saml?.clockSkew,
1744
+ requireTimestamps: options?.saml?.requireTimestamps,
1745
+ logger: ctx.context.logger
1746
+ });
1747
+ await validateInResponseTo(ctx, {
1748
+ extract,
1749
+ providerId,
1750
+ options: {
1751
+ enableInResponseToValidation: options?.saml?.enableInResponseToValidation,
1752
+ allowIdpInitiated: options?.saml?.allowIdpInitiated
1753
+ },
1754
+ redirectUrl: samlRedirectUrl
1755
+ });
1756
+ validateAudience(ctx, {
1757
+ extract,
1758
+ expectedAudience: parsedSamlConfig.audience,
1759
+ providerId,
1760
+ redirectUrl: samlRedirectUrl
1761
+ });
1762
+ const samlContent = parsedResponse.samlContent;
1763
+ const assertionId = samlContent ? extractAssertionId(samlContent) : null;
1764
+ if (assertionId) {
1765
+ const issuer = idp.entityMeta.getEntityID();
1766
+ const conditions = extract.conditions;
1767
+ const clockSkew = options?.saml?.clockSkew ?? 3e5;
1768
+ const expiresAt = conditions?.notOnOrAfter ? new Date(conditions.notOnOrAfter).getTime() + clockSkew : Date.now() + DEFAULT_ASSERTION_TTL_MS;
1769
+ const existingAssertion = await ctx.context.internalAdapter.findVerificationValue(`${USED_ASSERTION_KEY_PREFIX}${assertionId}`);
1770
+ let isReplay = false;
1771
+ if (existingAssertion) try {
1772
+ if (JSON.parse(existingAssertion.value).expiresAt >= Date.now()) isReplay = true;
1773
+ } catch (error) {
1774
+ ctx.context.logger.warn("Failed to parse stored assertion record", {
1775
+ assertionId,
1776
+ error
1777
+ });
1778
+ }
1779
+ if (isReplay) {
1780
+ ctx.context.logger.error("SAML assertion replay detected: assertion ID already used", {
1781
+ assertionId,
1782
+ issuer,
1783
+ providerId
1784
+ });
1785
+ throw ctx.redirect(`${samlRedirectUrl}?error=replay_detected&error_description=SAML+assertion+has+already+been+used`);
1786
+ }
1787
+ await ctx.context.internalAdapter.createVerificationValue({
1788
+ identifier: `${USED_ASSERTION_KEY_PREFIX}${assertionId}`,
1789
+ value: JSON.stringify({
1790
+ assertionId,
1791
+ issuer,
1792
+ providerId,
1793
+ usedAt: Date.now(),
1794
+ expiresAt
1795
+ }),
1796
+ expiresAt: new Date(expiresAt)
1797
+ });
1798
+ } else ctx.context.logger.warn("Could not extract assertion ID for replay protection", { providerId });
1799
+ const attributes = extract.attributes || {};
1800
+ const mapping = parsedSamlConfig.mapping ?? {};
1801
+ const userInfo = {
1802
+ ...Object.fromEntries(Object.entries(mapping.extraFields || {}).map(([key, value]) => [key, attributes[value]])),
1803
+ id: attributes[mapping.id || "nameID"] || extract.nameID,
1804
+ email: (attributes[mapping.email || "email"] || extract.nameID || "").toLowerCase(),
1805
+ name: [attributes[mapping.firstName || "givenName"], attributes[mapping.lastName || "surname"]].filter(Boolean).join(" ") || attributes[mapping.name || "displayName"] || extract.nameID,
1806
+ emailVerified: options?.trustEmailVerified && mapping.emailVerified ? attributes[mapping.emailVerified] || false : false
1807
+ };
1808
+ if (!userInfo.id || !userInfo.email) {
1809
+ ctx.context.logger.error("Missing essential user info from SAML response", {
1810
+ attributes: Object.keys(attributes),
1811
+ mapping,
1812
+ extractedId: userInfo.id,
1813
+ extractedEmail: userInfo.email
1814
+ });
1815
+ throw new APIError("BAD_REQUEST", { message: "Unable to extract user ID or email from SAML response" });
1816
+ }
1817
+ const isTrustedProvider = ctx.context.trustedProviders.includes(providerId) || "domainVerified" in provider && !!provider.domainVerified && validateEmailDomain(userInfo.email, provider.domain);
1818
+ const callbackUrl = relayState?.callbackURL || parsedSamlConfig.callbackUrl || ctx.context.baseURL;
1819
+ const result = await handleOAuthUserInfo(ctx, {
1820
+ userInfo: {
1821
+ email: userInfo.email,
1822
+ name: userInfo.name || userInfo.email,
1823
+ id: userInfo.id,
1824
+ emailVerified: Boolean(userInfo.emailVerified)
1825
+ },
1826
+ account: {
1827
+ providerId,
1828
+ accountId: userInfo.id,
1829
+ accessToken: "",
1830
+ refreshToken: ""
1831
+ },
1832
+ callbackURL: callbackUrl,
1833
+ disableSignUp: options?.disableImplicitSignUp,
1834
+ isTrustedProvider
1835
+ });
1836
+ if (result.error) throw ctx.redirect(`${samlRedirectUrl}?error=${result.error.split(" ").join("_")}`);
1837
+ const { session, user } = result.data;
1838
+ if (options?.provisionUser && (result.isRegister || options.provisionUserOnEveryLogin)) await options.provisionUser({
1839
+ user,
1840
+ userInfo,
1841
+ provider
1842
+ });
1843
+ await assignOrganizationFromProvider(ctx, {
1844
+ user,
1845
+ profile: {
1846
+ providerType: "saml",
1847
+ providerId,
1848
+ accountId: userInfo.id,
1849
+ email: userInfo.email,
1850
+ emailVerified: Boolean(userInfo.emailVerified),
1851
+ rawAttributes: attributes
1852
+ },
1853
+ provider,
1854
+ provisioningOptions: options?.organizationProvisioning
1855
+ });
1856
+ await setSessionCookie(ctx, {
1857
+ session,
1858
+ user
1859
+ });
1860
+ if (options?.saml?.enableSingleLogout && extract.nameID) {
1861
+ const samlSessionKey = `${SAML_SESSION_KEY_PREFIX}${providerId}:${extract.nameID}`;
1862
+ const samlSessionData = {
1863
+ sessionId: session.id,
1864
+ providerId,
1865
+ nameID: extract.nameID,
1866
+ sessionIndex: extract.sessionIndex?.sessionIndex
1867
+ };
1868
+ await ctx.context.internalAdapter.createVerificationValue({
1869
+ identifier: samlSessionKey,
1870
+ value: JSON.stringify(samlSessionData),
1871
+ expiresAt: session.expiresAt
1872
+ }).catch((e) => ctx.context.logger.warn("Failed to create SAML session record", { error: e }));
1873
+ await ctx.context.internalAdapter.createVerificationValue({
1874
+ identifier: `${SAML_SESSION_BY_ID_PREFIX}${session.id}`,
1875
+ value: samlSessionKey,
1876
+ expiresAt: session.expiresAt
1877
+ }).catch((e) => ctx.context.logger.warn("Failed to create SAML session lookup record", e));
1878
+ }
1879
+ return getSafeRedirectUrl(relayState?.callbackURL || parsedSamlConfig.callbackUrl, currentCallbackPath, appOrigin, (url, settings) => ctx.context.isTrustedOrigin(url, settings));
1880
+ }
1881
+ //#endregion
1882
+ //#region src/routes/sso.ts
1883
+ /**
1884
+ * Builds the OIDC redirect URI. Uses the shared `redirectURI` option
1885
+ * when set, otherwise falls back to `/sso/callback/:providerId`.
1886
+ */
1887
+ function getOIDCRedirectURI(baseURL, providerId, options) {
1888
+ if (options?.redirectURI?.trim()) try {
1889
+ new URL(options.redirectURI);
1890
+ return options.redirectURI;
1891
+ } catch {
1892
+ return `${baseURL}${options.redirectURI.startsWith("/") ? options.redirectURI : `/${options.redirectURI}`}`;
1893
+ }
1894
+ return `${baseURL}/sso/callback/${providerId}`;
1895
+ }
1568
1896
  const spMetadataQuerySchema = z.object({
1569
1897
  providerId: z.string(),
1570
1898
  format: z.enum(["xml", "json"]).default("xml")
@@ -1602,7 +1930,7 @@ const spMetadata = (options) => {
1602
1930
  entityID: parsedSamlConfig.spMetadata?.entityID || parsedSamlConfig.issuer,
1603
1931
  assertionConsumerService: [{
1604
1932
  Binding: "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST",
1605
- Location: parsedSamlConfig.callbackUrl || `${ctx.context.baseURL}/sso/saml2/sp/acs/${provider.id}`
1933
+ Location: parsedSamlConfig.callbackUrl || `${ctx.context.baseURL}/sso/saml2/sp/acs/${ctx.query.providerId}`
1606
1934
  }],
1607
1935
  singleLogoutService,
1608
1936
  wantMessageSigned: parsedSamlConfig.wantAssertionsSigned || false,
@@ -1618,11 +1946,17 @@ const ssoProviderBodySchema = z.object({
1618
1946
  domain: z.string({}).meta({ description: "The domain(s) of the provider. For enterprise multi-domain SSO where a single IdP serves multiple email domains, use comma-separated values (e.g., 'company.com,subsidiary.com,acquired-company.com')" }),
1619
1947
  oidcConfig: z.object({
1620
1948
  clientId: z.string({}).meta({ description: "The client ID" }),
1621
- clientSecret: z.string({}).meta({ description: "The client secret" }),
1949
+ clientSecret: z.string({}).optional().meta({ description: "The client secret. Required for client_secret_basic/client_secret_post. Optional for private_key_jwt." }),
1622
1950
  authorizationEndpoint: z.string({}).meta({ description: "The authorization endpoint" }).optional(),
1623
1951
  tokenEndpoint: z.string({}).meta({ description: "The token endpoint" }).optional(),
1624
1952
  userInfoEndpoint: z.string({}).meta({ description: "The user info endpoint" }).optional(),
1625
- tokenEndpointAuthentication: z.enum(["client_secret_post", "client_secret_basic"]).optional(),
1953
+ tokenEndpointAuthentication: z.enum([
1954
+ "client_secret_post",
1955
+ "client_secret_basic",
1956
+ "private_key_jwt"
1957
+ ]).optional(),
1958
+ privateKeyId: z.string().optional(),
1959
+ privateKeyAlgorithm: z.string().optional(),
1626
1960
  jwksEndpoint: z.string({}).meta({ description: "The JWKS endpoint" }).optional(),
1627
1961
  discoveryEndpoint: z.string().optional(),
1628
1962
  skipDiscovery: z.boolean().meta({ description: "Skip OIDC discovery during registration. When true, you must provide authorizationEndpoint, tokenEndpoint, and jwksEndpoint manually." }).optional(),
@@ -1925,6 +2259,8 @@ const registerSSOProvider = (options) => {
1925
2259
  authorizationEndpoint: body.oidcConfig.authorizationEndpoint,
1926
2260
  tokenEndpoint: body.oidcConfig.tokenEndpoint,
1927
2261
  tokenEndpointAuthentication: body.oidcConfig.tokenEndpointAuthentication || "client_secret_basic",
2262
+ privateKeyId: body.oidcConfig.privateKeyId,
2263
+ privateKeyAlgorithm: body.oidcConfig.privateKeyAlgorithm,
1928
2264
  jwksEndpoint: body.oidcConfig.jwksEndpoint,
1929
2265
  pkce: body.oidcConfig.pkce,
1930
2266
  discoveryEndpoint: body.oidcConfig.discoveryEndpoint || `${body.issuer}/.well-known/openid-configuration`,
@@ -1941,6 +2277,8 @@ const registerSSOProvider = (options) => {
1941
2277
  authorizationEndpoint: hydratedOIDCConfig.authorizationEndpoint,
1942
2278
  tokenEndpoint: hydratedOIDCConfig.tokenEndpoint,
1943
2279
  tokenEndpointAuthentication: hydratedOIDCConfig.tokenEndpointAuthentication,
2280
+ privateKeyId: body.oidcConfig.privateKeyId,
2281
+ privateKeyAlgorithm: body.oidcConfig.privateKeyAlgorithm,
1944
2282
  jwksEndpoint: hydratedOIDCConfig.jwksEndpoint,
1945
2283
  pkce: body.oidcConfig.pkce,
1946
2284
  discoveryEndpoint: hydratedOIDCConfig.discoveryEndpoint,
@@ -1950,17 +2288,35 @@ const registerSSOProvider = (options) => {
1950
2288
  overrideUserInfo: ctx.body.overrideUserInfo || options?.defaultOverrideUserInfo || false
1951
2289
  });
1952
2290
  };
1953
- if (body.samlConfig) validateConfigAlgorithms({
1954
- signatureAlgorithm: body.samlConfig.signatureAlgorithm,
1955
- digestAlgorithm: body.samlConfig.digestAlgorithm
1956
- }, options?.saml?.algorithms);
2291
+ if (body.samlConfig) {
2292
+ validateConfigAlgorithms({
2293
+ signatureAlgorithm: body.samlConfig.signatureAlgorithm,
2294
+ digestAlgorithm: body.samlConfig.digestAlgorithm
2295
+ }, options?.saml?.algorithms);
2296
+ const hasIdpMetadata = body.samlConfig.idpMetadata?.metadata;
2297
+ let hasEntryPoint = false;
2298
+ if (body.samlConfig.entryPoint) try {
2299
+ new URL(body.samlConfig.entryPoint);
2300
+ hasEntryPoint = true;
2301
+ } catch {}
2302
+ const hasSingleSignOnService = body.samlConfig.idpMetadata?.singleSignOnService?.length;
2303
+ if (!hasIdpMetadata && !hasEntryPoint && !hasSingleSignOnService) throw new APIError("BAD_REQUEST", { message: "SAML configuration requires either idpMetadata.metadata (IdP metadata XML), idpMetadata.singleSignOnService, or a valid entryPoint URL" });
2304
+ }
1957
2305
  const provider = await ctx.context.adapter.create({
1958
2306
  model: "ssoProvider",
1959
2307
  data: {
1960
2308
  issuer: body.issuer,
1961
2309
  domain: body.domain,
1962
2310
  domainVerified: false,
1963
- oidcConfig: buildOIDCConfig(),
2311
+ oidcConfig: (() => {
2312
+ const config = buildOIDCConfig();
2313
+ if (config) {
2314
+ const parsed = JSON.parse(config);
2315
+ if (parsed.tokenEndpointAuthentication !== "private_key_jwt" && !parsed.clientSecret) throw new APIError("BAD_REQUEST", { message: "clientSecret is required when using client_secret_basic or client_secret_post authentication" });
2316
+ if (parsed.tokenEndpointAuthentication === "private_key_jwt" && !options?.resolvePrivateKey && !options?.defaultSSO?.some((p) => p.providerId === body.providerId && "privateKey" in p && p.privateKey)) throw new APIError("BAD_REQUEST", { message: "private_key_jwt authentication requires either a resolvePrivateKey callback or a privateKey in defaultSSO" });
2317
+ }
2318
+ return config;
2319
+ })(),
1964
2320
  samlConfig: body.samlConfig ? JSON.stringify({
1965
2321
  issuer: body.issuer,
1966
2322
  entryPoint: body.samlConfig.entryPoint,
@@ -2181,7 +2537,8 @@ const signInSSO = (options) => {
2181
2537
  if (provider.samlConfig) {
2182
2538
  const parsedSamlConfig = typeof provider.samlConfig === "object" ? provider.samlConfig : safeJsonParse(provider.samlConfig);
2183
2539
  if (!parsedSamlConfig) throw new APIError("BAD_REQUEST", { message: "Invalid SAML configuration" });
2184
- if (parsedSamlConfig.authnRequestsSigned && !parsedSamlConfig.spMetadata?.privateKey && !parsedSamlConfig.privateKey) ctx.context.logger.warn("authnRequestsSigned is enabled but no privateKey provided - AuthnRequests will not be signed", { providerId: provider.providerId });
2540
+ 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" });
2541
+ const { state: relayState } = await generateRelayState(ctx, void 0, false);
2185
2542
  let metadata = parsedSamlConfig.spMetadata.metadata;
2186
2543
  if (!metadata) metadata = saml.SPMetadata({
2187
2544
  entityID: parsedSamlConfig.spMetadata?.entityID || parsedSamlConfig.issuer,
@@ -2197,7 +2554,8 @@ const signInSSO = (options) => {
2197
2554
  metadata,
2198
2555
  allowCreate: true,
2199
2556
  privateKey: parsedSamlConfig.spMetadata?.privateKey || parsedSamlConfig.privateKey,
2200
- privateKeyPass: parsedSamlConfig.spMetadata?.privateKeyPass
2557
+ privateKeyPass: parsedSamlConfig.spMetadata?.privateKeyPass,
2558
+ relayState
2201
2559
  });
2202
2560
  const idpData = parsedSamlConfig.idpMetadata;
2203
2561
  let idp;
@@ -2223,7 +2581,6 @@ const signInSSO = (options) => {
2223
2581
  });
2224
2582
  const loginRequest = sp.createLoginRequest(idp, "redirect");
2225
2583
  if (!loginRequest) throw new APIError("BAD_REQUEST", { message: "Invalid SAML request" });
2226
- const { state: relayState } = await generateRelayState(ctx, void 0, false);
2227
2584
  if (loginRequest.id && options?.saml?.enableInResponseToValidation !== false) {
2228
2585
  const ttl = options?.saml?.requestTTL ?? 3e5;
2229
2586
  const record = {
@@ -2239,7 +2596,7 @@ const signInSSO = (options) => {
2239
2596
  });
2240
2597
  }
2241
2598
  return ctx.json({
2242
- url: `${loginRequest.context}&RelayState=${encodeURIComponent(relayState)}`,
2599
+ url: loginRequest.context,
2243
2600
  redirect: true
2244
2601
  });
2245
2602
  }
@@ -2312,6 +2669,30 @@ async function handleOIDCCallback(ctx, options, providerId, stateData) {
2312
2669
  ]
2313
2670
  };
2314
2671
  if (!config.tokenEndpoint) throw ctx.redirect(`${errorURL || callbackURL}?error=invalid_provider&error_description=token_endpoint_not_found`);
2672
+ let authMethod = "basic";
2673
+ if (config.tokenEndpointAuthentication === "client_secret_post") authMethod = "post";
2674
+ else if (config.tokenEndpointAuthentication === "private_key_jwt") authMethod = "private_key_jwt";
2675
+ let clientAssertionConfig;
2676
+ if (authMethod === "private_key_jwt") {
2677
+ let resolved;
2678
+ const matchingDefault = options?.defaultSSO?.find((p) => p.providerId === provider.providerId && "privateKey" in p && p.privateKey);
2679
+ if (matchingDefault && "privateKey" in matchingDefault) resolved = matchingDefault.privateKey;
2680
+ if (!resolved && options?.resolvePrivateKey) resolved = await options.resolvePrivateKey({
2681
+ providerId: provider.providerId,
2682
+ keyId: config.privateKeyId,
2683
+ issuer: config.issuer
2684
+ });
2685
+ if (!resolved) throw ctx.redirect(`${errorURL || callbackURL}?error=invalid_provider&error_description=no_private_key_available`);
2686
+ const rawAlg = config.privateKeyAlgorithm ?? resolved.algorithm;
2687
+ const algorithm = rawAlg && ASSERTION_SIGNING_ALGORITHMS.includes(rawAlg) ? rawAlg : void 0;
2688
+ clientAssertionConfig = {
2689
+ privateKeyJwk: resolved.privateKeyJwk,
2690
+ privateKeyPem: resolved.privateKeyPem,
2691
+ kid: config.privateKeyId ?? resolved.kid,
2692
+ algorithm,
2693
+ tokenEndpoint: config.tokenEndpoint
2694
+ };
2695
+ }
2315
2696
  const tokenResponse = await validateAuthorizationCode({
2316
2697
  code,
2317
2698
  codeVerifier: config.pkce ? stateData.codeVerifier : void 0,
@@ -2321,7 +2702,8 @@ async function handleOIDCCallback(ctx, options, providerId, stateData) {
2321
2702
  clientSecret: config.clientSecret
2322
2703
  },
2323
2704
  tokenEndpoint: config.tokenEndpoint,
2324
- authentication: config.tokenEndpointAuthentication === "client_secret_post" ? "post" : "basic"
2705
+ authentication: authMethod,
2706
+ clientAssertion: clientAssertionConfig
2325
2707
  }).catch((e) => {
2326
2708
  ctx.context.logger.error("Error validating authorization code", e);
2327
2709
  if (e instanceof BetterFetchError) throw ctx.redirect(`${errorURL || callbackURL}?error=invalid_provider&error_description=${e.message}`);
@@ -2475,34 +2857,6 @@ const callbackSSOSAMLBodySchema = z.object({
2475
2857
  SAMLResponse: z.string(),
2476
2858
  RelayState: z.string().optional()
2477
2859
  });
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
2860
  const callbackSSOSAML = (options) => {
2507
2861
  return createAuthEndpoint("/sso/saml2/callback/:providerId", {
2508
2862
  method: ["GET", "POST"],
@@ -2534,261 +2888,12 @@ const callbackSSOSAML = (options) => {
2534
2888
  throw ctx.redirect(safeRedirectUrl);
2535
2889
  }
2536
2890
  if (!ctx.body?.SAMLResponse) throw new APIError("BAD_REQUEST", { message: "SAMLResponse is required for POST requests" });
2537
- const { SAMLResponse } = ctx.body;
2538
- const maxResponseSize = options?.saml?.maxResponseSize ?? 262144;
2539
- if (new TextEncoder().encode(SAMLResponse).length > maxResponseSize) throw new APIError("BAD_REQUEST", { message: `SAML response exceeds maximum allowed size (${maxResponseSize} bytes)` });
2540
- let relayState = null;
2541
- if (ctx.body.RelayState) try {
2542
- relayState = await parseRelayState(ctx);
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));
2891
+ const safeRedirectUrl = await processSAMLResponse(ctx, {
2892
+ SAMLResponse: ctx.body.SAMLResponse,
2893
+ RelayState: ctx.body.RelayState,
2894
+ providerId,
2895
+ currentCallbackPath
2896
+ }, options);
2792
2897
  throw ctx.redirect(safeRedirectUrl);
2793
2898
  });
2794
2899
  };
@@ -2811,256 +2916,27 @@ const acsEndpoint = (options) => {
2811
2916
  }
2812
2917
  }
2813
2918
  }, async (ctx) => {
2814
- const { SAMLResponse } = ctx.body;
2815
2919
  const { providerId } = ctx.params;
2816
2920
  const currentCallbackPath = `${ctx.context.baseURL}/sso/saml2/sp/acs/${providerId}`;
2817
2921
  const appOrigin = new URL(ctx.context.baseURL).origin;
2818
- const maxResponseSize = options?.saml?.maxResponseSize ?? 262144;
2819
- if (new TextEncoder().encode(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
2922
  try {
2875
- validateSingleAssertion(SAMLResponse);
2923
+ const safeRedirectUrl = await processSAMLResponse(ctx, {
2924
+ SAMLResponse: ctx.body.SAMLResponse,
2925
+ RelayState: ctx.body.RelayState,
2926
+ providerId,
2927
+ currentCallbackPath
2928
+ }, options);
2929
+ throw ctx.redirect(safeRedirectUrl);
2876
2930
  } catch (error) {
2877
- if (error instanceof APIError) {
2878
- const redirectUrl = relayState?.callbackURL || parsedSamlConfig.callbackUrl || ctx.context.baseURL;
2879
- const errorCode = error.body?.code === "SAML_MULTIPLE_ASSERTIONS" ? "multiple_assertions" : "no_assertion";
2880
- throw ctx.redirect(`${redirectUrl}?error=${errorCode}&error_description=${encodeURIComponent(error.message)}`);
2931
+ if (error instanceof Response || error && typeof error === "object" && "status" in error && error.status === 302) throw error;
2932
+ if (error instanceof APIError && error.statusCode === 400) {
2933
+ const internalCode = error.body?.code || "";
2934
+ const errorCode = internalCode === "SAML_MULTIPLE_ASSERTIONS" ? "multiple_assertions" : internalCode === "SAML_NO_ASSERTION" ? "no_assertion" : internalCode.toLowerCase() || "saml_error";
2935
+ const redirectUrl = getSafeRedirectUrl(ctx.body.RelayState || void 0, currentCallbackPath, appOrigin, (url, settings) => ctx.context.isTrustedOrigin(url, settings));
2936
+ throw ctx.redirect(`${redirectUrl}${redirectUrl.includes("?") ? "&" : "?"}error=${encodeURIComponent(errorCode)}&error_description=${encodeURIComponent(error.message)}`);
2881
2937
  }
2882
2938
  throw error;
2883
2939
  }
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
2940
  });
3065
2941
  };
3066
2942
  const sloSchema = z.object({
@@ -3091,10 +2967,10 @@ const sloEndpoint = (options) => {
3091
2967
  const provider = await findSAMLProvider(providerId, options, ctx.context.adapter);
3092
2968
  if (!provider?.samlConfig) throw APIError.from("NOT_FOUND", SAML_ERROR_CODES.SAML_PROVIDER_NOT_FOUND);
3093
2969
  const config = provider.samlConfig;
3094
- const sp = createSP(config, ctx.context.baseURL, providerId, {
2970
+ const sp = createSP(config, ctx.context.baseURL, providerId, { sloOptions: {
3095
2971
  wantLogoutRequestSigned: options?.saml?.wantLogoutRequestSigned,
3096
2972
  wantLogoutResponseSigned: options?.saml?.wantLogoutResponseSigned
3097
- });
2973
+ } });
3098
2974
  const idp = createIdP(config);
3099
2975
  if (samlResponse) return handleLogoutResponse(ctx, sp, idp, relayState, providerId);
3100
2976
  return handleLogoutRequest(ctx, sp, idp, relayState, providerId);
@@ -3180,10 +3056,10 @@ const initiateSLO = (options) => {
3180
3056
  if (!provider?.samlConfig) throw APIError.from("NOT_FOUND", SAML_ERROR_CODES.SAML_PROVIDER_NOT_FOUND);
3181
3057
  const config = provider.samlConfig;
3182
3058
  if (!(config.idpMetadata?.singleLogoutService?.length || config.idpMetadata?.metadata && config.idpMetadata.metadata.includes("SingleLogoutService"))) throw APIError.from("BAD_REQUEST", SAML_ERROR_CODES.IDP_SLO_NOT_SUPPORTED);
3183
- const sp = createSP(config, ctx.context.baseURL, providerId, {
3059
+ const sp = createSP(config, ctx.context.baseURL, providerId, { sloOptions: {
3184
3060
  wantLogoutRequestSigned: options?.saml?.wantLogoutRequestSigned,
3185
3061
  wantLogoutResponseSigned: options?.saml?.wantLogoutResponseSigned
3186
- });
3062
+ } });
3187
3063
  const idp = createIdP(config);
3188
3064
  const session = ctx.context.session;
3189
3065
  const sessionLookupKey = `${SAML_SESSION_BY_ID_PREFIX}${session.session.id}`;