diff --git a/src/oauth/nous.ts b/src/oauth/nous.ts index 56d74b5e48..94eb7780ab 100644 --- a/src/oauth/nous.ts +++ b/src/oauth/nous.ts @@ -39,6 +39,7 @@ import { join } from "node:path"; import type { OAuthController, OAuthCredentials } from "./types"; import { getAuthStorePath } from "./store"; import { atomicWriteFile, hardenConfigDir, hardenExistingSecret } from "../config"; +import { BOUNDED_BODY_MAX_BYTES, readBoundedResponseBytes } from "../lib/bounded-body"; export const NOUS_PORTAL_BASE_URL = "https://portal.nousresearch.com"; export const NOUS_INFERENCE_BASE_URL = "https://inference-api.nousresearch.com/v1"; @@ -87,6 +88,27 @@ interface NousJwtPayload { [key: string]: unknown; } +async function readOAuthJson(response: Response): Promise { + const { bytes, oversized } = await readBoundedResponseBytes(response, { + maxBytes: BOUNDED_BODY_MAX_BYTES, + }); + if (oversized) { + throw new NousTokenError( + response.status, + "response_too_large", + `Nous Portal OAuth response exceeded the ${BOUNDED_BODY_MAX_BYTES}-byte limit`, + ); + } + return JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes)); +} + +async function readOAuthJsonOrEmpty(response: Response): Promise { + return readOAuthJson(response).catch((error) => { + if (error instanceof NousTokenError && error.oauthError === "response_too_large") throw error; + return {}; + }); +} + // ── Durable refresh-intent (review blocker #2) ────────────────────────────── // A refresh-intent file records that we submitted `refreshToken` to the Portal // and whether we are certain the rotated token was persisted. It lives next to @@ -505,12 +527,12 @@ async function requestDeviceAuthorization(signal?: AbortSignal): Promise<{ redirect: "error", signal: requestSignal(signal), }); - if (!response.ok) throw tokenErrorFromPayload(response.status, await response.json().catch(() => ({}))); + if (!response.ok) throw tokenErrorFromPayload(response.status, await readOAuthJsonOrEmpty(response)); // A successful HTTP response may still carry an empty/HTML/non-JSON body. // Fall back to an empty object so the required-field check below produces the // clear "missing required fields" validation error instead of leaking a raw // JSON parser exception. - const payload = (await response.json().catch(() => ({}))) as NousDeviceAuthorizationResponse; + const payload = (await readOAuthJsonOrEmpty(response)) as NousDeviceAuthorizationResponse; const userCode = nonEmptyString(payload.user_code); const deviceCode = nonEmptyString(payload.device_code); const verificationUri = nonEmptyString(payload.verification_uri_complete) ?? nonEmptyString(payload.verification_uri); @@ -576,7 +598,7 @@ async function pollForToken( // Normalize a successful-but-non-object body (for example valid JSON // `null`) to an empty object so the required-field validation below // produces a terminal NousTokenError instead of a raw TypeError. - const parsed = (await response.json().catch(() => ({}))) as unknown; + const parsed = await readOAuthJsonOrEmpty(response); const payload = (parsed && typeof parsed === "object" ? parsed : {}) as NousTokenResponse; if (Date.now() >= deadline) break; if (response.ok) return parseTokenPayload(payload, ""); @@ -703,7 +725,6 @@ export async function refreshNousToken(refreshToken: string, signal?: AbortSigna if (!response.ok) { const status = response.status; - const payload = await response.json().catch(() => ({})); // The request reached the Portal's token endpoint. A non-2xx response does // NOT establish that the single-use refresh token was not consumed: 429 // rate limits, unknown/custom 4xx, and gateway-generated client-class @@ -719,6 +740,7 @@ export async function refreshNousToken(refreshToken: string, signal?: AbortSigna // The pre-dispatch "submitted" intent is still on disk, which also // blocks replay; surface the original HTTP error below. } + const payload = await readOAuthJsonOrEmpty(response); throw tokenErrorFromPayload(status, payload); } @@ -727,7 +749,7 @@ export async function refreshNousToken(refreshToken: string, signal?: AbortSigna // replay it. On success we deliberately LEAVE the intent as "submitted" // (the store clears it once the rotated token is persisted). try { - const creds = parseTokenPayload((await response.json()) as NousTokenResponse, refreshToken); + const creds = parseTokenPayload((await readOAuthJson(response)) as NousTokenResponse, refreshToken); return creds; } catch (e) { try { diff --git a/tests/nous-oauth.test.ts b/tests/nous-oauth.test.ts index 8accc3b117..e61c3cace1 100644 --- a/tests/nous-oauth.test.ts +++ b/tests/nous-oauth.test.ts @@ -6,6 +6,7 @@ import { clearNousRefreshIntent, identityFromNousTokens, loginNous, nousRefreshI import { getCredential, listAccounts, saveCredential } from "../src/oauth/store"; import type { OAuthController } from "../src/oauth/types"; import * as configModule from "../src/config"; +import { BOUNDED_BODY_MAX_BYTES } from "../src/lib/bounded-body"; const TEST_DIR = join(import.meta.dir, ".tmp-nous-oauth-test"); const TEST_PORTAL = "https://portal.test"; @@ -149,6 +150,47 @@ describe("Nous token-response wiring", () => { expect(cred.accountId).toBe("device-user"); }); + test.each([200, 400])("rejects oversized device-authorization responses with HTTP %i", async (status) => { + globalThis.fetch = (async () => + new Response("x".repeat(BOUNDED_BODY_MAX_BYTES + 1), { status })) as typeof fetch; + + await expect(loginNous({ onAuth() {} })).rejects.toMatchObject({ + name: "NousTokenError", + oauthError: "response_too_large", + }); + }); + + test("rejects oversized device-token responses at the bounded OAuth reader", async () => { + globalThis.fetch = (async (input: RequestInfo | URL) => { + if (String(input).endsWith("/api/oauth/device/code")) { + return new Response(JSON.stringify({ + device_code: "dev-123", + user_code: "ABCD-EFGH", + verification_uri: "https://portal.nousresearch.com/activate", + expires_in: 60, + interval: 1, + }), { status: 200 }); + } + return new Response("x".repeat(BOUNDED_BODY_MAX_BYTES + 1), { status: 200 }); + }) as typeof fetch; + + await expect(loginNous({ onAuth() {} })).rejects.toMatchObject({ + name: "NousTokenError", + oauthError: "response_too_large", + }); + }); + + test.each([200, 400])("rejects oversized refresh responses with HTTP %i", async (status) => { + globalThis.fetch = (async () => + new Response("x".repeat(BOUNDED_BODY_MAX_BYTES + 1), { status })) as typeof fetch; + + await expect(refreshNousToken(`old-refresh-${status}`)).rejects.toMatchObject({ + name: "NousTokenError", + oauthError: "response_too_large", + }); + expect(nousRefreshIntentBlocksReplay(`old-refresh-${status}`)).toBe(true); + }); + test("an implausible JWT exp falls back to expires_in instead of pinning a never-expiring credential", async () => { // A too-large `exp` (e.g. milliseconds instead of seconds, or clock skew) // must not produce an expiry so far in the future that the credential is