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

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