Files
HealthLog/src/lib/ai/__tests__/codex-oauth.test.ts
T
Marc-André BombeckandClaude Opus 4.7 e4a07bc2e4 fix(ai): complete Codex backend protocol implementation per spec
Stop iterating on individual 400 errors and implement the full Codex
Responses-API protocol once, from the source. Wrote a 713-line
reference at docs/codex-protocol-spec.md from the official
openai/codex Rust client; this commit is the TS implementation
against that spec.

Required pieces that were missing in v1.4.11:

- `ChatGPT-Account-ID` header — extracted from the `chatgpt_account_id`
  claim of the OAuth id_token (also accepts the URI-namespaced
  variant under `https://api.openai.com/auth`). Without it: 401.
- `originator`, `User-Agent`, `Accept: text/event-stream`,
  `session_id`/`session-id` (both spellings), `thread_id`/`thread-id`.
- Body fields `reasoning: null` and `include: []` are required even
  when no reasoning is requested.
- Model slug is `gpt-5-codex`, not `gpt-5.3-codex` (the latter is a
  test placeholder upstream).
- Refresh endpoint takes a JSON body, not form-urlencoded.

Storage codec rewrite: `codexAccessTokenEncrypted` now holds an
encrypted JSON blob `{ accessToken, accountId, expiresAt }` instead
of just the raw access token, so the account id stays available
across requests. v1.4.7-v1.4.11 records that lack the field cannot
be revived — `decryptCodexCreds` returns null for those rows and
`resolveCodexProvider` flips the connection to `expired`, prompting
the user to re-link once.

Removed the dead browser-flow routes (`/api/auth/codex/authorize`
and `/callback`). They never worked on hosted domains because
Hydra's redirect-URI allow-list for the public Codex client ID
covers only localhost; device-code is the only viable path.

CodexClient SSE parsing extended to handle `response.failed` and
`response.incomplete` terminal events, and to read the actual
routed model from the `OpenAI-Model` response header.

Tests rebuilt: codex-oauth covers the full device-code flow with a
synthesised id_token JWT, the JSON-body refresh, and the
storage-codec round-trip. CodexClient tests carry an `accountId`
through the constructor. All 720 tests pass; typecheck clean.

Refs: docs/codex-protocol-spec.md §1-§6 for every claim.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-09 14:36:12 +02:00

208 lines
6.8 KiB
TypeScript

