@better-auth/core 1.6.21 → 1.6.23

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.
@@ -2,7 +2,7 @@
2
2
  const symbol = Symbol.for("better-auth:global");
3
3
  let bind = null;
4
4
  const __context = {};
5
- const __betterAuthVersion = "1.6.21";
5
+ const __betterAuthVersion = "1.6.23";
6
6
  /**
7
7
  * We store context instance in the globalThis.
8
8
  *
@@ -2,7 +2,7 @@ import { ATTR_HTTP_RESPONSE_STATUS_CODE } from "./attributes.mjs";
2
2
  import { getOpenTelemetryAPI } from "./api.mjs";
3
3
  //#region src/instrumentation/tracer.ts
4
4
  const INSTRUMENTATION_SCOPE = "better-auth";
5
- const INSTRUMENTATION_VERSION = "1.6.21";
5
+ const INSTRUMENTATION_VERSION = "1.6.23";
6
6
  /**
7
7
  * Better-auth uses `throw ctx.redirect(url)` for flow control (e.g. OAuth
8
8
  * callbacks). These are APIErrors with 3xx status codes and should not be
@@ -1,5 +1,5 @@
1
+ import { fetchRefusingRedirects } from "./reject-redirects.mjs";
1
2
  import { base64Url } from "@better-auth/utils/base64";
2
- import { betterFetch } from "@better-fetch/fetch";
3
3
  //#region src/oauth2/client-credentials-token.ts
4
4
  async function clientCredentialsTokenRequest({ options, scope, authentication, resource }) {
5
5
  options = typeof options === "function" ? await options() : options;
@@ -43,7 +43,7 @@ async function clientCredentialsToken({ options, tokenEndpoint, scope, authentic
43
43
  authentication,
44
44
  resource
45
45
  });
46
- const { data, error } = await betterFetch(tokenEndpoint, {
46
+ const { data, error } = await fetchRefusingRedirects(tokenEndpoint, {
47
47
  method: "POST",
48
48
  body,
49
49
  headers
@@ -1,5 +1,5 @@
1
+ import { fetchRefusingRedirects } from "./reject-redirects.mjs";
1
2
  import { base64 } from "@better-auth/utils/base64";
2
- import { betterFetch } from "@better-fetch/fetch";
3
3
  //#region src/oauth2/refresh-access-token.ts
4
4
  async function refreshAccessTokenRequest({ refreshToken, options, authentication, extraParams, resource }) {
5
5
  options = typeof options === "function" ? await options() : options;
@@ -46,7 +46,7 @@ async function refreshAccessToken({ refreshToken, options, tokenEndpoint, authen
46
46
  authentication,
47
47
  extraParams
48
48
  });
49
- const { data, error } = await betterFetch(tokenEndpoint, {
49
+ const { data, error } = await fetchRefusingRedirects(tokenEndpoint, {
50
50
  method: "POST",
51
51
  body,
52
52
  headers
@@ -0,0 +1,60 @@
1
+ import { BetterAuthError } from "../error/index.mjs";
2
+ import { betterFetch } from "@better-fetch/fetch";
3
+ //#region src/oauth2/reject-redirects.ts
4
+ const HTTP_REDIRECT_STATUSES = new Set([
5
+ 301,
6
+ 302,
7
+ 303,
8
+ 307,
9
+ 308
10
+ ]);
11
+ /**
12
+ * Whether a response from a `redirect: "manual"` fetch is a redirect.
13
+ *
14
+ * Node/undici exposes the real 3xx status. Spec-compliant runtimes (Cloudflare
15
+ * Workers, Deno, browsers) return an opaque-redirect filtered response with
16
+ * status 0 and type `"opaqueredirect"`, so the status alone is not enough.
17
+ */
18
+ function isRedirectResponse(response) {
19
+ return response.type === "opaqueredirect" || HTTP_REDIRECT_STATUSES.has(response.status);
20
+ }
21
+ function redirectRefused(endpoint) {
22
+ return new BetterAuthError(`The OAuth endpoint "${endpoint}" returned an HTTP redirect. Server-side OAuth fetches refuse redirects to prevent SSRF; configure the final endpoint URL.`);
23
+ }
24
+ /**
25
+ * Fetch option that refuses HTTP redirects portably.
26
+ *
27
+ * Cloudflare Workers (workerd) rejects `redirect: "error"`, so manual mode is
28
+ * used and the resolved response is checked with {@link assertResponseNotRedirect}
29
+ * (or, for betterFetch, with {@link fetchRefusingRedirects}).
30
+ */
31
+ const NO_FOLLOW_REDIRECT = { redirect: "manual" };
32
+ /**
33
+ * Throw when a native-`fetch` response (e.g. jose's JWKS loader) resolved to a
34
+ * redirect, so an attacker-influenced endpoint cannot bounce a server-side
35
+ * request to an internal address.
36
+ */
37
+ function assertResponseNotRedirect(endpoint, response) {
38
+ if (isRedirectResponse(response)) throw redirectRefused(endpoint);
39
+ }
40
+ /**
41
+ * betterFetch that refuses HTTP redirects on a server-side OAuth fetch.
42
+ *
43
+ * Returns the betterFetch result and throws if the endpoint redirected, on both
44
+ * undici (real 3xx status) and spec-compliant runtimes (opaque redirect, where
45
+ * the error status is 0). The redirect is never followed on any runtime.
46
+ */
47
+ async function fetchRefusingRedirects(url, options) {
48
+ let redirected = false;
49
+ const result = await betterFetch(url, {
50
+ ...options,
51
+ ...NO_FOLLOW_REDIRECT,
52
+ onError(context) {
53
+ if (isRedirectResponse(context.response)) redirected = true;
54
+ }
55
+ });
56
+ if (redirected) throw redirectRefused(url);
57
+ return result;
58
+ }
59
+ //#endregion
60
+ export { NO_FOLLOW_REDIRECT, assertResponseNotRedirect, fetchRefusingRedirects };
@@ -1,7 +1,7 @@
1
+ import { NO_FOLLOW_REDIRECT, assertResponseNotRedirect, fetchRefusingRedirects } from "./reject-redirects.mjs";
1
2
  import { getOAuth2Tokens } from "./utils.mjs";
