@better-auth/core 1.7.0-beta.9 → 1.7.0-rc.0

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.
Files changed (34) hide show
  1. package/dist/context/global.mjs +1 -1
  2. package/dist/db/adapter/factory.mjs +1 -0
  3. package/dist/db/adapter/index.d.mts +8 -2
  4. package/dist/db/get-tables.mjs +2 -1
  5. package/dist/error/index.d.mts +7 -0
  6. package/dist/instrumentation/tracer.mjs +1 -1
  7. package/dist/oauth2/client-credentials-token.mjs +2 -2
  8. package/dist/oauth2/refresh-access-token.mjs +2 -2
  9. package/dist/oauth2/reject-redirects.mjs +65 -0
  10. package/dist/oauth2/validate-authorization-code.mjs +11 -4
  11. package/dist/oauth2/verify.mjs +3 -3
  12. package/dist/social-providers/google.d.mts +23 -3
  13. package/dist/social-providers/google.mjs +56 -9
  14. package/dist/social-providers/index.d.mts +2 -2
  15. package/dist/social-providers/index.mjs +2 -2
  16. package/dist/social-providers/paypal.d.mts +1 -0
  17. package/dist/social-providers/paypal.mjs +15 -0
  18. package/dist/types/init-options.d.mts +16 -1
  19. package/dist/utils/ip.d.mts +23 -1
  20. package/dist/utils/ip.mjs +115 -1
  21. package/package.json +10 -10
  22. package/src/db/adapter/factory.ts +9 -4
  23. package/src/db/adapter/index.ts +8 -2
  24. package/src/db/get-tables.ts +7 -1
  25. package/src/error/index.ts +9 -0
  26. package/src/oauth2/client-credentials-token.ts +2 -2
  27. package/src/oauth2/refresh-access-token.ts +2 -2
  28. package/src/oauth2/reject-redirects.ts +75 -0
  29. package/src/oauth2/validate-authorization-code.ts +14 -4
  30. package/src/oauth2/verify.ts +20 -19
  31. package/src/social-providers/google.ts +103 -17
  32. package/src/social-providers/paypal.ts +22 -0
  33. package/src/types/init-options.ts +16 -1
  34. package/src/utils/ip.ts +185 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@better-auth/core",
3
- "version": "1.7.0-beta.9",
3
+ "version": "1.7.0-rc.0",
4
4
  "description": "The most comprehensive authentication framework for TypeScript.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -147,29 +147,29 @@
147
147
  }
148
148
  },
149
149
  "dependencies": {
150
- "@opentelemetry/semantic-conventions": "^1.39.0",
150
+ "@opentelemetry/semantic-conventions": "^1.41.1",
151
151
  "@standard-schema/spec": "^1.1.0",
152
152
  "zod": "^4.3.6"
153
153
  },
154
154
  "devDependencies": {
155
155
  "@better-auth/utils": "0.4.2",
156
156
  "@better-fetch/fetch": "1.3.1",
157
- "@opentelemetry/api": "^1.9.0",
158
- "@opentelemetry/sdk-trace-base": "^1.30.0",
159
- "@opentelemetry/sdk-trace-node": "^1.30.0",
160
- "better-call": "1.3.6",
161
157
  "@cloudflare/workers-types": "^4.20250121.0",
162
- "jose": "^6.1.3",
158
+ "@opentelemetry/api": "^1.9.1",
159
+ "@opentelemetry/sdk-trace-base": "^2.8.0",
160
+ "@opentelemetry/sdk-trace-node": "^2.8.0",
161
+ "better-call": "1.3.7",
162
+ "jose": "^6.2.3",
163
163
  "kysely": "^0.28.17 || ^0.29.0",
164
- "nanostores": "^1.1.1",
164
+ "nanostores": "^1.3.0",
165
165
  "tsdown": "0.21.1"
166
166
  },
