Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 58 additions & 9 deletions src/oauth/nous.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -87,6 +88,44 @@ interface NousJwtPayload {
[key: string]: unknown;
}

async function readOAuthBytes(response: Response, signal: AbortSignal): Promise<Uint8Array> {
const { bytes, oversized } = await readBoundedResponseBytes(response, {
maxBytes: BOUNDED_BODY_MAX_BYTES,
signal,
});
if (oversized) {
throw new NousTokenError(
response.status,
"response_too_large",
`Nous Portal OAuth response exceeded the ${BOUNDED_BODY_MAX_BYTES}-byte limit`,
);
}
return bytes;
}

function parseOAuthJson(bytes: Uint8Array): unknown {
const parsed = JSON.parse(new TextDecoder("utf-8", { fatal: true }).decode(bytes));
return parsed !== null && typeof parsed === "object" && !Array.isArray(parsed)
? parsed as Record<string, unknown>
: {};
}

async function readOAuthJson(response: Response, signal: AbortSignal): Promise<unknown> {
return parseOAuthJson(await readOAuthBytes(response, signal));
}

async function readOAuthJsonOrEmpty(response: Response, signal: AbortSignal): Promise<unknown> {
const bytes = await readOAuthBytes(response, signal);
try {
return parseOAuthJson(bytes);
} catch {
// Preserve the pre-PR behavior for empty/HTML/malformed JSON only. Body
// read failures, timeouts, caller cancellation, and size-limit errors have
// already escaped readOAuthBytes and must retain their real identity.
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
Expand Down Expand Up @@ -495,6 +534,7 @@ async function requestDeviceAuthorization(signal?: AbortSignal): Promise<{
expiresInMs: number;
intervalMs: number;
}> {
const effectiveSignal = requestSignal(signal);
const response = await fetch(`${resolvePortalBaseUrl()}/api/oauth/device/code`, {
method: "POST",
headers: { Accept: "application/json", "Content-Type": "application/x-www-form-urlencoded" },
Expand All @@ -503,14 +543,16 @@ async function requestDeviceAuthorization(signal?: AbortSignal): Promise<{
scope: NOUS_OAUTH_SCOPE,
}),
redirect: "error",
signal: requestSignal(signal),
signal: effectiveSignal,
});
if (!response.ok) throw tokenErrorFromPayload(response.status, await response.json().catch(() => ({})));
if (!response.ok) {
throw tokenErrorFromPayload(response.status, await readOAuthJsonOrEmpty(response, effectiveSignal));
}
// 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, effectiveSignal) 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);
Expand Down Expand Up @@ -549,6 +591,7 @@ async function pollForToken(
while (Date.now() < deadline) {
if (signal?.aborted) throw new Error("Login cancelled");
let response: Response;
const effectiveSignal = requestSignal(signal);
try {
response = await fetch(`${resolvePortalBaseUrl()}/api/oauth/token`, {
method: "POST",
Expand All @@ -559,7 +602,7 @@ async function pollForToken(
grant_type: "urn:ietf:params:oauth:grant-type:device_code",
}),
redirect: "error",
signal: requestSignal(signal),
signal: effectiveSignal,
});
} catch (netErr) {
// Genuine cancellation must abort immediately. Any other transport-level
Expand All @@ -571,13 +614,15 @@ async function pollForToken(
if (await sleepUntilDeadline(waitMs)) continue;
break;
}
// Parse under the same deadline that covered the request headers. Keep the
// read outside the fetch retry catch: a bounded-reader error or caller
// cancellation is an observed response failure, not a safe poll retry.
const payload = await readOAuthJsonOrEmpty(response, effectiveSignal) as NousTokenResponse;
// Parse once and pass the payload through to the failure path (review #8),
// so we never try to re-read a body that has already been consumed.
// 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 payload = (parsed && typeof parsed === "object" ? parsed : {}) as NousTokenResponse;
if (Date.now() >= deadline) break;
if (response.ok) return parseTokenPayload(payload, "");
const error = payload.error;
Expand Down Expand Up @@ -670,6 +715,7 @@ export async function refreshNousToken(refreshToken: string, signal?: AbortSigna
}

let response: Response;
const effectiveSignal = requestSignal(signal);
try {
response = await fetch(`${baseUrl}/api/oauth/token`, {
method: "POST",
Expand All @@ -683,7 +729,7 @@ export async function refreshNousToken(refreshToken: string, signal?: AbortSigna
client_id: NOUS_OAUTH_CLIENT_ID,
}),
redirect: "error",
signal: requestSignal(signal),
signal: effectiveSignal,
});
} catch (netErr) {
// The request may have reached the server and rotated the token even on a
Expand All @@ -703,7 +749,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
Expand All @@ -719,6 +764,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, effectiveSignal);
throw tokenErrorFromPayload(status, payload);
}

Expand All @@ -727,7 +773,10 @@ 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, effectiveSignal)) as NousTokenResponse,
refreshToken,
);
return creds;
} catch (e) {
try {
Expand Down
88 changes: 88 additions & 0 deletions tests/nous-oauth.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -149,6 +150,93 @@ 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("normalizes a null device-authorization response before required-field validation", async () => {
globalThis.fetch = (async () =>
new Response("null", { status: 200 })) as typeof fetch;

await expect(loginNous({ onAuth() {} })).rejects.toThrow(
"Nous Portal device authorization response missing required fields",
);
});

test("propagates caller abort while the device-authorization body is pending", async () => {
const controller = new AbortController();
const abortReason = new DOMException("caller stopped", "AbortError");
let bodyReadStarted!: () => void;
const started = new Promise<void>(resolve => { bodyReadStarted = resolve; });
let cancelReason: unknown;
globalThis.fetch = (async () => new Response(new ReadableStream<Uint8Array>({
pull() {
bodyReadStarted();
return new Promise<void>(() => {});
},
cancel(reason) {
cancelReason = reason;
},
}), { status: 200 })) as typeof fetch;

const pending = loginNous({ onAuth() {}, signal: controller.signal });
await started;
controller.abort(abortReason);

await expect(pending).rejects.toBe(abortReason);
await Bun.sleep(0);
expect(cancelReason).toBe(abortReason);
});

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("normalizes a null refresh response to a terminal token error and blocks replay", async () => {
globalThis.fetch = (async () =>
new Response("null", { status: 200 })) as typeof fetch;

await expect(refreshNousToken("old-refresh-null")).rejects.toMatchObject({
name: "NousTokenError",
oauthError: "invalid_token",
terminal: true,
});
expect(nousRefreshIntentBlocksReplay("old-refresh-null")).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
Expand Down
Loading