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
94 changes: 90 additions & 4 deletions bun.lock

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,7 @@
"dependencies": {
"@corbits/agent-intern": "workspace:*",
"@corbits/codex-provider": "github:corbitsdev/corbits-codex-provider",
"@corbits/oauth-core": "github:corbitsdev/corbits-oauth-core",
"@corbits/oauth-core": "0.2.0",
"@corbits/openai-responses": "github:corbitsdev/corbits-openai-responses",
"@corbits/xai-provider": "github:corbitsdev/corbits-xai-provider",
"@intx/agent": "workspace:*",
Expand Down
8 changes: 6 additions & 2 deletions src/auth/codex/callback-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,11 @@ import {
callbackPageHtml,
type CallbackPageCopy,
} from "../callback-page.js";
import { CODEX_CALLBACK_PATH, CODEX_CALLBACK_PORT } from "./constants.js";
import {
CODEX_CALLBACK_HOST,
CODEX_CALLBACK_PATH,
CODEX_CALLBACK_PORT,
} from "./constants.js";

export type CodexCallbackServer = CallbackServer;

Expand All @@ -18,7 +22,7 @@ export async function startCodexCallbackServer(
return startCallbackServer(expectedState, {
port: CODEX_CALLBACK_PORT,
// Codex's registered redirect_uri uses localhost (not 127.0.0.1).
host: "localhost",
host: CODEX_CALLBACK_HOST,
path: CODEX_CALLBACK_PATH,
doneHtml: authorizationDoneHtml("Codex", copy),
failedHtml: (reason) =>
Expand Down
10 changes: 6 additions & 4 deletions src/auth/codex/constants.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { CODEX_REDIRECT_URI } from "@corbits/codex-provider";
import { callbackTargetFor } from "@corbits/oauth-core";
import { codexOAuthConfig } from "@corbits/codex-provider";

export {
CODEX_BASE_URL,
Expand All @@ -7,9 +8,10 @@ export {
CODEX_RESPONSES_PATH,
} from "@corbits/codex-provider";

const codexRedirect = new URL(CODEX_REDIRECT_URI);
export const CODEX_CALLBACK_PORT = Number(codexRedirect.port);
export const CODEX_CALLBACK_PATH = codexRedirect.pathname;
const callbackTarget = callbackTargetFor(codexOAuthConfig);
export const CODEX_CALLBACK_HOST = callbackTarget.host;
export const CODEX_CALLBACK_PORT = callbackTarget.port;
export const CODEX_CALLBACK_PATH = callbackTarget.path;

// The account's available model catalog. The models endpoint requires a
// client_version query param.
Expand Down
12 changes: 5 additions & 7 deletions src/auth/codex/session-failure.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,11 @@ import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { describe, expect, test } from "bun:test";
import {
OAuthRefreshFailedError,
OAuthTokenEndpointError,
} from "@corbits/oauth-core";
import { OAuthRefreshFailedError } from "@corbits/oauth-core";
import { errorMessage } from "../../agent/error-message.js";
import { saveCodexProfile } from "../../config/oauth-stores.js";
import { formatSubAgentSpawnAuthFailureMessage } from "../../subagent/inference-auth-failure.js";
import { isOAuthTokenEndpointError } from "../token-session-boundary.js";
import {
codexAuthFailureDiagnostic,
CodexAuthError,
Expand Down Expand Up @@ -150,21 +148,21 @@ describe("codex auth failure surface", () => {
.catch((error: unknown) => error);
expect(normal).toBeInstanceOf(OAuthRefreshFailedError);
const normalCause = (normal as OAuthRefreshFailedError).cause;
expect(normalCause).toBeInstanceOf(OAuthTokenEndpointError);
expect(isOAuthTokenEndpointError(normalCause)).toBe(true);
expect(normalCause).toMatchObject({ status: 401 });

const staged = await refreshStagedCodexTokens({ ...tokens }, now).catch(
(error: unknown) => error,
);
expect(staged).toBeInstanceOf(OAuthTokenEndpointError);
expect(isOAuthTokenEndpointError(staged)).toBe(true);
expect(staged).toMatchObject({ status: 401 });

for (const failure of [normal, staged]) {
let current: unknown = failure;
while (current instanceof Error) {
expect(current.message).not.toContain(refresh);
expect(current.stack).not.toContain(refresh);
if (current instanceof OAuthTokenEndpointError)
if (isOAuthTokenEndpointError(current))
expect(current.detail).not.toContain(refresh);
current = current.cause;
}
Expand Down
4 changes: 2 additions & 2 deletions src/auth/oauth-scope-check.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
// Never logs or persists the token or any response body — only the HTTP
// status is inspected to classify the result.

import { OAuthTokenEndpointError } from "@corbits/oauth-core";
import { isOAuthTokenEndpointError } from "./token-session-boundary.js";

import {
CODEX_BASE_URL,
Expand Down Expand Up @@ -81,7 +81,7 @@ export function isBlockingOAuthScopeCheckResult(
}

function isDefinitiveRefreshAuthRejection(err: unknown): boolean {
if (!(err instanceof OAuthTokenEndpointError)) return false;
if (!isOAuthTokenEndpointError(err)) return false;
if (err.status === 401 || err.status === 403) return true;
return /invalid_grant|revoked/i.test(err.detail);
}
Expand Down
29 changes: 28 additions & 1 deletion src/auth/token-session-boundary.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
import { describe, expect, test } from "bun:test";
import { OAuthTokenEndpointError } from "@corbits/oauth-core";
import { sanitizedRefreshFailure } from "./token-session-boundary.js";
import {
isOAuthTokenEndpointError,
sanitizedRefreshFailure,
} from "./token-session-boundary.js";

describe("sanitizedRefreshFailure", () => {
test("scrubs already-materialized stacks recursively without losing classification", () => {
Expand Down Expand Up @@ -28,3 +31,27 @@ describe("sanitizedRefreshFailure", () => {
expect(endpoint.stack).not.toContain(refresh);
});
});

describe("isOAuthTokenEndpointError", () => {
test("accepts the host class and a foreign copy with the same name", () => {
const local = new OAuthTokenEndpointError(401, "denied");
expect(isOAuthTokenEndpointError(local)).toBe(true);

const foreign = Object.assign(
new Error("OAuth token endpoint returned 401"),
{
name: "OAuthTokenEndpointError",
status: 401,
detail: "denied",
},
);
expect(foreign).not.toBeInstanceOf(OAuthTokenEndpointError);
expect(isOAuthTokenEndpointError(foreign)).toBe(true);
});

test("rejects errors that only share a message", () => {
expect(
isOAuthTokenEndpointError(new Error("OAuth token endpoint returned 401")),
).toBe(false);
});
});
16 changes: 16 additions & 0 deletions src/auth/token-session-boundary.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,22 @@ export function replaceMutableTokens<TTokens extends object>(
Object.assign(target, replacement);
}

// Sibling provider packages may still ship a distinct @corbits/oauth-core
// copy, so `instanceof` against this host's class identity is not reliable.
export type OAuthTokenEndpointFailure = Error & {
status: number;
detail: string;
};

export function isOAuthTokenEndpointError(
err: unknown,
): err is OAuthTokenEndpointFailure {
if (!(err instanceof Error) || err.name !== "OAuthTokenEndpointError")
return false;
if (!("status" in err) || typeof err.status !== "number") return false;
return "detail" in err && typeof err.detail === "string";
}

export function sanitizedRefreshFailure(
error: unknown,
refreshToken: string,
Expand Down
31 changes: 17 additions & 14 deletions src/auth/xai/callback-server.test.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { describe, expect, test } from "bun:test";
import { setTimeout as delay } from "node:timers/promises";

import type { CallbackPageCopy } from "../callback-page.js";
import { XAI_CALLBACK_PORT } from "./constants.js";
import { XAI_CALLBACK_PATH, XAI_CALLBACK_PORT } from "./constants.js";
import { startXaiCallbackServer } from "./callback-server.js";

const copy: CallbackPageCopy = {
Expand All @@ -12,12 +13,13 @@ const copy: CallbackPageCopy = {
githubLabel: "github.com/fixture",
};

const base = `http://127.0.0.1:${String(XAI_CALLBACK_PORT)}/callback`;
const base = `http://127.0.0.1:${String(XAI_CALLBACK_PORT)}${XAI_CALLBACK_PATH}`;

describe("xAI callback server", () => {
test("accepts a matching state and returns the code", async () => {
const server = await startXaiCallbackServer("expected", copy);
try {
expect(server.port).toBe(XAI_CALLBACK_PORT);
const wait = server.waitForCode(new AbortController().signal);
const res = await fetch(`${base}?code=abc&state=expected`);
expect(res.status).toBe(200);
Expand All @@ -27,20 +29,21 @@ describe("xAI callback server", () => {
}
});

test("rejects state mismatches before accepting a code", async () => {
test("keeps waiting after a state mismatch until a matching redirect", async () => {
const server = await startXaiCallbackServer("expected", copy);
try {
const wait = server.waitForCode(new AbortController().signal).then(
() => ({ ok: true as const }),
(err: unknown) => ({ ok: false as const, err }),
);
const res = await fetch(`${base}?code=abc&state=wrong`);
expect(res.status).toBe(400);
const result = await wait;
expect(result.ok).toBe(false);
if (!result.ok) expect(result.err).toBeInstanceOf(Error);
if (!result.ok && result.err instanceof Error)
expect(result.err.message).toMatch(/state did not match/);
const wait = server.waitForCode(new AbortController().signal);
const mismatch = await fetch(`${base}?code=abc&state=wrong`);
expect(mismatch.status).toBe(400);
expect(
await Promise.race([
wait.then(() => "settled"),
delay(50).then(() => "pending"),
]),
).toBe("pending");
const match = await fetch(`${base}?code=abc&state=expected`);
expect(match.status).toBe(200);
await expect(wait).resolves.toBe("abc");
} finally {
server.close();
}
Expand Down
8 changes: 6 additions & 2 deletions src/auth/xai/callback-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,11 @@ import {
callbackPageHtml,
type CallbackPageCopy,
} from "../callback-page.js";
import { XAI_CALLBACK_PATH, XAI_CALLBACK_PORT } from "./constants.js";
import {
XAI_CALLBACK_HOST,
XAI_CALLBACK_PATH,
XAI_CALLBACK_PORT,
} from "./constants.js";

export type XaiCallbackServer = CallbackServer;

Expand All @@ -15,7 +19,7 @@ export async function startXaiCallbackServer(
): Promise<XaiCallbackServer> {
return startCallbackServer(expectedState, {
port: XAI_CALLBACK_PORT,
host: "127.0.0.1",
host: XAI_CALLBACK_HOST,
path: XAI_CALLBACK_PATH,
doneHtml: authorizationDoneHtml("xAI", copy),
failedHtml: (reason) =>
Expand Down
12 changes: 7 additions & 5 deletions src/auth/xai/constants.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import { XAI_REDIRECT_URI } from "@corbits/xai-provider";

import { callbackTargetFor } from "@corbits/oauth-core";
import {
XAI_DEFAULT_MODELS as VENDOR_XAI_DEFAULT_MODELS,
XAI_OAUTH_PROXY_BASE_URL as XAI_BASE_URL,
XAI_REDIRECT_URI,
XAI_REFRESH_SKEW_MS,
xaiOAuthConfig,
} from "@corbits/xai-provider";

export { XAI_BASE_URL, XAI_REDIRECT_URI, XAI_REFRESH_SKEW_MS };
Expand Down Expand Up @@ -33,9 +34,10 @@ export const XAI_DEFAULT_MODELS = extendVendorXaiDefaultModels(
VENDOR_XAI_DEFAULT_MODELS,
);

const xaiRedirect = new URL(XAI_REDIRECT_URI);
export const XAI_CALLBACK_PORT = Number(xaiRedirect.port);
export const XAI_CALLBACK_PATH = xaiRedirect.pathname;
const callbackTarget = callbackTargetFor(xaiOAuthConfig);
export const XAI_CALLBACK_HOST = callbackTarget.host;
export const XAI_CALLBACK_PORT = callbackTarget.port;
export const XAI_CALLBACK_PATH = callbackTarget.path;

// The CLI chat proxy speaks the OpenAI Responses API and authenticates the
// caller by client headers in addition to the bearer token. Values mirror the
Expand Down
10 changes: 4 additions & 6 deletions src/auth/xai/session-refresh-race.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,11 @@ import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { describe, expect, test } from "bun:test";
import {
OAuthRefreshFailedError,
OAuthTokenEndpointError,
} from "@corbits/oauth-core";
import { OAuthRefreshFailedError } from "@corbits/oauth-core";
import { errorMessage } from "../../agent/error-message.js";
import { loadXaiProfile, saveXaiProfile } from "../../config/oauth-stores.js";
import { formatSubAgentSpawnAuthFailureMessage } from "../../subagent/inference-auth-failure.js";
import { isOAuthTokenEndpointError } from "../token-session-boundary.js";
import {
createXaiTokenSession,
getValidXaiToken,
Expand Down Expand Up @@ -285,14 +283,14 @@ describe("xAI shared-credential refresh race", () => {
.catch((error: unknown) => error);
expect(failure).toBeInstanceOf(OAuthRefreshFailedError);
const cause = (failure as OAuthRefreshFailedError).cause;
expect(cause).toBeInstanceOf(OAuthTokenEndpointError);
expect(isOAuthTokenEndpointError(cause)).toBe(true);
expect(cause).toMatchObject({ status: 403 });

let current: unknown = failure;
while (current instanceof Error) {
expect(current.message).not.toContain(refresh);
expect(current.stack).not.toContain(refresh);
if (current instanceof OAuthTokenEndpointError)
if (isOAuthTokenEndpointError(current))
expect(current.detail).not.toContain(refresh);
current = current.cause;
}
Expand Down
36 changes: 26 additions & 10 deletions tests/unit/codex-callback-server.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { test, expect, describe, afterEach } from "bun:test";
import { setTimeout as delay } from "node:timers/promises";
import { productCallbackCopy } from "../../src/branding.js";
import { startCodexCallbackServer } from "../../src/auth/codex/callback-server.js";
import {
Expand Down Expand Up @@ -40,6 +41,7 @@ describe("startCodexCallbackServer", () => {
productCallbackCopy,
);
active = server;
expect(server.port).toBe(CODEX_CALLBACK_PORT);
const result = settle(server, new AbortController().signal);
await fetch(`${base}?code=the-code&state=good-state`).catch(
() => undefined,
Expand All @@ -48,32 +50,46 @@ describe("startCodexCallbackServer", () => {
expect(r).toEqual({ ok: true, code: "the-code" });
});

test("rejects when the state does not match (CSRF guard)", async () => {
test("keeps waiting when the state does not match", async () => {
const server = await startCodexCallbackServer(
"expected-state",
productCallbackCopy,
);
active = server;
const result = settle(server, new AbortController().signal);
await fetch(`${base}?code=the-code&state=attacker-state`).catch(
const mismatch = await fetch(`${base}?code=the-code&state=attacker-state`);
expect(mismatch.status).toBe(400);
expect(
await Promise.race([
result.then(() => "settled"),
delay(50).then(() => "pending"),
]),
).toBe("pending");
await fetch(`${base}?code=the-code&state=expected-state`).catch(
() => undefined,
);
const r = await result;
expect(r.ok).toBe(false);
if (!r.ok) expect(r.message).toMatch(/state did not match/i);
expect(await result).toEqual({ ok: true, code: "the-code" });
});

test("rejects when the redirect carries no state at all", async () => {
test("keeps waiting when the redirect carries no state at all", async () => {
const server = await startCodexCallbackServer(
"expected-state",
productCallbackCopy,
);
active = server;
const result = settle(server, new AbortController().signal);
await fetch(`${base}?code=the-code`).catch(() => undefined);
const r = await result;
expect(r.ok).toBe(false);
if (!r.ok) expect(r.message).toMatch(/state did not match/i);
const missing = await fetch(`${base}?code=the-code`);
expect(missing.status).toBe(400);
expect(
await Promise.race([
result.then(() => "settled"),
delay(50).then(() => "pending"),
]),
).toBe("pending");
await fetch(`${base}?code=the-code&state=expected-state`).catch(
() => undefined,
);
expect(await result).toEqual({ ok: true, code: "the-code" });
});

test("rejects when the authorization server returns an error", async () => {
Expand Down
Loading