Skip to content

Commit 8c0aaee

Browse files
committed
fix(chutes): validate oauth token lifetimes
1 parent 21bcc0e commit 8c0aaee

4 files changed

Lines changed: 122 additions & 11 deletions

File tree

extensions/chutes/oauth.test.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import { describe, expect, it, vi } from "vitest";
2+
import { loginChutes } from "./oauth.js";
3+
4+
describe("chutes plugin OAuth", () => {
5+
it("rejects unsafe token lifetimes before storing credentials", async () => {
6+
const fetchFn = vi.fn(async (input: RequestInfo | URL) => {
7+
const url =
8+
typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
9+
if (url === "https://api.chutes.ai/idp/token") {
10+
return new Response(
11+
'{"access_token":"at_unsafe","refresh_token":"rt_unsafe","expires_in":1e309}',
12+
{ status: 200, headers: { "Content-Type": "application/json" } },
13+
);
14+
}
15+
return new Response("not found", { status: 404 });
16+
});
17+
18+
await expect(
19+
loginChutes({
20+
app: {
21+
clientId: "cid_test",
22+
redirectUri: "http://127.0.0.1:1456/oauth-callback",
23+
scopes: ["openid"],
24+
},
25+
manual: true,
26+
createState: () => "state_test",
27+
onAuth: vi.fn(async () => {}),
28+
onPrompt: vi.fn(
29+
async () => "http://127.0.0.1:1456/oauth-callback?code=code_test&state=state_test",
30+
),
31+
fetchFn,
32+
}),
33+
).rejects.toThrow("Chutes token exchange returned invalid expires_in");
34+
});
35+
});