167
167
  "peerDependencies": {
168
168
  "@better-auth/utils": "0.4.2",
169
169
  "@better-fetch/fetch": "1.3.1",
170
- "@opentelemetry/api": "^1.9.0",
171
- "better-call": "1.3.6",
172
170
  "@cloudflare/workers-types": ">=4",
171
+ "@opentelemetry/api": "^1.9.0",
172
+ "better-call": "1.3.7",
173
173
  "jose": "^6.1.0",
174
174
  "kysely": "^0.28.5 || ^0.29.0",
175
175
  "nanostores": "^1.0.1"
@@ -29,15 +29,15 @@ import type {
29
29
  } from "./types";
30
30
  import { withApplyDefault } from "./utils";
31
31
 
32
+ export * from "./types";
32
33
  export {
33
- initGetDefaultModelName,
34
34
  initGetDefaultFieldName,
35
- initGetModelName,
36
- initGetFieldName,
35
+ initGetDefaultModelName,
37
36
  initGetFieldAttributes,
37
+ initGetFieldName,
38
38
  initGetIdField,
39
+ initGetModelName,
39
40
  };
40
- export * from "./types";
41
41
 
42
42
  let debugLogs: { instance: string; args: any[] }[] = [];
43
43
  let transactionId = -1;
@@ -963,6 +963,11 @@ export const createAdapterFactory =
963
963
  where: unsafeWhere,
964
964
  action: "update",
965
965
  });
966
+ // `update` targets a single row. Empty predicates have no
967
+ // target, so fail closed and leave bulk writes to `updateMany`.
968
+ if (where.length === 0) {
969
+ return null;
970
+ }
966
971
  debugLog(
967
972
  { method: "update" },
968
973
  `${formatTransactionId(thisTransactionId)} ${formatStep(1, 4)}`,
@@ -434,8 +434,14 @@ export type DBAdapter<Options extends BetterAuthOptions = BetterAuthOptions> = {
434
434
  where?: Where[] | undefined;
435
435
  }) => Promise<number>;
436
436
  /**
437
- * ⚠︎ Update may not return the updated data
438
- * if multiple where clauses are provided
437
+ * Update a single row matching the where clause.
438
+ *
439
+ * Returns the updated row, or `null` when no row matched. Empty `where`
440
+ * clauses return `null`; use `updateMany` for intentional bulk updates.
441
+ *
442
+ * This is not the race-safe primitive for guarded state transitions. Use
443
+ * `incrementOne` when the predicate is both selector and guard, and use
444
+ * `consumeOne` for single-use destructive reads.
439
445
  */
440
446
  update: <T>(data: {
441
447
  model: string;
@@ -15,13 +15,19 @@ export const getAuthTables = (
15
15
  ...value.fields,
16
16
  },
17
17
  modelName: value.modelName || key,
18
+ disableMigrations:
19
+ value.disableMigration ?? acc[key]?.disableMigrations,
18
20
  };
19
21
  }
20
22
  return acc;
21
23
  },
22
24
  {} as Record<
23
25
  string,
24
- { fields: Record<string, DBFieldAttribute>; modelName: string }
26
+ {
27
+ fields: Record<string, DBFieldAttribute>;
28
+ modelName: string;
29
+ disableMigrations?: boolean | undefined;
30
+ }
25
31
  >,
26
32
  );
27
33
 
