@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.
- package/dist/context/global.mjs +1 -1
- package/dist/db/adapter/factory.mjs +1 -0
- package/dist/db/adapter/index.d.mts +8 -2
- package/dist/db/get-tables.mjs +2 -1
- package/dist/instrumentation/tracer.mjs +1 -1
- package/dist/oauth2/client-credentials-token.mjs +2 -2
- package/dist/oauth2/refresh-access-token.mjs +2 -2
- package/dist/oauth2/reject-redirects.mjs +60 -0
- package/dist/oauth2/validate-authorization-code.mjs +11 -4
- package/dist/oauth2/verify.mjs +3 -3
- package/dist/social-providers/google.d.mts +24 -3
- package/dist/social-providers/google.mjs +41 -18
- package/dist/social-providers/index.d.mts +2 -2
- package/dist/social-providers/index.mjs +2 -2
- package/dist/social-providers/paypal.d.mts +1 -0
- package/dist/social-providers/paypal.mjs +15 -1
- package/dist/types/init-options.d.mts +16 -1
- package/dist/utils/ip.d.mts +23 -1
- package/dist/utils/ip.mjs +115 -1
- package/package.json +3 -3
- package/src/db/adapter/factory.ts +5 -0
- package/src/db/adapter/index.ts +8 -2
- package/src/db/get-tables.ts +7 -1
- package/src/oauth2/client-credentials-token.ts +2 -2
- package/src/oauth2/refresh-access-token.ts +2 -2
- package/src/oauth2/reject-redirects.ts +70 -0
- package/src/oauth2/validate-authorization-code.ts +14 -4
- package/src/oauth2/verify.ts +20 -19
- package/src/social-providers/google.ts +71 -38
- package/src/social-providers/paypal.ts +22 -1
- package/src/types/init-options.ts +16 -1
- package/src/utils/ip.ts +185 -0
package/src/db/adapter/index.ts
CHANGED
|
@@ -434,8 +434,14 @@ export type DBAdapter<Options extends BetterAuthOptions = BetterAuthOptions> = {
|
|
|
434
434
|
where?: Where[] | undefined;
|
|
435
435
|
}) => Promise<number>;
|
|
436
436
|
/**
|
|
437
|
-
*
|
|
438
|
-
*
|
|
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;
|
package/src/db/get-tables.ts
CHANGED
|
@@ -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
|
-
{
|
|
26
|
+
{
|
|
27
|
+
fields: Record<string, DBFieldAttribute>;
|
|
28
|
+
modelName: string;
|
|
29
|
+
disableMigrations?: boolean | undefined;
|
|
30
|
+
}
|
|
25
31
|
>,
|
|
26
32
|
);
|
|
27
33
|
|
|
@@ -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
|
|
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
|
|
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 {
|
|
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
|
|
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,
|
package/src/oauth2/verify.ts
CHANGED
|
@@ -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
|
|
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 } =
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
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}`,
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { betterFetch } from "@better-fetch/fetch";
|
|
2
|
+
import type { JWTPayload } from "jose";
|
|
2
3
|
import { decodeJwt, decodeProtectedHeader, importJWK, jwtVerify } from "jose";
|
|
3
4
|
import { logger } from "../env";
|
|
4
5
|
import { APIError, BetterAuthError } from "../error";
|
|
@@ -52,12 +53,67 @@ 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
|
-
*
|
|
56
|
-
*
|
|
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
|
}
|
|
60
61
|
|
|
62
|
+
const GOOGLE_ID_TOKEN_MAX_AGE = "1h";
|
|
63
|
+
|
|
64
|
+
export interface VerifyGoogleIdTokenOptions {
|
|
65
|
+
token: string;
|
|
66
|
+
audience: string | string[];
|
|
67
|
+
nonce?: string | undefined;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Verifies a Google ID token against Google's issuer, audience, signature,
|
|
72
|
+
* expiry, and maximum token age.
|
|
73
|
+
*/
|
|
74
|
+
export const verifyGoogleIdToken = async ({
|
|
75
|
+
token,
|
|
76
|
+
audience,
|
|
77
|
+
nonce,
|
|
78
|
+
}: VerifyGoogleIdTokenOptions): Promise<JWTPayload | null> => {
|
|
79
|
+
try {
|
|
80
|
+
const { kid, alg: jwtAlg } = decodeProtectedHeader(token);
|
|
81
|
+
if (!kid || !jwtAlg) return null;
|
|
82
|
+
|
|
83
|
+
const publicKey = await getGooglePublicKey(kid);
|
|
84
|
+
const { payload: jwtClaims } = await jwtVerify(token, publicKey, {
|
|
85
|
+
algorithms: [jwtAlg],
|
|
86
|
+
issuer: ["https://accounts.google.com", "accounts.google.com"],
|
|
87
|
+
audience,
|
|
88
|
+
maxTokenAge: GOOGLE_ID_TOKEN_MAX_AGE,
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
if (nonce && jwtClaims.nonce !== nonce) {
|
|
92
|
+
return null;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return jwtClaims;
|
|
96
|
+
} catch {
|
|
97
|
+
return null;
|
|
98
|
+
}
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* Checks whether Google's verified `hd` claim satisfies the configured hosted
|
|
103
|
+
* domain restriction. `hd: "*"` accepts any Google Workspace hosted domain.
|
|
104
|
+
*/
|
|
105
|
+
export const isGoogleHostedDomainAllowed = (
|
|
106
|
+
configuredHostedDomain: string | undefined,
|
|
107
|
+
tokenHostedDomain: unknown,
|
|
108
|
+
) => {
|
|
109
|
+
if (!configuredHostedDomain) return true;
|
|
110
|
+
if (typeof tokenHostedDomain !== "string" || !tokenHostedDomain) {
|
|
111
|
+
return false;
|
|
112
|
+
}
|
|
113
|
+
if (configuredHostedDomain === "*") return true;
|
|
114
|
+
return tokenHostedDomain === configuredHostedDomain;
|
|
115
|
+
};
|
|
116
|
+
|
|
61
117
|
export const google = (options: GoogleOptions) => {
|
|
62
118
|
return {
|
|
63
119
|
id: "google",
|
|
@@ -133,38 +189,16 @@ export const google = (options: GoogleOptions) => {
|
|
|
133
189
|
return options.verifyIdToken(token, nonce);
|
|
134
190
|
}
|
|
135
191
|
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
const publicKey = await getGooglePublicKey(kid);
|
|
144
|
-
const { payload: jwtClaims } = await jwtVerify(token, publicKey, {
|
|
145
|
-
algorithms: [jwtAlg],
|
|
146
|
-
issuer: ["https://accounts.google.com", "accounts.google.com"],
|
|
147
|
-
audience: options.clientId,
|
|
148
|
-
maxTokenAge: "1h",
|
|
149
|
-
});
|
|
150
|
-
|
|
151
|
-
if (nonce && jwtClaims.nonce !== nonce) {
|
|
152
|
-
return false;
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
// Google's `hd` authorization parameter is only a UI hint and can
|
|
156
|
-
// be removed or changed by the user. When a hosted domain is
|
|
157
|
-
// configured, the `hd` claim in the verified id token is the
|
|
158
|
-
// authoritative value and must match, otherwise accounts outside
|
|
159
|
-
// the workspace domain would be accepted.
|
|
160
|
-
if (options.hd && jwtClaims.hd !== options.hd) {
|
|
161
|
-
return false;
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
return true;
|
|
165
|
-
} catch {
|
|
192
|
+
const jwtClaims = await verifyGoogleIdToken({
|
|
193
|
+
token,
|
|
194
|
+
audience: options.clientId,
|
|
195
|
+
nonce,
|
|
196
|
+
});
|
|
197
|
+
if (!jwtClaims) {
|
|
166
198
|
return false;
|
|
167
199
|
}
|
|
200
|
+
|
|
201
|
+
return isGoogleHostedDomainAllowed(options.hd, jwtClaims.hd);
|
|
168
202
|
},
|
|
169
203
|
async getUserInfo(token) {
|
|
170
204
|
if (options.getUserInfo) {
|
|
@@ -174,15 +208,14 @@ export const google = (options: GoogleOptions) => {
|
|
|
174
208
|
return null;
|
|
175
209
|
}
|
|
176
210
|
const user = decodeJwt(token.idToken) as GoogleProfile;
|
|
177
|
-
// Enforce the configured hosted domain on the callback profile path
|
|
178
|
-
//
|
|
179
|
-
//
|
|
180
|
-
|
|
181
|
-
if (options.hd && user.hd !== options.hd) {
|
|
211
|
+
// Enforce the configured hosted domain on the callback profile path.
|
|
212
|
+
// The authorization-time `hd` value is only a UI hint; the verified
|
|
213
|
+
// token/profile claim is the authoritative Workspace signal.
|
|
214
|
+
if (!isGoogleHostedDomainAllowed(options.hd, user.hd)) {
|
|
182
215
|
logger.error(
|
|
183
216
|
`Google sign-in rejected: id token hosted domain (hd) "${
|
|
184
217
|
user.hd ?? "<missing>"
|
|
185
|
-
}" does not
|
|
218
|
+
}" does not satisfy the configured "hd" option "${options.hd}".`,
|
|
186
219
|
);
|
|
187
220
|
return null;
|
|
188
221
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { base64 } from "@better-auth/utils/base64";
|
|
2
2
|
import { betterFetch } from "@better-fetch/fetch";
|
|
3
|
-
import { decodeProtectedHeader, importJWK, jwtVerify } from "jose";
|
|
3
|
+
import { decodeJwt, decodeProtectedHeader, importJWK, jwtVerify } from "jose";
|
|
4
4
|
import { logger } from "../env";
|
|
5
5
|
import { APIError, BetterAuthError } from "../error";
|
|
6
6
|
import type { OAuthProvider, ProviderOptions } from "../oauth2";
|
|
@@ -16,6 +16,7 @@ import { createAuthorizationURL } from "../oauth2";
|
|
|
16
16
|
const PAYPAL_ID_TOKEN_ALGORITHMS = ["RS256", "HS256"] as const;
|
|
17
17
|
|
|
18
18
|
export interface PayPalProfile {
|
|
19
|
+
sub?: string | undefined;
|
|
19
20
|
user_id: string;
|
|
20
21
|
name: string;
|
|
21
22
|
given_name: string;
|
|
@@ -298,6 +299,26 @@ export const paypal = (options: PayPalOptions) => {
|
|
|
298
299
|
}
|
|
299
300
|
|
|
300
301
|
const userInfo = response.data;
|
|
302
|
+
if (token.idToken) {
|
|
303
|
+
let idTokenSubject: string | undefined;
|
|
304
|
+
try {
|
|
305
|
+
idTokenSubject = decodeJwt(token.idToken).sub;
|
|
306
|
+
} catch (error) {
|
|
307
|
+
logger.error("Failed to decode PayPal ID token:", error);
|
|
308
|
+
return null;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
// OIDC binds UserInfo to the ID Token with `sub`. Keep `user_id`
|
|
312
|
+
// as the account id below for existing PayPal account mappings.
|
|
313
|
+
const userInfoSubject = userInfo.sub ?? userInfo.user_id;
|
|
314
|
+
if (!idTokenSubject || userInfoSubject !== idTokenSubject) {
|
|
315
|
+
logger.error(
|
|
316
|
+
"PayPal user info subject does not match ID token subject",
|
|
317
|
+
);
|
|
318
|
+
return null;
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
|
|
301
322
|
const userMap = await options.mapProfileToUser?.(userInfo);
|
|
302
323
|
|
|
303
324
|
const result = {
|
|
@@ -223,7 +223,7 @@ export type BetterAuthAdvancedOptions = {
|
|
|
223
223
|
* @example ["x-client-ip", "x-forwarded-for", "cf-connecting-ip"]
|
|
224
224
|
*
|
|
225
225
|
* @default
|
|
226
|
-
* @link https://github.com/better-auth/better-auth/blob/main/packages/
|
|
226
|
+
* @link https://github.com/better-auth/better-auth/blob/main/packages/core/src/utils/ip.ts
|
|
227
227
|
*/
|
|
228
228
|
ipAddressHeaders?: string[];
|
|
229
229
|
/**
|
|
@@ -240,6 +240,21 @@ export type BetterAuthAdvancedOptions = {
|
|
|
240
240
|
* @default 64
|
|
241
241
|
*/
|
|
242
242
|
ipv6Subnet?: number;
|
|
243
|
+
/**
|
|
244
|
+
* Trusted reverse-proxy IPs or CIDR ranges. When set, a forwarded IP
|
|
245
|
+
* chain is walked right to left, trusted hops are skipped, and the
|
|
246
|
+
* first untrusted address is the client IP. Unset trusts only
|
|
247
|
+
* single-value IP headers. Use the actual address or subnet of your
|
|
248
|
+
* proxies, not a broad private range that also covers clients.
|
|
249
|
+
*
|
|
250
|
+
* This only interprets the forwarded header chain and cannot verify
|
|
251
|
+
* the direct sender. It is safe only when your origin is reachable
|
|
252
|
+
* through these proxies and clients cannot set forwarded headers
|
|
253
|
+
* directly.
|
|
254
|
+
*
|
|
255
|
+
* @example ["192.0.2.10", "10.0.0.0/24"]
|
|
256
|
+
*/
|
|
257
|
+
trustedProxies?: string[];
|
|
243
258
|
}
|
|
244
259
|
| undefined;
|
|
245
260
|
/**
|
package/src/utils/ip.ts
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import * as z from "zod";
|
|
2
|
+
import { isDevelopment, isTest } from "../env";
|
|
3
|
+
import type { BetterAuthOptions } from "../types";
|
|
2
4
|
|
|
3
5
|
/**
|
|
4
6
|
* Normalizes an IP address for consistent rate limiting.
|
|
@@ -195,6 +197,189 @@ export function normalizeIP(
|
|
|
195
197
|
return normalizeIPv6(ip, subnetPrefix);
|
|
196
198
|
}
|
|
197
199
|
|
|
200
|
+
/**
|
|
201
|
+
* Raw bytes of an IP for CIDR comparison. Returns `null` for an invalid IP.
|
|
202
|
+
*/
|
|
203
|
+
function ipToBytes(ip: string): Uint8Array | null {
|
|
204
|
+
if (z.ipv4().safeParse(ip).success) {
|
|
205
|
+
return Uint8Array.from(ip.split(".").map((octet) => Number(octet)));
|
|
206
|
+
}
|
|
207
|
+
if (!isIPv6(ip)) {
|
|
208
|
+
return null;
|
|
209
|
+
}
|
|
210
|
+
const mapped = extractIPv4FromMapped(ip);
|
|
211
|
+
if (mapped) {
|
|
212
|
+
return Uint8Array.from(mapped.split(".").map((octet) => Number(octet)));
|
|
213
|
+
}
|
|
214
|
+
const groups = expandIPv6(ip);
|
|
215
|
+
const bytes = new Uint8Array(16);
|
|
216
|
+
for (let i = 0; i < 8; i++) {
|
|
217
|
+
const group = Number.parseInt(groups[i] ?? "0", 16);
|
|
218
|
+
bytes[i * 2] = (group >> 8) & 0xff;
|
|
219
|
+
bytes[i * 2 + 1] = group & 0xff;
|
|
220
|
+
}
|
|
221
|
+
return bytes;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// A CIDR prefix length must be decimal digits only, so values like "8x" or
|
|
225
|
+
// "1e3" that `Number()` would otherwise coerce are rejected.
|
|
226
|
+
const CIDR_PREFIX_PATTERN = /^\d+$/;
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Parses an IP or `IP/prefix` string into network bytes and a prefix length.
|
|
230
|
+
* The prefix must be digits only and within the address family. `null` if the
|
|
231
|
+
* value is not a valid IP or CIDR range, which keeps a malformed entry from
|
|
232
|
+
* silently behaving like a non-match.
|
|
233
|
+
*/
|
|
234
|
+
function parseCIDR(
|
|
235
|
+
value: string,
|
|
236
|
+
): { bytes: Uint8Array; prefix: number } | null {
|
|
237
|
+
const slash = value.lastIndexOf("/");
|
|
238
|
+
const bytes = ipToBytes(slash === -1 ? value : value.slice(0, slash));
|
|
239
|
+
if (!bytes) {
|
|
240
|
+
return null;
|
|
241
|
+
}
|
|
242
|
+
const maxBits = bytes.length * 8;
|
|
243
|
+
if (slash === -1) {
|
|
244
|
+
return { bytes, prefix: maxBits };
|
|
245
|
+
}
|
|
246
|
+
const prefixPart = value.slice(slash + 1);
|
|
247
|
+
if (!CIDR_PREFIX_PATTERN.test(prefixPart)) {
|
|
248
|
+
return null;
|
|
249
|
+
}
|
|
250
|
+
const prefix = Number(prefixPart);
|
|
251
|
+
return prefix <= maxBits ? { bytes, prefix } : null;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Whether `ipBytes` falls inside an already-parsed CIDR network.
|
|
256
|
+
*/
|
|
257
|
+
function matchesCIDR(
|
|
258
|
+
ipBytes: Uint8Array,
|
|
259
|
+
net: { bytes: Uint8Array; prefix: number },
|
|
260
|
+
): boolean {
|
|
261
|
+
if (ipBytes.length !== net.bytes.length) {
|
|
262
|
+
return false;
|
|
263
|
+
}
|
|
264
|
+
let bitsRemaining = net.prefix;
|
|
265
|
+
for (let i = 0; i < ipBytes.length && bitsRemaining > 0; i++) {
|
|
266
|
+
const take = bitsRemaining >= 8 ? 8 : bitsRemaining;
|
|
267
|
+
const mask = take === 8 ? 0xff : (0xff << (8 - take)) & 0xff;
|
|
268
|
+
if (((ipBytes[i] ?? 0) & mask) !== ((net.bytes[i] ?? 0) & mask)) {
|
|
269
|
+
return false;
|
|
270
|
+
}
|
|
271
|
+
bitsRemaining -= 8;
|
|
272
|
+
}
|
|
273
|
+
return true;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* Trusted-proxy entries that are not a valid IP address or CIDR range.
|
|
278
|
+
*/
|
|
279
|
+
export function findInvalidTrustedProxies(entries: string[]): string[] {
|
|
280
|
+
return entries.filter((entry) => parseCIDR(entry) === null);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* Resolves the client IP from a forwarded header. The leftmost token is spoofable,
|
|
285
|
+
* so with `trustedProxies` the chain is stripped from the right to the first
|
|
286
|
+
* untrusted hop. Otherwise only a single-value header is trusted. Returns `null`
|
|
287
|
+
* when no trustworthy client IP can be resolved.
|
|
288
|
+
*/
|
|
289
|
+
export function getIPFromHeader(
|
|
290
|
+
value: string,
|
|
291
|
+
options: {
|
|
292
|
+
ipv6Subnet?: number;
|
|
293
|
+
trustedProxies?: string[];
|
|
294
|
+
} = {},
|
|
295
|
+
): string | null {
|
|
296
|
+
const forwardedIps = value
|
|
297
|
+
.split(",")
|
|
298
|
+
.map((ip) => ip.trim())
|
|
299
|
+
.filter(Boolean);
|
|
300
|
+
if (forwardedIps.length === 0) {
|
|
301
|
+
return null;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// Parse trusted proxies once, dropping malformed entries so a config typo
|
|
305
|
+
// cannot leave the chain enabled-but-empty and return a real proxy hop as
|
|
306
|
+
// the client. With no valid proxy the chain mode does not engage.
|
|
307
|
+
const trustedProxies = (options.trustedProxies ?? [])
|
|
308
|
+
.map(parseCIDR)
|
|
309
|
+
.filter((proxy): proxy is { bytes: Uint8Array; prefix: number } => {
|
|
310
|
+
return proxy !== null;
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
if (trustedProxies.length > 0) {
|
|
314
|
+
for (let i = forwardedIps.length - 1; i >= 0; i--) {
|
|
315
|
+
const ip = forwardedIps[i];
|
|
316
|
+
const ipBytes = ip ? ipToBytes(ip) : null;
|
|
317
|
+
// A malformed hop breaks the chain: fail closed.
|
|
318
|
+
if (!ip || !ipBytes) {
|
|
319
|
+
return null;
|
|
320
|
+
}
|
|
321
|
+
if (trustedProxies.some((proxy) => matchesCIDR(ipBytes, proxy))) {
|
|
322
|
+
continue;
|
|
323
|
+
}
|
|
324
|
+
return normalizeIP(ip, { ipv6Subnet: options.ipv6Subnet });
|
|
325
|
+
}
|
|
326
|
+
return null;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
// Without valid trusted proxies a multi-hop chain is unresolvable.
|
|
330
|
+
if (forwardedIps.length !== 1) {
|
|
331
|
+
return null;
|
|
332
|
+
}
|
|
333
|
+
const selectedIp = forwardedIps[0];
|
|
334
|
+
if (!selectedIp || !isValidIP(selectedIp)) {
|
|
335
|
+
return null;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
return normalizeIP(selectedIp, { ipv6Subnet: options.ipv6Subnet });
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
const LOCALHOST_IP = "127.0.0.1";
|
|
342
|
+
const DEFAULT_IP_HEADERS = ["x-forwarded-for"];
|
|
343
|
+
|
|
344
|
+
/**
|
|
345
|
+
* Resolves the client IP for a request from the configured IP headers.
|
|
346
|
+
* Honors `disableIpTracking`, walks `ipAddressHeaders` in order (default
|
|
347
|
+
* `x-forwarded-for`), and falls back to localhost in development and test.
|
|
348
|
+
* Returns `null` when tracking is disabled or no trustworthy IP can be resolved.
|
|
349
|
+
*/
|
|
350
|
+
export function getIp(
|
|
351
|
+
req: Request | Headers,
|
|
352
|
+
options: BetterAuthOptions,
|
|
353
|
+
): string | null {
|
|
354
|
+
if (options.advanced?.ipAddress?.disableIpTracking) {
|
|
355
|
+
return null;
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
const headers = "headers" in req ? req.headers : req;
|
|
359
|
+
|
|
360
|
+
const ipHeaders =
|
|
361
|
+
options.advanced?.ipAddress?.ipAddressHeaders || DEFAULT_IP_HEADERS;
|
|
362
|
+
|
|
363
|
+
for (const key of ipHeaders) {
|
|
364
|
+
const value = "get" in headers ? headers.get(key) : headers[key];
|
|
365
|
+
if (typeof value === "string") {
|
|
366
|
+
const ip = getIPFromHeader(value, {
|
|
367
|
+
ipv6Subnet: options.advanced?.ipAddress?.ipv6Subnet,
|
|
368
|
+
trustedProxies: options.advanced?.ipAddress?.trustedProxies,
|
|
369
|
+
});
|
|
370
|
+
if (ip) {
|
|
371
|
+
return ip;
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
if (isTest() || isDevelopment()) {
|
|
377
|
+
return LOCALHOST_IP;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
return null;
|
|
381
|
+
}
|
|
382
|
+
|
|
198
383
|
/**
|
|
199
384
|
* Creates a rate limit key from IP and path
|
|
200
385
|
* Uses a separator to prevent collision attacks
|