@better-auth/core 1.6.21 → 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/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/package.json +1 -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/dist/context/global.mjs
CHANGED
|
@@ -2,7 +2,7 @@ import { ATTR_HTTP_RESPONSE_STATUS_CODE } from "./attributes.mjs";
|
|
|
2
2
|
import { getOpenTelemetryAPI } from "./api.mjs";
|
|
3
3
|
//#region src/instrumentation/tracer.ts
|
|
4
4
|
const INSTRUMENTATION_SCOPE = "better-auth";
|
|
5
|
-
const INSTRUMENTATION_VERSION = "1.6.
|
|
5
|
+
const INSTRUMENTATION_VERSION = "1.6.22";
|
|
6
6
|
/**
|
|
7
7
|
* Better-auth uses `throw ctx.redirect(url)` for flow control (e.g. OAuth
|
|
8
8
|
* callbacks). These are APIErrors with 3xx status codes and should not be
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
+
import { fetchRefusingRedirects } from "./reject-redirects.mjs";
|
|
1
2
|
import { base64Url } from "@better-auth/utils/base64";
|
|
2
|
-
import { betterFetch } from "@better-fetch/fetch";
|
|
3
3
|
//#region src/oauth2/client-credentials-token.ts
|
|
4
4
|
async function clientCredentialsTokenRequest({ options, scope, authentication, resource }) {
|
|
5
5
|
options = typeof options === "function" ? await options() : options;
|
|
@@ -43,7 +43,7 @@ async function clientCredentialsToken({ options, tokenEndpoint, scope, authentic
|
|
|
43
43
|
authentication,
|
|
44
44
|
resource
|
|
45
45
|
});
|
|
46
|
-
const { data, error } = await
|
|
46
|
+
const { data, error } = await fetchRefusingRedirects(tokenEndpoint, {
|
|
47
47
|
method: "POST",
|
|
48
48
|
body,
|
|
49
49
|
headers
|
|
@@ -1,5 +1,5 @@
|
|
|
1
|
+
import { fetchRefusingRedirects } from "./reject-redirects.mjs";
|
|
1
2
|
import { base64 } from "@better-auth/utils/base64";
|
|
2
|
-
import { betterFetch } from "@better-fetch/fetch";
|
|
3
3
|
//#region src/oauth2/refresh-access-token.ts
|
|
4
4
|
async function refreshAccessTokenRequest({ refreshToken, options, authentication, extraParams, resource }) {
|
|
5
5
|
options = typeof options === "function" ? await options() : options;
|
|
@@ -46,7 +46,7 @@ async function refreshAccessToken({ refreshToken, options, tokenEndpoint, authen
|
|
|
46
46
|
authentication,
|
|
47
47
|
extraParams
|
|
48
48
|
});
|
|
49
|
-
const { data, error } = await
|
|
49
|
+
const { data, error } = await fetchRefusingRedirects(tokenEndpoint, {
|
|
50
50
|
method: "POST",
|
|
51
51
|
body,
|
|
52
52
|
headers
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { BetterAuthError } from "../error/index.mjs";
|
|
2
|
+
import { betterFetch } from "@better-fetch/fetch";
|
|
3
|
+
//#region src/oauth2/reject-redirects.ts
|
|
4
|
+
const HTTP_REDIRECT_STATUSES = new Set([
|
|
5
|
+
301,
|
|
6
|
+
302,
|
|
7
|
+
303,
|
|
8
|
+
307,
|
|
9
|
+
308
|
|
10
|
+
]);
|
|
11
|
+
/**
|
|
12
|
+
* Whether a response from a `redirect: "manual"` fetch is a redirect.
|
|
13
|
+
*
|
|
14
|
+
* Node/undici exposes the real 3xx status. Spec-compliant runtimes (Cloudflare
|
|
15
|
+
* Workers, Deno, browsers) return an opaque-redirect filtered response with
|
|
16
|
+
* status 0 and type `"opaqueredirect"`, so the status alone is not enough.
|
|
17
|
+
*/
|
|
18
|
+
function isRedirectResponse(response) {
|
|
19
|
+
return response.type === "opaqueredirect" || HTTP_REDIRECT_STATUSES.has(response.status);
|
|
20
|
+
}
|
|
21
|
+
function redirectRefused(endpoint) {
|
|
22
|
+
return new BetterAuthError(`The OAuth endpoint "${endpoint}" returned an HTTP redirect. Server-side OAuth fetches refuse redirects to prevent SSRF; configure the final endpoint URL.`);
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Fetch option that refuses HTTP redirects portably.
|
|
26
|
+
*
|
|
27
|
+
* Cloudflare Workers (workerd) rejects `redirect: "error"`, so manual mode is
|
|
28
|
+
* used and the resolved response is checked with {@link assertResponseNotRedirect}
|
|
29
|
+
* (or, for betterFetch, with {@link fetchRefusingRedirects}).
|
|
30
|
+
*/
|
|
31
|
+
const NO_FOLLOW_REDIRECT = { redirect: "manual" };
|
|
32
|
+
/**
|
|
33
|
+
* Throw when a native-`fetch` response (e.g. jose's JWKS loader) resolved to a
|
|
34
|
+
* redirect, so an attacker-influenced endpoint cannot bounce a server-side
|
|
35
|
+
* request to an internal address.
|
|
36
|
+
*/
|
|
37
|
+
function assertResponseNotRedirect(endpoint, response) {
|
|
38
|
+
if (isRedirectResponse(response)) throw redirectRefused(endpoint);
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* betterFetch that refuses HTTP redirects on a server-side OAuth fetch.
|
|
42
|
+
*
|
|
43
|
+
* Returns the betterFetch result and throws if the endpoint redirected, on both
|
|
44
|
+
* undici (real 3xx status) and spec-compliant runtimes (opaque redirect, where
|
|
45
|
+
* the error status is 0). The redirect is never followed on any runtime.
|
|
46
|
+
*/
|
|
47
|
+
async function fetchRefusingRedirects(url, options) {
|
|
48
|
+
let redirected = false;
|
|
49
|
+
const result = await betterFetch(url, {
|
|
50
|
+
...options,
|
|
51
|
+
...NO_FOLLOW_REDIRECT,
|
|
52
|
+
onError(context) {
|
|
53
|
+
if (isRedirectResponse(context.response)) redirected = true;
|
|
54
|
+
}
|
|
55
|
+
});
|
|
56
|
+
if (redirected) throw redirectRefused(url);
|
|
57
|
+
return result;
|
|
58
|
+
}
|
|
59
|
+
//#endregion
|
|
60
|
+
export { NO_FOLLOW_REDIRECT, assertResponseNotRedirect, fetchRefusingRedirects };
|
|
@@ -1,7 +1,7 @@
|
|
|
1
|
+
import { NO_FOLLOW_REDIRECT, assertResponseNotRedirect, fetchRefusingRedirects } from "./reject-redirects.mjs";
|
|
1
2
|
import { getOAuth2Tokens } from "./utils.mjs";
|
|
2
3
|
import { base64 } from "@better-auth/utils/base64";
|
|
3
|
-
import {
|
|
4
|
-
import { createRemoteJWKSet, jwtVerify } from "jose";
|
|
4
|
+
import { createRemoteJWKSet, customFetch, jwtVerify } from "jose";
|
|
5
5
|
//#region src/oauth2/validate-authorization-code.ts
|
|
6
6
|
async function authorizationCodeRequest({ code, codeVerifier, redirectURI, options, authentication, deviceId, headers, additionalParams = {}, resource }) {
|
|
7
7
|
options = typeof options === "function" ? await options() : options;
|
|
@@ -61,7 +61,7 @@ async function validateAuthorizationCode({ code, codeVerifier, redirectURI, opti
|
|
|
61
61
|
additionalParams,
|
|
62
62
|
resource
|
|
63
63
|
});
|
|
64
|
-
const { data, error } = await
|
|
64
|
+
const { data, error } = await fetchRefusingRedirects(tokenEndpoint, {
|
|
65
65
|
method: "POST",
|
|
66
66
|
body,
|
|
67
67
|
headers: requestHeaders
|
|
@@ -70,7 +70,14 @@ async function validateAuthorizationCode({ code, codeVerifier, redirectURI, opti
|
|
|
70
70
|
return getOAuth2Tokens(data);
|
|
71
71
|
}
|
|
72
72
|
async function validateToken(token, jwksEndpoint, options) {
|
|
73
|
-
return await jwtVerify(token, createRemoteJWKSet(new URL(jwksEndpoint)
|
|
73
|
+
return await jwtVerify(token, createRemoteJWKSet(new URL(jwksEndpoint), { [customFetch]: async (url, init) => {
|
|
74
|
+
const response = await fetch(url, {
|
|
75
|
+
...init,
|
|
76
|
+
...NO_FOLLOW_REDIRECT
|
|
77
|
+
});
|
|
78
|
+
assertResponseNotRedirect(String(url), response);
|
|
79
|
+
return response;
|
|
80
|
+
} }), {
|
|
74
81
|
audience: options?.audience,
|
|
75
82
|
issuer: options?.issuer
|
|
76
83
|
});
|
package/dist/oauth2/verify.mjs
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { logger } from "../env/logger.mjs";
|
|
2
|
+
import { fetchRefusingRedirects } from "./reject-redirects.mjs";
|
|
2
3
|
import { APIError } from "better-call";
|
|
3
|
-
import { betterFetch } from "@better-fetch/fetch";
|
|
4
4
|
import { UnsecuredJWT, createLocalJWKSet, decodeProtectedHeader, errors, jwtVerify } from "jose";
|
|
5
5
|
//#region src/oauth2/verify.ts
|
|
6
6
|
const joseInfrastructureErrorCodes = new Set([
|
|
@@ -44,7 +44,7 @@ function shouldRefetchCachedJwksWithoutKid(error, resolved) {
|
|
|
44
44
|
return Date.now() - resolved.noKidRefetchedAt >= JWKS_NO_KID_REFETCH_COOLDOWN_MS;
|
|
45
45
|
}
|
|
46
46
|
async function fetchJwks(jwksFetch) {
|
|
47
|
-
const jwks = typeof jwksFetch === "string" ? await
|
|
47
|
+
const jwks = typeof jwksFetch === "string" ? await fetchRefusingRedirects(jwksFetch, { headers: { Accept: "application/json" } }).then(async (res) => {
|
|
48
48
|
if (res.error) throw new Error(`Jwks failed: ${res.error.message ?? res.error.statusText}`);
|
|
49
49
|
return res.data;
|
|
50
50
|
}) : await jwksFetch();
|
|
@@ -166,7 +166,7 @@ async function verifyAccessToken(token, opts) {
|
|
|
166
166
|
else throw new Error(error);
|
|
167
167
|
}
|
|
168
168
|
if (opts?.remoteVerify) {
|
|
169
|
-
const { data: introspect, error: introspectError } = await
|
|
169
|
+
const { data: introspect, error: introspectError } = await fetchRefusingRedirects(opts.remoteVerify.introspectUrl, {
|
|
170
170
|
method: "POST",
|
|
171
171
|
headers: {
|
|
172
172
|
Accept: "application/json",
|
package/package.json
CHANGED
|
@@ -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}`,
|