extensions/chutes/oauth.ts

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { randomBytes } from "node:crypto";
2+
import { parseStrictPositiveInteger } from "openclaw/plugin-sdk/number-runtime";
23
import { generatePkceVerifierChallenge, toFormUrlEncoded } from "openclaw/plugin-sdk/provider-auth";
34
import {
45
parseOAuthCallbackInput,
@@ -97,9 +98,17 @@ function buildAuthorizeUrl(params: {
9798
return `${CHUTES_AUTHORIZE_ENDPOINT}?${qs.toString()}`;
9899
}
99100

100-
function coerceExpiresAt(expiresInSeconds: number, now: number): number {
101-
const value = now + Math.max(0, Math.floor(expiresInSeconds)) * 1000 - 5 * 60 * 1000;
102-
return Math.max(value, now + 30_000);
101+
function resolveChutesExpiresAt(value: unknown, now: number): number | undefined {
102+
const expiresInSeconds = parseStrictPositiveInteger(value);
103+
if (expiresInSeconds === undefined) {
104+
return undefined;
105+
}
106+
const lifetimeMs = expiresInSeconds * 1000;
107+
const expiresAt = now + lifetimeMs - 5 * 60 * 1000;
108+
if (!Number.isSafeInteger(lifetimeMs) || !Number.isSafeInteger(expiresAt)) {
109+
return undefined;
110+
}
111+
return Math.max(expiresAt, now + 30_000);
103112
}
104113

105114
async function fetchChutesUserInfo(params: {
@@ -155,18 +164,22 @@ async function exchangeChutesCodeForTokens(params: {
155164
};
156165
const access = normalizeOptionalString(data.access_token);
157166
const refresh = normalizeOptionalString(data.refresh_token);
167+
const expires = resolveChutesExpiresAt(data.expires_in, now);
158168
if (!access) {
159169
throw new Error("Chutes token exchange returned no access_token");
160170
}
161171
if (!refresh) {
162172
throw new Error("Chutes token exchange returned no refresh_token");
163173
}
174+
if (expires === undefined) {
175+
throw new Error("Chutes token exchange returned invalid expires_in");
176+
}
164177

165178
const info = await fetchChutesUserInfo({ accessToken: access, fetchFn });
166179
return {
167180
access,
168181
refresh,
169-
expires: coerceExpiresAt(data.expires_in ?? 0, now),
182+
expires,
170183
email: info?.username,
171184
accountId: info?.sub,
172185
clientId: params.app.clientId,

src/agents/chutes-oauth.flow.test.ts

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -86,6 +86,33 @@ describe("chutes-oauth", () => {
8686
expect(creds.expires).toBe(now + 3600 * 1000 - 5 * 60 * 1000);
8787
});
8888

89+
it("rejects unsafe exchange token lifetimes", async () => {
90+
const fetchFn = withFetchPreconnect(async (input: RequestInfo | URL) => {
91+
const url = urlToString(input);
92+
if (url !== CHUTES_TOKEN_ENDPOINT) {
93+
return new Response("not found", { status: 404 });
94+
}
95+
return new Response(
96+
'{"access_token":"at_unsafe","refresh_token":"rt_unsafe","expires_in":1e309}',
97+
{ status: 200, headers: { "Content-Type": "application/json" } },
98+
);
99+
});
100+
101+
await expect(
102+
exchangeChutesCodeForTokens({
103+
app: {
104+
clientId: "cid_test",
105+
redirectUri: "http://127.0.0.1:1456/oauth-callback",
106+
scopes: ["openid"],
107+
},
108+
code: "code_unsafe",
109+
codeVerifier: "verifier_unsafe",
110+
fetchFn,
111+
now: 1_000_000,
112+
}),
113+
).rejects.toThrow("Chutes token exchange returned invalid expires_in");
114+
});
115+
89116
it("refreshes tokens using stored client id and falls back to old refresh token", async () => {
90117
const fetchFn = withFetchPreconnect(async (input: RequestInfo | URL, init?: RequestInit) => {
91118
const url = urlToString(input);
@@ -142,4 +169,25 @@ describe("chutes-oauth", () => {
142169

143170
expectRefreshedCredential(refreshed, now);
144171
});
172+
173+
it("rejects unsafe refresh token lifetimes", async () => {
174+
const fetchFn = withFetchPreconnect(async (input: RequestInfo | URL) => {
175+
const url = urlToString(input);
176+
if (url !== CHUTES_TOKEN_ENDPOINT) {
177+
return new Response("not found", { status: 404 });
178+
}
179+
return new Response('{"access_token":"at_new","expires_in":1e309}', {
180+
status: 200,
181+
headers: { "Content-Type": "application/json" },
182+
});
183+
});
184+
185+
await expect(
186+
refreshChutesTokens({
187+
credential: createStoredCredential(4_000_000),
188+
fetchFn,
189+
now: 4_000_000,
190+
}),
191+
).rejects.toThrow("Chutes token refresh returned invalid expires_in");
192+
});
145193
});

src/agents/chutes-oauth.ts

Lines changed: 22 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { createHash, randomBytes } from "node:crypto";
2+
import { parseStrictPositiveInteger } from "../infra/parse-finite-number.js";
23
import type { OAuthCredentials } from "../llm/oauth.js";
34
import { normalizeOptionalString } from "../shared/string-coerce.js";
45

@@ -81,9 +82,17 @@ export function parseOAuthCallbackInput(
8182
return { code, state };
8283
}
8384

84-
function coerceExpiresAt(expiresInSeconds: number, now: number): number {
85-
const value = now + Math.max(0, Math.floor(expiresInSeconds)) * 1000 - DEFAULT_EXPIRES_BUFFER_MS;
86-
return Math.max(value, now + 30_000);
85+
function resolveChutesExpiresAt(value: unknown, now: number): number | undefined {
86+
const expiresInSeconds = parseStrictPositiveInteger(value);
87+
if (expiresInSeconds === undefined) {
88+
return undefined;
89+
}
90+
const lifetimeMs = expiresInSeconds * 1000;
91+
const expiresAt = now + lifetimeMs - DEFAULT_EXPIRES_BUFFER_MS;
92+
if (!Number.isSafeInteger(lifetimeMs) || !Number.isSafeInteger(expiresAt)) {
93+
return undefined;
94+
}
95+
return Math.max(expiresAt, now + 30_000);
8796
}
8897

8998
async function fetchChutesUserInfo(params: {
@@ -144,21 +153,24 @@ export async function exchangeChutesCodeForTokens(params: {
144153

145154
const access = data.access_token?.trim();
146155
const refresh = data.refresh_token?.trim();
147-
const expiresIn = data.expires_in ?? 0;
156+
const expires = resolveChutesExpiresAt(data.expires_in, now);
148157

149158
if (!access) {
150159
throw new Error("Chutes token exchange returned no access_token");
151160
}
152161
if (!refresh) {
153162
throw new Error("Chutes token exchange returned no refresh_token");
154163
}
164+
if (expires === undefined) {
165+
throw new Error("Chutes token exchange returned invalid expires_in");
166+
}
155167

156168
const info = await fetchChutesUserInfo({ accessToken: access, fetchFn });
157169

158170
return {
159171
access,
160172
refresh,
161-
expires: coerceExpiresAt(expiresIn, now),
173+
expires,
162174
email: info?.username,
163175
accountId: info?.sub,
164176
clientId: params.app.clientId,
@@ -210,18 +222,21 @@ export async function refreshChutesTokens(params: {
210222
};
211223
const access = data.access_token?.trim();
212224
const newRefresh = data.refresh_token?.trim();
213-
const expiresIn = data.expires_in ?? 0;
225+
const expires = resolveChutesExpiresAt(data.expires_in, now);
214226

215227
if (!access) {
216228
throw new Error("Chutes token refresh returned no access_token");
217229
}
230+
if (expires === undefined) {
231+
throw new Error("Chutes token refresh returned invalid expires_in");
232+
}
218233

219234
return {
220235
...params.credential,
221236
access,
222237
// RFC 6749 section 6: new refresh token is optional; if present, replace old.
223238
refresh: newRefresh || refreshToken,
224-
expires: coerceExpiresAt(expiresIn, now),
239+
expires,
225240
clientId,
226241
} as unknown as ChutesStoredOAuth;
227242
}

0 commit comments

Comments
 (0)