@better-auth/sso 1.4.18 → 1.4.20

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 {
@@ -24,6 +25,7 @@ import type { BindingContext } from "samlify/types/src/entity";
24
25
  import type { IdentityProvider } from "samlify/types/src/entity-idp";
25
26
  import type { FlowResult } from "samlify/types/src/flow";
26
27
  import z from "zod/v4";
28
+ import { getVerificationIdentifier } from "./domain-verification";
27
29
 
28
30
  interface AuthnRequestRecord {
29
31
  id: string;
@@ -228,6 +230,7 @@ export const spMetadata = () => {
228
230
  `${ctx.context.baseURL}/sso/saml2/sp/acs/${provider.id}`,
229
231
  },
230
232
  ],
233
+ authnRequestsSigned: parsedSamlConfig.authnRequestsSigned || false,
231
234
  wantMessageSigned: parsedSamlConfig.wantAssertionsSigned || false,
232
235
  nameIDFormat: parsedSamlConfig.identifierFormat
233
236
  ? [parsedSamlConfig.identifierFormat]
@@ -860,9 +863,7 @@ export const registerSSOProvider = <O extends SSOOptions>(options: O) => {
860
863
  await ctx.context.adapter.create<Verification>({
861
864
  model: "verification",
862
865
  data: {
863
- identifier: options.domainVerification?.tokenPrefix
864
- ? `${options.domainVerification?.tokenPrefix}-${provider.providerId}`
865
- : `better-auth-token-${provider.providerId}`,
866
+ identifier: getVerificationIdentifier(options, provider.providerId),
866
867
  createdAt: new Date(),
867
868
  updatedAt: new Date(),
868
869
  value: domainVerificationToken as string,
@@ -1283,6 +1284,8 @@ export const signInSSO = (options?: SSOOptions) => {
1283
1284
  `${ctx.context.baseURL}/sso/saml2/sp/acs/${provider.providerId}`,
1284
1285
  },
1285
1286
  ],
1287
+ authnRequestsSigned:
1288
+ parsedSamlConfig.authnRequestsSigned || false,
1286
1289
  wantMessageSigned:
1287
1290
  parsedSamlConfig.wantAssertionsSigned || false,
1288
1291
  nameIDFormat: parsedSamlConfig.identifierFormat
@@ -1294,16 +1297,41 @@ export const signInSSO = (options?: SSOOptions) => {
1294
1297
 
1295
1298
  const sp = saml.ServiceProvider({
1296
1299
  metadata: metadata,
1300
+ privateKey:
1301
+ parsedSamlConfig.spMetadata?.privateKey ||
1302
+ parsedSamlConfig.privateKey,
1303
+ privateKeyPass: parsedSamlConfig.spMetadata?.privateKeyPass,
1297
1304
  allowCreate: true,
1298
1305
  });
1299
1306
 
1300
- const idp = saml.IdentityProvider({
1301
- metadata: parsedSamlConfig.idpMetadata?.metadata,
1302
- entityID: parsedSamlConfig.idpMetadata?.entityID,
1303
- encryptCert: parsedSamlConfig.idpMetadata?.cert,
1304
- singleSignOnService:
1305
- parsedSamlConfig.idpMetadata?.singleSignOnService,
1306
- });
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
+ }
1307
1335
  const loginRequest = sp.createLoginRequest(
1308
1336
  idp,
1309
1337
  "redirect",
@@ -1447,7 +1475,7 @@ export const callbackSSO = (options?: SSOOptions) => {
1447
1475
  throw ctx.redirect(
1448
1476
  `${
1449
1477
  errorURL || callbackURL
1450
- }/error?error=invalid_provider&error_description=provider not found`,
1478
+ }?error=invalid_provider&error_description=provider not found`,
1451
1479
  );
1452
1480
  }
1453
1481
 
@@ -1466,7 +1494,7 @@ export const callbackSSO = (options?: SSOOptions) => {
1466
1494
  throw ctx.redirect(
1467
1495
  `${
1468
1496
  errorURL || callbackURL
1469
- }/error?error=invalid_provider&error_description=provider not found`,
1497
+ }?error=invalid_provider&error_description=provider not found`,
1470
1498
  );
1471
1499
  }
1472
1500
 
@@ -1493,7 +1521,7 @@ export const callbackSSO = (options?: SSOOptions) => {
1493
1521
  throw ctx.redirect(
1494
1522
  `${
1495
1523
  errorURL || callbackURL
1496
- }/error?error=invalid_provider&error_description=token_endpoint_not_found`,
1524
+ }?error=invalid_provider&error_description=token_endpoint_not_found`,
1497
1525
  );
1498
1526
  }
1499
1527
 
@@ -1524,7 +1552,7 @@ export const callbackSSO = (options?: SSOOptions) => {
1524
1552
  throw ctx.redirect(
1525
1553
  `${
1526
1554
  errorURL || callbackURL
1527
- }/error?error=invalid_provider&error_description=token_response_not_found`,
1555
+ }?error=invalid_provider&error_description=token_response_not_found`,
1528
1556
  );
1529
1557
  }
1530
1558
  let userInfo: {
@@ -1541,12 +1569,16 @@ export const callbackSSO = (options?: SSOOptions) => {
1541
1569
  throw ctx.redirect(
1542
1570
  `${
1543
1571
  errorURL || callbackURL
1544
- }/error?error=invalid_provider&error_description=jwks_endpoint_not_found`,
1572
+ }?error=invalid_provider&error_description=jwks_endpoint_not_found`,
1545
1573
  );
1546
1574
  }
1547
1575
  const verified = await validateToken(
1548
1576
  tokenResponse.idToken,
1549
1577
  config.jwksEndpoint,
1578
+ {
1579
+ audience: config.clientId,
1580
+ issuer: provider.issuer,
1581
+ },
1550
1582
  ).catch((e) => {
1551
1583
  ctx.context.logger.error(e);
1552
1584
  return null;
@@ -1555,14 +1587,7 @@ export const callbackSSO = (options?: SSOOptions) => {
1555
1587
  throw ctx.redirect(
1556
1588
  `${
1557
1589
  errorURL || callbackURL
1558
- }/error?error=invalid_provider&error_description=token_not_verified`,
1559
- );
1560
- }
1561
- if (verified.payload.iss !== provider.issuer) {
1562
- throw ctx.redirect(
1563
- `${
1564
- errorURL || callbackURL
1565
- }/error?error=invalid_provider&error_description=issuer_mismatch`,
1590
+ }?error=invalid_provider&error_description=token_not_verified`,
1566
1591
  );
1567
1592
  }
1568
1593
 
@@ -1595,7 +1620,7 @@ export const callbackSSO = (options?: SSOOptions) => {
1595
1620
  throw ctx.redirect(
1596
1621
  `${
1597
1622
  errorURL || callbackURL
1598
- }/error?error=invalid_provider&error_description=user_info_endpoint_not_found`,
1623
+ }?error=invalid_provider&error_description=user_info_endpoint_not_found`,
1599
1624
  );
1600
1625
  }
1601
1626
  const userInfoResponse = await betterFetch<{
@@ -1613,7 +1638,7 @@ export const callbackSSO = (options?: SSOOptions) => {
1613
1638
  throw ctx.redirect(
1614
1639
  `${
1615
1640
  errorURL || callbackURL
1616
- }/error?error=invalid_provider&error_description=${
1641
+ }?error=invalid_provider&error_description=${
1617
1642
  userInfoResponse.error.message
1618
1643
  }`,
1619
1644
  );
@@ -1625,7 +1650,7 @@ export const callbackSSO = (options?: SSOOptions) => {
1625
1650
  throw ctx.redirect(
1626
1651
  `${
1627
1652
  errorURL || callbackURL
1628
- }/error?error=invalid_provider&error_description=missing_user_info`,
1653
+ }?error=invalid_provider&error_description=missing_user_info`,
1629
1654
  );
1630
1655
  }
1631
1656
  const isTrustedProvider =
@@ -1659,9 +1684,7 @@ export const callbackSSO = (options?: SSOOptions) => {
1659
1684
  isTrustedProvider,
1660
1685
  });
1661
1686
  if (linked.error) {
1662
- throw ctx.redirect(
1663
- `${errorURL || callbackURL}/error?error=${linked.error}`,
1664
- );
1687
+ throw ctx.redirect(`${errorURL || callbackURL}?error=${linked.error}`);
1665
1688
  }
1666
1689
  const { session, user } = linked.data!;
1667
1690
 
@@ -1913,6 +1936,21 @@ export const callbackSSOSAML = (options?: SSOOptions) => {
1913
1936
  message: "Invalid SAML configuration",
1914
1937
  });
