@better-auth/sso 1.6.1 → 1.6.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/client.d.mts CHANGED
@@ -1,4 +1,4 @@
1
- import { t as SSOPlugin } from "./index-iRhhiRKL.mjs";
1
+ import { t as SSOPlugin } from "./index-DyoL-0jp.mjs";
2
2
 
3
3
  //#region src/client.d.ts
4
4
  interface SSOClientOptions {
package/dist/client.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-1sp6DKT-.mjs";
2
2
  //#region src/client.ts
3
3
  const ssoClient = (options) => {
4
4
  return {
@@ -892,7 +892,7 @@ declare const deleteSSOProvider: () => better_call0.StrictEndpoint<"/sso/delete-
892
892
  success: boolean;
893
893
  }>;
894
894
  //#endregion
895
- //#region src/routes/sso.d.ts
895
+ //#region src/saml/timestamp.d.ts
896
896
  interface TimestampValidationOptions {
897
897
  clockSkew?: number;
898
898
  requireTimestamps?: boolean;
@@ -911,6 +911,8 @@ interface SAMLConditions {
911
911
  * @throws {APIError} If timestamps are invalid, expired, or not yet valid
912
912
  */
913
913
  declare function validateSAMLTimestamp(conditions: SAMLConditions | undefined, options?: TimestampValidationOptions): void;
914
+ //#endregion
915
+ //#region src/routes/sso.d.ts
914
916
  declare const spMetadata: (options?: SSOOptions) => better_call0.StrictEndpoint<"/sso/saml2/sp/metadata", {
915
917
  method: "GET";
916
918
  query: z.ZodObject<{
package/dist/index.d.mts CHANGED
@@ -1,2 +1,2 @@
1
- import { A as DataEncryptionAlgorithm, C as TimestampValidationOptions, D as SSOOptions, E as SAMLConfig, M as DigestAlgorithm, N as KeyEncryptionAlgorithm, O as SSOProvider, P as SignatureAlgorithm, S as SAMLConditions, T as OIDCConfig, _ as REQUIRED_DISCOVERY_FIELDS, a as fetchDiscoveryDocument, b as DEFAULT_MAX_SAML_METADATA_SIZE, c as normalizeUrl, d as validateDiscoveryUrl, f as DiscoverOIDCConfigParams, g as OIDCDiscoveryDocument, h as HydratedOIDCConfig, i as discoverOIDCConfig, j as DeprecatedAlgorithmBehavior, k as AlgorithmValidationOptions, l as selectTokenEndpointAuthMethod, m as DiscoveryErrorCode, n as sso, o as needsRuntimeDiscovery, p as DiscoveryError, r as computeDiscoveryUrl, s as normalizeDiscoveryUrls, t as SSOPlugin, u as validateDiscoveryDocument, v as RequiredDiscoveryField, w as validateSAMLTimestamp, x as DEFAULT_MAX_SAML_RESPONSE_SIZE, y as DEFAULT_CLOCK_SKEW_MS } from "./index-iRhhiRKL.mjs";
1
+ import { A as DataEncryptionAlgorithm, C as TimestampValidationOptions, D as SSOOptions, E as SAMLConfig, M as DigestAlgorithm, N as KeyEncryptionAlgorithm, O as SSOProvider, P as SignatureAlgorithm, S as SAMLConditions, T as OIDCConfig, _ as REQUIRED_DISCOVERY_FIELDS, a as fetchDiscoveryDocument, b as DEFAULT_MAX_SAML_METADATA_SIZE, c as normalizeUrl, d as validateDiscoveryUrl, f as DiscoverOIDCConfigParams, g as OIDCDiscoveryDocument, h as HydratedOIDCConfig, i as discoverOIDCConfig, j as DeprecatedAlgorithmBehavior, k as AlgorithmValidationOptions, l as selectTokenEndpointAuthMethod, m as DiscoveryErrorCode, n as sso, o as needsRuntimeDiscovery, p as DiscoveryError, r as computeDiscoveryUrl, s as normalizeDiscoveryUrls, t as SSOPlugin, u as validateDiscoveryDocument, v as RequiredDiscoveryField, w as validateSAMLTimestamp, x as DEFAULT_MAX_SAML_RESPONSE_SIZE, y as DEFAULT_CLOCK_SKEW_MS } from "./index-DyoL-0jp.mjs";
2
2
  export { AlgorithmValidationOptions, DEFAULT_CLOCK_SKEW_MS, DEFAULT_MAX_SAML_METADATA_SIZE, DEFAULT_MAX_SAML_RESPONSE_SIZE, DataEncryptionAlgorithm, DeprecatedAlgorithmBehavior, DigestAlgorithm, DiscoverOIDCConfigParams, DiscoveryError, DiscoveryErrorCode, HydratedOIDCConfig, KeyEncryptionAlgorithm, OIDCConfig, OIDCDiscoveryDocument, REQUIRED_DISCOVERY_FIELDS, RequiredDiscoveryField, SAMLConditions, SAMLConfig, SSOOptions, SSOPlugin, SSOProvider, SignatureAlgorithm, TimestampValidationOptions, computeDiscoveryUrl, discoverOIDCConfig, fetchDiscoveryDocument, needsRuntimeDiscovery, normalizeDiscoveryUrls, normalizeUrl, selectTokenEndpointAuthMethod, sso, validateDiscoveryDocument, validateDiscoveryUrl, validateSAMLTimestamp };
package/dist/index.mjs CHANGED
@@ -1,4 +1,4 @@
1
- import { t as PACKAGE_VERSION } from "./version-CfoAqE_A.mjs";
1
+ import { t as PACKAGE_VERSION } from "./version-1sp6DKT-.mjs";
2
2
  import { APIError, createAuthEndpoint, createAuthMiddleware, getSessionFromCtx, sessionMiddleware } from "better-auth/api";
3
3
  import { XMLParser, XMLValidator } from "fast-xml-parser";
4
4
  import * as saml from "samlify";
@@ -606,7 +606,7 @@ function countAssertions(xml) {
606
606
  function validateSingleAssertion(samlResponse) {
607
607
  let xml;
608
608
  try {
609
- xml = new TextDecoder().decode(base64.decode(samlResponse));
609
+ xml = new TextDecoder().decode(base64.decode(samlResponse.replace(/\s+/g, "")));
610
610
  if (!xml.includes("<")) throw new Error("Not XML");
611
611
  } catch {
612
612
  throw new APIError("BAD_REQUEST", {
@@ -1436,13 +1436,15 @@ async function findSAMLProvider(providerId, options, adapter) {
1436
1436
  samlConfig: res.samlConfig ? safeJsonParse(res.samlConfig) || void 0 : void 0
1437
1437
  };
1438
1438
  }
1439
- function createSP(config, baseURL, providerId, sloOptions) {
1439
+ function createSP(config, baseURL, providerId, opts) {
1440
+ const spData = config.spMetadata;
1440
1441
  const sloLocation = `${baseURL}/sso/saml2/sp/slo/${providerId}`;
1442
+ const acsUrl = config.callbackUrl || `${baseURL}/sso/saml2/sp/acs/${providerId}`;
1441
1443
  return saml.ServiceProvider({
1442
- entityID: config.spMetadata?.entityID || config.issuer,
1443
- assertionConsumerService: [{
1444
+ entityID: spData?.entityID || config.issuer,
1445
+ assertionConsumerService: spData?.metadata ? void 0 : [{
1444
1446
  Binding: "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST",
1445
- Location: config.callbackUrl || `${baseURL}/sso/saml2/sp/acs/${providerId}`
1447
+ Location: acsUrl
1446
1448
  }],
1447
1449
  singleLogoutService: [{
1448
1450
  Binding: "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST",
@@ -1452,11 +1454,16 @@ function createSP(config, baseURL, providerId, sloOptions) {
1452
1454
  Location: sloLocation
1453
1455
  }],
1454
1456
  wantMessageSigned: config.wantAssertionsSigned || false,
1455
- wantLogoutRequestSigned: sloOptions?.wantLogoutRequestSigned ?? false,
1456
- wantLogoutResponseSigned: sloOptions?.wantLogoutResponseSigned ?? false,
1457
- metadata: config.spMetadata?.metadata,
1458
- privateKey: config.spMetadata?.privateKey || config.privateKey,
1459
- privateKeyPass: config.spMetadata?.privateKeyPass
1457
+ wantLogoutRequestSigned: opts?.sloOptions?.wantLogoutRequestSigned ?? false,
1458
+ wantLogoutResponseSigned: opts?.sloOptions?.wantLogoutResponseSigned ?? false,
1459
+ metadata: spData?.metadata,
1460
+ privateKey: spData?.privateKey || config.privateKey,
1461
+ privateKeyPass: spData?.privateKeyPass,
1462
+ isAssertionEncrypted: spData?.isAssertionEncrypted || false,
1463
+ encPrivateKey: spData?.encPrivateKey,
1464
+ encPrivateKeyPass: spData?.encPrivateKeyPass,
1465
+ nameIDFormat: config.identifierFormat ? [config.identifierFormat] : void 0,
1466
+ relayState: opts?.relayState
1460
1467
  });
1461
1468
  }
1462
1469
  function createIdP(config) {
@@ -1465,6 +1472,7 @@ function createIdP(config) {
1465
1472
  metadata: idpData.metadata,
1466
1473
  privateKey: idpData.privateKey,
1467
1474
  privateKeyPass: idpData.privateKeyPass,
1475
+ isAssertionEncrypted: idpData.isAssertionEncrypted,
1468
1476
  encPrivateKey: idpData.encPrivateKey,
1469
1477
  encPrivateKeyPass: idpData.encPrivateKeyPass
1470
1478
  });
@@ -1475,7 +1483,11 @@ function createIdP(config) {
1475
1483
  Location: config.entryPoint
1476
1484
  }],
1477
1485
  singleLogoutService: idpData?.singleLogoutService,
1478
- signingCert: idpData?.cert || config.cert
1486
+ signingCert: idpData?.cert || config.cert,
1487
+ wantAuthnRequestsSigned: config.authnRequestsSigned || false,
1488
+ isAssertionEncrypted: idpData?.isAssertionEncrypted || false,
1489
+ encPrivateKey: idpData?.encPrivateKey,
1490
+ encPrivateKeyPass: idpData?.encPrivateKeyPass
1479
1491
  });
1480
1492
  }
1481
1493
  function escapeHtml(str) {
@@ -1491,20 +1503,7 @@ function createSAMLPostForm(action, samlParam, samlValue, relayState) {
1491
1503
  return new Response(html, { headers: { "Content-Type": "text/html" } });
1492
1504
  }
1493
1505
  //#endregion
1494
- //#region src/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
- }
1506
+ //#region src/saml/timestamp.ts
1508
1507
  /**
1509
1508
  * Validates SAML assertion timestamp conditions (NotBefore/NotOnOrAfter).
1510
1509
  * Prevents acceptance of expired or future-dated assertions.
@@ -1544,9 +1543,39 @@ function validateSAMLTimestamp(conditions, options = {}) {
1544
1543
  });
1545
1544
  }
1546
1545
  }
1546
+ //#endregion
1547
+ //#region src/routes/saml-pipeline.ts
1548
+ /**
1549
+ * Validates and returns a safe redirect URL.
1550
+ * - Prevents open redirect attacks by validating against trusted origins
1551
+ * - Prevents redirect loops by checking if URL points to callback route
1552
+ * - Falls back to appOrigin if URL is invalid or unsafe
1553
+ */
1554
+ function getSafeRedirectUrl(url, callbackPath, appOrigin, isTrustedOrigin) {
1555
+ if (!url) return appOrigin;
1556
+ if (url.startsWith("/") && !url.startsWith("//")) {
1557
+ try {
1558
+ const absoluteUrl = new URL(url, appOrigin);
1559
+ if (absoluteUrl.origin !== appOrigin) return appOrigin;
1560
+ const callbackPathname = new URL(callbackPath).pathname;
1561
+ if (absoluteUrl.pathname === callbackPathname) return appOrigin;
1562
+ } catch {
1563
+ return appOrigin;
1564
+ }
1565
+ return url;
1566
+ }
1567
+ if (!isTrustedOrigin(url, { allowRelativePaths: false })) return appOrigin;
1568
+ try {
1569
+ const callbackPathname = new URL(callbackPath).pathname;
1570
+ if (new URL(url).pathname === callbackPathname) return appOrigin;
1571
+ } catch {
1572
+ if (url === callbackPath || url.startsWith(`${callbackPath}?`)) return appOrigin;
1573
+ }
1574
+ return url;
1575
+ }
1547
1576
  /**
1548
1577
  * Extracts the Assertion ID from a SAML response XML.
1549
- * Returns null if the assertion ID cannot be found.
1578
+ * Used for replay protection per SAML 2.0 Core section 2.3.3.
1550
1579
  */
1551
1580
  function extractAssertionId(samlContent) {
1552
1581
  try {
@@ -1565,6 +1594,227 @@ function extractAssertionId(samlContent) {
1565
1594
  return null;
1566
1595
  }
1567
1596
  }
1597
+ /**
1598
+ * Unified SAML response processing pipeline.
1599
+ *
1600
+ * Both `/sso/saml2/callback/:providerId` (POST) and `/sso/saml2/sp/acs/:providerId`
1601
+ * delegate to this function. It handles the full lifecycle: provider lookup,
1602
+ * SP/IdP construction, response validation, session creation, and redirect
1603
+ * URL computation.
1604
+ */
1605
+ async function processSAMLResponse(ctx, params, options) {
1606
+ const { providerId, currentCallbackPath } = params;
1607
+ const appOrigin = new URL(ctx.context.baseURL).origin;
1608
+ const maxResponseSize = options?.saml?.maxResponseSize ?? 262144;
1609
+ if (new TextEncoder().encode(params.SAMLResponse).length > maxResponseSize) throw new APIError("BAD_REQUEST", { message: `SAML response exceeds maximum allowed size (${maxResponseSize} bytes)` });
1610
+ const SAMLResponse = params.SAMLResponse.replace(/\s+/g, "");
1611
+ let relayState = null;
1612
+ if (params.RelayState) try {
1613
+ relayState = await parseRelayState(ctx);
1614
+ } catch {
1615
+ relayState = null;
1616
+ }
1617
+ const provider = await findSAMLProvider(providerId, options, ctx.context.adapter);
1618
+ if (!provider?.samlConfig) throw new APIError("NOT_FOUND", { message: "No SAML provider found" });
1619
+ if (options?.domainVerification?.enabled && !("domainVerified" in provider && provider.domainVerified)) throw new APIError("UNAUTHORIZED", { message: "Provider domain has not been verified" });
1620
+ const parsedSamlConfig = typeof provider.samlConfig === "object" ? provider.samlConfig : safeJsonParse(provider.samlConfig);
1621
+ if (!parsedSamlConfig) throw new APIError("BAD_REQUEST", { message: "Invalid SAML configuration" });
1622
+ const sp = createSP(parsedSamlConfig, ctx.context.baseURL, providerId);
1623
+ const idp = createIdP(parsedSamlConfig);
1624
+ const samlRedirectUrl = getSafeRedirectUrl(relayState?.callbackURL || parsedSamlConfig.callbackUrl, params.currentCallbackPath, appOrigin, (url, settings) => ctx.context.isTrustedOrigin(url, settings));
1625
+ validateSingleAssertion(SAMLResponse);
1626
+ let parsedResponse;
1627
+ try {
1628
+ parsedResponse = await sp.parseLoginResponse(idp, "post", { body: {
1629
+ SAMLResponse,
1630
+ RelayState: params.RelayState || void 0
1631
+ } });
1632
+ if (!parsedResponse?.extract) throw new Error("Invalid SAML response structure");
1633
+ } catch (error) {
1634
+ ctx.context.logger.error("SAML response validation failed", {
1635
+ error,
1636
+ samlResponsePreview: SAMLResponse.slice(0, 200)
1637
+ });
1638
+ throw new APIError("BAD_REQUEST", {
1639
+ message: "Invalid SAML response",
1640
+ details: error instanceof Error ? error.message : String(error)
1641
+ });
1642
+ }
1643
+ const { extract } = parsedResponse;
1644
+ validateSAMLAlgorithms(parsedResponse, options?.saml?.algorithms);
1645
+ validateSAMLTimestamp(extract.conditions, {
1646
+ clockSkew: options?.saml?.clockSkew,
1647
+ requireTimestamps: options?.saml?.requireTimestamps,
1648
+ logger: ctx.context.logger
1649
+ });
1650
+ const inResponseTo = extract.inResponseTo;
1651
+ if (options?.saml?.enableInResponseToValidation !== false) {
1652
+ const allowIdpInitiated = options?.saml?.allowIdpInitiated !== false;
1653
+ if (inResponseTo) {
1654
+ let storedRequest = null;
1655
+ const verification = await ctx.context.internalAdapter.findVerificationValue(`${AUTHN_REQUEST_KEY_PREFIX}${inResponseTo}`);
1656
+ if (verification) try {
1657
+ storedRequest = JSON.parse(verification.value);
1658
+ if (storedRequest && storedRequest.expiresAt < Date.now()) storedRequest = null;
1659
+ } catch {
1660
+ storedRequest = null;
1661
+ }
1662
+ if (!storedRequest) {
1663
+ ctx.context.logger.error("SAML InResponseTo validation failed: unknown or expired request ID", {
1664
+ inResponseTo,
1665
+ providerId
1666
+ });
1667
+ throw ctx.redirect(`${samlRedirectUrl}?error=invalid_saml_response&error_description=Unknown+or+expired+request+ID`);
1668
+ }
1669
+ if (storedRequest.providerId !== providerId) {
1670
+ ctx.context.logger.error("SAML InResponseTo validation failed: provider mismatch", {
1671
+ inResponseTo,
1672
+ expectedProvider: storedRequest.providerId,
1673
+ actualProvider: providerId
1674
+ });
1675
+ await ctx.context.internalAdapter.deleteVerificationByIdentifier(`${AUTHN_REQUEST_KEY_PREFIX}${inResponseTo}`);
1676
+ throw ctx.redirect(`${samlRedirectUrl}?error=invalid_saml_response&error_description=Provider+mismatch`);
1677
+ }
1678
+ await ctx.context.internalAdapter.deleteVerificationByIdentifier(`${AUTHN_REQUEST_KEY_PREFIX}${inResponseTo}`);
1679
+ } else if (!allowIdpInitiated) {
1680
+ ctx.context.logger.error("SAML IdP-initiated SSO rejected: InResponseTo missing and allowIdpInitiated is false", { providerId });
1681
+ throw ctx.redirect(`${samlRedirectUrl}?error=unsolicited_response&error_description=IdP-initiated+SSO+not+allowed`);
1682
+ }
1683
+ }
1684
+ const samlContent = parsedResponse.samlContent;
1685
+ const assertionId = samlContent ? extractAssertionId(samlContent) : null;
1686
+ if (assertionId) {
1687
+ const issuer = idp.entityMeta.getEntityID();
1688
+ const conditions = extract.conditions;
1689
+ const clockSkew = options?.saml?.clockSkew ?? 3e5;
1690
+ const expiresAt = conditions?.notOnOrAfter ? new Date(conditions.notOnOrAfter).getTime() + clockSkew : Date.now() + DEFAULT_ASSERTION_TTL_MS;
1691
+ const existingAssertion = await ctx.context.internalAdapter.findVerificationValue(`${USED_ASSERTION_KEY_PREFIX}${assertionId}`);
1692
+ let isReplay = false;
1693
+ if (existingAssertion) try {
1694
+ if (JSON.parse(existingAssertion.value).expiresAt >= Date.now()) isReplay = true;
1695
+ } catch (error) {
1696
+ ctx.context.logger.warn("Failed to parse stored assertion record", {
1697
+ assertionId,
1698
+ error
1699
+ });
1700
+ }
1701
+ if (isReplay) {
1702
+ ctx.context.logger.error("SAML assertion replay detected: assertion ID already used", {
1703
+ assertionId,
1704
+ issuer,
1705
+ providerId
1706
+ });
1707
+ throw ctx.redirect(`${samlRedirectUrl}?error=replay_detected&error_description=SAML+assertion+has+already+been+used`);
1708
+ }
1709
+ await ctx.context.internalAdapter.createVerificationValue({
1710
+ identifier: `${USED_ASSERTION_KEY_PREFIX}${assertionId}`,
1711
+ value: JSON.stringify({
1712
+ assertionId,
1713
+ issuer,
1714
+ providerId,
1715
+ usedAt: Date.now(),
1716
+ expiresAt
1717
+ }),
1718
+ expiresAt: new Date(expiresAt)
1719
+ });
1720
+ } else ctx.context.logger.warn("Could not extract assertion ID for replay protection", { providerId });
1721
+ const attributes = extract.attributes || {};
1722
+ const mapping = parsedSamlConfig.mapping ?? {};
1723
+ const userInfo = {
1724
+ ...Object.fromEntries(Object.entries(mapping.extraFields || {}).map(([key, value]) => [key, attributes[value]])),
1725
+ id: attributes[mapping.id || "nameID"] || extract.nameID,
1726
+ email: (attributes[mapping.email || "email"] || extract.nameID).toLowerCase(),
1727
+ name: [attributes[mapping.firstName || "givenName"], attributes[mapping.lastName || "surname"]].filter(Boolean).join(" ") || attributes[mapping.name || "displayName"] || extract.nameID,
1728
+ emailVerified: options?.trustEmailVerified && mapping.emailVerified ? attributes[mapping.emailVerified] || false : false
1729
+ };
1730
+ if (!userInfo.id || !userInfo.email) {
1731
+ ctx.context.logger.error("Missing essential user info from SAML response", {
1732
+ attributes: Object.keys(attributes),
1733
+ mapping,
1734
+ extractedId: userInfo.id,
1735
+ extractedEmail: userInfo.email
1736
+ });
1737
+ throw new APIError("BAD_REQUEST", { message: "Unable to extract user ID or email from SAML response" });
1738
+ }
1739
+ const isTrustedProvider = ctx.context.trustedProviders.includes(providerId) || "domainVerified" in provider && !!provider.domainVerified && validateEmailDomain(userInfo.email, provider.domain);
1740
+ const callbackUrl = relayState?.callbackURL || parsedSamlConfig.callbackUrl || ctx.context.baseURL;
1741
+ const result = await handleOAuthUserInfo(ctx, {
1742
+ userInfo: {
1743
+ email: userInfo.email,
1744
+ name: userInfo.name || userInfo.email,
1745
+ id: userInfo.id,
1746
+ emailVerified: Boolean(userInfo.emailVerified)
1747
+ },
1748
+ account: {
1749
+ providerId,
1750
+ accountId: userInfo.id,
1751
+ accessToken: "",
1752
+ refreshToken: ""
1753
+ },
1754
+ callbackURL: callbackUrl,
1755
+ disableSignUp: options?.disableImplicitSignUp,
1756
+ isTrustedProvider
1757
+ });
1758
+ if (result.error) throw ctx.redirect(`${callbackUrl}?error=${result.error.split(" ").join("_")}`);
1759
+ const { session, user } = result.data;
1760
+ if (options?.provisionUser && (result.isRegister || options.provisionUserOnEveryLogin)) await options.provisionUser({
1761
+ user,
1762
+ userInfo,
1763
+ provider
1764
+ });
1765
+ await assignOrganizationFromProvider(ctx, {
1766
+ user,
1767
+ profile: {
1768
+ providerType: "saml",
1769
+ providerId,
1770
+ accountId: userInfo.id,
1771
+ email: userInfo.email,
1772
+ emailVerified: Boolean(userInfo.emailVerified),
1773
+ rawAttributes: attributes
1774
+ },
1775
+ provider,
1776
+ provisioningOptions: options?.organizationProvisioning
1777
+ });
1778
+ await setSessionCookie(ctx, {
1779
+ session,
1780
+ user
1781
+ });
1782
+ if (options?.saml?.enableSingleLogout && extract.nameID) {
1783
+ const samlSessionKey = `${SAML_SESSION_KEY_PREFIX}${providerId}:${extract.nameID}`;
1784
+ const samlSessionData = {
1785
+ sessionId: session.id,
1786
+ providerId,
1787
+ nameID: extract.nameID,
1788
+ sessionIndex: extract.sessionIndex
1789
+ };
1790
+ await ctx.context.internalAdapter.createVerificationValue({
1791
+ identifier: samlSessionKey,
1792
+ value: JSON.stringify(samlSessionData),
1793
+ expiresAt: session.expiresAt
1794
+ }).catch((e) => ctx.context.logger.warn("Failed to create SAML session record", { error: e }));
1795
+ await ctx.context.internalAdapter.createVerificationValue({
1796
+ identifier: `${SAML_SESSION_BY_ID_PREFIX}${session.id}`,
1797
+ value: samlSessionKey,
1798
+ expiresAt: session.expiresAt
1799
+ }).catch((e) => ctx.context.logger.warn("Failed to create SAML session lookup record", e));
1800
+ }
1801
+ return getSafeRedirectUrl(relayState?.callbackURL || parsedSamlConfig.callbackUrl, currentCallbackPath, appOrigin, (url, settings) => ctx.context.isTrustedOrigin(url, settings));
1802
+ }
1803
+ //#endregion
1804
+ //#region src/routes/sso.ts
1805
+ /**
1806
+ * Builds the OIDC redirect URI. Uses the shared `redirectURI` option
1807
+ * when set, otherwise falls back to `/sso/callback/:providerId`.
1808
+ */
1809
+ function getOIDCRedirectURI(baseURL, providerId, options) {
1810
+ if (options?.redirectURI?.trim()) try {
1811
+ new URL(options.redirectURI);
1812
+ return options.redirectURI;
1813
+ } catch {
1814
+ return `${baseURL}${options.redirectURI.startsWith("/") ? options.redirectURI : `/${options.redirectURI}`}`;
1815
+ }
1816
+ return `${baseURL}/sso/callback/${providerId}`;
1817
+ }
1568
1818
  const spMetadataQuerySchema = z.object({
1569
1819
  providerId: z.string(),
1570
1820
  format: z.enum(["xml", "json"]).default("xml")
@@ -1602,7 +1852,7 @@ const spMetadata = (options) => {
1602
1852
  entityID: parsedSamlConfig.spMetadata?.entityID || parsedSamlConfig.issuer,
1603
1853
  assertionConsumerService: [{
1604
1854
  Binding: "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST",
1605
- Location: parsedSamlConfig.callbackUrl || `${ctx.context.baseURL}/sso/saml2/sp/acs/${provider.id}`
1855
+ Location: parsedSamlConfig.callbackUrl || `${ctx.context.baseURL}/sso/saml2/sp/acs/${ctx.query.providerId}`
1606
1856
  }],
1607
1857
  singleLogoutService,
1608
1858
  wantMessageSigned: parsedSamlConfig.wantAssertionsSigned || false,
@@ -1950,10 +2200,20 @@ const registerSSOProvider = (options) => {
1950
2200
  overrideUserInfo: ctx.body.overrideUserInfo || options?.defaultOverrideUserInfo || false
1951
2201
  });
1952
2202
  };
1953
- if (body.samlConfig) validateConfigAlgorithms({
1954
- signatureAlgorithm: body.samlConfig.signatureAlgorithm,
1955
- digestAlgorithm: body.samlConfig.digestAlgorithm
1956
- }, options?.saml?.algorithms);
2203
+ if (body.samlConfig) {
2204
+ validateConfigAlgorithms({
2205
+ signatureAlgorithm: body.samlConfig.signatureAlgorithm,
2206
+ digestAlgorithm: body.samlConfig.digestAlgorithm
2207
+ }, options?.saml?.algorithms);
2208
+ const hasIdpMetadata = body.samlConfig.idpMetadata?.metadata;
2209
+ let hasEntryPoint = false;
2210
+ if (body.samlConfig.entryPoint) try {
2211
+ new URL(body.samlConfig.entryPoint);
2212
+ hasEntryPoint = true;
2213
+ } catch {}
2214
+ const hasSingleSignOnService = body.samlConfig.idpMetadata?.singleSignOnService?.length;
2215
+ if (!hasIdpMetadata && !hasEntryPoint && !hasSingleSignOnService) throw new APIError("BAD_REQUEST", { message: "SAML configuration requires either idpMetadata.metadata (IdP metadata XML), idpMetadata.singleSignOnService, or a valid entryPoint URL" });
2216
+ }
1957
2217
  const provider = await ctx.context.adapter.create({
1958
2218
  model: "ssoProvider",
1959
2219
  data: {
@@ -2181,7 +2441,8 @@ const signInSSO = (options) => {
2181
2441
  if (provider.samlConfig) {
2182
2442
  const parsedSamlConfig = typeof provider.samlConfig === "object" ? provider.samlConfig : safeJsonParse(provider.samlConfig);
2183
2443
  if (!parsedSamlConfig) throw new APIError("BAD_REQUEST", { message: "Invalid SAML configuration" });
2184
- if (parsedSamlConfig.authnRequestsSigned && !parsedSamlConfig.spMetadata?.privateKey && !parsedSamlConfig.privateKey) ctx.context.logger.warn("authnRequestsSigned is enabled but no privateKey provided - AuthnRequests will not be signed", { providerId: provider.providerId });
2444
+ if (parsedSamlConfig.authnRequestsSigned && !parsedSamlConfig.spMetadata?.privateKey && !parsedSamlConfig.privateKey) throw new APIError("BAD_REQUEST", { message: "authnRequestsSigned is enabled but no privateKey provided in spMetadata or samlConfig" });
2445
+ const { state: relayState } = await generateRelayState(ctx, void 0, false);
2185
2446
  let metadata = parsedSamlConfig.spMetadata.metadata;
2186
2447
  if (!metadata) metadata = saml.SPMetadata({
2187
2448
  entityID: parsedSamlConfig.spMetadata?.entityID || parsedSamlConfig.issuer,
@@ -2197,7 +2458,8 @@ const signInSSO = (options) => {
2197
2458
  metadata,
2198
2459
  allowCreate: true,
2199
2460
  privateKey: parsedSamlConfig.spMetadata?.privateKey || parsedSamlConfig.privateKey,
2200
- privateKeyPass: parsedSamlConfig.spMetadata?.privateKeyPass
2461
+ privateKeyPass: parsedSamlConfig.spMetadata?.privateKeyPass,
2462
+ relayState
2201
2463
  });
2202
2464
  const idpData = parsedSamlConfig.idpMetadata;
2203
2465
  let idp;
@@ -2223,7 +2485,6 @@ const signInSSO = (options) => {
2223
2485
  });
2224
2486
  const loginRequest = sp.createLoginRequest(idp, "redirect");
2225
2487
  if (!loginRequest) throw new APIError("BAD_REQUEST", { message: "Invalid SAML request" });
2226
- const { state: relayState } = await generateRelayState(ctx, void 0, false);
2227
2488
  if (loginRequest.id && options?.saml?.enableInResponseToValidation !== false) {
2228
2489
  const ttl = options?.saml?.requestTTL ?? 3e5;
2229
2490
  const record = {
@@ -2239,7 +2500,7 @@ const signInSSO = (options) => {
2239
2500
  });
2240
2501
  }
2241
2502
  return ctx.json({
2242
- url: `${loginRequest.context}&RelayState=${encodeURIComponent(relayState)}`,
2503
+ url: loginRequest.context,
2243
2504
  redirect: true
2244
2505
  });
2245
2506
  }
@@ -2475,34 +2736,6 @@ const callbackSSOSAMLBodySchema = z.object({
2475
2736
  SAMLResponse: z.string(),
2476
2737
  RelayState: z.string().optional()
2477
2738
  });
2478
- /**
2479
- * Validates and returns a safe redirect URL.
2480
- * - Prevents open redirect attacks by validating against trusted origins
2481
- * - Prevents redirect loops by checking if URL points to callback route
2482
- * - Falls back to appOrigin if URL is invalid or unsafe
2483
- */
2484
- const getSafeRedirectUrl = (url, callbackPath, appOrigin, isTrustedOrigin) => {
2485
- if (!url) return appOrigin;
2486
- if (url.startsWith("/") && !url.startsWith("//")) {
2487
- try {
2488
- const absoluteUrl = new URL(url, appOrigin);
2489
- if (absoluteUrl.origin !== appOrigin) return appOrigin;
2490
- const callbackPathname = new URL(callbackPath).pathname;
2491
- if (absoluteUrl.pathname === callbackPathname) return appOrigin;
2492
- } catch {
2493
- return appOrigin;
2494
- }
2495
- return url;
2496
- }
2497
- if (!isTrustedOrigin(url, { allowRelativePaths: false })) return appOrigin;
2498
- try {
2499
- const callbackPathname = new URL(callbackPath).pathname;
2500
- if (new URL(url).pathname === callbackPathname) return appOrigin;
2501
- } catch {
2502
- if (url === callbackPath || url.startsWith(`${callbackPath}?`)) return appOrigin;
2503
- }
2504
- return url;
2505
- };
2506
2739
  const callbackSSOSAML = (options) => {
2507
2740
  return createAuthEndpoint("/sso/saml2/callback/:providerId", {
2508
2741
  method: ["GET", "POST"],
@@ -2534,261 +2767,12 @@ const callbackSSOSAML = (options) => {
2534
2767
  throw ctx.redirect(safeRedirectUrl);
2535
2768
  }
2536
2769
  if (!ctx.body?.SAMLResponse) throw new APIError("BAD_REQUEST", { message: "SAMLResponse is required for POST requests" });
2537
- const { 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));
2770
+ const safeRedirectUrl = await processSAMLResponse(ctx, {
2771
+ SAMLResponse: ctx.body.SAMLResponse,
2772
+ RelayState: ctx.body.RelayState,
2773
+ providerId,
2774
+ currentCallbackPath
2775
+ }, options);
2792
2776
  throw ctx.redirect(safeRedirectUrl);
2793
2777
  });
2794
2778
  };
@@ -2811,256 +2795,27 @@ const acsEndpoint = (options) => {
2811
2795
  }
2812
2796
  }
2813
2797
  }, async (ctx) => {
2814
- const { SAMLResponse } = ctx.body;
2815
2798
  const { providerId } = ctx.params;
2816
2799
  const currentCallbackPath = `${ctx.context.baseURL}/sso/saml2/sp/acs/${providerId}`;
2817
2800
  const appOrigin = new URL(ctx.context.baseURL).origin;
2818
- const maxResponseSize = options?.saml?.maxResponseSize ?? 262144;
2819
- if (new TextEncoder().encode(SAMLResponse).length > maxResponseSize) throw new APIError("BAD_REQUEST", { message: `SAML response exceeds maximum allowed size (${maxResponseSize} bytes)` });
2820
- let relayState = null;
2821
- if (ctx.body.RelayState) try {
2822
- relayState = await parseRelayState(ctx);
2823
- } catch {
2824
- relayState = null;
2825
- }
2826
- let provider = null;
2827
- if (options?.defaultSSO?.length) {
2828
- const matchingDefault = providerId ? options.defaultSSO.find((defaultProvider) => defaultProvider.providerId === providerId) : options.defaultSSO[0];
2829
- if (matchingDefault) provider = {
2830
- issuer: matchingDefault.samlConfig?.issuer || "",
2831
- providerId: matchingDefault.providerId,
2832
- userId: "default",
2833
- samlConfig: matchingDefault.samlConfig,
2834
- domain: matchingDefault.domain,
2835
- ...options.domainVerification?.enabled ? { domainVerified: true } : {}
2836
- };
2837
- } else provider = await ctx.context.adapter.findOne({
2838
- model: "ssoProvider",
2839
- where: [{
2840
- field: "providerId",
2841
- value: providerId
2842
- }]
2843
- }).then((res) => {
2844
- if (!res) return null;
2845
- return {
2846
- ...res,
2847
- samlConfig: res.samlConfig ? safeJsonParse(res.samlConfig) || void 0 : void 0
2848
- };
2849
- });
2850
- if (!provider?.samlConfig) throw new APIError("NOT_FOUND", { message: "No SAML provider found" });
2851
- if (options?.domainVerification?.enabled && !("domainVerified" in provider && provider.domainVerified)) throw new APIError("UNAUTHORIZED", { message: "Provider domain has not been verified" });
2852
- const parsedSamlConfig = provider.samlConfig;
2853
- const sp = saml.ServiceProvider({
2854
- entityID: parsedSamlConfig.spMetadata?.entityID || parsedSamlConfig.issuer,
2855
- assertionConsumerService: [{
2856
- Binding: "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST",
2857
- Location: parsedSamlConfig.callbackUrl || `${ctx.context.baseURL}/sso/saml2/sp/acs/${providerId}`
2858
- }],
2859
- wantMessageSigned: parsedSamlConfig.wantAssertionsSigned || false,
2860
- metadata: parsedSamlConfig.spMetadata?.metadata,
2861
- privateKey: parsedSamlConfig.spMetadata?.privateKey || parsedSamlConfig.privateKey,
2862
- privateKeyPass: parsedSamlConfig.spMetadata?.privateKeyPass,
2863
- nameIDFormat: parsedSamlConfig.identifierFormat ? [parsedSamlConfig.identifierFormat] : void 0
2864
- });
2865
- const idpData = parsedSamlConfig.idpMetadata;
2866
- const idp = !idpData?.metadata ? saml.IdentityProvider({
2867
- entityID: idpData?.entityID || parsedSamlConfig.issuer,
2868
- singleSignOnService: idpData?.singleSignOnService || [{
2869
- Binding: "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect",
2870
- Location: parsedSamlConfig.entryPoint
2871
- }],
2872
- signingCert: idpData?.cert || parsedSamlConfig.cert
2873
- }) : saml.IdentityProvider({ metadata: idpData.metadata });
2874
2801
  try {
2875
- validateSingleAssertion(SAMLResponse);
2802
+ const safeRedirectUrl = await processSAMLResponse(ctx, {
2803
+ SAMLResponse: ctx.body.SAMLResponse,
2804
+ RelayState: ctx.body.RelayState,
2805
+ providerId,
2806
+ currentCallbackPath
2807
+ }, options);
2808
+ throw ctx.redirect(safeRedirectUrl);
2876
2809
  } catch (error) {
2877
- if (error instanceof 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)}`);
2810
+ if (error instanceof Response || error && typeof error === "object" && "status" in error && error.status === 302) throw error;
2811
+ if (error instanceof APIError && error.statusCode === 400) {
2812
+ const internalCode = error.body?.code || "";
2813
+ const errorCode = internalCode === "SAML_MULTIPLE_ASSERTIONS" ? "multiple_assertions" : internalCode === "SAML_NO_ASSERTION" ? "no_assertion" : internalCode.toLowerCase() || "saml_error";
2814
+ const redirectUrl = getSafeRedirectUrl(ctx.body.RelayState || void 0, currentCallbackPath, appOrigin, (url, settings) => ctx.context.isTrustedOrigin(url, settings));
2815
+ throw ctx.redirect(`${redirectUrl}${redirectUrl.includes("?") ? "&" : "?"}error=${encodeURIComponent(errorCode)}&error_description=${encodeURIComponent(error.message)}`);
2881
2816
  }
2882
2817
  throw error;
2883
2818
  }
2884
- let parsedResponse;
2885
- try {
2886
- parsedResponse = await sp.parseLoginResponse(idp, "post", { body: {
2887
- SAMLResponse,
2888
- RelayState: ctx.body.RelayState || void 0
2889
- } });
2890
- if (!parsedResponse?.extract) throw new Error("Invalid SAML response structure");
2891
- } catch (error) {
2892
- ctx.context.logger.error("SAML response validation failed", {
2893
- error,
2894
- decodedResponse: Buffer.from(SAMLResponse, "base64").toString("utf-8")
2895
- });
2896
- throw new APIError("BAD_REQUEST", {
2897
- message: "Invalid SAML response",
2898
- details: error instanceof Error ? error.message : String(error)
2899
- });
2900
- }
2901
- const { extract } = parsedResponse;
2902
- validateSAMLAlgorithms(parsedResponse, options?.saml?.algorithms);
2903
- validateSAMLTimestamp(extract.conditions, {
2904
- clockSkew: options?.saml?.clockSkew,
2905
- requireTimestamps: options?.saml?.requireTimestamps,
2906
- logger: ctx.context.logger
2907
- });
2908
- const inResponseToAcs = extract.inResponseTo;
2909
- if (options?.saml?.enableInResponseToValidation !== false) {
2910
- const allowIdpInitiated = options?.saml?.allowIdpInitiated !== false;
2911
- if (inResponseToAcs) {
2912
- let storedRequest = null;
2913
- const verification = await ctx.context.internalAdapter.findVerificationValue(`${AUTHN_REQUEST_KEY_PREFIX}${inResponseToAcs}`);
2914
- if (verification) try {
2915
- storedRequest = JSON.parse(verification.value);
2916
- if (storedRequest && storedRequest.expiresAt < Date.now()) storedRequest = null;
2917
- } catch {
2918
- storedRequest = null;
2919
- }
2920
- if (!storedRequest) {
2921
- ctx.context.logger.error("SAML InResponseTo validation failed: unknown or expired request ID", {
2922
- inResponseTo: inResponseToAcs,
2923
- providerId
2924
- });
2925
- const redirectUrl = relayState?.callbackURL || parsedSamlConfig.callbackUrl || ctx.context.baseURL;
2926
- throw ctx.redirect(`${redirectUrl}?error=invalid_saml_response&error_description=Unknown+or+expired+request+ID`);
2927
- }
2928
- if (storedRequest.providerId !== providerId) {
2929
- ctx.context.logger.error("SAML InResponseTo validation failed: provider mismatch", {
2930
- inResponseTo: inResponseToAcs,
2931
- expectedProvider: storedRequest.providerId,
2932
- actualProvider: providerId
2933
- });
2934
- await ctx.context.internalAdapter.deleteVerificationByIdentifier(`${AUTHN_REQUEST_KEY_PREFIX}${inResponseToAcs}`);
2935
- const redirectUrl = relayState?.callbackURL || parsedSamlConfig.callbackUrl || ctx.context.baseURL;
2936
- throw ctx.redirect(`${redirectUrl}?error=invalid_saml_response&error_description=Provider+mismatch`);
2937
- }
2938
- await ctx.context.internalAdapter.deleteVerificationByIdentifier(`${AUTHN_REQUEST_KEY_PREFIX}${inResponseToAcs}`);
2939
- } else if (!allowIdpInitiated) {
2940
- ctx.context.logger.error("SAML IdP-initiated SSO rejected: InResponseTo missing and allowIdpInitiated is false", { providerId });
2941
- const redirectUrl = relayState?.callbackURL || parsedSamlConfig.callbackUrl || ctx.context.baseURL;
2942
- throw ctx.redirect(`${redirectUrl}?error=unsolicited_response&error_description=IdP-initiated+SSO+not+allowed`);
2943
- }
2944
- }
2945
- const assertionIdAcs = extractAssertionId(Buffer.from(SAMLResponse, "base64").toString("utf-8"));
2946
- if (assertionIdAcs) {
2947
- const issuer = idp.entityMeta.getEntityID();
2948
- const conditions = extract.conditions;
2949
- const clockSkew = options?.saml?.clockSkew ?? 3e5;
2950
- const expiresAt = conditions?.notOnOrAfter ? new Date(conditions.notOnOrAfter).getTime() + clockSkew : Date.now() + DEFAULT_ASSERTION_TTL_MS;
2951
- const existingAssertion = await ctx.context.internalAdapter.findVerificationValue(`${USED_ASSERTION_KEY_PREFIX}${assertionIdAcs}`);
2952
- let isReplay = false;
2953
- if (existingAssertion) try {
2954
- if (JSON.parse(existingAssertion.value).expiresAt >= Date.now()) isReplay = true;
2955
- } catch (error) {
2956
- ctx.context.logger.warn("Failed to parse stored assertion record", {
2957
- assertionId: assertionIdAcs,
2958
- error
2959
- });
2960
- }
2961
- if (isReplay) {
2962
- ctx.context.logger.error("SAML assertion replay detected: assertion ID already used", {
2963
- assertionId: assertionIdAcs,
2964
- issuer,
2965
- providerId
2966
- });
2967
- const redirectUrl = relayState?.callbackURL || parsedSamlConfig.callbackUrl || ctx.context.baseURL;
2968
- throw ctx.redirect(`${redirectUrl}?error=replay_detected&error_description=SAML+assertion+has+already+been+used`);
2969
- }
2970
- await ctx.context.internalAdapter.createVerificationValue({
2971
- identifier: `${USED_ASSERTION_KEY_PREFIX}${assertionIdAcs}`,
2972
- value: JSON.stringify({
2973
- assertionId: assertionIdAcs,
2974
- issuer,
2975
- providerId,
2976
- usedAt: Date.now(),
2977
- expiresAt
2978
- }),
2979
- expiresAt: new Date(expiresAt)
2980
- });
2981
- } else ctx.context.logger.warn("Could not extract assertion ID for replay protection", { providerId });
2982
- const attributes = extract.attributes || {};
2983
- const mapping = parsedSamlConfig.mapping ?? {};
2984
- const userInfo = {
2985
- ...Object.fromEntries(Object.entries(mapping.extraFields || {}).map(([key, value]) => [key, attributes[value]])),
2986
- id: attributes[mapping.id || "nameID"] || extract.nameID,
2987
- email: (attributes[mapping.email || "email"] || extract.nameID).toLowerCase(),
2988
- name: [attributes[mapping.firstName || "givenName"], attributes[mapping.lastName || "surname"]].filter(Boolean).join(" ") || attributes[mapping.name || "displayName"] || extract.nameID,
2989
- emailVerified: options?.trustEmailVerified && mapping.emailVerified ? attributes[mapping.emailVerified] || false : false
2990
- };
2991
- if (!userInfo.id || !userInfo.email) {
2992
- ctx.context.logger.error("Missing essential user info from SAML response", {
2993
- attributes: Object.keys(attributes),
2994
- mapping,
2995
- extractedId: userInfo.id,
2996
- extractedEmail: userInfo.email
2997
- });
2998
- throw new APIError("BAD_REQUEST", { message: "Unable to extract user ID or email from SAML response" });
2999
- }
3000
- const isTrustedProvider = ctx.context.trustedProviders.includes(provider.providerId) || "domainVerified" in provider && !!provider.domainVerified && validateEmailDomain(userInfo.email, provider.domain);
3001
- const callbackUrl = relayState?.callbackURL || parsedSamlConfig.callbackUrl || ctx.context.baseURL;
3002
- const result = await handleOAuthUserInfo(ctx, {
3003
- userInfo: {
3004
- email: userInfo.email,
3005
- name: userInfo.name || userInfo.email,
3006
- id: userInfo.id,
3007
- emailVerified: Boolean(userInfo.emailVerified)
3008
- },
3009
- account: {
3010
- providerId: provider.providerId,
3011
- accountId: userInfo.id,
3012
- accessToken: "",
3013
- refreshToken: ""
3014
- },
3015
- callbackURL: callbackUrl,
3016
- disableSignUp: options?.disableImplicitSignUp,
3017
- isTrustedProvider
3018
- });
3019
- if (result.error) throw ctx.redirect(`${callbackUrl}?error=${result.error.split(" ").join("_")}`);
3020
- const { session, user } = result.data;
3021
- if (options?.provisionUser && (result.isRegister || options.provisionUserOnEveryLogin)) await options.provisionUser({
3022
- user,
3023
- userInfo,
3024
- provider
3025
- });
3026
- await assignOrganizationFromProvider(ctx, {
3027
- user,
3028
- profile: {
3029
- providerType: "saml",
3030
- providerId: provider.providerId,
3031
- accountId: userInfo.id,
3032
- email: userInfo.email,
3033
- emailVerified: Boolean(userInfo.emailVerified),
3034
- rawAttributes: attributes
3035
- },
3036
- provider,
3037
- provisioningOptions: options?.organizationProvisioning
3038
- });
3039
- await setSessionCookie(ctx, {
3040
- session,
3041
- user
3042
- });
3043
- if (options?.saml?.enableSingleLogout && extract.nameID) {
3044
- const samlSessionKey = `${SAML_SESSION_KEY_PREFIX}${providerId}:${extract.nameID}`;
3045
- const samlSessionData = {
3046
- sessionId: session.id,
3047
- providerId,
3048
- nameID: extract.nameID,
3049
- sessionIndex: extract.sessionIndex
3050
- };
3051
- await ctx.context.internalAdapter.createVerificationValue({
3052
- identifier: samlSessionKey,
3053
- value: JSON.stringify(samlSessionData),
3054
- expiresAt: session.expiresAt
3055
- }).catch((e) => ctx.context.logger.warn("Failed to create SAML session record", { error: e }));
3056
- await ctx.context.internalAdapter.createVerificationValue({
3057
- identifier: `${SAML_SESSION_BY_ID_PREFIX}${session.id}`,
3058
- value: samlSessionKey,
3059
- expiresAt: session.expiresAt
3060
- }).catch((e) => ctx.context.logger.warn("Failed to create SAML session lookup record", e));
3061
- }
3062
- const safeRedirectUrl = getSafeRedirectUrl(relayState?.callbackURL || parsedSamlConfig.callbackUrl, currentCallbackPath, appOrigin, (url, settings) => ctx.context.isTrustedOrigin(url, settings));
3063
- throw ctx.redirect(safeRedirectUrl);
3064
2819
  });
3065
2820
  };
3066
2821
  const sloSchema = z.object({
@@ -3091,10 +2846,10 @@ const sloEndpoint = (options) => {
3091
2846
  const provider = await findSAMLProvider(providerId, options, ctx.context.adapter);
3092
2847
  if (!provider?.samlConfig) throw APIError.from("NOT_FOUND", SAML_ERROR_CODES.SAML_PROVIDER_NOT_FOUND);
3093
2848
  const config = provider.samlConfig;
3094
- const sp = createSP(config, ctx.context.baseURL, providerId, {
2849
+ const sp = createSP(config, ctx.context.baseURL, providerId, { sloOptions: {
3095
2850
  wantLogoutRequestSigned: options?.saml?.wantLogoutRequestSigned,
3096
2851
  wantLogoutResponseSigned: options?.saml?.wantLogoutResponseSigned
3097
- });
2852
+ } });
3098
2853
  const idp = createIdP(config);
3099
2854
  if (samlResponse) return handleLogoutResponse(ctx, sp, idp, relayState, providerId);
3100
2855
  return handleLogoutRequest(ctx, sp, idp, relayState, providerId);
@@ -3180,10 +2935,10 @@ const initiateSLO = (options) => {
3180
2935
  if (!provider?.samlConfig) throw APIError.from("NOT_FOUND", SAML_ERROR_CODES.SAML_PROVIDER_NOT_FOUND);
3181
2936
  const config = provider.samlConfig;
3182
2937
  if (!(config.idpMetadata?.singleLogoutService?.length || config.idpMetadata?.metadata && config.idpMetadata.metadata.includes("SingleLogoutService"))) throw APIError.from("BAD_REQUEST", SAML_ERROR_CODES.IDP_SLO_NOT_SUPPORTED);
3183
- const sp = createSP(config, ctx.context.baseURL, providerId, {
2938
+ const sp = createSP(config, ctx.context.baseURL, providerId, { sloOptions: {
3184
2939
  wantLogoutRequestSigned: options?.saml?.wantLogoutRequestSigned,
3185
2940
  wantLogoutResponseSigned: options?.saml?.wantLogoutResponseSigned
3186
- });
2941
+ } });
3187
2942
  const idp = createIdP(config);
3188
2943
  const session = ctx.context.session;
3189
2944
  const sessionLookupKey = `${SAML_SESSION_BY_ID_PREFIX}${session.session.id}`;
@@ -1,5 +1,5 @@
1
1
  //#endregion
2
2
  //#region src/version.ts
3
- const PACKAGE_VERSION = "1.6.1";
3
+ const PACKAGE_VERSION = "1.6.3";
4
4
  //#endregion
5
5
  export { PACKAGE_VERSION as t };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@better-auth/sso",
3
- "version": "1.6.1",
3
+ "version": "1.6.3",
4
4
  "description": "SSO plugin for Better Auth",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -70,15 +70,15 @@
70
70
  "express": "^5.2.1",
71
71
  "oauth2-mock-server": "^8.2.2",
72
72
  "tsdown": "0.21.1",
73
- "@better-auth/core": "1.6.1",
74
- "better-auth": "1.6.1"
73
+ "@better-auth/core": "1.6.3",
74
+ "better-auth": "1.6.3"
75
75
  },
76
76
  "peerDependencies": {
77
77
  "@better-auth/utils": "0.4.0",
78
78
  "@better-fetch/fetch": "1.1.21",
79
79
  "better-call": "1.3.5",
80
- "@better-auth/core": "^1.6.1",
81
- "better-auth": "^1.6.1"
80
+ "@better-auth/core": "^1.6.3",
81
+ "better-auth": "^1.6.3"
82
82
  },
83
83
  "scripts": {
84
84
  "build": "tsdown",