@better-auth/sso 1.6.2 → 1.6.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/client.d.mts CHANGED
@@ -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-BVfKiZvw.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-BVfKiZvw.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";
@@ -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: {
@@ -2476,34 +2736,6 @@ const callbackSSOSAMLBodySchema = z.object({
2476
2736
  SAMLResponse: z.string(),
2477
2737
  RelayState: z.string().optional()
2478
2738
  });
2479
- /**
2480
- * Validates and returns a safe redirect URL.
2481
- * - Prevents open redirect attacks by validating against trusted origins
2482
- * - Prevents redirect loops by checking if URL points to callback route
2483
- * - Falls back to appOrigin if URL is invalid or unsafe
2484
- */
2485
- const getSafeRedirectUrl = (url, callbackPath, appOrigin, isTrustedOrigin) => {
2486
- if (!url) return appOrigin;
2487
- if (url.startsWith("/") && !url.startsWith("//")) {
2488
- try {
2489
- const absoluteUrl = new URL(url, appOrigin);
2490
- if (absoluteUrl.origin !== appOrigin) return appOrigin;
2491
- const callbackPathname = new URL(callbackPath).pathname;
2492
- if (absoluteUrl.pathname === callbackPathname) return appOrigin;
2493
- } catch {
2494
- return appOrigin;
2495
- }
2496
- return url;
2497
- }
2498
- if (!isTrustedOrigin(url, { allowRelativePaths: false })) return appOrigin;
2499
- try {
2500
- const callbackPathname = new URL(callbackPath).pathname;
2501
- if (new URL(url).pathname === callbackPathname) return appOrigin;
2502
- } catch {
2503
- if (url === callbackPath || url.startsWith(`${callbackPath}?`)) return appOrigin;
2504
- }
2505
- return url;
2506
- };
2507
2739
  const callbackSSOSAML = (options) => {
2508
2740
  return createAuthEndpoint("/sso/saml2/callback/:providerId", {
2509
2741
  method: ["GET", "POST"],
@@ -2535,261 +2767,12 @@ const callbackSSOSAML = (options) => {
2535
2767
  throw ctx.redirect(safeRedirectUrl);
2536
2768
  }
2537
2769
  if (!ctx.body?.SAMLResponse) throw new APIError("BAD_REQUEST", { message: "SAMLResponse is required for POST requests" });
2538
- const 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));
2770
+ const safeRedirectUrl = await processSAMLResponse(ctx, {
2771
+ SAMLResponse: ctx.body.SAMLResponse,
2772
+ RelayState: ctx.body.RelayState,
2773
+ providerId,
2774
+ currentCallbackPath
2775
+ }, options);
2793
2776
  throw ctx.redirect(safeRedirectUrl);
2794
2777
  });
2795
2778
  };