1915
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
+
1916
1954
  const idpData = parsedSamlConfig.idpMetadata;
1917
1955
  let idp: IdentityProvider | null = null;
1918
1956
 
@@ -1920,7 +1958,7 @@ export const callbackSSOSAML = (options?: SSOOptions) => {
1920
1958
  if (!idpData?.metadata) {
1921
1959
  idp = saml.IdentityProvider({
1922
1960
  entityID: idpData?.entityID || parsedSamlConfig.issuer,
1923
- singleSignOnService: [
1961
+ singleSignOnService: idpData?.singleSignOnService || [
1924
1962
  {
1925
1963
  Binding: "urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect",
1926
1964
  Location: parsedSamlConfig.entryPoint,
@@ -1928,7 +1966,7 @@ export const callbackSSOSAML = (options?: SSOOptions) => {
1928
1966
  ],
1929
1967
  signingCert: idpData?.cert || parsedSamlConfig.cert,
1930
1968
  wantAuthnRequestsSigned:
1931
- parsedSamlConfig.wantAssertionsSigned || false,
1969
+ parsedSamlConfig.authnRequestsSigned || false,
1932
1970
  isAssertionEncrypted: idpData?.isAssertionEncrypted || false,
1933
1971
  encPrivateKey: idpData?.encPrivateKey,
1934
1972
  encPrivateKeyPass: idpData?.encPrivateKeyPass,
@@ -1985,8 +2023,8 @@ export const callbackSSOSAML = (options?: SSOOptions) => {
1985
2023
  } catch (error) {
1986
2024
  ctx.context.logger.error("SAML response validation failed", {
1987
2025
  error,
1988
- decodedResponse: Buffer.from(SAMLResponse, "base64").toString(
1989
- "utf-8",
2026
+ decodedResponse: new TextDecoder().decode(
2027
+ base64.decode(SAMLResponse),
1990
2028
  ),
1991
2029
  });
1992
2030
  throw new APIError("BAD_REQUEST", {
@@ -2037,12 +2075,8 @@ export const callbackSSOSAML = (options?: SSOOptions) => {
2037
2075
  "SAML InResponseTo validation failed: unknown or expired request ID",
2038
2076
  { inResponseTo, providerId: provider.providerId },
2039
2077
  );
2040
- const redirectUrl =
2041
- relayState?.callbackURL ||
2042
- parsedSamlConfig.callbackUrl ||
2043
- ctx.context.baseURL;
2044
2078
  throw ctx.redirect(
2045
- `${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`,
2046
2080
  );
2047
2081
  }
2048
2082
 
@@ -2059,12 +2093,8 @@ export const callbackSSOSAML = (options?: SSOOptions) => {
2059
2093
  await ctx.context.internalAdapter.deleteVerificationByIdentifier(
2060
2094
  `${AUTHN_REQUEST_KEY_PREFIX}${inResponseTo}`,
2061
2095
  );
2062
- const redirectUrl =
2063
- relayState?.callbackURL ||
2064
- parsedSamlConfig.callbackUrl ||
2065
- ctx.context.baseURL;
2066
2096
  throw ctx.redirect(
2067
- `${redirectUrl}?error=invalid_saml_response&error_description=Provider+mismatch`,
2097
+ `${safeErrorUrl}?error=invalid_saml_response&error_description=Provider+mismatch`,
2068
2098
  );
2069
2099
  }
2070
2100
 
@@ -2076,12 +2106,8 @@ export const callbackSSOSAML = (options?: SSOOptions) => {
2076
2106
  "SAML IdP-initiated SSO rejected: InResponseTo missing and allowIdpInitiated is false",
2077
2107
  { providerId: provider.providerId },
2078
2108
  );
2079
- const redirectUrl =
2080
- relayState?.callbackURL ||
2081
- parsedSamlConfig.callbackUrl ||
2082
- ctx.context.baseURL;
2083
2109
  throw ctx.redirect(
2084
- `${redirectUrl}?error=unsolicited_response&error_description=IdP-initiated+SSO+not+allowed`,
2110
+ `${safeErrorUrl}?error=unsolicited_response&error_description=IdP-initiated+SSO+not+allowed`,
2085
2111
  );
2086
2112
  }
2087
2113
  }
@@ -2131,12 +2157,8 @@ export const callbackSSOSAML = (options?: SSOOptions) => {
2131
2157
  providerId: provider.providerId,
2132
2158
  },
2133
2159
  );
2134
- const redirectUrl =
2135
- relayState?.callbackURL ||
2136
- parsedSamlConfig.callbackUrl ||
2137
- ctx.context.baseURL;
2138
2160
  throw ctx.redirect(
2139
- `${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`,
2140
2162
  );
2141
2163
  }
2142
2164
 
@@ -2207,10 +2229,12 @@ export const callbackSSOSAML = (options?: SSOOptions) => {
2207
2229
  !!(provider as { domainVerified?: boolean }).domainVerified &&
2208
2230
  validateEmailDomain(userInfo.email as string, provider.domain));
2209
2231
 
2210
- const callbackUrl =
2211
- relayState?.callbackURL ||
2212
- parsedSamlConfig.callbackUrl ||
2213
- ctx.context.baseURL;
2232
+ const safeCallbackUrl = getSafeRedirectUrl(
2233
+ relayState?.callbackURL || parsedSamlConfig.callbackUrl,
2234
+ currentCallbackPath,
2235
+ appOrigin,
2236
+ isTrusted,
2237
+ );
2214
2238
 
2215
2239
  const result = await handleOAuthUserInfo(ctx, {
2216
2240
  userInfo: {
@@ -2225,14 +2249,14 @@ export const callbackSSOSAML = (options?: SSOOptions) => {
2225
2249
  accessToken: "",
2226
2250
  refreshToken: "",
2227
2251
  },
2228
- callbackURL: callbackUrl,
2252
+ callbackURL: safeCallbackUrl,
2229
2253
  disableSignUp: options?.disableImplicitSignUp,
2230
2254
  isTrustedProvider,
2231
2255
  });
2232
2256
 
2233
2257
  if (result.error) {
2234
2258
  throw ctx.redirect(
2235
- `${callbackUrl}?error=${result.error.split(" ").join("_")}`,
2259
+ `${safeCallbackUrl}?error=${result.error.split(" ").join("_")}`,
2236
2260
  );
2237
2261
  }
2238
2262
 
@@ -2261,14 +2285,7 @@ export const callbackSSOSAML = (options?: SSOOptions) => {
2261
2285
  });
2262
2286
 
2263
2287
  await setSessionCookie(ctx, { session, user });
2264
-
2265
- const safeRedirectUrl = getSafeRedirectUrl(
2266
- relayState?.callbackURL || parsedSamlConfig.callbackUrl,
2267
- currentCallbackPath,
2268
- appOrigin,
2269
- (url, settings) => ctx.context.isTrustedOrigin(url, settings),
2270
- );
2271
- throw ctx.redirect(safeRedirectUrl);
2288
+ throw ctx.redirect(safeCallbackUrl);
2272
2289
  },
2273
2290
  );
2274
2291
  };
@@ -2305,8 +2322,10 @@ export const acsEndpoint = (options?: SSOOptions) => {
2305
2322
  },
2306
2323
  },
2307
2324
  async (ctx) => {
2308
- const { SAMLResponse, RelayState = "" } = ctx.body;
2325
+ const { SAMLResponse } = ctx.body;
2309
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;
2310
2329
 
2311
2330
  const maxResponseSize =
2312
2331
  options?.saml?.maxResponseSize ?? DEFAULT_MAX_SAML_RESPONSE_SIZE;
@@ -2315,6 +2334,14 @@ export const acsEndpoint = (options?: SSOOptions) => {
2315
2334
  message: `SAML response exceeds maximum allowed size (${maxResponseSize} bytes)`,
2316
2335
  });
2317
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
+ }
2318
2345
 
2319
2346
  // If defaultSSO is configured, use it as the provider
2320
2347
  let provider: SSOProvider<SSOOptions> | null = null;
@@ -2379,6 +2406,23 @@ export const acsEndpoint = (options?: SSOOptions) => {
2379
2406
  }
2380
2407
 
2381
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
+
2382
2426
  // Configure SP and IdP
2383
2427
  const sp = saml.ServiceProvider({
2384
2428
  entityID:
@@ -2423,14 +2467,12 @@ export const acsEndpoint = (options?: SSOOptions) => {
2423
2467
  validateSingleAssertion(SAMLResponse);
2424
2468
  } catch (error) {
2425
2469
  if (error instanceof APIError) {
2426
- const redirectUrl =
2427
- RelayState || parsedSamlConfig.callbackUrl || ctx.context.baseURL;
2428
2470
  const errorCode =
2429
2471
  error.body?.code === "SAML_MULTIPLE_ASSERTIONS"
2430
2472
  ? "multiple_assertions"
2431
2473
  : "no_assertion";
2432
2474
  throw ctx.redirect(
2433
- `${redirectUrl}?error=${errorCode}&error_description=${encodeURIComponent(error.message)}`,
2475
+ `${safeErrorUrl}?error=${errorCode}&error_description=${encodeURIComponent(error.message)}`,
2434
2476
  );
2435
2477
  }
2436
2478
  throw error;
@@ -2442,7 +2484,7 @@ export const acsEndpoint = (options?: SSOOptions) => {
2442
2484
  parsedResponse = await sp.parseLoginResponse(idp, "post", {
2443
2485
  body: {
2444
2486
  SAMLResponse,
2445
- RelayState: RelayState || undefined,
2487
+ RelayState: ctx.body.RelayState || undefined,
2446
2488
  },
2447
2489
  });
2448
2490
 
@@ -2452,8 +2494,8 @@ export const acsEndpoint = (options?: SSOOptions) => {
2452
2494
  } catch (error) {
2453
2495
  ctx.context.logger.error("SAML response validation failed", {
2454
2496
  error,
2455
- decodedResponse: Buffer.from(SAMLResponse, "base64").toString(
2456
- "utf-8",
2497
+ decodedResponse: new TextDecoder().decode(
2498
+ base64.decode(SAMLResponse),
2457
2499
  ),
2458
2500
  });
2459
2501
  throw new APIError("BAD_REQUEST", {
@@ -2506,10 +2548,8 @@ export const acsEndpoint = (options?: SSOOptions) => {
2506
2548
  "SAML InResponseTo validation failed: unknown or expired request ID",
2507
2549
  { inResponseTo: inResponseToAcs, providerId },
2508
2550
  );
2509
- const redirectUrl =
2510
- RelayState || parsedSamlConfig.callbackUrl || ctx.context.baseURL;
2511
2551
  throw ctx.redirect(
2512
- `${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`,
2513
2553
  );
2514
2554
  }
2515
2555
 
@@ -2525,10 +2565,8 @@ export const acsEndpoint = (options?: SSOOptions) => {
2525
2565
  await ctx.context.internalAdapter.deleteVerificationByIdentifier(
2526
2566
  `${AUTHN_REQUEST_KEY_PREFIX}${inResponseToAcs}`,
2527
2567
  );
2528
- const redirectUrl =
2529
- RelayState || parsedSamlConfig.callbackUrl || ctx.context.baseURL;
2530
2568
  throw ctx.redirect(
2531
- `${redirectUrl}?error=invalid_saml_response&error_description=Provider+mismatch`,
2569
+ `${safeErrorUrl}?error=invalid_saml_response&error_description=Provider+mismatch`,
2532
2570
  );
2533
2571
  }
2534
2572
 
@@ -2540,17 +2578,15 @@ export const acsEndpoint = (options?: SSOOptions) => {
2540
2578
  "SAML IdP-initiated SSO rejected: InResponseTo missing and allowIdpInitiated is false",
2541
2579
  { providerId },
2542
2580
  );
2543
- const redirectUrl =
2544
- RelayState || parsedSamlConfig.callbackUrl || ctx.context.baseURL;
2545
2581
  throw ctx.redirect(
2546
- `${redirectUrl}?error=unsolicited_response&error_description=IdP-initiated+SSO+not+allowed`,
2582
+ `${safeErrorUrl}?error=unsolicited_response&error_description=IdP-initiated+SSO+not+allowed`,
2547
2583
  );
2548
2584
  }
2549
2585
  }
2550
2586
 
2551
2587
  // Assertion Replay Protection
2552
- const samlContentAcs = Buffer.from(SAMLResponse, "base64").toString(
2553
- "utf-8",
2588
+ const samlContentAcs = new TextDecoder().decode(
2589
+ base64.decode(SAMLResponse),
2554
2590
  );
2555
2591
  const assertionIdAcs = extractAssertionId(samlContentAcs);
2556
2592
 
@@ -2593,10 +2629,8 @@ export const acsEndpoint = (options?: SSOOptions) => {
2593
2629
  providerId,
2594
2630
  },
2595
2631
  );
2596
- const redirectUrl =
2597
- RelayState || parsedSamlConfig.callbackUrl || ctx.context.baseURL;
2598
2632
  throw ctx.redirect(
2599
- `${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`,
2600
2634
  );
2601
2635
  }
2602
2636
 
@@ -2668,8 +2702,12 @@ export const acsEndpoint = (options?: SSOOptions) => {
2668
2702
  !!(provider as { domainVerified?: boolean }).domainVerified &&
2669
2703
  validateEmailDomain(userInfo.email as string, provider.domain));
2670
2704
 
2671
- const callbackUrl =
2672
- RelayState || parsedSamlConfig.callbackUrl || ctx.context.baseURL;
2705
+ const safeCallbackUrl = getSafeRedirectUrl(
2706
+ relayState?.callbackURL || parsedSamlConfig.callbackUrl,
2707
+ currentCallbackPath,
2708
+ appOrigin,
2709
+ isTrusted,
2710
+ );
2673
2711
 
2674
2712
  const result = await handleOAuthUserInfo(ctx, {
2675
2713
  userInfo: {
@@ -2684,14 +2722,14 @@ export const acsEndpoint = (options?: SSOOptions) => {
2684
2722
  accessToken: "",
2685
2723
  refreshToken: "",
2686
2724
  },
2687
- callbackURL: callbackUrl,
2725
+ callbackURL: safeCallbackUrl,
2688
2726
  disableSignUp: options?.disableImplicitSignUp,
2689
2727
  isTrustedProvider,
2690
2728
  });
2691
2729
 
2692
2730
  if (result.error) {
2693
2731
  throw ctx.redirect(
2694
- `${callbackUrl}?error=${result.error.split(" ").join("_")}`,
2732
+ `${safeCallbackUrl}?error=${result.error.split(" ").join("_")}`,
2695
2733
  );
2696
2734
  }
2697
2735
 
@@ -2720,7 +2758,7 @@ export const acsEndpoint = (options?: SSOOptions) => {
2720
2758
  });
2721
2759
 
2722
2760
  await setSessionCookie(ctx, { session, user });
2723
- throw ctx.redirect(callbackUrl);
2761
+ throw ctx.redirect(safeCallbackUrl);
2724
2762
  },
2725
2763
  );
2726
2764
  };
package/src/saml-state.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import type { GenericEndpointContext, StateData } from "better-auth";
2
2
  import { generateGenericState, parseGenericState } from "better-auth";
3
+ import { APIError } from "better-auth/api";
3
4
  import { generateRandomString } from "better-auth/crypto";
4
- import { APIError } from "better-call";
5
5
 
6
6
  export async function generateRelayState(
7
7
  c: GenericEndpointContext,