2
3
  import { base64 } from "@better-auth/utils/base64";
3
- import { betterFetch } from "@better-fetch/fetch";
4
- import { createRemoteJWKSet, jwtVerify } from "jose";
4
+ import { createRemoteJWKSet, customFetch, jwtVerify } from "jose";
5
5
  //#region src/oauth2/validate-authorization-code.ts
6
6
  async function authorizationCodeRequest({ code, codeVerifier, redirectURI, options, authentication, deviceId, headers, additionalParams = {}, resource }) {
7
7
  options = typeof options === "function" ? await options() : options;
@@ -61,7 +61,7 @@ async function validateAuthorizationCode({ code, codeVerifier, redirectURI, opti
61
61
  additionalParams,
62
62
  resource
63
63
  });
64
- const { data, error } = await betterFetch(tokenEndpoint, {
64
+ const { data, error } = await fetchRefusingRedirects(tokenEndpoint, {
65
65
  method: "POST",
66
66
  body,
67
67
  headers: requestHeaders
@@ -70,7 +70,14 @@ async function validateAuthorizationCode({ code, codeVerifier, redirectURI, opti
70
70
  return getOAuth2Tokens(data);
71
71
  }
72
72
  async function validateToken(token, jwksEndpoint, options) {
73
- return await jwtVerify(token, createRemoteJWKSet(new URL(jwksEndpoint)), {
73
+ return await jwtVerify(token, createRemoteJWKSet(new URL(jwksEndpoint), { [customFetch]: async (url, init) => {
74
+ const response = await fetch(url, {
75
+ ...init,
76
+ ...NO_FOLLOW_REDIRECT
77
+ });
78
+ assertResponseNotRedirect(String(url), response);
79
+ return response;
80
+ } }), {
74
81
  audience: options?.audience,
75
82
  issuer: options?.issuer
76
83
  });
@@ -1,6 +1,6 @@
1
1
  import { logger } from "../env/logger.mjs";
2
+ import { fetchRefusingRedirects } from "./reject-redirects.mjs";
2
3
  import { APIError } from "better-call";
3
- import { betterFetch } from "@better-fetch/fetch";
4
4
  import { UnsecuredJWT, createLocalJWKSet, decodeProtectedHeader, errors, jwtVerify } from "jose";
5
5
  //#region src/oauth2/verify.ts
6
6
  const joseInfrastructureErrorCodes = new Set([
@@ -44,7 +44,7 @@ function shouldRefetchCachedJwksWithoutKid(error, resolved) {
44
44
  return Date.now() - resolved.noKidRefetchedAt >= JWKS_NO_KID_REFETCH_COOLDOWN_MS;
45
45
  }
46
46
  async function fetchJwks(jwksFetch) {
47
- const jwks = typeof jwksFetch === "string" ? await betterFetch(jwksFetch, { headers: { Accept: "application/json" } }).then(async (res) => {
47
+ const jwks = typeof jwksFetch === "string" ? await fetchRefusingRedirects(jwksFetch, { headers: { Accept: "application/json" } }).then(async (res) => {
48
48
  if (res.error) throw new Error(`Jwks failed: ${res.error.message ?? res.error.statusText}`);
49
49
  return res.data;
50
50
  }) : await jwksFetch();
@@ -166,7 +166,7 @@ async function verifyAccessToken(token, opts) {
166
166
  else throw new Error(error);
167
167
  }
168
168
  if (opts?.remoteVerify) {
169
- const { data: introspect, error: introspectError } = await betterFetch(opts.remoteVerify.introspectUrl, {
169
+ const { data: introspect, error: introspectError } = await fetchRefusingRedirects(opts.remoteVerify.introspectUrl, {
170
170
  method: "POST",
171
171
  headers: {
172
172
  Accept: "application/json",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@better-auth/core",
3
- "version": "1.6.21",
3
+ "version": "1.6.23",
4
4
  "description": "The most comprehensive authentication framework for TypeScript.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -1,7 +1,7 @@
1
1
  import { base64Url } from "@better-auth/utils/base64";
2
- import { betterFetch } from "@better-fetch/fetch";
3
2
  import type { AwaitableFunction } from "../types";
4
3
  import type { OAuth2Tokens, ProviderOptions } from "./oauth-provider";
4
+ import { fetchRefusingRedirects } from "./reject-redirects";
5
5
 
6
6
  export async function clientCredentialsTokenRequest({
7
7
  options,
@@ -96,7 +96,7 @@ export async function clientCredentialsToken({
96
96
  resource,
97
97
  });
98
98
 
99
- const { data, error } = await betterFetch<{
99
+ const { data, error } = await fetchRefusingRedirects<{
100
100
  access_token: string;
101
101
  expires_in?: number | undefined;
102
102
  token_type?: string | undefined;
@@ -1,7 +1,7 @@
1
1
  import { base64 } from "@better-auth/utils/base64";
2
- import { betterFetch } from "@better-fetch/fetch";
3
2
  import type { AwaitableFunction } from "../types";
4
3
  import type { OAuth2Tokens, ProviderOptions } from "./oauth-provider";
4
+ import { fetchRefusingRedirects } from "./reject-redirects";
5
5
 
6
6
  export async function refreshAccessTokenRequest({
7
7
  refreshToken,
@@ -115,7 +115,7 @@ export async function refreshAccessToken({
115
115
  extraParams,
116
116
  });
117
117
 
118
- const { data, error } = await betterFetch<{
118
+ const { data, error } = await fetchRefusingRedirects<{
119
119
  access_token: string;
120
120
  refresh_token?: string | undefined;
121
121
  expires_in?: number | undefined;
@@ -0,0 +1,70 @@
1
+ // cspell:ignore workerd
2
+ import type { BetterFetchOption } from "@better-fetch/fetch";
3
+ import { betterFetch } from "@better-fetch/fetch";
4
+ import { BetterAuthError } from "../error";
5
+
6
+ const HTTP_REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]);
7
+
8
+ /**
9
+ * Whether a response from a `redirect: "manual"` fetch is a redirect.
10
+ *
11
+ * Node/undici exposes the real 3xx status. Spec-compliant runtimes (Cloudflare
12
+ * Workers, Deno, browsers) return an opaque-redirect filtered response with
13
+ * status 0 and type `"opaqueredirect"`, so the status alone is not enough.
14
+ */
15
+ function isRedirectResponse(response: Response): boolean {
16
+ return (
17
+ response.type === "opaqueredirect" ||
18
+ HTTP_REDIRECT_STATUSES.has(response.status)
19
+ );
20
+ }
21
+
22
+ function redirectRefused(endpoint: string): BetterAuthError {
23
+ return new BetterAuthError(
24
+ `The OAuth endpoint "${endpoint}" returned an HTTP redirect. Server-side OAuth fetches refuse redirects to prevent SSRF; configure the final endpoint URL.`,
25
+ );
26
+ }
27
+
28
+ /**
29
+ * Fetch option that refuses HTTP redirects portably.
30
+ *
31
+ * Cloudflare Workers (workerd) rejects `redirect: "error"`, so manual mode is
32
+ * used and the resolved response is checked with {@link assertResponseNotRedirect}
33
+ * (or, for betterFetch, with {@link fetchRefusingRedirects}).
34
+ */
35
+ export const NO_FOLLOW_REDIRECT = { redirect: "manual" } as const;
36
+
37
+ /**
38
+ * Throw when a native-`fetch` response (e.g. jose's JWKS loader) resolved to a
39
+ * redirect, so an attacker-influenced endpoint cannot bounce a server-side
40
+ * request to an internal address.
41
+ */
42
+ export function assertResponseNotRedirect(
43
+ endpoint: string,
44
+ response: Response,
45
+ ): void {
46
+ if (isRedirectResponse(response)) throw redirectRefused(endpoint);
47
+ }
48
+
49
+ /**
50
+ * betterFetch that refuses HTTP redirects on a server-side OAuth fetch.
51
+ *
52
+ * Returns the betterFetch result and throws if the endpoint redirected, on both
53
+ * undici (real 3xx status) and spec-compliant runtimes (opaque redirect, where
54
+ * the error status is 0). The redirect is never followed on any runtime.
55
+ */
56
+ export async function fetchRefusingRedirects<T>(
57
+ url: string,
58
+ options?: BetterFetchOption,
59
+ ) {
60
+ let redirected = false;
61
+ const result = await betterFetch<T>(url, {
62
+ ...options,
63
+ ...NO_FOLLOW_REDIRECT,
64
+ onError(context) {
65
+ if (isRedirectResponse(context.response)) redirected = true;
66
+ },
67
+ });
68
+ if (redirected) throw redirectRefused(url);
69
+ return result;
70
+ }
@@ -1,9 +1,13 @@
1
1
  import { base64 } from "@better-auth/utils/base64";
2
- import { betterFetch } from "@better-fetch/fetch";
3
- import { createRemoteJWKSet, jwtVerify } from "jose";
2
+ import { createRemoteJWKSet, customFetch, jwtVerify } from "jose";
4
3
  import type { AwaitableFunction } from "../types";
5
4
  import type { ProviderOptions } from "./index";
6
5
  import { getOAuth2Tokens } from "./index";
6
+ import {
7
+ assertResponseNotRedirect,
8
+ fetchRefusingRedirects,
9
+ NO_FOLLOW_REDIRECT,
10
+ } from "./reject-redirects";
7
11
 
8
12
  export async function authorizationCodeRequest({
9
13
  code,
@@ -151,7 +155,7 @@ export async function validateAuthorizationCode({
151
155
  resource,
152
156
  });
153
157
 
154
- const { data, error } = await betterFetch<object>(tokenEndpoint, {
158
+ const { data, error } = await fetchRefusingRedirects<object>(tokenEndpoint, {
155
159
  method: "POST",
156
160
  body: body,
157
161
  headers: requestHeaders,
@@ -171,7 +175,13 @@ export async function validateToken(
171
175
  issuer?: string | string[];
172
176
  },
173
177
  ) {
174
- const jwks = createRemoteJWKSet(new URL(jwksEndpoint));
178
+ const jwks = createRemoteJWKSet(new URL(jwksEndpoint), {
179
+ [customFetch]: async (url, init) => {
180
+ const response = await fetch(url, { ...init, ...NO_FOLLOW_REDIRECT });
181
+ assertResponseNotRedirect(String(url), response);
182
+ return response;
183
+ },
184
+ });
175
185
  const verified = await jwtVerify(token, jwks, {
176
186
  audience: options?.audience,
177
187
  issuer: options?.issuer,
@@ -1,4 +1,3 @@
1
- import { betterFetch } from "@better-fetch/fetch";
2
1
  import { APIError } from "better-call";
3
2
  import type {
4
3
  JSONWebKeySet,
@@ -15,6 +14,7 @@ import {
15
14
  UnsecuredJWT,
16
15
  } from "jose";
17
16
  import { logger } from "../env";
17
+ import { fetchRefusingRedirects } from "./reject-redirects";
18
18
 
19
19
  const joseInfrastructureErrorCodes = new Set([
20
20
  joseErrors.JWKSTimeout.code,
@@ -107,7 +107,7 @@ async function fetchJwks(
107
107
  ): Promise<JSONWebKeySet> {
108
108
  const jwks =
109
109
  typeof jwksFetch === "string"
110
- ? await betterFetch<JSONWebKeySet>(jwksFetch, {
110
+ ? await fetchRefusingRedirects<JSONWebKeySet>(jwksFetch, {
111
111
  headers: {
112
112
  Accept: "application/json",
113
113
  },
@@ -332,23 +332,24 @@ export async function verifyAccessToken(
332
332
 
333
333
  // Remote verify
334
334
  if (opts?.remoteVerify) {
335
- const { data: introspect, error: introspectError } = await betterFetch<
336
- JWTPayload & {
337
- active: boolean;
338
- }
339
- >(opts.remoteVerify.introspectUrl, {
340
- method: "POST",
341
- headers: {
342
- Accept: "application/json",
343
- "Content-Type": "application/x-www-form-urlencoded",
344
- },
345
- body: new URLSearchParams({
346
- client_id: opts.remoteVerify.clientId,
347
- client_secret: opts.remoteVerify.clientSecret,
348
- token,
349
- token_type_hint: "access_token",
350
- }).toString(),
351
- });
335
+ const { data: introspect, error: introspectError } =
336
+ await fetchRefusingRedirects<
337
+ JWTPayload & {
338
+ active: boolean;
339
+ }
340
+ >(opts.remoteVerify.introspectUrl, {
341
+ method: "POST",
342
+ headers: {
343
+ Accept: "application/json",
344
+ "Content-Type": "application/x-www-form-urlencoded",
345
+ },
346
+ body: new URLSearchParams({
347
+ client_id: opts.remoteVerify.clientId,
348
+ client_secret: opts.remoteVerify.clientSecret,
349
+ token,
350
+ token_type_hint: "access_token",
351
+ }).toString(),
352
+ });
352
353
  if (introspectError)
353
354
  logger.error(
354
355
  `Introspection failed: ${introspectError.message ?? introspectError.statusText}`,