@better-auth/core 1.7.0-beta.8 → 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.
- 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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@better-auth/core",
|
|
3
|
-
"version": "1.7.0-
|
|
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.
|
|
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
|
-
"
|
|
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.
|
|
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
|
-
|
|
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)}`,
|
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
|
|
package/src/error/index.ts
CHANGED
|
@@ -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
|
|
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
|
|
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 {
|
|
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
|
|
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,
|
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,
|
|
@@ -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
|
|
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 } =
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
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 {
|
|
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
|
-
*
|
|
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
|
/**
|
|
@@ -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:
|
|
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
|
|
153
|
-
// be accepted on the id_token
|
|
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) =>
|
|
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
|
-
//
|
|
168
|
-
//
|
|
169
|
-
|
|
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
|
|
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
|
|
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
|
|
214
|
-
if (!
|
|
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
|
|
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/
|
|
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
|
/**
|