import { afterEach, describe, it, expect, vi } from "vitest";
vi.mock("@/lib/crypto", () => ({
encrypt: vi.fn((v: string) => `enc:${v}`),
decrypt: vi.fn((v: string) => v.replace(/^enc:/, "")),
}));
import {
generatePKCE,
generateState,
requestDeviceCode,
pollDeviceCode,
refreshDeviceTokens,
encryptCodexCreds,
decryptCodexCreds,
getCodexClientId,
} from "../codex-oauth";
/**
* Minimal id_token JWT for tests — only the payload matters; signature
* is unverified by our client. Payload includes the
* `chatgpt_account_id` claim and a far-future `exp`.
*/
function makeIdToken(claims: Record<string, unknown>): string {
const header = Buffer.from(JSON.stringify({ alg: "none" })).toString(
"base64url",
);
const payload = Buffer.from(JSON.stringify(claims)).toString("base64url");
return `${header}.${payload}.`;
}
describe("codex-oauth", () => {
describe("generatePKCE", () => {
it("generates verifier and challenge of correct format", () => {
const { verifier, challenge } = generatePKCE();
expect(verifier.length).toBeGreaterThanOrEqual(43);
expect(challenge.length).toBeGreaterThanOrEqual(43);
});
it("generates different values each time", () => {
const a = generatePKCE();
const b = generatePKCE();
expect(a.verifier).not.toBe(b.verifier);
});
});
describe("generateState", () => {
it("generates a non-empty base64url string", () => {
const state = generateState();
expect(state.length).toBeGreaterThan(20);
});
});
describe("getCodexClientId", () => {
const ORIGINAL = process.env.CODEX_OAUTH_CLIENT_ID;
afterEach(() => {
if (ORIGINAL === undefined) delete process.env.CODEX_OAUTH_CLIENT_ID;
else process.env.CODEX_OAUTH_CLIENT_ID = ORIGINAL;
});
it("falls back to the public Codex CLI client ID when env is unset", () => {
delete process.env.CODEX_OAUTH_CLIENT_ID;
expect(getCodexClientId()).toBe("app_EMoamEEZ73f0CkXaXp7hrann");
});
});
describe("device-code flow", () => {
let originalFetch: typeof fetch;
beforeEachInit();
afterEach(() => {
global.fetch = originalFetch;
});
function beforeEachInit() {
originalFetch = global.fetch;
}
it("requestDeviceCode returns user code + verification URL", async () => {
global.fetch = vi.fn(
async () =>
new Response(
JSON.stringify({
device_auth_id: "dev-1",
user_code: "ABCD-1234",
interval: "5",
}),
{ status: 200, headers: { "content-type": "application/json" } },
),
) as never;
const code = await requestDeviceCode();
expect(code.userCode).toBe("ABCD-1234");
expect(code.deviceAuthId).toBe("dev-1");
expect(code.intervalSeconds).toBe(5);
expect(code.verificationUrl).toBe("https://auth.openai.com/codex/device");
});
it("pollDeviceCode returns 'pending' on 403", async () => {
global.fetch = vi.fn(
async () => new Response("", { status: 403 }),
) as never;
const r = await pollDeviceCode({ deviceAuthId: "x", userCode: "y" });
expect(r.status).toBe("pending");
});
it("pollDeviceCode resolves to creds with account id from id_token", async () => {
const idToken = makeIdToken({
chatgpt_account_id: "acct-test",
exp: Math.floor(Date.now() / 1000) + 3600,
});
let call = 0;
global.fetch = vi.fn(async () => {
call += 1;
if (call === 1) {
// device-poll: returns auth code + PKCE values
return new Response(
JSON.stringify({
authorization_code: "auth-code",
code_challenge: "challenge",
code_verifier: "verifier",
}),
{ status: 200, headers: { "content-type": "application/json" } },
);
}
// /oauth/token exchange
return new Response(
JSON.stringify({
id_token: idToken,
access_token: "oauth-access",
refresh_token: "refresh-1",
expires_in: 3600,
}),
{ status: 200, headers: { "content-type": "application/json" } },
);
}) as never;
const r = await pollDeviceCode({ deviceAuthId: "x", userCode: "y" });
expect(r.status).toBe("connected");
if (r.status === "connected") {
expect(r.creds.accessToken).toBe("oauth-access");
expect(r.creds.refreshToken).toBe("refresh-1");
expect(r.creds.accountId).toBe("acct-test");
}
});
it("refreshDeviceTokens uses JSON body and returns rotated tokens", async () => {
const idToken = makeIdToken({
chatgpt_account_id: "acct-test",
exp: Math.floor(Date.now() / 1000) + 3600,
});
const calls: string[] = [];
global.fetch = vi.fn(async (_url: string, init?: RequestInit) => {
calls.push(String(init?.body ?? ""));
return new Response(
JSON.stringify({
id_token: idToken,
access_token: "oauth-fresh",
refresh_token: "refresh-2",
expires_in: 3600,
}),
{ status: 200, headers: { "content-type": "application/json" } },
);
}) as never;
const r = await refreshDeviceTokens("refresh-1");
expect(r.accessToken).toBe("oauth-fresh");
expect(r.refreshToken).toBe("refresh-2");
expect(r.accountId).toBe("acct-test");
// Body must be JSON (not form-urlencoded), per the spec.
expect(calls[0]).toMatch(/^\{/);
expect(JSON.parse(calls[0])).toMatchObject({
grant_type: "refresh_token",
refresh_token: "refresh-1",
});
});
});
describe("encryptCodexCreds / decryptCodexCreds", () => {
it("round-trips access token, refresh token, and account id", () => {
const expiresAt = new Date(Date.now() + 3600_000);
const enc = encryptCodexCreds({
accessToken: "oauth-access",
refreshToken: "refresh-test",
accountId: "acct-123",
expiresAt,
});
const dec = decryptCodexCreds({
accessEncrypted: enc.accessEncrypted,
refreshEncrypted: enc.refreshEncrypted,
});
expect(dec).not.toBeNull();
expect(dec!.accessToken).toBe("oauth-access");
expect(dec!.refreshToken).toBe("refresh-test");
expect(dec!.accountId).toBe("acct-123");
expect(dec!.expiresAt.getTime()).toBe(expiresAt.getTime());
});
it("returns null for legacy v1.4.7-v1.4.11 token storage (raw string)", () => {
// Pre-v1.4.12 stored just the access token plaintext. Decoder
// detects this (not JSON) and returns null so the caller can
// mark the connection expired.
const dec = decryptCodexCreds({
accessEncrypted: "enc:legacy-raw-token",
refreshEncrypted: "enc:refresh",
});
expect(dec).toBeNull();
});
});
});