@better-auth/sso 1.4.17 → 1.4.19

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/src/routes/sso.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { base64 } from "@better-auth/utils/base64";
1
2
  import { BetterFetchError, betterFetch } from "@better-fetch/fetch";
2
3
  import type { User, Verification } from "better-auth";
3
4
  import {
@@ -11,6 +12,7 @@ import {
11
12
  import {
12
13
  APIError,
13
14
  createAuthEndpoint,
15
+ getSessionFromCtx,
14
16
  sessionMiddleware,
15
17
  } from "better-auth/api";
16
18
  import { setSessionCookie } from "better-auth/cookies";
@@ -23,6 +25,7 @@ import type { BindingContext } from "samlify/types/src/entity";
23
25
  import type { IdentityProvider } from "samlify/types/src/entity-idp";
24
26
  import type { FlowResult } from "samlify/types/src/flow";
25
27
  import z from "zod/v4";
28
+ import { getVerificationIdentifier } from "./domain-verification";
26
29
 
27
30
  interface AuthnRequestRecord {
28
31
  id: string;
@@ -52,8 +55,9 @@ import {
52
55
  validateSAMLAlgorithms,
53
56
  validateSingleAssertion,
54
57
  } from "../saml";
58
+ import { generateRelayState, parseRelayState } from "../saml-state";
55
59
  import type { OIDCConfig, SAMLConfig, SSOOptions, SSOProvider } from "../types";
56
- import { safeJsonParse, validateEmailDomain } from "../utils";
60
+ import { domainMatches, safeJsonParse, validateEmailDomain } from "../utils";
57
61
 
58
62
  export interface TimestampValidationOptions {
59
63
  clockSkew?: number;
@@ -165,6 +169,8 @@ const spMetadataQuerySchema = z.object({
165
169
  format: z.enum(["xml", "json"]).default("xml"),
166
170
  });
167
171
 
172
+ type RelayState = Awaited<ReturnType<typeof parseRelayState>>;
173
+
168
174
  export const spMetadata = () => {
169
175
  return createAuthEndpoint(
170
176
  "/sso/saml2/sp/metadata",
@@ -224,6 +230,7 @@ export const spMetadata = () => {
224
230
  `${ctx.context.baseURL}/sso/saml2/sp/acs/${provider.id}`,
225
231
  },
226
232
  ],
233
+ authnRequestsSigned: parsedSamlConfig.authnRequestsSigned || false,
227
234
  wantMessageSigned: parsedSamlConfig.wantAssertionsSigned || false,
228
235
  nameIDFormat: parsedSamlConfig.identifierFormat
229
236
  ? [parsedSamlConfig.identifierFormat]
@@ -247,7 +254,8 @@ const ssoProviderBodySchema = z.object({
247
254
  description: "The issuer of the provider",
248
255
  }),
249
256
  domain: z.string({}).meta({
250
- description: "The domain of the provider. This is used for email matching",
257
+ description:
258
+ "The domain(s) of the provider. For enterprise multi-domain SSO where a single IdP serves multiple email domains, use comma-separated values (e.g., 'company.com,subsidiary.com,acquired-company.com')",
251
259
  }),
252
260
  oidcConfig: z
253
261
  .object({
@@ -855,9 +863,7 @@ export const registerSSOProvider = <O extends SSOOptions>(options: O) => {
855
863
  await ctx.context.adapter.create<Verification>({
856
864
  model: "verification",
857
865
  data: {
858
- identifier: options.domainVerification?.tokenPrefix
859
- ? `${options.domainVerification?.tokenPrefix}-${provider.providerId}`
860
- : `better-auth-token-${provider.providerId}`,
866
+ identifier: getVerificationIdentifier(options, provider.providerId),
861
867
  createdAt: new Date(),
862
868
  updatedAt: new Date(),
863
869
  value: domainVerificationToken as string,
@@ -1121,38 +1127,58 @@ export const signInSSO = (options?: SSOOptions) => {
1121
1127
  }
1122
1128
  // Try to find provider in database
1123
1129
  if (!provider) {
1124
- provider = await ctx.context.adapter
1125
- .findOne<SSOProvider<SSOOptions>>({
1126
- model: "ssoProvider",
1127
- where: [
1128
- {
1129
- field: providerId
1130
- ? "providerId"
1131
- : orgId
1132
- ? "organizationId"
1133
- : "domain",
1134
- value: providerId || orgId || domain!,
1135
- },
1136
- ],
1137
- })
1138
- .then((res) => {
1139
- if (!res) {
1140
- return null;
1141
- }
1142
- return {
1143
- ...res,
1144
- oidcConfig: res.oidcConfig
1145
- ? safeJsonParse<OIDCConfig>(
1146
- res.oidcConfig as unknown as string,
1147
- ) || undefined
1148
- : undefined,
1149
- samlConfig: res.samlConfig
1150
- ? safeJsonParse<SAMLConfig>(
1151
- res.samlConfig as unknown as string,
1152
- ) || undefined
1153
- : undefined,
1154
- };
1155
- });
1130
+ const parseProvider = (res: SSOProvider<SSOOptions> | null) => {
1131
+ if (!res) return null;
1132
+ return {
1133
+ ...res,
1134
+ oidcConfig: res.oidcConfig
1135
+ ? safeJsonParse<OIDCConfig>(
1136
+ res.oidcConfig as unknown as string,
1137
+ ) || undefined
1138
+ : undefined,
1139
+ samlConfig: res.samlConfig
1140
+ ? safeJsonParse<SAMLConfig>(
1141
+ res.samlConfig as unknown as string,
1142
+ ) || undefined
1143
+ : undefined,
1144
+ };
1145
+ };
1146
+
1147
+ if (providerId || orgId) {
1148
+ // Exact match for providerId or orgId
1149
+ provider = parseProvider(
1150
+ await ctx.context.adapter.findOne<SSOProvider<SSOOptions>>({
1151
+ model: "ssoProvider",
1152
+ where: [
1153
+ {
1154
+ field: providerId ? "providerId" : "organizationId",
1155
+ value: providerId || orgId!,
1156
+ },
1157
+ ],
1158
+ }),
1159
+ );
1160
+ } else if (domain) {
1161
+ // For domain lookup, support comma-separated domains
1162
+ // First try exact match (fast path)
1163
+ provider = parseProvider(
1164
+ await ctx.context.adapter.findOne<SSOProvider<SSOOptions>>({
1165
+ model: "ssoProvider",
1166
+ where: [{ field: "domain", value: domain }],
1167
+ }),
1168
+ );
1169
+ // If not found, search all providers for comma-separated domain match
1170
+ if (!provider) {
1171
+ const allProviders = await ctx.context.adapter.findMany<
1172
+ SSOProvider<SSOOptions>
1173
+ >({
1174
+ model: "ssoProvider",
1175
+ });
1176
+ const matchingProvider = allProviders.find((p) =>
1177
+ domainMatches(domain, p.domain),
1178
+ );
1179
+ provider = parseProvider(matchingProvider ?? null);
1180
+ }
1181
+ }
1156
1182
  }
1157
1183
 
1158
1184
  if (!provider) {
@@ -1258,6 +1284,8 @@ export const signInSSO = (options?: SSOOptions) => {
1258
1284
  `${ctx.context.baseURL}/sso/saml2/sp/acs/${provider.providerId}`,
1259
1285
  },
1260
1286
  ],
1287
+ authnRequestsSigned:
1288
+ parsedSamlConfig.authnRequestsSigned || false,
1261
1289
  wantMessageSigned:
1262
1290
  parsedSamlConfig.wantAssertionsSigned || false,
1263
1291
  nameIDFormat: parsedSamlConfig.identifierFormat
@@ -1269,16 +1297,41 @@ export const signInSSO = (options?: SSOOptions) => {
1269
1297
 
1270
1298
  const sp = saml.ServiceProvider({
1271
1299
  metadata: metadata,
1300
+ privateKey:
1301
+ parsedSamlConfig.spMetadata?.privateKey ||
1302
+ parsedSamlConfig.privateKey,
1303
+ privateKeyPass: parsedSamlConfig.spMetadata?.privateKeyPass,
1272
1304
  allowCreate: true,
1273
1305
  });
1274
1306
 
1275
- const idp = saml.IdentityProvider({
1276
- metadata: parsedSamlConfig.idpMetadata?.metadata,
1277
- entityID: parsedSamlConfig.idpMetadata?.entityID,
1278
- encryptCert: parsedSamlConfig.idpMetadata?.cert,
1279
- singleSignOnService:
1280
- parsedSamlConfig.idpMetadata?.singleSignOnService,
1281
- });
1307
+ const idpData = parsedSamlConfig.idpMetadata;
1308
+ let idp: IdentityProvider;
1309
+ if (!idpData?.metadata) {
1310
+ idp = saml.IdentityProvider({
1311
+ entityID: idpData?.entityID || parsedSamlConfig.issuer,
1312
+ singleSignOnService: idpData?.singleSignOnService || [
1313
+ {
1314
+ Binding: "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect",
1315
+ Location: parsedSamlConfig.entryPoint,
1316
+ },
1317
+ ],
1318
+ signingCert: idpData?.cert || parsedSamlConfig.cert,
1319
+ wantAuthnRequestsSigned:
1320
+ parsedSamlConfig.authnRequestsSigned || false,
1321
+ isAssertionEncrypted: idpData?.isAssertionEncrypted || false,
1322
+ encPrivateKey: idpData?.encPrivateKey,
1323
+ encPrivateKeyPass: idpData?.encPrivateKeyPass,
1324
+ });
1325
+ } else {
1326
+ idp = saml.IdentityProvider({
1327
+ metadata: idpData.metadata,
1328
+ privateKey: idpData.privateKey,
1329
+ privateKeyPass: idpData.privateKeyPass,
1330
+ isAssertionEncrypted: idpData.isAssertionEncrypted,
1331
+ encPrivateKey: idpData.encPrivateKey,
1332
+ encPrivateKeyPass: idpData.encPrivateKeyPass,
1333
+ });
1334
+ }
1282
1335
  const loginRequest = sp.createLoginRequest(
1283
1336
  idp,
1284
1337
  "redirect",
@@ -1293,6 +1346,12 @@ export const signInSSO = (options?: SSOOptions) => {
1293
1346
  });
1294
1347
  }
1295
1348
 
1349
+ const { state: relayState } = await generateRelayState(
1350
+ ctx,
1351
+ undefined,
1352
+ false,
1353
+ );
1354
+
1296
1355
  const shouldSaveRequest =
1297
1356
  loginRequest.id && options?.saml?.enableInResponseToValidation;
1298
1357
  if (shouldSaveRequest) {
@@ -1311,9 +1370,7 @@ export const signInSSO = (options?: SSOOptions) => {
1311
1370
  }
1312
1371
 
1313
1372
  return ctx.json({
1314
- url: `${loginRequest.context}&RelayState=${encodeURIComponent(
1315
- body.callbackURL,
1316
- )}`,
1373
+ url: `${loginRequest.context}&RelayState=${encodeURIComponent(relayState)}`,
1317
1374
  redirect: true,
1318
1375
  });
1319
1376
  }
@@ -1418,7 +1475,7 @@ export const callbackSSO = (options?: SSOOptions) => {
1418
1475
  throw ctx.redirect(
1419
1476
  `${
1420
1477
  errorURL || callbackURL
1421
- }/error?error=invalid_provider&error_description=provider not found`,
1478
+ }?error=invalid_provider&error_description=provider not found`,
1422
1479
  );
1423
1480
  }
1424
1481
 
@@ -1437,7 +1494,7 @@ export const callbackSSO = (options?: SSOOptions) => {
1437
1494
  throw ctx.redirect(
1438
1495
  `${
1439
1496
  errorURL || callbackURL
1440
- }/error?error=invalid_provider&error_description=provider not found`,
1497
+ }?error=invalid_provider&error_description=provider not found`,
1441
1498
  );
1442
1499
  }
1443
1500
 
@@ -1464,7 +1521,7 @@ export const callbackSSO = (options?: SSOOptions) => {
1464
1521
  throw ctx.redirect(
1465
1522
  `${
1466
1523
  errorURL || callbackURL
1467
- }/error?error=invalid_provider&error_description=token_endpoint_not_found`,
1524
+ }?error=invalid_provider&error_description=token_endpoint_not_found`,
1468
1525
  );
1469
1526
  }
1470
1527
 
@@ -1495,7 +1552,7 @@ export const callbackSSO = (options?: SSOOptions) => {
1495
1552
  throw ctx.redirect(
1496
1553
  `${
1497
1554
  errorURL || callbackURL
1498
- }/error?error=invalid_provider&error_description=token_response_not_found`,
1555
+ }?error=invalid_provider&error_description=token_response_not_found`,
1499
1556
  );
1500
1557
  }
1501
1558
  let userInfo: {
@@ -1512,12 +1569,16 @@ export const callbackSSO = (options?: SSOOptions) => {
1512
1569
  throw ctx.redirect(
1513
1570
  `${
1514
1571
  errorURL || callbackURL
1515
- }/error?error=invalid_provider&error_description=jwks_endpoint_not_found`,
1572
+ }?error=invalid_provider&error_description=jwks_endpoint_not_found`,
1516
1573
  );
1517
1574
  }
1518
1575
  const verified = await validateToken(
1519
1576
  tokenResponse.idToken,
1520
1577
  config.jwksEndpoint,
1578
+ {
1579
+ audience: config.clientId,
1580
+ issuer: provider.issuer,
1581
+ },
1521
1582
  ).catch((e) => {
1522
1583
  ctx.context.logger.error(e);
1523
1584
  return null;
@@ -1526,14 +1587,7 @@ export const callbackSSO = (options?: SSOOptions) => {
1526
1587
  throw ctx.redirect(
1527
1588
  `${
1528
1589
  errorURL || callbackURL
1529
- }/error?error=invalid_provider&error_description=token_not_verified`,
1530
- );
1531
- }
1532
- if (verified.payload.iss !== provider.issuer) {
1533
- throw ctx.redirect(
1534
- `${
1535
- errorURL || callbackURL
1536
- }/error?error=invalid_provider&error_description=issuer_mismatch`,
1590
+ }?error=invalid_provider&error_description=token_not_verified`,
1537
1591
  );
1538
1592
  }
1539
1593
 
@@ -1566,7 +1620,7 @@ export const callbackSSO = (options?: SSOOptions) => {
1566
1620
  throw ctx.redirect(
1567
1621
  `${
1568
1622
  errorURL || callbackURL
1569
- }/error?error=invalid_provider&error_description=user_info_endpoint_not_found`,
1623
+ }?error=invalid_provider&error_description=user_info_endpoint_not_found`,
1570
1624
  );
1571
1625
  }
1572
1626
  const userInfoResponse = await betterFetch<{
@@ -1584,7 +1638,7 @@ export const callbackSSO = (options?: SSOOptions) => {
1584
1638
  throw ctx.redirect(
1585
1639
  `${
1586
1640
  errorURL || callbackURL
1587
- }/error?error=invalid_provider&error_description=${
1641
+ }?error=invalid_provider&error_description=${
1588
1642
  userInfoResponse.error.message
1589
1643
  }`,
1590
1644
  );
@@ -1596,7 +1650,7 @@ export const callbackSSO = (options?: SSOOptions) => {
1596
1650
  throw ctx.redirect(
1597
1651
  `${
1598
1652
  errorURL || callbackURL
1599
- }/error?error=invalid_provider&error_description=missing_user_info`,
1653
+ }?error=invalid_provider&error_description=missing_user_info`,
1600
1654
  );
1601
1655
  }
1602
1656
  const isTrustedProvider =
@@ -1630,9 +1684,7 @@ export const callbackSSO = (options?: SSOOptions) => {
1630
1684
  isTrustedProvider,
1631
1685
  });
1632
1686
  if (linked.error) {
1633
- throw ctx.redirect(
1634
- `${errorURL || callbackURL}/error?error=${linked.error}`,
1635
- );
1687
+ throw ctx.redirect(`${errorURL || callbackURL}?error=${linked.error}`);
1636
1688
  }
1637
1689
  const { session, user } = linked.data!;
1638
1690
 
@@ -1683,12 +1735,71 @@ const callbackSSOSAMLBodySchema = z.object({
1683
1735
  RelayState: z.string().optional(),
1684
1736
  });
1685
1737
 
1738
+ /**
1739
+ * Validates and returns a safe redirect URL.
1740
+ * - Prevents open redirect attacks by validating against trusted origins
1741
+ * - Prevents redirect loops by checking if URL points to callback route
1742
+ * - Falls back to appOrigin if URL is invalid or unsafe
1743
+ */
1744
+ const getSafeRedirectUrl = (
1745
+ url: string | undefined,
1746
+ callbackPath: string,
1747
+ appOrigin: string,
1748
+ isTrustedOrigin: (
1749
+ url: string,
1750
+ settings?: { allowRelativePaths: boolean },
1751
+ ) => boolean,
1752
+ ): string => {
1753
+ if (!url) {
1754
+ return appOrigin;
1755
+ }
1756
+
1757
+ if (url.startsWith("/") && !url.startsWith("//")) {
1758
+ try {
1759
+ const absoluteUrl = new URL(url, appOrigin);
1760
+ if (absoluteUrl.origin !== appOrigin) {
1761
+ return appOrigin;
1762
+ }
1763
+ const callbackPathname = new URL(callbackPath).pathname;
1764
+ if (absoluteUrl.pathname === callbackPathname) {
1765
+ return appOrigin;
1766
+ }
1767
+ } catch {
1768
+ return appOrigin;
1769
+ }
1770
+ return url;
1771
+ }
1772
+
1773
+ if (!isTrustedOrigin(url, { allowRelativePaths: false })) {
1774
+ return appOrigin;
1775
+ }
1776
+
1777
+ try {
1778
+ const callbackPathname = new URL(callbackPath).pathname;
1779
+ const urlPathname = new URL(url).pathname;
1780
+ if (urlPathname === callbackPathname) {
1781
+ return appOrigin;
1782
+ }
1783
+ } catch {
1784
+ if (url === callbackPath || url.startsWith(`${callbackPath}?`)) {
1785
+ return appOrigin;
1786
+ }
1787
+ }
1788
+
1789
+ return url;
1790
+ };
1791
+
1686
1792
  export const callbackSSOSAML = (options?: SSOOptions) => {
1687
1793
  return createAuthEndpoint(
1688
1794
  "/sso/saml2/callback/:providerId",
1689
1795
  {
1690
- method: "POST",
1691
- body: callbackSSOSAMLBodySchema,
1796
+ method: ["GET", "POST"],
1797
+ body: callbackSSOSAMLBodySchema.optional(),
1798
+ query: z
1799
+ .object({
1800
+ RelayState: z.string().optional(),
1801
+ })
1802
+ .optional(),
1692
1803
  metadata: {
1693
1804
  ...HIDE_METADATA,
1694
1805
  allowedMediaTypes: [
@@ -1699,7 +1810,7 @@ export const callbackSSOSAML = (options?: SSOOptions) => {
1699
1810
  operationId: "handleSAMLCallback",
1700
1811
  summary: "Callback URL for SAML provider",
1701
1812
  description:
1702
- "This endpoint is used as the callback URL for SAML providers.",
1813
+ "This endpoint is used as the callback URL for SAML providers. Supports both GET and POST methods for IdP-initiated and SP-initiated flows.",
1703
1814
  responses: {
1704
1815
  "302": {
1705
1816
  description: "Redirects to the callback URL",
@@ -1715,8 +1826,41 @@ export const callbackSSOSAML = (options?: SSOOptions) => {
1715
1826
  },
1716
1827
  },
1717
1828
  async (ctx) => {
1718
- const { SAMLResponse, RelayState } = ctx.body;
1719
1829
  const { providerId } = ctx.params;
1830
+ const appOrigin = new URL(ctx.context.baseURL).origin;
1831
+ const errorURL =
1832
+ ctx.context.options.onAPIError?.errorURL || `${appOrigin}/error`;
1833
+ const currentCallbackPath = `${ctx.context.baseURL}/sso/saml2/callback/${providerId}`;
1834
+
1835
+ // Determine if this is a GET request by checking both method AND body presence
1836
+ // When called via auth.api.*, ctx.method may not be reliable, so we also check for body
1837
+ const isGetRequest = ctx.method === "GET" && !ctx.body?.SAMLResponse;
1838
+
1839
+ if (isGetRequest) {
1840
+ const session = await getSessionFromCtx(ctx);
1841
+
1842
+ if (!session?.session) {
1843
+ throw ctx.redirect(`${errorURL}?error=invalid_request`);
1844
+ }
1845
+
1846
+ const relayState = ctx.query?.RelayState as string | undefined;
1847
+ const safeRedirectUrl = getSafeRedirectUrl(
1848
+ relayState,
1849
+ currentCallbackPath,
1850
+ appOrigin,
1851
+ (url, settings) => ctx.context.isTrustedOrigin(url, settings),
1852
+ );
1853
+
1854
+ throw ctx.redirect(safeRedirectUrl);
1855
+ }
1856
+
1857
+ if (!ctx.body?.SAMLResponse) {
1858
+ throw new APIError("BAD_REQUEST", {
1859
+ message: "SAMLResponse is required for POST requests",
1860
+ });
1861
+ }
1862
+
1863
+ const { SAMLResponse } = ctx.body;
1720
1864
 
1721
1865
  const maxResponseSize =
1722
1866
  options?.saml?.maxResponseSize ?? DEFAULT_MAX_SAML_RESPONSE_SIZE;
@@ -1726,6 +1870,14 @@ export const callbackSSOSAML = (options?: SSOOptions) => {
1726
1870
  });
1727
1871
  }
1728
1872
 
1873
+ let relayState: RelayState | null = null;
1874
+ if (ctx.body.RelayState) {
1875
+ try {
1876
+ relayState = await parseRelayState(ctx);
1877
+ } catch {
1878
+ relayState = null;
1879
+ }
1880
+ }
1729
1881
  let provider: SSOProvider<SSOOptions> | null = null;
1730
1882
  if (options?.defaultSSO?.length) {
1731
1883
  const matchingDefault = options.defaultSSO.find(
@@ -1784,6 +1936,21 @@ export const callbackSSOSAML = (options?: SSOOptions) => {
1784
1936
  message: "Invalid SAML configuration",
1785
1937
  });
1786
1938
  }
1939
+
1940
+ const isTrusted = (
1941
+ url: string,
1942
+ settings?: { allowRelativePaths: boolean },
1943
+ ) => ctx.context.isTrustedOrigin(url, settings);
1944
+
1945
+ const safeErrorUrl = getSafeRedirectUrl(
1946
+ relayState?.errorURL ||
1947
+ relayState?.callbackURL ||
1948
+ parsedSamlConfig.callbackUrl,
1949
+ currentCallbackPath,
1950
+ appOrigin,
1951
+ isTrusted,
1952
+ );
1953
+
1787
1954
  const idpData = parsedSamlConfig.idpMetadata;
1788
1955
  let idp: IdentityProvider | null = null;
1789
1956
 
@@ -1791,7 +1958,7 @@ export const callbackSSOSAML = (options?: SSOOptions) => {
1791
1958
  if (!idpData?.metadata) {
1792
1959
  idp = saml.IdentityProvider({
1793
1960
  entityID: idpData?.entityID || parsedSamlConfig.issuer,
1794
- singleSignOnService: [
1961
+ singleSignOnService: idpData?.singleSignOnService || [
1795
1962
  {
1796
1963
  Binding: "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect",
1797
1964
  Location: parsedSamlConfig.entryPoint,
@@ -1799,7 +1966,7 @@ export const callbackSSOSAML = (options?: SSOOptions) => {
1799
1966
  ],
1800
1967
  signingCert: idpData?.cert || parsedSamlConfig.cert,
1801
1968
  wantAuthnRequestsSigned:
1802
- parsedSamlConfig.wantAssertionsSigned || false,
1969
+ parsedSamlConfig.authnRequestsSigned || false,
1803
1970
  isAssertionEncrypted: idpData?.isAssertionEncrypted || false,
1804
1971
  encPrivateKey: idpData?.encPrivateKey,
1805
1972
  encPrivateKeyPass: idpData?.encPrivateKeyPass,
@@ -1846,7 +2013,7 @@ export const callbackSSOSAML = (options?: SSOOptions) => {
1846
2013
  parsedResponse = await sp.parseLoginResponse(idp, "post", {
1847
2014
  body: {
1848
2015
  SAMLResponse,
1849
- RelayState: RelayState || undefined,
2016
+ RelayState: ctx.body.RelayState || undefined,
1850
2017
  },
1851
2018
  });
1852
2019
 
@@ -1856,8 +2023,8 @@ export const callbackSSOSAML = (options?: SSOOptions) => {
1856
2023
  } catch (error) {
1857
2024
  ctx.context.logger.error("SAML response validation failed", {
1858
2025
  error,
1859
- decodedResponse: Buffer.from(SAMLResponse, "base64").toString(
1860
- "utf-8",
2026
+ decodedResponse: new TextDecoder().decode(
2027
+ base64.decode(SAMLResponse),
1861
2028
  ),
1862
2029
  });
1863
2030
  throw new APIError("BAD_REQUEST", {
@@ -1908,10 +2075,8 @@ export const callbackSSOSAML = (options?: SSOOptions) => {
1908
2075
  "SAML InResponseTo validation failed: unknown or expired request ID",
1909
2076
  { inResponseTo, providerId: provider.providerId },
1910
2077
  );
1911
- const redirectUrl =
1912
- RelayState || parsedSamlConfig.callbackUrl || ctx.context.baseURL;
1913
2078
  throw ctx.redirect(
1914
- `${redirectUrl}?error=invalid_saml_response&error_description=Unknown+or+expired+request+ID`,
2079
+ `${safeErrorUrl}?error=invalid_saml_response&error_description=Unknown+or+expired+request+ID`,
1915
2080
  );
1916
2081
  }
1917
2082
 
@@ -1928,10 +2093,8 @@ export const callbackSSOSAML = (options?: SSOOptions) => {
1928
2093
  await ctx.context.internalAdapter.deleteVerificationByIdentifier(
1929
2094
  `${AUTHN_REQUEST_KEY_PREFIX}${inResponseTo}`,
1930
2095
  );
1931
- const redirectUrl =
1932
- RelayState || parsedSamlConfig.callbackUrl || ctx.context.baseURL;
1933
2096
  throw ctx.redirect(
1934
- `${redirectUrl}?error=invalid_saml_response&error_description=Provider+mismatch`,
2097
+ `${safeErrorUrl}?error=invalid_saml_response&error_description=Provider+mismatch`,
1935
2098
  );
1936
2099
  }
1937
2100
 
@@ -1943,10 +2106,8 @@ export const callbackSSOSAML = (options?: SSOOptions) => {
1943
2106
  "SAML IdP-initiated SSO rejected: InResponseTo missing and allowIdpInitiated is false",
1944
2107
  { providerId: provider.providerId },
1945
2108
  );
1946
- const redirectUrl =
1947
- RelayState || parsedSamlConfig.callbackUrl || ctx.context.baseURL;
1948
2109
  throw ctx.redirect(
1949
- `${redirectUrl}?error=unsolicited_response&error_description=IdP-initiated+SSO+not+allowed`,
2110
+ `${safeErrorUrl}?error=unsolicited_response&error_description=IdP-initiated+SSO+not+allowed`,
1950
2111
  );
1951
2112
  }
1952
2113
  }
@@ -1996,10 +2157,8 @@ export const callbackSSOSAML = (options?: SSOOptions) => {
1996
2157
  providerId: provider.providerId,
1997
2158
  },
1998
2159
  );
1999
- const redirectUrl =
2000
- RelayState || parsedSamlConfig.callbackUrl || ctx.context.baseURL;
2001
2160
  throw ctx.redirect(
2002
- `${redirectUrl}?error=replay_detected&error_description=SAML+assertion+has+already+been+used`,
2161
+ `${safeErrorUrl}?error=replay_detected&error_description=SAML+assertion+has+already+been+used`,
2003
2162
  );
2004
2163
  }
2005
2164
 
@@ -2070,8 +2229,12 @@ export const callbackSSOSAML = (options?: SSOOptions) => {
2070
2229
  !!(provider as { domainVerified?: boolean }).domainVerified &&
2071
2230
  validateEmailDomain(userInfo.email as string, provider.domain));
2072
2231
 
2073
- const callbackUrl =
2074
- RelayState || parsedSamlConfig.callbackUrl || ctx.context.baseURL;
2232
+ const safeCallbackUrl = getSafeRedirectUrl(
2233
+ relayState?.callbackURL || parsedSamlConfig.callbackUrl,
2234
+ currentCallbackPath,
2235
+ appOrigin,
2236
+ isTrusted,
2237
+ );
2075
2238
 
2076
2239
  const result = await handleOAuthUserInfo(ctx, {
2077
2240
  userInfo: {
@@ -2086,14 +2249,14 @@ export const callbackSSOSAML = (options?: SSOOptions) => {
2086
2249
  accessToken: "",
2087
2250
  refreshToken: "",
2088
2251
  },
2089
- callbackURL: callbackUrl,
2252
+ callbackURL: safeCallbackUrl,
2090
2253
  disableSignUp: options?.disableImplicitSignUp,
2091
2254
  isTrustedProvider,
2092
2255
  });
2093
2256
 
2094
2257
  if (result.error) {
2095
2258
  throw ctx.redirect(
2096
- `${callbackUrl}?error=${result.error.split(" ").join("_")}`,
2259
+ `${safeCallbackUrl}?error=${result.error.split(" ").join("_")}`,
2097
2260
  );
2098
2261
  }
2099
2262
 
@@ -2122,7 +2285,7 @@ export const callbackSSOSAML = (options?: SSOOptions) => {
2122
2285
  });
2123
2286
 
2124
2287
  await setSessionCookie(ctx, { session, user });
2125
- throw ctx.redirect(callbackUrl);
2288
+ throw ctx.redirect(safeCallbackUrl);
2126
2289
  },
2127
2290
  );
2128
2291
  };
@@ -2159,8 +2322,10 @@ export const acsEndpoint = (options?: SSOOptions) => {
2159
2322
  },
2160
2323
  },
2161
2324
  async (ctx) => {
2162
- const { SAMLResponse, RelayState = "" } = ctx.body;
2325
+ const { SAMLResponse } = ctx.body;
2163
2326
  const { providerId } = ctx.params;
2327
+ const currentCallbackPath = `${ctx.context.baseURL}/sso/saml2/sp/acs/${providerId}`;
2328
+ const appOrigin = new URL(ctx.context.baseURL).origin;
2164
2329
 
2165
2330
  const maxResponseSize =
2166
2331
  options?.saml?.maxResponseSize ?? DEFAULT_MAX_SAML_RESPONSE_SIZE;
@@ -2169,6 +2334,14 @@ export const acsEndpoint = (options?: SSOOptions) => {
2169
2334
  message: `SAML response exceeds maximum allowed size (${maxResponseSize} bytes)`,
2170
2335
  });
2171
2336
  }
2337
+ let relayState: RelayState | null = null;
2338
+ if (ctx.body.RelayState) {
2339
+ try {
2340
+ relayState = await parseRelayState(ctx);
2341
+ } catch {
2342
+ relayState = null;
2343
+ }
2344
+ }
2172
2345
 
2173
2346
  // If defaultSSO is configured, use it as the provider
2174
2347
  let provider: SSOProvider<SSOOptions> | null = null;
@@ -2233,6 +2406,23 @@ export const acsEndpoint = (options?: SSOOptions) => {
2233
2406
  }
2234
2407
 
2235
2408
  const parsedSamlConfig = provider.samlConfig;
2409
+
2410
+ const isTrusted = (
2411
+ url: string,
2412
+ settings?: { allowRelativePaths: boolean },
2413
+ ) => ctx.context.isTrustedOrigin(url, settings);
2414
+
2415
+ // Compute a safe error redirect URL once, reused by all error paths.
2416
+ // Prefers errorURL from relay state, falls back to callbackURL, then provider config, then baseURL.
2417
+ const safeErrorUrl = getSafeRedirectUrl(
2418
+ relayState?.errorURL ||
2419
+ relayState?.callbackURL ||
2420
+ parsedSamlConfig.callbackUrl,
2421
+ currentCallbackPath,
2422
+ appOrigin,
2423
+ isTrusted,
2424
+ );
2425
+
2236
2426
  // Configure SP and IdP
2237
2427
  const sp = saml.ServiceProvider({
2238
2428
  entityID:
@@ -2277,14 +2467,12 @@ export const acsEndpoint = (options?: SSOOptions) => {
2277
2467
  validateSingleAssertion(SAMLResponse);
2278
2468
  } catch (error) {
2279
2469
  if (error instanceof APIError) {
2280
- const redirectUrl =
2281
- RelayState || parsedSamlConfig.callbackUrl || ctx.context.baseURL;
2282
2470
  const errorCode =
2283
2471
  error.body?.code === "SAML_MULTIPLE_ASSERTIONS"
2284
2472
  ? "multiple_assertions"
2285
2473
  : "no_assertion";
2286
2474
  throw ctx.redirect(
2287
- `${redirectUrl}?error=${errorCode}&error_description=${encodeURIComponent(error.message)}`,
2475
+ `${safeErrorUrl}?error=${errorCode}&error_description=${encodeURIComponent(error.message)}`,
2288
2476
  );
2289
2477
  }
2290
2478
  throw error;
@@ -2296,7 +2484,7 @@ export const acsEndpoint = (options?: SSOOptions) => {
2296
2484
  parsedResponse = await sp.parseLoginResponse(idp, "post", {
2297
2485
  body: {
2298
2486
  SAMLResponse,
2299
- RelayState: RelayState || undefined,
2487
+ RelayState: ctx.body.RelayState || undefined,
2300
2488
  },
2301
2489
  });
2302
2490
 
@@ -2306,8 +2494,8 @@ export const acsEndpoint = (options?: SSOOptions) => {
2306
2494
  } catch (error) {
2307
2495
  ctx.context.logger.error("SAML response validation failed", {
2308
2496
  error,
2309
- decodedResponse: Buffer.from(SAMLResponse, "base64").toString(
2310
- "utf-8",
2497
+ decodedResponse: new TextDecoder().decode(
2498
+ base64.decode(SAMLResponse),
2311
2499
  ),
2312
2500
  });
2313
2501
  throw new APIError("BAD_REQUEST", {
@@ -2360,10 +2548,8 @@ export const acsEndpoint = (options?: SSOOptions) => {
2360
2548
  "SAML InResponseTo validation failed: unknown or expired request ID",
2361
2549
  { inResponseTo: inResponseToAcs, providerId },
2362
2550
  );
2363
- const redirectUrl =
2364
- RelayState || parsedSamlConfig.callbackUrl || ctx.context.baseURL;
2365
2551
  throw ctx.redirect(
2366
- `${redirectUrl}?error=invalid_saml_response&error_description=Unknown+or+expired+request+ID`,
2552
+ `${safeErrorUrl}?error=invalid_saml_response&error_description=Unknown+or+expired+request+ID`,
2367
2553
  );
2368
2554
  }
2369
2555
 
@@ -2379,10 +2565,8 @@ export const acsEndpoint = (options?: SSOOptions) => {
2379
2565
  await ctx.context.internalAdapter.deleteVerificationByIdentifier(
2380
2566
  `${AUTHN_REQUEST_KEY_PREFIX}${inResponseToAcs}`,
2381
2567
  );
2382
- const redirectUrl =
2383
- RelayState || parsedSamlConfig.callbackUrl || ctx.context.baseURL;
2384
2568
  throw ctx.redirect(
2385
- `${redirectUrl}?error=invalid_saml_response&error_description=Provider+mismatch`,
2569
+ `${safeErrorUrl}?error=invalid_saml_response&error_description=Provider+mismatch`,
2386
2570
  );
2387
2571
  }
2388
2572
 
@@ -2394,17 +2578,15 @@ export const acsEndpoint = (options?: SSOOptions) => {
2394
2578
  "SAML IdP-initiated SSO rejected: InResponseTo missing and allowIdpInitiated is false",
2395
2579
  { providerId },
2396
2580
  );
2397
- const redirectUrl =
2398
- RelayState || parsedSamlConfig.callbackUrl || ctx.context.baseURL;
2399
2581
  throw ctx.redirect(
2400
- `${redirectUrl}?error=unsolicited_response&error_description=IdP-initiated+SSO+not+allowed`,
2582
+ `${safeErrorUrl}?error=unsolicited_response&error_description=IdP-initiated+SSO+not+allowed`,
2401
2583
  );
2402
2584
  }
2403
2585
  }
2404
2586
 
2405
2587
  // Assertion Replay Protection
2406
- const samlContentAcs = Buffer.from(SAMLResponse, "base64").toString(
2407
- "utf-8",
2588
+ const samlContentAcs = new TextDecoder().decode(
2589
+ base64.decode(SAMLResponse),
2408
2590
  );
2409
2591
  const assertionIdAcs = extractAssertionId(samlContentAcs);
2410
2592
 
@@ -2447,10 +2629,8 @@ export const acsEndpoint = (options?: SSOOptions) => {
2447
2629
  providerId,
2448
2630
  },
2449
2631
  );
2450
- const redirectUrl =
2451
- RelayState || parsedSamlConfig.callbackUrl || ctx.context.baseURL;
2452
2632
  throw ctx.redirect(
2453
- `${redirectUrl}?error=replay_detected&error_description=SAML+assertion+has+already+been+used`,
2633
+ `${safeErrorUrl}?error=replay_detected&error_description=SAML+assertion+has+already+been+used`,
2454
2634
  );
2455
2635
  }
2456
2636
 
@@ -2522,8 +2702,12 @@ export const acsEndpoint = (options?: SSOOptions) => {
2522
2702
  !!(provider as { domainVerified?: boolean }).domainVerified &&
2523
2703
  validateEmailDomain(userInfo.email as string, provider.domain));
2524
2704
 
2525
- const callbackUrl =
2526
- RelayState || parsedSamlConfig.callbackUrl || ctx.context.baseURL;
2705
+ const safeCallbackUrl = getSafeRedirectUrl(
2706
+ relayState?.callbackURL || parsedSamlConfig.callbackUrl,
2707
+ currentCallbackPath,
2708
+ appOrigin,
2709
+ isTrusted,
2710
+ );
2527
2711
 
2528
2712
  const result = await handleOAuthUserInfo(ctx, {
2529
2713
  userInfo: {
@@ -2538,14 +2722,14 @@ export const acsEndpoint = (options?: SSOOptions) => {
2538
2722
  accessToken: "",
2539
2723
  refreshToken: "",
2540
2724
  },
2541
- callbackURL: callbackUrl,
2725
+ callbackURL: safeCallbackUrl,
2542
2726
  disableSignUp: options?.disableImplicitSignUp,
2543
2727
  isTrustedProvider,
2544
2728
  });
2545
2729
 
2546
2730
  if (result.error) {
2547
2731
  throw ctx.redirect(
2548
- `${callbackUrl}?error=${result.error.split(" ").join("_")}`,
2732
+ `${safeCallbackUrl}?error=${result.error.split(" ").join("_")}`,
2549
2733
  );
2550
2734
  }
2551
2735
 
@@ -2574,7 +2758,7 @@ export const acsEndpoint = (options?: SSOOptions) => {
2574
2758
  });
2575
2759
 
2576
2760
  await setSessionCookie(ctx, { session, user });
2577
- throw ctx.redirect(callbackUrl);
2761
+ throw ctx.redirect(safeCallbackUrl);
2578
2762
  },
2579
2763
  );
2580
2764
  };