@better-auth/core 1.7.0-beta.9 → 1.7.0-rc.1
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/error/index.d.mts +7 -0
- 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 +65 -0
- package/dist/oauth2/validate-authorization-code.mjs +11 -4
- package/dist/oauth2/verify.mjs +3 -3
- package/dist/social-providers/google.d.mts +23 -3
- package/dist/social-providers/google.mjs +56 -9
- 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 -0
- 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 +10 -10
- package/src/db/adapter/factory.ts +9 -4
- package/src/db/adapter/index.ts +8 -2
- package/src/db/get-tables.ts +7 -1
- package/src/error/index.ts +9 -0
- package/src/oauth2/client-credentials-token.ts +2 -2
- package/src/oauth2/refresh-access-token.ts +2 -2
- package/src/oauth2/reject-redirects.ts +75 -0
- package/src/oauth2/validate-authorization-code.ts +14 -4
- package/src/oauth2/verify.ts +20 -19
- package/src/social-providers/google.ts +103 -17
- package/src/social-providers/paypal.ts +22 -0
- package/src/types/init-options.ts +16 -1
- package/src/utils/ip.ts +185 -0
package/dist/context/global.mjs
CHANGED
|
@@ -458,6 +458,7 @@ const createAdapterFactory = ({ adapter: customAdapter, config: cfg }) => (optio
|
|
|
458
458
|
where: unsafeWhere,
|
|
459
459
|
action: "update"
|
|
460
460
|
});
|
|
461
|
+
if (where.length === 0) return null;
|
|
461
462
|
debugLog({ method: "update" }, `${formatTransactionId(thisTransactionId)} ${formatStep(1, 4)}`, `${formatMethod("update")} ${formatAction("Unsafe Input")}:`, {
|
|
462
463
|
model,
|
|
463
464
|
data: unsafeData
|
|
@@ -396,8 +396,14 @@ type DBAdapter<Options extends BetterAuthOptions = BetterAuthOptions> = {
|
|
|
396
396
|
where?: Where[] | undefined;
|
|
397
397
|
}) => Promise<number>;
|
|
398
398
|
/**
|
|
399
|
-
*
|
|
400
|
-
*
|
|
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;
|
package/dist/db/get-tables.mjs
CHANGED
|
@@ -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
|
}, {});
|
package/dist/error/index.d.mts
CHANGED
|
@@ -7,7 +7,14 @@ declare class BetterAuthError extends Error {
|
|
|
7
7
|
cause?: unknown | undefined;
|
|
8
8
|
});
|
|
9
9
|
}
|
|
10
|
+
type BaseAPIErrorInstance = InstanceType<typeof APIError$1>;
|
|
10
11
|
declare class APIError extends APIError$1 {
|
|
12
|
+
status: BaseAPIErrorInstance["status"];
|
|
13
|
+
body: BaseAPIErrorInstance["body"];
|
|
14
|
+
headers: BaseAPIErrorInstance["headers"];
|
|
15
|
+
statusCode: BaseAPIErrorInstance["statusCode"];
|
|
16
|
+
message: string;
|
|
17
|
+
errorStack: BaseAPIErrorInstance["errorStack"];
|
|
11
18
|
constructor(...args: ConstructorParameters<typeof APIError$1>);
|
|
12
19
|
static fromStatus(status: ConstructorParameters<typeof APIError$1>[0], body?: ConstructorParameters<typeof APIError$1>[1]): APIError;
|
|
13
20
|
static from(status: ConstructorParameters<typeof APIError$1>[0], error: {
|
|
@@ -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.7.0-
|
|
5
|
+
const INSTRUMENTATION_VERSION = "1.7.0-rc.1";
|
|
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 { applyTokenEndpointAuth } from "./token-endpoint-auth.mjs";
|
|
2
|
-
import { betterFetch } from "@better-fetch/fetch";
|
|
3
3
|
//#region src/oauth2/client-credentials-token.ts
|
|
4
4
|
async function clientCredentialsTokenRequest({ options, scope, authentication, tokenEndpointAuth, tokenEndpoint, resource }) {
|
|
5
5
|
options = typeof options === "function" ? await options() : options;
|
|
@@ -46,7 +46,7 @@ async function clientCredentialsToken({ options, tokenEndpoint, scope, authentic
|
|
|
46
46
|
tokenEndpoint,
|
|
47
47
|
resource
|
|
48
48
|
});
|
|
49
|
-
const { data, error } = await
|
|
49
|
+
const { data, error } = await fetchRefusingRedirects(tokenEndpoint, {
|
|
50
50
|
method: "POST",
|
|
51
51
|
body,
|
|
52
52
|
headers
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { parseScopeField } from "./utils.mjs";
|
|
2
|
+
import { fetchRefusingRedirects } from "./reject-redirects.mjs";
|
|
2
3
|
import { applyTokenEndpointAuth } from "./token-endpoint-auth.mjs";
|
|
3
|
-
import { betterFetch } from "@better-fetch/fetch";
|
|
4
4
|
//#region src/oauth2/refresh-access-token.ts
|
|
5
5
|
const BLOCKED_REFRESH_TOKEN_PARAMS_SET = new Set([
|
|
6
6
|
"grant_type",
|
|
@@ -61,7 +61,7 @@ async function refreshAccessToken({ refreshToken, options, tokenEndpoint, authen
|
|
|
61
61
|
extraParams,
|
|
62
62
|
resource
|
|
63
63
|
});
|
|
64
|
-
const { data, error } = await
|
|
64
|
+
const { data, error } = await fetchRefusingRedirects(tokenEndpoint, {
|
|
65
65
|
method: "POST",
|
|
66
66
|
body,
|
|
67
67
|
headers
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { BetterAuthError } from "../error/index.mjs";
|
|
2
|
+
import { betterFetch } from "@better-fetch/fetch";
|
|
3
|
+
//#region src/oauth2/reject-redirects.ts
|
|
4
|
+
const httpRedirectStatuses = 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" || httpRedirectStatuses.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 noFollowRedirect = { 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 onError = options?.onError;
|
|
50
|
+
const result = await betterFetch(url, {
|
|
51
|
+
...options,
|
|
52
|
+
...noFollowRedirect,
|
|
53
|
+
async onError(context) {
|
|
54
|
+
if (isRedirectResponse(context.response)) redirected = true;
|
|
55
|
+
await onError?.(context);
|
|
56
|
+
}
|
|
57
|
+
}).catch((error) => {
|
|
58
|
+
if (redirected) throw redirectRefused(url);
|
|
59
|
+
throw error;
|
|
60
|
+
});
|
|
61
|
+
if (redirected) throw redirectRefused(url);
|
|
62
|
+
return result;
|
|
63
|
+
}
|
|
64
|
+
//#endregion
|
|
65
|
+
export { assertResponseNotRedirect, fetchRefusingRedirects, noFollowRedirect };
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { getOAuth2Tokens } from "./utils.mjs";
|
|
2
|
+
import { assertResponseNotRedirect, fetchRefusingRedirects, noFollowRedirect } from "./reject-redirects.mjs";
|
|
2
3
|
import { applyTokenEndpointAuth } from "./token-endpoint-auth.mjs";
|
|
3
|
-
import { createRemoteJWKSet, jwtVerify } from "jose";
|
|
4
|
-
import { betterFetch } from "@better-fetch/fetch";
|
|
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, tokenEndpointAuth, tokenEndpoint, 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
|
|
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
|
+
...noFollowRedirect
|
|
77
|
+
});
|
|
78
|
+
assertResponseNotRedirect(String(url), response);
|
|
79
|
+
return response;
|
|
80
|
+
} }), {
|
|
74
81
|
audience: options?.audience,
|
|
75
82
|
issuer: options?.issuer
|
|
76
83
|
});
|
package/dist/oauth2/verify.mjs
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { logger } from "../env/logger.mjs";
|
|
2
|
+
import { fetchRefusingRedirects } from "./reject-redirects.mjs";
|
|
2
3
|
import { createInMemoryDpopReplayStore, enforceDpopBinding, getDpopJktFromPayload, isDpopBindingError, parseAccessTokenAuthorization } from "./dpop.mjs";
|
|
3
4
|
import { APIError } from "better-call";
|
|
4
5
|
import { UnsecuredJWT, createLocalJWKSet, decodeProtectedHeader, errors, jwtVerify } from "jose";
|
|
5
|
-
import { betterFetch } from "@better-fetch/fetch";
|
|
6
6
|
//#region src/oauth2/verify.ts
|
|
7
7
|
const joseInfrastructureErrorCodes = new Set([
|
|
8
8
|
errors.JWKSTimeout.code,
|
|
@@ -45,7 +45,7 @@ function shouldRefetchCachedJwksWithoutKid(error, resolved) {
|
|
|
45
45
|
return Date.now() - resolved.noKidRefetchedAt >= JWKS_NO_KID_REFETCH_COOLDOWN_MS;
|
|
46
46
|
}
|
|
47
47
|
async function fetchJwks(jwksFetch) {
|
|
48
|
-
const jwks = typeof jwksFetch === "string" ? await
|
|
48
|
+
const jwks = typeof jwksFetch === "string" ? await fetchRefusingRedirects(jwksFetch, { headers: { Accept: "application/json" } }).then(async (res) => {
|
|
49
49
|
if (res.error) throw new Error(`Jwks failed: ${res.error.message ?? res.error.statusText}`);
|
|
50
50
|
return res.data;
|
|
51
51
|
}) : await jwksFetch();
|
|
@@ -181,7 +181,7 @@ async function verifyAccessTokenPayload(token, opts) {
|
|
|
181
181
|
else throw new Error(error);
|
|
182
182
|
}
|
|
183
183
|
if (opts?.remoteVerify) {
|
|
184
|
-
const { data: introspect, error: introspectError } = await
|
|
184
|
+
const { data: introspect, error: introspectError } = await fetchRefusingRedirects(opts.remoteVerify.introspectUrl, {
|
|
185
185
|
method: "POST",
|
|
186
186
|
headers: {
|
|
187
187
|
Accept: "application/json",
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { OAuth2Tokens, ProviderOptions } from "../oauth2/oauth-provider.mjs";
|
|
2
2
|
import * as jose from "jose";
|
|
3
|
+
import { JWTPayload } from "jose";
|
|
3
4
|
|
|
4
5
|
//#region src/social-providers/google.d.ts
|
|
5
6
|
interface GoogleProfile {
|
|
@@ -43,8 +44,8 @@ interface GoogleOptions extends ProviderOptions<GoogleProfile> {
|
|
|
43
44
|
*
|
|
44
45
|
* This is sent to Google as the `hd` authorization hint and, when set, is
|
|
45
46
|
* also enforced against the `hd` claim of the returned id token/profile.
|
|
46
|
-
*
|
|
47
|
-
*
|
|
47
|
+
* Set `hd: "*"` to require any Workspace hosted-domain claim. Sign-in is
|
|
48
|
+
* rejected when the claim is missing or does not satisfy this restriction.
|
|
48
49
|
*/
|
|
49
50
|
hd?: string | undefined;
|
|
50
51
|
/**
|
|
@@ -59,6 +60,25 @@ interface GoogleOptions extends ProviderOptions<GoogleProfile> {
|
|
|
59
60
|
*/
|
|
60
61
|
includeGrantedScopes?: boolean | undefined;
|
|
61
62
|
}
|
|
63
|
+
interface VerifyGoogleIdTokenOptions {
|
|
64
|
+
token: string;
|
|
65
|
+
audience: string | string[];
|
|
66
|
+
nonce?: string | undefined;
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Verifies a Google ID token against Google's issuer, audience, signature,
|
|
70
|
+
* expiry, and maximum token age.
|
|
71
|
+
*/
|
|
72
|
+
declare const verifyGoogleIdToken: ({
|
|
73
|
+
token,
|
|
74
|
+
audience,
|
|
75
|
+
nonce
|
|
76
|
+
}: VerifyGoogleIdTokenOptions) => Promise<JWTPayload | null>;
|
|
77
|
+
/**
|
|
78
|
+
* Checks whether Google's verified `hd` claim satisfies the configured hosted
|
|
79
|
+
* domain restriction. `hd: "*"` accepts any Google Workspace hosted domain.
|
|
80
|
+
*/
|
|
81
|
+
declare const isGoogleHostedDomainAllowed: (configuredHostedDomain: string | undefined, tokenHostedDomain: unknown) => boolean;
|
|
62
82
|
declare const google: (options: GoogleOptions) => {
|
|
63
83
|
id: "google";
|
|
64
84
|
name: string;
|
|
@@ -122,4 +142,4 @@ declare const google: (options: GoogleOptions) => {
|
|
|
122
142
|
};
|
|
123
143
|
declare const getGooglePublicKey: (kid: string) => Promise<Uint8Array<ArrayBufferLike> | CryptoKey>;
|
|
124
144
|
//#endregion
|
|
125
|
-
export { GoogleOptions, GoogleProfile, getGooglePublicKey, google };
|
|
145
|
+
export { GoogleOptions, GoogleProfile, VerifyGoogleIdTokenOptions, getGooglePublicKey, google, isGoogleHostedDomainAllowed, verifyGoogleIdToken };
|
|
@@ -4,9 +4,51 @@ import { getPrimaryClientId } from "../oauth2/utils.mjs";
|
|
|
4
4
|
import { createAuthorizationURL } from "../oauth2/create-authorization-url.mjs";
|
|
5
5
|
import { refreshAccessToken } from "../oauth2/refresh-access-token.mjs";
|
|
6
6
|
import { validateAuthorizationCode } from "../oauth2/validate-authorization-code.mjs";
|
|
7
|
-
import { decodeJwt, importJWK } from "jose";
|
|
7
|
+
import { decodeJwt, decodeProtectedHeader, importJWK, jwtVerify } from "jose";
|
|
8
8
|
import { betterFetch } from "@better-fetch/fetch";
|
|
9
9
|
//#region src/social-providers/google.ts
|
|
10
|
+
const GOOGLE_ID_TOKEN_MAX_AGE = "1h";
|
|
11
|
+
const GOOGLE_ID_TOKEN_ALGORITHM = "RS256";
|
|
12
|
+
const GOOGLE_ID_TOKEN_ALGORITHMS = [GOOGLE_ID_TOKEN_ALGORITHM];
|
|
13
|
+
function isGoogleIdTokenAlgorithm(algorithm) {
|
|
14
|
+
return GOOGLE_ID_TOKEN_ALGORITHMS.includes(algorithm);
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Verifies a Google ID token against Google's issuer, audience, signature,
|
|
18
|
+
* expiry, and maximum token age.
|
|
19
|
+
*/
|
|
20
|
+
const verifyGoogleIdToken = async ({ token, audience, nonce }) => {
|
|
21
|
+
try {
|
|
22
|
+
const { kid, alg } = decodeProtectedHeader(token);
|
|
23
|
+
if (!isGoogleIdTokenAlgorithm(alg)) return null;
|
|
24
|
+
const publicKeys = await getGooglePublicKeys(kid);
|
|
25
|
+
for (const publicKey of publicKeys) try {
|
|
26
|
+
const { payload: jwtClaims } = await jwtVerify(token, publicKey, {
|
|
27
|
+
algorithms: GOOGLE_ID_TOKEN_ALGORITHMS,
|
|
28
|
+
issuer: ["https://accounts.google.com", "accounts.google.com"],
|
|
29
|
+
audience,
|
|
30
|
+
maxTokenAge: GOOGLE_ID_TOKEN_MAX_AGE
|
|
31
|
+
});
|
|
32
|
+
if (nonce && jwtClaims.nonce !== nonce) return null;
|
|
33
|
+
return jwtClaims;
|
|
34
|
+
} catch {
|
|
35
|
+
continue;
|
|
36
|
+
}
|
|
37
|
+
return null;
|
|
38
|
+
} catch {
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
};
|
|
42
|
+
/**
|
|
43
|
+
* Checks whether Google's verified `hd` claim satisfies the configured hosted
|
|
44
|
+
* domain restriction. `hd: "*"` accepts any Google Workspace hosted domain.
|
|
45
|
+
*/
|
|
46
|
+
const isGoogleHostedDomainAllowed = (configuredHostedDomain, tokenHostedDomain) => {
|
|
47
|
+
if (!configuredHostedDomain) return true;
|
|
48
|
+
if (typeof tokenHostedDomain !== "string" || !tokenHostedDomain) return false;
|
|
49
|
+
if (configuredHostedDomain === "*") return true;
|
|
50
|
+
return tokenHostedDomain === configuredHostedDomain;
|
|
51
|
+
};
|
|
10
52
|
const google = (options) => {
|
|
11
53
|
return {
|
|
12
54
|
id: "google",
|
|
@@ -67,15 +109,15 @@ const google = (options) => {
|
|
|
67
109
|
jwks: (header) => getGooglePublicKey(header.kid),
|
|
68
110
|
issuer: ["https://accounts.google.com", "accounts.google.com"],
|
|
69
111
|
audience: options.clientId,
|
|
70
|
-
maxTokenAge:
|
|
71
|
-
verifyClaims: options.hd ? (claims) =>
|
|
112
|
+
maxTokenAge: GOOGLE_ID_TOKEN_MAX_AGE,
|
|
113
|
+
verifyClaims: options.hd ? (claims) => isGoogleHostedDomainAllowed(options.hd, claims.hd) : void 0
|
|
72
114
|
},
|
|
73
115
|
async getUserInfo(token) {
|
|
74
116
|
if (options.getUserInfo) return options.getUserInfo(token);
|
|
75
117
|
if (!token.idToken) return null;
|
|
76
118
|
const user = decodeJwt(token.idToken);
|
|
77
|
-
if (options.hd
|
|
78
|
-
logger.error(`Google sign-in rejected: id token hosted domain (hd) "${user.hd ?? "<missing>"}" does not
|
|
119
|
+
if (!isGoogleHostedDomainAllowed(options.hd, user.hd)) {
|
|
120
|
+
logger.error(`Google sign-in rejected: id token hosted domain (hd) "${user.hd ?? "<missing>"}" does not satisfy the configured "hd" option "${options.hd}".`);
|
|
79
121
|
return null;
|
|
80
122
|
}
|
|
81
123
|
const userMap = await options.mapProfileToUser?.(user);
|
|
@@ -95,11 +137,16 @@ const google = (options) => {
|
|
|
95
137
|
};
|
|
96
138
|
};
|
|
97
139
|
const getGooglePublicKey = async (kid) => {
|
|
140
|
+
const [publicKey] = await getGooglePublicKeys(kid);
|
|
141
|
+
if (!publicKey) throw new Error(`JWK with kid ${kid} not found`);
|
|
142
|
+
return publicKey;
|
|
143
|
+
};
|
|
144
|
+
const getGooglePublicKeys = async (kid) => {
|
|
98
145
|
const { data } = await betterFetch("https://www.googleapis.com/oauth2/v3/certs");
|
|
99
146
|
if (!data?.keys) throw new APIError("BAD_REQUEST", { message: "Keys not found" });
|
|
100
|
-
const
|
|
101
|
-
if (!
|
|
102
|
-
return
|
|
147
|
+
const jwks = kid ? data.keys.filter((key) => key.kid === kid) : data.keys;
|
|
148
|
+
if (!jwks.length) throw new Error(`JWK with kid ${kid} not found`);
|
|
149
|
+
return Promise.all(jwks.map((jwk) => importJWK(jwk, GOOGLE_ID_TOKEN_ALGORITHM)));
|
|
103
150
|
};
|
|
104
151
|
//#endregion
|
|
105
|
-
export { getGooglePublicKey, google };
|
|
152
|
+
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";
|
|
@@ -2007,4 +2007,4 @@ type SocialProviders = { [K in SocialProviderList[number]]?: AwaitableFunction<P
|
|
|
2007
2007
|
}> };
|
|
2008
2008
|
type SocialProviderList = typeof socialProviderList;
|
|
2009
2009
|
//#endregion
|
|
2010
|
-
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, 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 };
|
|
2010
|
+
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, 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, 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, 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 };
|
|
@@ -2,6 +2,7 @@ import { BetterAuthError } from "../error/index.mjs";
|
|
|
2
2
|
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
|
+
import { decodeJwt } from "jose";
|
|
5
6
|
import { betterFetch } from "@better-fetch/fetch";
|
|
6
7
|
//#region src/social-providers/paypal.ts
|
|
7
8
|
const paypal = (options) => {
|
|
@@ -106,6 +107,20 @@ const paypal = (options) => {
|
|
|
106
107
|
return null;
|
|
107
108
|
}
|
|
108
109
|
const userInfo = response.data;
|
|
110
|
+
if (token.idToken) {
|
|
111
|
+
let idTokenSubject;
|
|
112
|
+
try {
|
|
113
|
+
idTokenSubject = decodeJwt(token.idToken).sub;
|
|
114
|
+
} catch (error) {
|
|
115
|
+
logger.error("Failed to decode PayPal ID token:", error);
|
|
116
|
+
return null;
|
|
117
|
+
}
|
|
118
|
+
const userInfoSubject = userInfo.sub ?? userInfo.user_id;
|
|
119
|
+
if (!idTokenSubject || userInfoSubject !== idTokenSubject) {
|
|
120
|
+
logger.error("PayPal user info subject does not match ID token subject");
|
|
121
|
+
return null;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
109
124
|
const userMap = await options.mapProfileToUser?.(userInfo);
|
|
110
125
|
return {
|
|
111
126
|
user: {
|
|
@@ -236,7 +236,7 @@ type BetterAuthAdvancedOptions = {
|
|
|
236
236
|
* @example ["x-client-ip", "x-forwarded-for", "cf-connecting-ip"]
|
|
237
237
|
*
|
|
238
238
|
* @default
|
|
239
|
-
* @link https://github.com/better-auth/better-auth/blob/main/packages/
|
|
239
|
+
* @link https://github.com/better-auth/better-auth/blob/main/packages/core/src/utils/ip.ts
|
|
240
240
|
*/
|
|
241
241
|
ipAddressHeaders?: string[];
|
|
242
242
|
/**
|
|
@@ -253,6 +253,21 @@ type BetterAuthAdvancedOptions = {
|
|
|
253
253
|
* @default 64
|
|
254
254
|
*/
|
|
255
255
|
ipv6Subnet?: number;
|
|
256
|
+
/**
|
|
257
|
+
* Trusted reverse-proxy IPs or CIDR ranges. When set, a forwarded IP
|
|
258
|
+
* chain is walked right to left, trusted hops are skipped, and the
|
|
259
|
+
* first untrusted address is the client IP. Unset trusts only
|
|
260
|
+
* single-value IP headers. Use the actual address or subnet of your
|
|
261
|
+
* proxies, not a broad private range that also covers clients.
|
|
262
|
+
*
|
|
263
|
+
* This only interprets the forwarded header chain and cannot verify
|
|
264
|
+
* the direct sender. It is safe only when your origin is reachable
|
|
265
|
+
* through these proxies and clients cannot set forwarded headers
|
|
266
|
+
* directly.
|
|
267
|
+
*
|
|
268
|
+
* @example ["192.0.2.10", "10.0.0.0/24"]
|
|
269
|
+
*/
|
|
270
|
+
trustedProxies?: string[];
|
|
256
271
|
} | undefined;
|
|
257
272
|
/**
|
|
258
273
|
* Force cookies to always use the `Secure` attribute. By default,
|
package/dist/utils/ip.d.mts
CHANGED
|
@@ -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, getIP, getIPFromHeader, 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, getIP, getIPFromHeader, isValidIP, normalizeIP };
|