Skip to content

Commit b4eedf3

Browse files
committed
fix: avoid reusing disturbed request bodies
1 parent 7a15b30 commit b4eedf3

3 files changed

Lines changed: 69 additions & 16 deletions

File tree

src/server/auth-client.ts

Lines changed: 12 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -203,6 +203,7 @@ export interface AuthClientOptions {
203203
enableAccessTokenEndpoint?: boolean;
204204
noContentProfileResponseWhenUnauthenticated?: boolean;
205205
enableConnectAccountEndpoint?: boolean;
206+
tokenRefreshLeeway?: number;
206207

207208
useDPoP?: boolean;
208209
dpopKeyPair?: DpopKeyPair;
@@ -258,6 +259,7 @@ export class AuthClient {
258259
private readonly enableAccessTokenEndpoint: boolean;
259260
private readonly noContentProfileResponseWhenUnauthenticated: boolean;
260261
private readonly enableConnectAccountEndpoint: boolean;
262+
private readonly tokenRefreshLeeway: number;
261263

262264
private dpopOptions?: DpopOptions;
263265

@@ -379,6 +381,7 @@ export class AuthClient {
379381
options.noContentProfileResponseWhenUnauthenticated ?? false;
380382
this.enableConnectAccountEndpoint =
381383
options.enableConnectAccountEndpoint ?? false;
384+
this.tokenRefreshLeeway = options.tokenRefreshLeeway ?? 0;
382385

383386
this.useDPoP = options.useDPoP ?? false;
384387

@@ -1200,6 +1203,13 @@ export class AuthClient {
12001203
audience: options.audience ?? this.authorizationParameters.audience
12011204
}
12021205
);
1206+
const now = Date.now() / 1000;
1207+
const expiresAt =
1208+
typeof tokenSet.expiresAt === "number" ? tokenSet.expiresAt : undefined;
1209+
const isExpired = typeof expiresAt === "number" && expiresAt <= now;
1210+
const isWithinRefreshWindow =
1211+
typeof expiresAt === "number" &&
1212+
expiresAt <= now + this.tokenRefreshLeeway;
12031213

12041214
// no access token was found that matches the, optional, provided audience and scope
12051215
if (!tokenSet.refreshToken && !tokenSet.accessToken) {
@@ -1213,12 +1223,7 @@ export class AuthClient {
12131223
}
12141224

12151225
// the access token was found, but it has expired and we do not have a refresh token
1216-
if (
1217-
!tokenSet.refreshToken &&
1218-
tokenSet.accessToken &&
1219-
tokenSet.expiresAt &&
1220-
tokenSet.expiresAt <= Date.now() / 1000
1221-
) {
1226+
if (!tokenSet.refreshToken && tokenSet.accessToken && isExpired) {
12221227
return [
12231228
new AccessTokenError(
12241229
AccessTokenErrorCode.MISSING_REFRESH_TOKEN,
@@ -1230,11 +1235,7 @@ export class AuthClient {
12301235

12311236
if (tokenSet.refreshToken) {
12321237
// either the access token has expired or we are forcing a refresh
1233-
if (
1234-
options.refresh ||
1235-
!tokenSet.expiresAt ||
1236-
tokenSet.expiresAt <= Date.now() / 1000
1237-
) {
1238+
if (options.refresh || !expiresAt || isWithinRefreshWindow) {
12381239
const [error, response] = await this.#refreshTokenSet(tokenSet, {
12391240
audience: options.audience,
12401241
scope: options.scope ? scope : undefined,

src/server/next-compat.test.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -46,6 +46,19 @@ describe("next-compat", () => {
4646
expect(parsed).toEqual({ foo: "bar" });
4747
});
4848

49+
it("should not throw when the input body is already used", async () => {
50+
const plainReq = new Request("https://example.com/api/data", {
51+
method: "POST",
52+
body: JSON.stringify({ foo: "bar" })
53+
});
54+
55+
await plainReq.text(); // consume body
56+
57+
const nextReq = toNextRequest(plainReq);
58+
expect(nextReq).toBeInstanceOf(NextRequest);
59+
expect(nextReq.method).toBe("POST");
60+
});
61+
4962
it("should set duplex to 'half' if not provided", () => {
5063
const req = new Request("https://example.com", { method: "GET" });
5164
const nextReq = toNextRequest(req);

src/server/next-compat.ts

Lines changed: 44 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,41 @@ function collectFromNextUrl(input: Request): NextConfig | undefined {
4242
return config && Object.keys(config).length ? config : undefined;
4343
}
4444

45+
function tryCloneRequest(input: Request): Request | null {
46+
if (typeof (input as any).clone !== "function") {
47+
return null;
48+
}
49+
50+
try {
51+
return input.clone();
52+
} catch {
53+
return null;
54+
}
55+
}
56+
57+
function getSafeBody(input: any): BodyInit | null | undefined {
58+
if (!("body" in input)) {
59+
return undefined;
60+
}
61+
62+
const body = input.body;
63+
64+
if (body == null) {
65+
return body;
66+
}
67+
68+
if (input.bodyUsed) {
69+
return undefined;
70+
}
71+
72+
const locked = (body as any).locked;
73+
if (typeof locked === "boolean" && locked) {
74+
return undefined;
75+
}
76+
77+
return body as BodyInit;
78+
}
79+
4580
/**
4681
* Normalize a Request or NextRequest to a NextRequest instance.
4782
* Ensures consistent behavior across Next.js 15 (Edge) and 16 (Node Proxy).
@@ -54,18 +89,22 @@ export function toNextRequest(input: Request | NextRequest): NextRequest {
5489

5590
const nextConfig = collectFromNextUrl(input);
5691

92+
const source =
93+
input instanceof Request ? (tryCloneRequest(input) ?? input) : input;
94+
const body = getSafeBody(source);
95+
5796
const init: any = {
58-
method: input.method,
59-
headers: input.headers,
60-
body: input.body as any,
61-
duplex: (input as any).duplex ?? "half"
97+
method: source.method,
98+
headers: source.headers,
99+
duplex: (source as any).duplex ?? "half",
100+
...(body !== undefined ? { body } : {})
62101
};
63102

64103
if (nextConfig) {
65104
init.nextConfig = nextConfig;
66105
}
67106

68-
return new NextRequest(input.url, init);
107+
return new NextRequest(source.url, init);
69108
}
70109

71110
/**

0 commit comments

Comments
 (0)