@better-auth/core 1.6.20 → 1.6.22

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.20";
5
+ const __betterAuthVersion = "1.6.22";
6
6
  /**
7
7
  * We store context instance in the globalThis.
8
8
  *
@@ -459,6 +459,7 @@ const createAdapterFactory = ({ adapter: customAdapter, config: cfg }) => (optio
459
459
  where: unsafeWhere,
460
460
  action: "update"
461
461
  });
462
+ if (where.length === 0) return null;
462
463
  debugLog({ method: "update" }, `${formatTransactionId(thisTransactionId)} ${formatStep(1, 4)}`, `${formatMethod("update")} ${formatAction("Unsafe Input")}:`, {
463
464
  model,
464
465
  data: unsafeData
@@ -396,8 +396,14 @@ type DBAdapter<Options extends BetterAuthOptions = BetterAuthOptions> = {
396
396
  where?: Where[] | undefined;
397
397
  }) => Promise<number>;
398
398
  /**
399
- * ⚠︎ Update may not return the updated data
400
- * if multiple where clauses are provided
399
+ * Update a single row matching the where clause.
400
+ *
401
+ * Returns the updated row, or `null` when no row matched. Empty `where`
402
+ * clauses return `null`; use `updateMany` for intentional bulk updates.
403
+ *
404
+ * This is not the race-safe primitive for guarded state transitions. Use
405
+ * `incrementOne` when the predicate is both selector and guard, and use
406
+ * `consumeOne` for single-use destructive reads.
401
407
  */
402
408
  update: <T>(data: {
403
409
  model: string;
@@ -8,7 +8,8 @@ const getAuthTables = (options) => {
8
8
  ...acc[key]?.fields,
9
9
  ...value.fields
10
10
  },
11
- modelName: value.modelName || key
11
+ modelName: value.modelName || key,
12
+ disableMigrations: value.disableMigration ?? acc[key]?.disableMigrations
12
13
  };
13
14
  return acc;
14
15
  }, {});
@@ -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.20";
5
+ const INSTRUMENTATION_VERSION = "1.6.22";
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",
@@ -1,4 +1,6 @@
1
1
  import { OAuth2Tokens, ProviderOptions } from "../oauth2/oauth-provider.mjs";
2
+ import { JWTPayload } from "jose";
3
+
2
4
  //#region src/social-providers/google.d.ts