@@ -2815,253 +2798,24 @@ const acsEndpoint = (options) => {
2815
2798
  const { providerId } = ctx.params;
2816
2799
  const currentCallbackPath = `${ctx.context.baseURL}/sso/saml2/sp/acs/${providerId}`;
2817
2800
  const appOrigin = new URL(ctx.context.baseURL).origin;
2818
- const maxResponseSize = options?.saml?.maxResponseSize ?? 262144;
2819
- if (new TextEncoder().encode(ctx.body.SAMLResponse).length > maxResponseSize) throw new APIError("BAD_REQUEST", { message: `SAML response exceeds maximum allowed size (${maxResponseSize} bytes)` });
2820
- const SAMLResponse = ctx.body.SAMLResponse.replace(/\s+/g, "");
2821
- let relayState = null;
2822
- if (ctx.body.RelayState) try {
2823
- relayState = await parseRelayState(ctx);
2824
- } catch {
2825
- relayState = null;
2826
- }
2827
- let provider = null;
2828
- if (options?.defaultSSO?.length) {
2829
- const matchingDefault = providerId ? options.defaultSSO.find((defaultProvider) => defaultProvider.providerId === providerId) : options.defaultSSO[0];
2830
- if (matchingDefault) provider = {
2831
- issuer: matchingDefault.samlConfig?.issuer || "",
2832
- providerId: matchingDefault.providerId,
2833
- userId: "default",
2834
- samlConfig: matchingDefault.samlConfig,
2835
- domain: matchingDefault.domain,
2836
- ...options.domainVerification?.enabled ? { domainVerified: true } : {}
2837
- };
2838
- } else provider = await ctx.context.adapter.findOne({
2839
- model: "ssoProvider",
2840
- where: [{
2841
- field: "providerId",
2842
- value: providerId
2843
- }]
2844
- }).then((res) => {
2845
- if (!res) return null;
2846
- return {
2847
- ...res,
2848
- samlConfig: res.samlConfig ? safeJsonParse(res.samlConfig) || void 0 : void 0
2849
- };
2850
- });
2851
- if (!provider?.samlConfig) throw new APIError("NOT_FOUND", { message: "No SAML provider found" });
2852
- if (options?.domainVerification?.enabled && !("domainVerified" in provider && provider.domainVerified)) throw new APIError("UNAUTHORIZED", { message: "Provider domain has not been verified" });
2853
- const parsedSamlConfig = provider.samlConfig;
2854
- const sp = saml.ServiceProvider({
2855
- entityID: parsedSamlConfig.spMetadata?.entityID || parsedSamlConfig.issuer,
2856
- assertionConsumerService: [{
2857
- Binding: "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-POST",
2858
- Location: parsedSamlConfig.callbackUrl || `${ctx.context.baseURL}/sso/saml2/sp/acs/${providerId}`
2859
- }],
2860
- wantMessageSigned: parsedSamlConfig.wantAssertionsSigned || false,
2861
- metadata: parsedSamlConfig.spMetadata?.metadata,
2862
- privateKey: parsedSamlConfig.spMetadata?.privateKey || parsedSamlConfig.privateKey,
2863
- privateKeyPass: parsedSamlConfig.spMetadata?.privateKeyPass,
2864
- nameIDFormat: parsedSamlConfig.identifierFormat ? [parsedSamlConfig.identifierFormat] : void 0
2865
- });
2866
- const idpData = parsedSamlConfig.idpMetadata;
2867
- const idp = !idpData?.metadata ? saml.IdentityProvider({
2868
- entityID: idpData?.entityID || parsedSamlConfig.issuer,
2869
- singleSignOnService: idpData?.singleSignOnService || [{
2870
- Binding: "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect",
2871
- Location: parsedSamlConfig.entryPoint
2872
- }],
2873
- signingCert: idpData?.cert || parsedSamlConfig.cert
2874
- }) : saml.IdentityProvider({ metadata: idpData.metadata });
2875
2801
  try {
2876
- 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);
2877
2809
  } 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)}`);
2810
+ if (error instanceof Response || error && typeof error === "object" && "status" in error && error.status === 302) throw error;
2811
+ if (error instanceof APIError && error.statusCode === 400) {
2812
+ const internalCode = error.body?.code || "";
2813
+ const errorCode = internalCode === "SAML_MULTIPLE_ASSERTIONS" ? "multiple_assertions" : internalCode === "SAML_NO_ASSERTION" ? "no_assertion" : internalCode.toLowerCase() || "saml_error";
2814
+ const redirectUrl = getSafeRedirectUrl(ctx.body.RelayState || void 0, currentCallbackPath, appOrigin, (url, settings) => ctx.context.isTrustedOrigin(url, settings));
2815
+ throw ctx.redirect(`${redirectUrl}${redirectUrl.includes("?") ? "&" : "?"}error=${encodeURIComponent(errorCode)}&error_description=${encodeURIComponent(error.message)}`);
2882
2816
  }
2883
2817
  throw error;
2884
2818
  }
2885
- let parsedResponse;
2886
- try {
2887
- parsedResponse = await sp.parseLoginResponse(idp, "post", { body: {
2888
- SAMLResponse,
2889
- RelayState: ctx.body.RelayState || void 0
2890
- } });
2891
- if (!parsedResponse?.extract) throw new Error("Invalid SAML response structure");
2892
- } catch (error) {
2893
- ctx.context.logger.error("SAML response validation failed", {
2894
- error,
2895
- decodedResponse: Buffer.from(SAMLResponse, "base64").toString("utf-8")
2896
- });
2897
- throw new APIError("BAD_REQUEST", {
2898
- message: "Invalid SAML response",
2899
- details: error instanceof Error ? error.message : String(error)
2900
- });
2901
- }
2902
- const { extract } = parsedResponse;
2903
- validateSAMLAlgorithms(parsedResponse, options?.saml?.algorithms);
2904
- validateSAMLTimestamp(extract.conditions, {
2905
- clockSkew: options?.saml?.clockSkew,
2906
- requireTimestamps: options?.saml?.requireTimestamps,
2907
- logger: ctx.context.logger
2908
- });
2909
- const inResponseToAcs = extract.inResponseTo;
2910
- if (options?.saml?.enableInResponseToValidation !== false) {
2911
- const allowIdpInitiated = options?.saml?.allowIdpInitiated !== false;
2912
- if (inResponseToAcs) {
2913
- let storedRequest = null;
2914
- const verification = await ctx.context.internalAdapter.findVerificationValue(`${AUTHN_REQUEST_KEY_PREFIX}${inResponseToAcs}`);
2915
- if (verification) try {
2916
- storedRequest = JSON.parse(verification.value);
2917
- if (storedRequest && storedRequest.expiresAt < Date.now()) storedRequest = null;
2918
- } catch {
2919
- storedRequest = null;
2920
- }
2921
- if (!storedRequest) {
2922
- ctx.context.logger.error("SAML InResponseTo validation failed: unknown or expired request ID", {
2923
- inResponseTo: inResponseToAcs,
2924
- providerId
2925
- });
2926
- const redirectUrl = relayState?.callbackURL || parsedSamlConfig.callbackUrl || ctx.context.baseURL;
2927
- throw ctx.redirect(`${redirectUrl}?error=invalid_saml_response&error_description=Unknown+or+expired+request+ID`);
2928
- }
2929
- if (storedRequest.providerId !== providerId) {
2930
- ctx.context.logger.error("SAML InResponseTo validation failed: provider mismatch", {
2931
- inResponseTo: inResponseToAcs,
2932
- expectedProvider: storedRequest.providerId,
2933
- actualProvider: providerId
2934
- });
2935
- await ctx.context.internalAdapter.deleteVerificationByIdentifier(`${AUTHN_REQUEST_KEY_PREFIX}${inResponseToAcs}`);
2936
- const redirectUrl = relayState?.callbackURL || parsedSamlConfig.callbackUrl || ctx.context.baseURL;
2937
- throw ctx.redirect(`${redirectUrl}?error=invalid_saml_response&error_description=Provider+mismatch`);
2938
- }
2939
- await ctx.context.internalAdapter.deleteVerificationByIdentifier(`${AUTHN_REQUEST_KEY_PREFIX}${inResponseToAcs}`);
2940
- } else if (!allowIdpInitiated) {
2941
- ctx.context.logger.error("SAML IdP-initiated SSO rejected: InResponseTo missing and allowIdpInitiated is false", { providerId });
2942
- const redirectUrl = relayState?.callbackURL || parsedSamlConfig.callbackUrl || ctx.context.baseURL;
2943
- throw ctx.redirect(`${redirectUrl}?error=unsolicited_response&error_description=IdP-initiated+SSO+not+allowed`);
2944
- }
2945
- }
2946
- const assertionIdAcs = extractAssertionId(Buffer.from(SAMLResponse, "base64").toString("utf-8"));
2947
- if (assertionIdAcs) {
2948
- const issuer = idp.entityMeta.getEntityID();
2949
- const conditions = extract.conditions;
2950
- const clockSkew = options?.saml?.clockSkew ?? 3e5;
2951
- const expiresAt = conditions?.notOnOrAfter ? new Date(conditions.notOnOrAfter).getTime() + clockSkew : Date.now() + DEFAULT_ASSERTION_TTL_MS;
2952
- const existingAssertion = await ctx.context.internalAdapter.findVerificationValue(`${USED_ASSERTION_KEY_PREFIX}${assertionIdAcs}`);
2953
- let isReplay = false;
2954
- if (existingAssertion) try {
2955
- if (JSON.parse(existingAssertion.value).expiresAt >= Date.now()) isReplay = true;
2956
- } catch (error) {
2957
- ctx.context.logger.warn("Failed to parse stored assertion record", {
2958
- assertionId: assertionIdAcs,
2959
- error
2960
- });
2961
- }
2962
- if (isReplay) {
2963
- ctx.context.logger.error("SAML assertion replay detected: assertion ID already used", {
2964
- assertionId: assertionIdAcs,
2965
- issuer,
2966
- providerId
2967
- });
2968
- const redirectUrl = relayState?.callbackURL || parsedSamlConfig.callbackUrl || ctx.context.baseURL;
2969
- throw ctx.redirect(`${redirectUrl}?error=replay_detected&error_description=SAML+assertion+has+already+been+used`);
2970
- }
2971
- await ctx.context.internalAdapter.createVerificationValue({
2972
- identifier: `${USED_ASSERTION_KEY_PREFIX}${assertionIdAcs}`,
2973
- value: JSON.stringify({
2974
- assertionId: assertionIdAcs,
2975
- issuer,
2976
- providerId,
2977
- usedAt: Date.now(),
2978
- expiresAt
2979
- }),
2980
- expiresAt: new Date(expiresAt)
2981
- });
2982
- } else ctx.context.logger.warn("Could not extract assertion ID for replay protection", { providerId });
2983
- const attributes = extract.attributes || {};
2984
- const mapping = parsedSamlConfig.mapping ?? {};
2985
- const userInfo = {
2986
- ...Object.fromEntries(Object.entries(mapping.extraFields || {}).map(([key, value]) => [key, attributes[value]])),
2987
- id: attributes[mapping.id || "nameID"] || extract.nameID,
2988
- email: (attributes[mapping.email || "email"] || extract.nameID).toLowerCase(),
2989
- name: [attributes[mapping.firstName || "givenName"], attributes[mapping.lastName || "surname"]].filter(Boolean).join(" ") || attributes[mapping.name || "displayName"] || extract.nameID,
2990
- emailVerified: options?.trustEmailVerified && mapping.emailVerified ? attributes[mapping.emailVerified] || false : false
2991
- };
2992
- if (!userInfo.id || !userInfo.email) {
2993
- ctx.context.logger.error("Missing essential user info from SAML response", {
2994
- attributes: Object.keys(attributes),
2995
- mapping,
2996
- extractedId: userInfo.id,
2997
- extractedEmail: userInfo.email
2998
- });
2999
- throw new APIError("BAD_REQUEST", { message: "Unable to extract user ID or email from SAML response" });
3000
- }
3001
- const isTrustedProvider = ctx.context.trustedProviders.includes(provider.providerId) || "domainVerified" in provider && !!provider.domainVerified && validateEmailDomain(userInfo.email, provider.domain);
3002
- const callbackUrl = relayState?.callbackURL || parsedSamlConfig.callbackUrl || ctx.context.baseURL;
3003
- const result = await handleOAuthUserInfo(ctx, {
3004
- userInfo: {
3005
- email: userInfo.email,
3006
- name: userInfo.name || userInfo.email,
3007
- id: userInfo.id,
3008
- emailVerified: Boolean(userInfo.emailVerified)
3009
- },
3010
- account: {
3011
- providerId: provider.providerId,
3012
- accountId: userInfo.id,
3013
- accessToken: "",
3014
- refreshToken: ""
3015
- },
3016
- callbackURL: callbackUrl,
3017
- disableSignUp: options?.disableImplicitSignUp,
3018
- isTrustedProvider
3019
- });
3020
- if (result.error) throw ctx.redirect(`${callbackUrl}?error=${result.error.split(" ").join("_")}`);
3021
- const { session, user } = result.data;
3022
- if (options?.provisionUser && (result.isRegister || options.provisionUserOnEveryLogin)) await options.provisionUser({
3023
- user,
3024
- userInfo,
3025
- provider
3026
- });
3027
- await assignOrganizationFromProvider(ctx, {
3028
- user,
3029
- profile: {
3030
- providerType: "saml",
3031
- providerId: provider.providerId,
3032
- accountId: userInfo.id,
3033
- email: userInfo.email,
3034
- emailVerified: Boolean(userInfo.emailVerified),
3035
- rawAttributes: attributes
3036
- },
3037
- provider,
3038
- provisioningOptions: options?.organizationProvisioning
3039
- });
3040
- await setSessionCookie(ctx, {
3041
- session,
3042
- user
3043
- });
3044
- if (options?.saml?.enableSingleLogout && extract.nameID) {
3045
- const samlSessionKey = `${SAML_SESSION_KEY_PREFIX}${providerId}:${extract.nameID}`;
3046
- const samlSessionData = {
3047
- sessionId: session.id,
3048
- providerId,
3049
- nameID: extract.nameID,
3050
- sessionIndex: extract.sessionIndex
3051
- };
3052
- await ctx.context.internalAdapter.createVerificationValue({
3053
- identifier: samlSessionKey,
3054
- value: JSON.stringify(samlSessionData),
3055
- expiresAt: session.expiresAt
3056
- }).catch((e) => ctx.context.logger.warn("Failed to create SAML session record", { error: e }));
3057
- await ctx.context.internalAdapter.createVerificationValue({
3058
- identifier: `${SAML_SESSION_BY_ID_PREFIX}${session.id}`,
3059
- value: samlSessionKey,
3060
- expiresAt: session.expiresAt
3061
- }).catch((e) => ctx.context.logger.warn("Failed to create SAML session lookup record", e));
3062
- }
3063
- const safeRedirectUrl = getSafeRedirectUrl(relayState?.callbackURL || parsedSamlConfig.callbackUrl, currentCallbackPath, appOrigin, (url, settings) => ctx.context.isTrustedOrigin(url, settings));
3064
- throw ctx.redirect(safeRedirectUrl);
3065
2819
  });
3066
2820
  };
3067
2821
  const sloSchema = z.object({
@@ -3092,10 +2846,10 @@ const sloEndpoint = (options) => {
3092
2846
  const provider = await findSAMLProvider(providerId, options, ctx.context.adapter);
3093
2847
  if (!provider?.samlConfig) throw APIError.from("NOT_FOUND", SAML_ERROR_CODES.SAML_PROVIDER_NOT_FOUND);
3094
2848
  const config = provider.samlConfig;
3095
- const sp = createSP(config, ctx.context.baseURL, providerId, {
2849
+ const sp = createSP(config, ctx.context.baseURL, providerId, { sloOptions: {
3096
2850
  wantLogoutRequestSigned: options?.saml?.wantLogoutRequestSigned,
3097
2851
  wantLogoutResponseSigned: options?.saml?.wantLogoutResponseSigned
3098
- });
2852
+ } });
3099
2853
  const idp = createIdP(config);
3100
2854
  if (samlResponse) return handleLogoutResponse(ctx, sp, idp, relayState, providerId);
3101
2855
  return handleLogoutRequest(ctx, sp, idp, relayState, providerId);
@@ -3181,10 +2935,10 @@ const initiateSLO = (options) => {
3181
2935
  if (!provider?.samlConfig) throw APIError.from("NOT_FOUND", SAML_ERROR_CODES.SAML_PROVIDER_NOT_FOUND);
3182
2936
  const config = provider.samlConfig;
3183
2937
  if (!(config.idpMetadata?.singleLogoutService?.length || config.idpMetadata?.metadata && config.idpMetadata.metadata.includes("SingleLogoutService"))) throw APIError.from("BAD_REQUEST", SAML_ERROR_CODES.IDP_SLO_NOT_SUPPORTED);
3184
- const sp = createSP(config, ctx.context.baseURL, providerId, {
2938
+ const sp = createSP(config, ctx.context.baseURL, providerId, { sloOptions: {
3185
2939
  wantLogoutRequestSigned: options?.saml?.wantLogoutRequestSigned,
3186
2940
  wantLogoutResponseSigned: options?.saml?.wantLogoutResponseSigned
3187
- });
2941
+ } });
3188
2942
  const idp = createIdP(config);
3189
2943
  const session = ctx.context.session;
3190
2944
  const sessionLookupKey = `${SAML_SESSION_BY_ID_PREFIX}${session.session.id}`;
@@ -1,5 +1,5 @@
1
1
  //#endregion
2
2
  //#region src/version.ts
3
- const PACKAGE_VERSION = "1.6.2";
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.2",
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.2",
74
- "better-auth": "1.6.2"
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.2",
81
- "better-auth": "^1.6.2"
80
+ "@better-auth/core": "^1.6.3",
81
+ "better-auth": "^1.6.3"
82
82
  },
83
83
  "scripts": {
84
84
  "build": "tsdown",