@@ -11,7 +11,16 @@ export class BetterAuthError extends Error {
11
11
 
12
12
  export { type APIErrorCode, BASE_ERROR_CODES } from "./codes";
13
13
 
14
+ type BaseAPIErrorInstance = InstanceType<typeof BaseAPIError>;
15
+
14
16
  export class APIError extends BaseAPIError {
17
+ declare status: BaseAPIErrorInstance["status"];
18
+ declare body: BaseAPIErrorInstance["body"];
19
+ declare headers: BaseAPIErrorInstance["headers"];
20
+ declare statusCode: BaseAPIErrorInstance["statusCode"];
21
+ declare message: string;
22
+ declare errorStack: BaseAPIErrorInstance["errorStack"];
23
+
15
24
  constructor(...args: ConstructorParameters<typeof BaseAPIError>) {
16
25
  super(...args);
17
26
  }
@@ -1,6 +1,6 @@
1
- import { betterFetch } from "@better-fetch/fetch";
2
1
  import type { AwaitableFunction } from "../types";
3
2
  import type { OAuth2Tokens, ProviderOptions } from "./oauth-provider";
3
+ import { fetchRefusingRedirects } from "./reject-redirects";
4
4
  import type {
5
5
  TokenEndpointAuth,
6
6
  TokenEndpointSecretAuthentication,
@@ -109,7 +109,7 @@ export async function clientCredentialsToken({
109
109
  resource,
110
110
  });
111
111
 
112
- const { data, error } = await betterFetch<{
112
+ const { data, error } = await fetchRefusingRedirects<{
113
113
  access_token: string;
114
114
  expires_in?: number | undefined;
115
115
  token_type?: string | undefined;
@@ -1,6 +1,6 @@
1
- import { betterFetch } from "@better-fetch/fetch";
2
1
  import type { AwaitableFunction } from "../types";
3
2
  import type { OAuth2Tokens, ProviderOptions } from "./oauth-provider";
3
+ import { fetchRefusingRedirects } from "./reject-redirects";
4
4
  import type {
5
5
  TokenEndpointAuth,
6
6
  TokenEndpointSecretAuthentication,
@@ -138,7 +138,7 @@ export async function refreshAccessToken({
138
138
  resource,
139
139
  });
140
140
 
141
- const { data, error } = await betterFetch<{
141
+ const { data, error } = await fetchRefusingRedirects<{
142
142
  access_token: string;
143
143
  refresh_token?: string | undefined;
144
144
  expires_in?: number | undefined;
@@ -0,0 +1,75 @@
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 httpRedirectStatuses = 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
+ httpRedirectStatuses.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 noFollowRedirect = { 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 onError = options?.onError;
62
+ const result = await betterFetch<T>(url, {
63
+ ...options,
64
+ ...noFollowRedirect,
65
+ async onError(context) {
66
+ if (isRedirectResponse(context.response)) redirected = true;
67
+ await onError?.(context);
68
+ },
69
+ }).catch((error) => {
70
+ if (redirected) throw redirectRefused(url);
71
+ throw error;
72
+ });
73
+ if (redirected) throw redirectRefused(url);
74
+ return result;
75
+ }
@@ -1,8 +1,12 @@
1
- import { betterFetch } from "@better-fetch/fetch";
2
- import { createRemoteJWKSet, jwtVerify } from "jose";
1
+ import { createRemoteJWKSet, customFetch, jwtVerify } from "jose";
3
2
  import type { AwaitableFunction } from "../types";
4
3
  import type { ProviderOptions } from "./index";
5
4
  import { getOAuth2Tokens } from "./index";
5
+ import {
6
+ assertResponseNotRedirect,
7
+ fetchRefusingRedirects,
8
+ noFollowRedirect,
9
+ } from "./reject-redirects";
6
10
  import type {
7
11
  TokenEndpointAuth,
8
12
  TokenEndpointSecretAuthentication,
@@ -145,7 +149,7 @@ export async function validateAuthorizationCode({
145
149
  resource,
146
150
  });
147
151
 
148
- const { data, error } = await betterFetch<object>(tokenEndpoint, {
152
+ const { data, error } = await fetchRefusingRedirects<object>(tokenEndpoint, {
149
153
  method: "POST",
150
154
  body: body,
151
155
  headers: requestHeaders,
@@ -165,7 +169,13 @@ export async function validateToken(
165
169
  issuer?: string | string[];
166
170
  },
167
171
  ) {
168
- const jwks = createRemoteJWKSet(new URL(jwksEndpoint));
172
+ const jwks = createRemoteJWKSet(new URL(jwksEndpoint), {
173
+ [customFetch]: async (url, init) => {
174
+ const response = await fetch(url, { ...init, ...noFollowRedirect });
175
+ assertResponseNotRedirect(String(url), response);
176
+ return response;
177
+ },
178
+ });
169
179
  const verified = await jwtVerify(token, jwks, {
170
180
  audience: options?.audience,
171
181
  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,
@@ -23,6 +22,7 @@ import {
23
22
  isDpopBindingError,
24
23
  parseAccessTokenAuthorization,
25
24
  } from "./dpop";
25
+ import { fetchRefusingRedirects } from "./reject-redirects";
26
26
 
27
27
  const joseInfrastructureErrorCodes = new Set([
28
28
  joseErrors.JWKSTimeout.code,
@@ -115,7 +115,7 @@ async function fetchJwks(
115
115
  ): Promise<JSONWebKeySet> {
116
116
  const jwks =
117
117
  typeof jwksFetch === "string"
118
- ? await betterFetch<JSONWebKeySet>(jwksFetch, {
118
+ ? await fetchRefusingRedirects<JSONWebKeySet>(jwksFetch, {
119
119
  headers: {
120
120
  Accept: "application/json",
121
121
  },
@@ -384,23 +384,24 @@ async function verifyAccessTokenPayload(
384
384
 
385
385
  // Remote verify
386
386
  if (opts?.remoteVerify) {
387
- const { data: introspect, error: introspectError } = await betterFetch<
388
- JWTPayload & {
389
- active: boolean;
390
- }
391
- >(opts.remoteVerify.introspectUrl, {
392
- method: "POST",
393
- headers: {
394
- Accept: "application/json",
395
- "Content-Type": "application/x-www-form-urlencoded",
396
- },
397
- body: new URLSearchParams({
398
- client_id: opts.remoteVerify.clientId,
399
- client_secret: opts.remoteVerify.clientSecret,
400
- token,
401
- token_type_hint: "access_token",
402
- }).toString(),
403
- });
387
+ const { data: introspect, error: introspectError } =
388
+ await fetchRefusingRedirects<
389
+ JWTPayload & {
390
+ active: boolean;
391
+ }
392
+ >(opts.remoteVerify.introspectUrl, {
393
+ method: "POST",
394
+ headers: {
395
+ Accept: "application/json",
396
+ "Content-Type": "application/x-www-form-urlencoded",
397
+ },
398
+ body: new URLSearchParams({
399
+ client_id: opts.remoteVerify.clientId,
400
+ client_secret: opts.remoteVerify.clientSecret,
401
+ token,
402
+ token_type_hint: "access_token",
403
+ }).toString(),
404
+ });
404
405
  if (introspectError)
405
406
  logger.error(
406
407
  `Introspection failed: ${introspectError.message ?? introspectError.statusText}`,
@@ -1,5 +1,6 @@
1
1
  import { betterFetch } from "@better-fetch/fetch";
2
- import { decodeJwt, importJWK } from "jose";
2
+ import type { JWTPayload } from "jose";
3
+ import { decodeJwt, decodeProtectedHeader, importJWK, jwtVerify } from "jose";
3
4
  import { logger } from "../env";
4
5
  import { APIError, BetterAuthError } from "../error";
5
6
  import type { OAuthProvider, ProviderOptions } from "../oauth2";
@@ -52,8 +53,8 @@ export interface GoogleOptions extends ProviderOptions<GoogleProfile> {
52
53
  *
53
54
  * This is sent to Google as the `hd` authorization hint and, when set, is
54
55
  * also enforced against the `hd` claim of the returned id token/profile.
55
- * Sign-in is rejected when the claim is missing or does not match, so this
56
- * can be used to restrict sign-in to a Workspace domain.
56
+ * Set `hd: "*"` to require any Workspace hosted-domain claim. Sign-in is
57
+ * rejected when the claim is missing or does not satisfy this restriction.
57
58
  */
58
59
  hd?: string | undefined;
59
60
  /**
@@ -69,6 +70,82 @@ export interface GoogleOptions extends ProviderOptions<GoogleProfile> {
69
70
  includeGrantedScopes?: boolean | undefined;
70
71
  }
71
72
 
73
+ const GOOGLE_ID_TOKEN_MAX_AGE = "1h";
74
+ const GOOGLE_ID_TOKEN_ALGORITHM = "RS256";
75
+ type GoogleIdTokenAlgorithm = typeof GOOGLE_ID_TOKEN_ALGORITHM;
76
+ const GOOGLE_ID_TOKEN_ALGORITHMS: GoogleIdTokenAlgorithm[] = [
77
+ GOOGLE_ID_TOKEN_ALGORITHM,
78
+ ];
79
+
80
+ function isGoogleIdTokenAlgorithm(
81
+ algorithm: unknown,
82
+ ): algorithm is GoogleIdTokenAlgorithm {
83
+ return GOOGLE_ID_TOKEN_ALGORITHMS.includes(
84
+ algorithm as GoogleIdTokenAlgorithm,
85
+ );
86
+ }
87
+
88
+ export interface VerifyGoogleIdTokenOptions {
89
+ token: string;
90
+ audience: string | string[];
91
+ nonce?: string | undefined;
92
+ }
93
+
94
+ /**
95
+ * Verifies a Google ID token against Google's issuer, audience, signature,
96
+ * expiry, and maximum token age.
97
+ */
98
+ export const verifyGoogleIdToken = async ({
99
+ token,
100
+ audience,
101
+ nonce,
102
+ }: VerifyGoogleIdTokenOptions): Promise<JWTPayload | null> => {
103
+ try {
104
+ const { kid, alg } = decodeProtectedHeader(token);
105
+ if (!isGoogleIdTokenAlgorithm(alg)) return null;
106
+
107
+ const publicKeys = await getGooglePublicKeys(kid);
108
+ for (const publicKey of publicKeys) {
109
+ try {
110
+ const { payload: jwtClaims } = await jwtVerify(token, publicKey, {
111
+ algorithms: GOOGLE_ID_TOKEN_ALGORITHMS,
112
+ issuer: ["https://accounts.google.com", "accounts.google.com"],
113
+ audience,
114
+ maxTokenAge: GOOGLE_ID_TOKEN_MAX_AGE,
115
+ });
116
+
117
+ if (nonce && jwtClaims.nonce !== nonce) {
118
+ return null;
119
+ }
120
+
121
+ return jwtClaims;
122
+ } catch {
123
+ continue;
124
+ }
125
+ }
126
+
127
+ return null;
128
+ } catch {
129
+ return null;
130
+ }
131
+ };
132
+
133
+ /**
134
+ * Checks whether Google's verified `hd` claim satisfies the configured hosted
135
+ * domain restriction. `hd: "*"` accepts any Google Workspace hosted domain.
136
+ */
137
+ export const isGoogleHostedDomainAllowed = (
138
+ configuredHostedDomain: string | undefined,
139
+ tokenHostedDomain: unknown,
140
+ ) => {
141
+ if (!configuredHostedDomain) return true;
142
+ if (typeof tokenHostedDomain !== "string" || !tokenHostedDomain) {
143
+ return false;
144
+ }
145
+ if (configuredHostedDomain === "*") return true;
146
+ return tokenHostedDomain === configuredHostedDomain;
147
+ };
148
+
72
149
  export const google = (options: GoogleOptions) => {
73
150
  return {
74
151
  id: "google",
@@ -145,14 +222,14 @@ export const google = (options: GoogleOptions) => {
145
222
  jwks: (header) => getGooglePublicKey(header.kid!),
146
223
  issuer: ["https://accounts.google.com", "accounts.google.com"],
147
224
  audience: options.clientId,
148
- maxTokenAge: "1h",
225
+ maxTokenAge: GOOGLE_ID_TOKEN_MAX_AGE,
149
226
  // Google's `hd` authorization parameter is only a UI hint and can be
150
227
  // removed or changed by the user. When a hosted domain is configured,
151
228
  // the `hd` claim in the verified id token is the authoritative value
152
- // and must match, otherwise accounts outside the workspace domain would
153
- // be accepted on the id_token sign-in path.
229
+ // and must satisfy the configured restriction, otherwise accounts
230
+ // outside the workspace domain would be accepted on the id_token path.
154
231
  verifyClaims: options.hd
155
- ? (claims) => claims.hd === options.hd
232
+ ? (claims) => isGoogleHostedDomainAllowed(options.hd, claims.hd)
156
233
  : undefined,
157
234
  },
158
235
  async getUserInfo(token) {
@@ -163,15 +240,14 @@ export const google = (options: GoogleOptions) => {
163
240
  return null;
164
241
  }
165
242
  const user = decodeJwt(token.idToken) as GoogleProfile;
166
- // Enforce the configured hosted domain on the callback profile path
167
- // as well. The `hd` claim must be present and match, since the
168
- // authorization-time `hd` hint does not restrict which account signs
169
- // in.
170
- if (options.hd && user.hd !== options.hd) {
243
+ // Enforce the configured hosted domain on the callback profile path.
244
+ // The authorization-time `hd` value is only a UI hint; the verified
245
+ // token/profile claim is the authoritative Workspace signal.
246
+ if (!isGoogleHostedDomainAllowed(options.hd, user.hd)) {
171
247
  logger.error(
172
248
  `Google sign-in rejected: id token hosted domain (hd) "${
173
249
  user.hd ?? "<missing>"
174
- }" does not match the configured "hd" option "${options.hd}".`,
250
+ }" does not satisfy the configured "hd" option "${options.hd}".`,
175
251
  );
176
252
  return null;
177
253
  }
@@ -193,10 +269,18 @@ export const google = (options: GoogleOptions) => {
193
269
  };
194
270
 
195
271
  export const getGooglePublicKey = async (kid: string) => {
272
+ const [publicKey] = await getGooglePublicKeys(kid);
273
+ if (!publicKey) {
274
+ throw new Error(`JWK with kid ${kid} not found`);
275
+ }
276
+ return publicKey;
277
+ };
278
+
279
+ const getGooglePublicKeys = async (kid?: string) => {
196
280
  const { data } = await betterFetch<{
197
281
  keys: Array<{
198
282
  kid: string;
199
- alg: string;
283
+ alg?: string;
200
284
  kty: string;
201
285
  use: string;
202
286
  n: string;
@@ -210,10 +294,12 @@ export const getGooglePublicKey = async (kid: string) => {
210
294
  });
211
295
  }
212
296
 
213
- const jwk = data.keys.find((key) => key.kid === kid);
214
- if (!jwk) {
297
+ const jwks = kid ? data.keys.filter((key) => key.kid === kid) : data.keys;
298
+ if (!jwks.length) {
215
299
  throw new Error(`JWK with kid ${kid} not found`);
216
300
  }
217
301
 
218
- return await importJWK(jwk, jwk.alg);
302
+ return Promise.all(
303
+ jwks.map((jwk) => importJWK(jwk, GOOGLE_ID_TOKEN_ALGORITHM)),
304
+ );
219
305
  };
@@ -1,11 +1,13 @@
1
1
  import { base64 } from "@better-auth/utils/base64";
2
2
  import { betterFetch } from "@better-fetch/fetch";
3
+ import { decodeJwt } from "jose";
3
4
  import { logger } from "../env";
4
5
  import { BetterAuthError } from "../error";
5
6
  import type { OAuthProvider, ProviderOptions } from "../oauth2";
6
7
  import { createAuthorizationURL } from "../oauth2";
7
8
 
8
9
  export interface PayPalProfile {
10
+ sub?: string | undefined;
9
11
  user_id: string;
10
12
  name: string;
11
13
  given_name: string;
@@ -226,6 +228,26 @@ export const paypal = (options: PayPalOptions) => {
226
228
  }
227
229
 
228
230
  const userInfo = response.data;
231
+ if (token.idToken) {
232
+ let idTokenSubject: string | undefined;
233
+ try {
234
+ idTokenSubject = decodeJwt(token.idToken).sub;
235
+ } catch (error) {
236
+ logger.error("Failed to decode PayPal ID token:", error);
237
+ return null;
238
+ }
239
+
240
+ // OIDC binds UserInfo to the ID Token with `sub`. Keep `user_id`
241
+ // as the account id below for existing PayPal account mappings.
242
+ const userInfoSubject = userInfo.sub ?? userInfo.user_id;
243
+ if (!idTokenSubject || userInfoSubject !== idTokenSubject) {
244
+ logger.error(
245
+ "PayPal user info subject does not match ID token subject",
246
+ );
247
+ return null;
248
+ }
249
+ }
250
+
229
251
  const userMap = await options.mapProfileToUser?.(userInfo);
230
252
 
231
253
  const result = {
@@ -303,7 +303,7 @@ export type BetterAuthAdvancedOptions = {
303
303
  * @example ["x-client-ip", "x-forwarded-for", "cf-connecting-ip"]
304
304
  *
305
305
  * @default
306
- * @link https://github.com/better-auth/better-auth/blob/main/packages/better-auth/src/utils/get-request-ip.ts#L8
306
+ * @link https://github.com/better-auth/better-auth/blob/main/packages/core/src/utils/ip.ts
307
307
  */
308
308
  ipAddressHeaders?: string[];
309
309
  /**
@@ -320,6 +320,21 @@ export type BetterAuthAdvancedOptions = {
320
320
  * @default 64
321
321
  */
322
322
  ipv6Subnet?: number;
323
+ /**
324
+ * Trusted reverse-proxy IPs or CIDR ranges. When set, a forwarded IP
325
+ * chain is walked right to left, trusted hops are skipped, and the
326
+ * first untrusted address is the client IP. Unset trusts only
327
+ * single-value IP headers. Use the actual address or subnet of your
328
+ * proxies, not a broad private range that also covers clients.
329
+ *
330
+ * This only interprets the forwarded header chain and cannot verify
331
+ * the direct sender. It is safe only when your origin is reachable
332
+ * through these proxies and clients cannot set forwarded headers
333
+ * directly.
334
+ *
335
+ * @example ["192.0.2.10", "10.0.0.0/24"]
336
+ */
337
+ trustedProxies?: string[];
323
338
  }
324
339
  | undefined;
325
340
  /**