3
5
  interface GoogleProfile {
4
6
  aud: string;
@@ -41,11 +43,30 @@ interface GoogleOptions extends ProviderOptions<GoogleProfile> {
41
43
  *
42
44
  * This is sent to Google as the `hd` authorization hint and, when set, is
43
45
  * also enforced against the `hd` claim of the returned id token/profile.
44
- * Sign-in is rejected when the claim is missing or does not match, so this
45
- * can be used to restrict sign-in to a Workspace domain.
46
+ * Set `hd: "*"` to require any Workspace hosted-domain claim. Sign-in is
47
+ * rejected when the claim is missing or does not satisfy this restriction.
46
48
  */
47
49
  hd?: string | undefined;
48
50
  }
51
+ interface VerifyGoogleIdTokenOptions {
52
+ token: string;
53
+ audience: string | string[];
54
+ nonce?: string | undefined;
55
+ }
56
+ /**
57
+ * Verifies a Google ID token against Google's issuer, audience, signature,
58
+ * expiry, and maximum token age.
59
+ */
60
+ declare const verifyGoogleIdToken: ({
61
+ token,
62
+ audience,
63
+ nonce
64
+ }: VerifyGoogleIdTokenOptions) => Promise<JWTPayload | null>;
65
+ /**
66
+ * Checks whether Google's verified `hd` claim satisfies the configured hosted
67
+ * domain restriction. `hd: "*"` accepts any Google Workspace hosted domain.
68
+ */
69
+ declare const isGoogleHostedDomainAllowed: (configuredHostedDomain: string | undefined, tokenHostedDomain: unknown) => boolean;
49
70
  declare const google: (options: GoogleOptions) => {
50
71
  id: "google";
51
72
  name: string;
@@ -99,4 +120,4 @@ declare const google: (options: GoogleOptions) => {
99
120
  };
100
121
  declare const getGooglePublicKey: (kid: string) => Promise<Uint8Array<ArrayBufferLike> | CryptoKey>;
101
122
  //#endregion
102
- export { GoogleOptions, GoogleProfile, getGooglePublicKey, google };
123
+ export { GoogleOptions, GoogleProfile, VerifyGoogleIdTokenOptions, getGooglePublicKey, google, isGoogleHostedDomainAllowed, verifyGoogleIdToken };
@@ -7,6 +7,37 @@ import { validateAuthorizationCode } from "../oauth2/validate-authorization-code
7
7
  import { betterFetch } from "@better-fetch/fetch";
8
8
  import { decodeJwt, decodeProtectedHeader, importJWK, jwtVerify } from "jose";
9
9
  //#region src/social-providers/google.ts
10
+ const GOOGLE_ID_TOKEN_MAX_AGE = "1h";
11
+ /**
12
+ * Verifies a Google ID token against Google's issuer, audience, signature,
13
+ * expiry, and maximum token age.
14
+ */
15
+ const verifyGoogleIdToken = async ({ token, audience, nonce }) => {
16
+ try {
17
+ const { kid, alg: jwtAlg } = decodeProtectedHeader(token);
18
+ if (!kid || !jwtAlg) return null;
19
+ const { payload: jwtClaims } = await jwtVerify(token, await getGooglePublicKey(kid), {
20
+ algorithms: [jwtAlg],
21
+ issuer: ["https://accounts.google.com", "accounts.google.com"],
22
+ audience,
23
+ maxTokenAge: GOOGLE_ID_TOKEN_MAX_AGE
24
+ });
25
+ if (nonce && jwtClaims.nonce !== nonce) return null;
26
+ return jwtClaims;
27
+ } catch {
28
+ return null;
29
+ }
30
+ };
31
+ /**
32
+ * Checks whether Google's verified `hd` claim satisfies the configured hosted
33
+ * domain restriction. `hd: "*"` accepts any Google Workspace hosted domain.
34
+ */
35
+ const isGoogleHostedDomainAllowed = (configuredHostedDomain, tokenHostedDomain) => {
36
+ if (!configuredHostedDomain) return true;
37
+ if (typeof tokenHostedDomain !== "string" || !tokenHostedDomain) return false;
38
+ if (configuredHostedDomain === "*") return true;
39
+ return tokenHostedDomain === configuredHostedDomain;
40
+ };
10
41
  const google = (options) => {
11
42
  return {
12
43
  id: "google",
@@ -63,28 +94,20 @@ const google = (options) => {
63
94
  async verifyIdToken(token, nonce) {
64
95
  if (options.disableIdTokenSignIn) return false;
65
96
  if (options.verifyIdToken) return options.verifyIdToken(token, nonce);
66
- try {
67
- const { kid, alg: jwtAlg } = decodeProtectedHeader(token);
68
- if (!kid || !jwtAlg) return false;
69
- const { payload: jwtClaims } = await jwtVerify(token, await getGooglePublicKey(kid), {
70
- algorithms: [jwtAlg],
71
- issuer: ["https://accounts.google.com", "accounts.google.com"],
72
- audience: options.clientId,
73
- maxTokenAge: "1h"
74
- });
75
- if (nonce && jwtClaims.nonce !== nonce) return false;
76
- if (options.hd && jwtClaims.hd !== options.hd) return false;
77
- return true;
78
- } catch {
79
- return false;
80
- }
97
+ const jwtClaims = await verifyGoogleIdToken({
98
+ token,
99
+ audience: options.clientId,
100
+ nonce
101
+ });
102
+ if (!jwtClaims) return false;
103
+ return isGoogleHostedDomainAllowed(options.hd, jwtClaims.hd);
81
104
  },
82
105
  async getUserInfo(token) {
83
106
  if (options.getUserInfo) return options.getUserInfo(token);
84
107
  if (!token.idToken) return null;
85
108
  const user = decodeJwt(token.idToken);
86
- if (options.hd && user.hd !== options.hd) {
87
- logger.error(`Google sign-in rejected: id token hosted domain (hd) "${user.hd ?? "<missing>"}" does not match the configured "hd" option "${options.hd}".`);
109
+ if (!isGoogleHostedDomainAllowed(options.hd, user.hd)) {
110
+ logger.error(`Google sign-in rejected: id token hosted domain (hd) "${user.hd ?? "<missing>"}" does not satisfy the configured "hd" option "${options.hd}".`);
88
111
  return null;
89
112
  }
90
113
  const userMap = await options.mapProfileToUser?.(user);
@@ -111,4 +134,4 @@ const getGooglePublicKey = async (kid) => {
111
134
  return await importJWK(jwk, jwk.alg);
112
135
  };
113
136
  //#endregion
114
- export { getGooglePublicKey, google };
137
+ export { getGooglePublicKey, google, isGoogleHostedDomainAllowed, verifyGoogleIdToken };
@@ -6,7 +6,7 @@ import { FacebookOptions, FacebookProfile, facebook } from "./facebook.mjs";
6
6
  import { FigmaOptions, FigmaProfile, figma } from "./figma.mjs";
7
7
  import { GithubOptions, GithubProfile, github } from "./github.mjs";
8
8
  import { MicrosoftEntraIDProfile, MicrosoftOptions, getMicrosoftPublicKey, microsoft } from "./microsoft-entra-id.mjs";
9
- import { GoogleOptions, GoogleProfile, getGooglePublicKey, google } from "./google.mjs";
9
+ import { GoogleOptions, GoogleProfile, VerifyGoogleIdTokenOptions, getGooglePublicKey, google, isGoogleHostedDomainAllowed, verifyGoogleIdToken } from "./google.mjs";
10
10
  import { HuggingFaceOptions, HuggingFaceProfile, huggingface } from "./huggingface.mjs";
11
11
  import { SlackOptions, SlackProfile, slack } from "./slack.mjs";
12
12
  import { SpotifyOptions, SpotifyProfile, spotify } from "./spotify.mjs";
@@ -1831,4 +1831,4 @@ type SocialProviders = { [K in SocialProviderList[number]]?: AwaitableFunction<P
1831
1831
  }> };
1832
1832
  type SocialProviderList = typeof socialProviderList;
1833
1833
  //#endregion
1834
- export { AccountStatus, AppleNonConformUser, AppleOptions, AppleProfile, AtlassianOptions, AtlassianProfile, CognitoOptions, CognitoProfile, DiscordOptions, DiscordProfile, DropboxOptions, DropboxProfile, FacebookOptions, FacebookProfile, FigmaOptions, FigmaProfile, GithubOptions, GithubProfile, GitlabOptions, GitlabProfile, GoogleOptions, GoogleProfile, HuggingFaceOptions, HuggingFaceProfile, KakaoOptions, KakaoProfile, KickOptions, KickProfile, LineIdTokenPayload, LineOptions, LineUserInfo, LinearOptions, LinearProfile, LinearUser, LinkedInOptions, LinkedInProfile, LoginType, MicrosoftEntraIDProfile, MicrosoftOptions, NaverOptions, NaverProfile, NotionOptions, NotionProfile, PayPalOptions, PayPalProfile, PayPalTokenResponse, PaybinOptions, PaybinProfile, PhoneNumber, PolarOptions, PolarProfile, PronounOption, RailwayOptions, RailwayProfile, RedditOptions, RedditProfile, RobloxOptions, RobloxProfile, SalesforceOptions, SalesforceProfile, SlackOptions, SlackProfile, SocialProvider, SocialProviderList, SocialProviderListEnum, SocialProviders, SpotifyOptions, SpotifyProfile, TiktokOptions, TiktokProfile, TwitchOptions, TwitchProfile, TwitterOption, TwitterProfile, VercelOptions, VercelProfile, VkOption, VkProfile, WeChatOptions, WeChatProfile, ZoomOptions, ZoomProfile, apple, atlassian, cognito, discord, dropbox, facebook, figma, getApplePublicKey, getCognitoPublicKey, getGooglePublicKey, getMicrosoftPublicKey, getPayPalPublicKey, github, gitlab, google, huggingface, kakao, kick, line, linear, linkedin, microsoft, naver, notion, paybin, paypal, polar, railway, reddit, roblox, salesforce, slack, socialProviderList, socialProviders, spotify, tiktok, twitch, twitter, vercel, vk, wechat, zoom };
1834
+ export { AccountStatus, AppleNonConformUser, AppleOptions, AppleProfile, AtlassianOptions, AtlassianProfile, CognitoOptions, CognitoProfile, DiscordOptions, DiscordProfile, DropboxOptions, DropboxProfile, FacebookOptions, FacebookProfile, FigmaOptions, FigmaProfile, GithubOptions, GithubProfile, GitlabOptions, GitlabProfile, GoogleOptions, GoogleProfile, HuggingFaceOptions, HuggingFaceProfile, KakaoOptions, KakaoProfile, KickOptions, KickProfile, LineIdTokenPayload, LineOptions, LineUserInfo, LinearOptions, LinearProfile, LinearUser, LinkedInOptions, LinkedInProfile, LoginType, MicrosoftEntraIDProfile, MicrosoftOptions, NaverOptions, NaverProfile, NotionOptions, NotionProfile, PayPalOptions, PayPalProfile, PayPalTokenResponse, PaybinOptions, PaybinProfile, PhoneNumber, PolarOptions, PolarProfile, PronounOption, RailwayOptions, RailwayProfile, RedditOptions, RedditProfile, RobloxOptions, RobloxProfile, SalesforceOptions, SalesforceProfile, SlackOptions, SlackProfile, SocialProvider, SocialProviderList, SocialProviderListEnum, SocialProviders, SpotifyOptions, SpotifyProfile, TiktokOptions, TiktokProfile, TwitchOptions, TwitchProfile, TwitterOption, TwitterProfile, VercelOptions, VercelProfile, VerifyGoogleIdTokenOptions, VkOption, VkProfile, WeChatOptions, WeChatProfile, ZoomOptions, ZoomProfile, apple, atlassian, cognito, discord, dropbox, facebook, figma, getApplePublicKey, getCognitoPublicKey, getGooglePublicKey, getMicrosoftPublicKey, getPayPalPublicKey, github, gitlab, google, huggingface, isGoogleHostedDomainAllowed, kakao, kick, line, linear, linkedin, microsoft, naver, notion, paybin, paypal, polar, railway, reddit, roblox, salesforce, slack, socialProviderList, socialProviders, spotify, tiktok, twitch, twitter, vercel, verifyGoogleIdToken, vk, wechat, zoom };
@@ -7,7 +7,7 @@ import { facebook } from "./facebook.mjs";
7
7
  import { figma } from "./figma.mjs";
8
8
  import { github } from "./github.mjs";
9
9
  import { gitlab } from "./gitlab.mjs";
10
- import { getGooglePublicKey, google } from "./google.mjs";
10
+ import { getGooglePublicKey, google, isGoogleHostedDomainAllowed, verifyGoogleIdToken } from "./google.mjs";
11
11
  import { huggingface } from "./huggingface.mjs";
12
12
  import { kakao } from "./kakao.mjs";
13
13
  import { kick } from "./kick.mjs";
@@ -75,4 +75,4 @@ const socialProviders = {
75
75
  const socialProviderList = Object.keys(socialProviders);
76
76
  const SocialProviderListEnum = z.enum(socialProviderList).or(z.string());
77
77
  //#endregion
78
- export { SocialProviderListEnum, apple, atlassian, cognito, discord, dropbox, facebook, figma, getApplePublicKey, getCognitoPublicKey, getGooglePublicKey, getMicrosoftPublicKey, getPayPalPublicKey, github, gitlab, google, huggingface, kakao, kick, line, linear, linkedin, microsoft, naver, notion, paybin, paypal, polar, railway, reddit, roblox, salesforce, slack, socialProviderList, socialProviders, spotify, tiktok, twitch, twitter, vercel, vk, wechat, zoom };
78
+ export { SocialProviderListEnum, apple, atlassian, cognito, discord, dropbox, facebook, figma, getApplePublicKey, getCognitoPublicKey, getGooglePublicKey, getMicrosoftPublicKey, getPayPalPublicKey, github, gitlab, google, huggingface, isGoogleHostedDomainAllowed, kakao, kick, line, linear, linkedin, microsoft, naver, notion, paybin, paypal, polar, railway, reddit, roblox, salesforce, slack, socialProviderList, socialProviders, spotify, tiktok, twitch, twitter, vercel, verifyGoogleIdToken, vk, wechat, zoom };
@@ -1,6 +1,7 @@
1
1
  import { OAuth2Tokens, ProviderOptions } from "../oauth2/oauth-provider.mjs";
2
2
  //#region src/social-providers/paypal.d.ts
3
3
  interface PayPalProfile {
4
+ sub?: string | undefined;
4
5
  user_id: string;
5
6
  name: string;
6
7
  given_name: string;
@@ -3,7 +3,7 @@ import { logger } from "../env/logger.mjs";
3
3
  import { createAuthorizationURL } from "../oauth2/create-authorization-url.mjs";
4
4
  import { base64 } from "@better-auth/utils/base64";
5
5
  import { betterFetch } from "@better-fetch/fetch";
6
- import { decodeProtectedHeader, importJWK, jwtVerify } from "jose";
6
+ import { decodeJwt, decodeProtectedHeader, importJWK, jwtVerify } from "jose";
7
7
  //#region src/social-providers/paypal.ts
8
8
  /**
9
9
  * ID token signing algorithms advertised by PayPal's OpenID configuration.
@@ -143,6 +143,20 @@ const paypal = (options) => {
143
143
  return null;
144
144
  }
145
145
  const userInfo = response.data;
146
+ if (token.idToken) {
147
+ let idTokenSubject;
148
+ try {
149
+ idTokenSubject = decodeJwt(token.idToken).sub;
150
+ } catch (error) {
151
+ logger.error("Failed to decode PayPal ID token:", error);
152
+ return null;
153
+ }
154
+ const userInfoSubject = userInfo.sub ?? userInfo.user_id;
155
+ if (!idTokenSubject || userInfoSubject !== idTokenSubject) {
156
+ logger.error("PayPal user info subject does not match ID token subject");
157
+ return null;
158
+ }
159
+ }
146
160
  const userMap = await options.mapProfileToUser?.(userInfo);
147
161
  return {
148
162
  user: {
@@ -176,7 +176,7 @@ type BetterAuthAdvancedOptions = {
176
176
  * @example ["x-client-ip", "x-forwarded-for", "cf-connecting-ip"]
177
177
  *
178
178
  * @default
179
- * @link https://github.com/better-auth/better-auth/blob/main/packages/better-auth/src/utils/get-request-ip.ts#L8
179
+ * @link https://github.com/better-auth/better-auth/blob/main/packages/core/src/utils/ip.ts
180
180
  */
181
181
  ipAddressHeaders?: string[];
182
182
  /**
@@ -193,6 +193,21 @@ type BetterAuthAdvancedOptions = {
193
193
  * @default 64
194
194
  */
195
195
  ipv6Subnet?: number;
196
+ /**
197
+ * Trusted reverse-proxy IPs or CIDR ranges. When set, a forwarded IP
198
+ * chain is walked right to left, trusted hops are skipped, and the
199
+ * first untrusted address is the client IP. Unset trusts only
200
+ * single-value IP headers. Use the actual address or subnet of your
201
+ * proxies, not a broad private range that also covers clients.
202
+ *
203
+ * This only interprets the forwarded header chain and cannot verify
204
+ * the direct sender. It is safe only when your origin is reachable
205
+ * through these proxies and clients cannot set forwarded headers
206
+ * directly.
207
+ *
208
+ * @example ["192.0.2.10", "10.0.0.0/24"]
209
+ */
210
+ trustedProxies?: string[];
196
211
  } | undefined;
197
212
  /**
198
213
  * Force cookies to always use the `Secure` attribute. By default,
@@ -1,3 +1,4 @@
1
+ import { BetterAuthOptions } from "../types/init-options.mjs";
1
2
  //#region src/utils/ip.d.ts
2
3
  /**
3
4
  * Normalizes an IP address for consistent rate limiting.
@@ -42,6 +43,27 @@ declare function isValidIP(ip: string): boolean;
42
43
  * // -> "2001:0db8:0000:0000:0000:0000:0000:0000" (subnet /64)
43
44
  */
44
45
  declare function normalizeIP(ip: string, options?: NormalizeIPOptions): string;
46
+ /**
47
+ * Trusted-proxy entries that are not a valid IP address or CIDR range.
48
+ */
49
+ declare function findInvalidTrustedProxies(entries: string[]): string[];
50
+ /**
51
+ * Resolves the client IP from a forwarded header. The leftmost token is spoofable,
52
+ * so with `trustedProxies` the chain is stripped from the right to the first
53
+ * untrusted hop. Otherwise only a single-value header is trusted. Returns `null`
54
+ * when no trustworthy client IP can be resolved.
55
+ */
56
+ declare function getIPFromHeader(value: string, options?: {
57
+ ipv6Subnet?: number;
58
+ trustedProxies?: string[];
59
+ }): string | null;
60
+ /**
61
+ * Resolves the client IP for a request from the configured IP headers.
62
+ * Honors `disableIpTracking`, walks `ipAddressHeaders` in order (default
63
+ * `x-forwarded-for`), and falls back to localhost in development and test.
64
+ * Returns `null` when tracking is disabled or no trustworthy IP can be resolved.
65
+ */
66
+ declare function getIp(req: Request | Headers, options: BetterAuthOptions): string | null;
45
67
  /**
46
68
  * Creates a rate limit key from IP and path
47
69
  * Uses a separator to prevent collision attacks
@@ -52,4 +74,4 @@ declare function normalizeIP(ip: string, options?: NormalizeIPOptions): string;
52
74
  */
53
75
  declare function createRateLimitKey(ip: string, path: string): string;
54
76
  //#endregion
55
- export { createRateLimitKey, isValidIP, normalizeIP };
77
+ export { createRateLimitKey, findInvalidTrustedProxies, getIPFromHeader, getIp, isValidIP, normalizeIP };
package/dist/utils/ip.mjs CHANGED
@@ -1,3 +1,4 @@
1
+ import { isDevelopment, isTest } from "../env/env-impl.mjs";
1
2
  import * as z from "zod";
2
3
  //#region src/utils/ip.ts
3
4
  /**
@@ -102,6 +103,119 @@ function normalizeIP(ip, options = {}) {
102
103
  return normalizeIPv6(ip, options.ipv6Subnet ?? 64);
103
104
  }
104
105
  /**
106
+ * Raw bytes of an IP for CIDR comparison. Returns `null` for an invalid IP.
107
+ */
108
+ function ipToBytes(ip) {
109
+ if (z.ipv4().safeParse(ip).success) return Uint8Array.from(ip.split(".").map((octet) => Number(octet)));
110
+ if (!isIPv6(ip)) return null;
111
+ const mapped = extractIPv4FromMapped(ip);
112
+ if (mapped) return Uint8Array.from(mapped.split(".").map((octet) => Number(octet)));
113
+ const groups = expandIPv6(ip);
114
+ const bytes = new Uint8Array(16);
115
+ for (let i = 0; i < 8; i++) {
116
+ const group = Number.parseInt(groups[i] ?? "0", 16);
117
+ bytes[i * 2] = group >> 8 & 255;
118
+ bytes[i * 2 + 1] = group & 255;
119
+ }
120
+ return bytes;
121
+ }
122
+ const CIDR_PREFIX_PATTERN = /^\d+$/;
123
+ /**
124
+ * Parses an IP or `IP/prefix` string into network bytes and a prefix length.
125
+ * The prefix must be digits only and within the address family. `null` if the
126
+ * value is not a valid IP or CIDR range, which keeps a malformed entry from
127
+ * silently behaving like a non-match.
128
+ */
129
+ function parseCIDR(value) {
130
+ const slash = value.lastIndexOf("/");
131
+ const bytes = ipToBytes(slash === -1 ? value : value.slice(0, slash));
132
+ if (!bytes) return null;
133
+ const maxBits = bytes.length * 8;
134
+ if (slash === -1) return {
135
+ bytes,
136
+ prefix: maxBits
137
+ };
138
+ const prefixPart = value.slice(slash + 1);
139
+ if (!CIDR_PREFIX_PATTERN.test(prefixPart)) return null;
140
+ const prefix = Number(prefixPart);
141
+ return prefix <= maxBits ? {
142
+ bytes,
143
+ prefix
144
+ } : null;
145
+ }
146
+ /**
147
+ * Whether `ipBytes` falls inside an already-parsed CIDR network.
148
+ */
149
+ function matchesCIDR(ipBytes, net) {
150
+ if (ipBytes.length !== net.bytes.length) return false;
151
+ let bitsRemaining = net.prefix;
152
+ for (let i = 0; i < ipBytes.length && bitsRemaining > 0; i++) {
153
+ const take = bitsRemaining >= 8 ? 8 : bitsRemaining;
154
+ const mask = take === 8 ? 255 : 255 << 8 - take & 255;
155
+ if (((ipBytes[i] ?? 0) & mask) !== ((net.bytes[i] ?? 0) & mask)) return false;
156
+ bitsRemaining -= 8;
157
+ }
158
+ return true;
159
+ }
160
+ /**
161
+ * Trusted-proxy entries that are not a valid IP address or CIDR range.
162
+ */
163
+ function findInvalidTrustedProxies(entries) {
164
+ return entries.filter((entry) => parseCIDR(entry) === null);
165
+ }
166
+ /**
167
+ * Resolves the client IP from a forwarded header. The leftmost token is spoofable,
168
+ * so with `trustedProxies` the chain is stripped from the right to the first
169
+ * untrusted hop. Otherwise only a single-value header is trusted. Returns `null`
170
+ * when no trustworthy client IP can be resolved.
171
+ */
172
+ function getIPFromHeader(value, options = {}) {
173
+ const forwardedIps = value.split(",").map((ip) => ip.trim()).filter(Boolean);
174
+ if (forwardedIps.length === 0) return null;
175
+ const trustedProxies = (options.trustedProxies ?? []).map(parseCIDR).filter((proxy) => {
176
+ return proxy !== null;
177
+ });
178
+ if (trustedProxies.length > 0) {
179
+ for (let i = forwardedIps.length - 1; i >= 0; i--) {
180
+ const ip = forwardedIps[i];
181
+ const ipBytes = ip ? ipToBytes(ip) : null;
182
+ if (!ip || !ipBytes) return null;
183
+ if (trustedProxies.some((proxy) => matchesCIDR(ipBytes, proxy))) continue;
184
+ return normalizeIP(ip, { ipv6Subnet: options.ipv6Subnet });
185
+ }
186
+ return null;
187
+ }
188
+ if (forwardedIps.length !== 1) return null;
189
+ const selectedIp = forwardedIps[0];
190
+ if (!selectedIp || !isValidIP(selectedIp)) return null;
191
+ return normalizeIP(selectedIp, { ipv6Subnet: options.ipv6Subnet });
192
+ }
193
+ const LOCALHOST_IP = "127.0.0.1";
194
+ const DEFAULT_IP_HEADERS = ["x-forwarded-for"];
195
+ /**
196
+ * Resolves the client IP for a request from the configured IP headers.
197
+ * Honors `disableIpTracking`, walks `ipAddressHeaders` in order (default
198
+ * `x-forwarded-for`), and falls back to localhost in development and test.
199
+ * Returns `null` when tracking is disabled or no trustworthy IP can be resolved.
200
+ */
201
+ function getIp(req, options) {
202
+ if (options.advanced?.ipAddress?.disableIpTracking) return null;
203
+ const headers = "headers" in req ? req.headers : req;
204
+ const ipHeaders = options.advanced?.ipAddress?.ipAddressHeaders || DEFAULT_IP_HEADERS;
205
+ for (const key of ipHeaders) {
206
+ const value = "get" in headers ? headers.get(key) : headers[key];
207
+ if (typeof value === "string") {
208
+ const ip = getIPFromHeader(value, {
209
+ ipv6Subnet: options.advanced?.ipAddress?.ipv6Subnet,
210
+ trustedProxies: options.advanced?.ipAddress?.trustedProxies
211
+ });
212
+ if (ip) return ip;
213
+ }
214
+ }
215
+ if (isTest() || isDevelopment()) return LOCALHOST_IP;
216
+ return null;
217
+ }
218
+ /**
105
219
  * Creates a rate limit key from IP and path
106
220
  * Uses a separator to prevent collision attacks
107
221
  *
@@ -113,4 +227,4 @@ function createRateLimitKey(ip, path) {
113
227
  return `${ip}|${path}`;
114
228
  }
115
229
  //#endregion
116
- export { createRateLimitKey, isValidIP, normalizeIP };
230
+ export { createRateLimitKey, findInvalidTrustedProxies, getIPFromHeader, getIp, isValidIP, normalizeIP };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@better-auth/core",
3
- "version": "1.6.20",
3
+ "version": "1.6.22",
4
4
  "description": "The most comprehensive authentication framework for TypeScript.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -157,7 +157,7 @@
157
157
  "@opentelemetry/api": "^1.9.0",
158
158
  "@opentelemetry/sdk-trace-base": "^1.30.0",
159
159
  "@opentelemetry/sdk-trace-node": "^1.30.0",
160
- "better-call": "1.3.6",
160
+ "better-call": "1.3.7",
161
161
  "@cloudflare/workers-types": "^4.20250121.0",
162
162
  "jose": "^6.1.3",
163
163
  "kysely": "^0.28.17 || ^0.29.0",
@@ -168,7 +168,7 @@
168
168
  "@better-auth/utils": "0.4.2",
169
169
  "@better-fetch/fetch": "1.3.1",
170
170
  "@opentelemetry/api": "^1.9.0",
171
- "better-call": "1.3.6",
171
+ "better-call": "1.3.7",
172
172
  "@cloudflare/workers-types": ">=4",
173
173
  "jose": "^6.1.0",
174
174
  "kysely": "^0.28.5 || ^0.29.0",
@@ -967,6 +967,11 @@ export const createAdapterFactory =
967
967
  where: unsafeWhere,
968
968
  action: "update",
969
969
  });
970
+ // `update` targets a single row. Empty predicates have no
971
+ // target, so fail closed and leave bulk writes to `updateMany`.
972
+ if (where.length === 0) {
973
+ return null;
974
+ }
970
975
  debugLog(
971
976
  { method: "update" },
972
977
  `${formatTransactionId(thisTransactionId)} ${formatStep(1, 4)}`,