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>
This commit is contained in:
Marc-André BombeckandClaude Opus 4.7 committed 2026-05-09 14:36:12 +02:00
1 parent e0de659fdd
commit e4a07bc2e4
12 files changed
+1311 -680

No files matched your search

-69
View File
@@ -1,69 +0,0 @@
import { NextRequest, NextResponse } from "next/server";
import { apiHandler, requireAuth, HttpError } from "@/lib/api-handler";
import { checkRateLimit } from "@/lib/rate-limit";
import {
generatePKCE,
generateState,
buildAuthorizationUrl,
CodexOAuthNotConfiguredError,
isCodexOAuthConfigured,
} from "@/lib/ai/codex-oauth";
import { cookies } from "next/headers";
import { annotate } from "@/lib/logging/context";
export const GET = apiHandler(async (_request: NextRequest) => {
const { user } = await requireAuth();
// Surface configuration errors as 503 instead of silently redirecting
// to a dead chatgpt.com URL. The v1.4.2 build dropped the `client_id`
// out of the authorize URL — the user landed on the chatgpt.com login
// page, no callback ever fired, and the connect button "did nothing".
if (!isCodexOAuthConfigured()) {
throw new HttpError(
503,
"Codex OAuth is not configured on this instance — set CODEX_OAUTH_CLIENT_ID",
);
}
const rl = await checkRateLimit(`codex-authorize:${user.id}`, 5, 60_000);
if (!rl.allowed) throw new HttpError(429, "Too many requests");
const { verifier, challenge } = generatePKCE();
const state = generateState();
const appUrl = process.env.APP_URL || process.env.NEXT_PUBLIC_APP_URL;
if (!appUrl) throw new Error("APP_URL not configured");
const redirectUri = `${appUrl}/api/auth/codex/callback`;
const cookieStore = await cookies();
const cookieOptions = {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax" as const,
path: "/",
maxAge: 300,
};
cookieStore.set("codex_verifier", verifier, cookieOptions);
cookieStore.set("codex_state", state, cookieOptions);
let authUrl: string;
try {
authUrl = buildAuthorizationUrl({
codeChallenge: challenge,
state,
redirectUri,
});
} catch (err) {
// Race between the env check above and the URL build — extremely
// unlikely but the typed error path is cheap insurance.
if (err instanceof CodexOAuthNotConfiguredError) {
throw new HttpError(503, err.message);
}
throw err;
}
annotate({ action: { name: "codex.oauth.authorize" } });
return NextResponse.redirect(authUrl);
});
-105
View File
@@ -1,105 +0,0 @@
import { NextRequest, NextResponse } from "next/server";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { checkRateLimit } from "@/lib/rate-limit";
import { exchangeCodeForTokens, encryptCodexCreds } from "@/lib/ai/codex-oauth";
import { prisma } from "@/lib/db";
import { auditLog } from "@/lib/auth/audit";
import { getClientIp } from "@/lib/api-response";
import { cookies } from "next/headers";
import { annotate } from "@/lib/logging/context";
export const GET = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
const rl = await checkRateLimit(`codex-callback:${user.id}`, 10, 60_000);
if (!rl.allowed) {
const appUrl = process.env.APP_URL || process.env.NEXT_PUBLIC_APP_URL;
return NextResponse.redirect(
`${appUrl}/settings/integrations?codex_error=rate_limited`,
);
}
const url = new URL(request.url);
const code = url.searchParams.get("code");
const state = url.searchParams.get("state");
const error = url.searchParams.get("error");
const appUrl = process.env.APP_URL || process.env.NEXT_PUBLIC_APP_URL;
if (!appUrl) throw new Error("APP_URL not configured");
if (error) {
annotate({ meta: { oauth_error: error } });
return NextResponse.redirect(
`${appUrl}/settings/integrations?codex_error=${encodeURIComponent(error)}`,
);
}
if (!code || !state) {
return NextResponse.redirect(
`${appUrl}/settings/integrations?codex_error=missing_params`,
);
}
const cookieStore = await cookies();
const storedState = cookieStore.get("codex_state")?.value;
const storedVerifier = cookieStore.get("codex_verifier")?.value;
cookieStore.delete("codex_state");
cookieStore.delete("codex_verifier");
if (!storedState || !storedVerifier || state !== storedState) {
return NextResponse.redirect(
`${appUrl}/settings/integrations?codex_error=invalid_state`,
);
}
const redirectUri = `${appUrl}/api/auth/codex/callback`;
try {
const creds = await exchangeCodeForTokens({
code,
codeVerifier: storedVerifier,
redirectUri,
});
const encrypted = encryptCodexCreds({
apiKey: creds.apiKey,
refreshToken: creds.refreshToken,
});
// The DB field is named `codexAccessTokenEncrypted` for historical
// reasons. Since v1.4.7 the value stored is the **OpenAI API key**
// obtained via the token-exchange grant (RFC 8693), not the OAuth
// access token. The schema name stays for migration-free rollout;
// the helpers (`encryptCodexCreds` / `decryptCodexCreds`) are the
// canonical surface from now on.
await prisma.user.update({
where: { id: user.id },
data: {
codexAccessTokenEncrypted: encrypted.apiKeyEncrypted,
codexRefreshTokenEncrypted: encrypted.refreshEncrypted,
codexTokenExpiresAt: creds.expiresAt,
codexConnectedAt: new Date(),
codexConnectionStatus: "connected",
insightsCachedAt: null,
insightsCachedText: null,
},
});
await auditLog("codex.oauth.connected", {
userId: user.id,
ipAddress: getClientIp(request),
});
annotate({ action: { name: "codex.oauth.callback.success" } });
return NextResponse.redirect(
`${appUrl}/settings/integrations?codex_connected=true`,
);
} catch (err) {
const message = err instanceof Error ? err.message : "unknown";
annotate({ meta: { codex_token_error: message } });
return NextResponse.redirect(
`${appUrl}/settings/integrations?codex_error=token_exchange_failed`,
);
}
});
+9 -10
View File
@@ -1,8 +1,7 @@
import { NextRequest } from "next/server";
import { apiHandler, requireAuth, HttpError } from "@/lib/api-handler";
import { checkRateLimit } from "@/lib/rate-limit";
import { pollDeviceCode } from "@/lib/ai/codex-oauth";
import { encrypt } from "@/lib/crypto";
import { pollDeviceCode, encryptCodexCreds } from "@/lib/ai/codex-oauth";
import { cookies } from "next/headers";
import { decrypt } from "@/lib/crypto";
import { prisma } from "@/lib/db";
@@ -68,17 +67,17 @@ export const POST = apiHandler(async (request: NextRequest) => {
return apiSuccess({ status: "pending" });
}
// status === "connected" → persist OAuth tokens directly. Device-
// code flow uses the access_token against
// https://chatgpt.com/backend-api/codex/responses — no api-key
// exchange, because the id_token here lacks the organization claim
// that exchange would require.
// status === "connected" → persist the full credential blob
// (access token + chatgpt_account_id + refresh token), encrypted.
// The account id is mandatory for the `ChatGPT-Account-ID` header
// on every Codex backend request.
const enc = encryptCodexCreds(result.creds);
await prisma.user.update({
where: { id: user.id },
data: {
codexAccessTokenEncrypted: encrypt(result.tokens.accessToken),
codexRefreshTokenEncrypted: encrypt(result.tokens.refreshToken),
codexTokenExpiresAt: result.tokens.expiresAt,
codexAccessTokenEncrypted: enc.accessEncrypted,
codexRefreshTokenEncrypted: enc.refreshEncrypted,
codexTokenExpiresAt: result.creds.expiresAt,
codexConnectedAt: new Date(),
codexConnectionStatus: "connected",
insightsCachedAt: null,
+23 -5
View File
@@ -51,7 +51,10 @@ describe("CodexClient", () => {
const client = new CodexClient({
accessToken: "test-token",
onTokenRefresh: vi.fn(),
accountId: "acct-test",
onTokenRefresh: vi
.fn()
.mockResolvedValue({ accessToken: "x", accountId: "acct-test" }),
});
const result = await client.generateCompletion({
@@ -95,7 +98,10 @@ describe("CodexClient", () => {
const client = new CodexClient({
accessToken: "test-token",
onTokenRefresh: vi.fn(),
accountId: "acct-test",
onTokenRefresh: vi
.fn()
.mockResolvedValue({ accessToken: "x", accountId: "acct-test" }),
});
const result = await client.generateCompletion({
systemPrompt: "test",
@@ -122,7 +128,11 @@ describe("CodexClient", () => {
const client = new CodexClient({
accessToken: "expired-token",
onTokenRefresh: onRefresh,
accountId: "acct-test",
onTokenRefresh: async () => {
const t = await onRefresh();
return { accessToken: t, accountId: "acct-test" };
},
});
const result = await client.generateCompletion({
@@ -149,7 +159,11 @@ describe("CodexClient", () => {
const client = new CodexClient({
accessToken: "bad",
onTokenRefresh: onRefresh,
accountId: "acct-test",
onTokenRefresh: async () => {
const t = await onRefresh();
return { accessToken: t, accountId: "acct-test" };
},
});
await expect(
@@ -170,7 +184,11 @@ describe("CodexClient", () => {
const onRefresh = vi.fn();
const client = new CodexClient({
accessToken: "test",
onTokenRefresh: onRefresh,
accountId: "acct-test",
onTokenRefresh: async () => {
const t = await onRefresh();
return { accessToken: t, accountId: "acct-test" };
},
});
await expect(
+121 -128
View File
@@ -1,4 +1,4 @@
import { afterEach, beforeEach, describe, it, expect, vi } from "vitest";
import { afterEach, describe, it, expect, vi } from "vitest";
vi.mock("@/lib/crypto", () => ({
encrypt: vi.fn((v: string) => `enc:${v}`),
@@ -8,25 +8,34 @@ vi.mock("@/lib/crypto", () => ({
import {
generatePKCE,
generateState,
buildAuthorizationUrl,
exchangeCodeForTokens,
refreshTokens,
obtainApiKey,
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);
expect(verifier).not.toContain("=");
expect(challenge).not.toContain("=");
});
it("generates different values each time", () => {
const a = generatePKCE();
const b = generatePKCE();
@@ -38,7 +47,6 @@ describe("codex-oauth", () => {
it("generates a non-empty base64url string", () => {
const state = generateState();
expect(state.length).toBeGreaterThan(20);
expect(state).not.toContain("=");
});
});
@@ -48,167 +56,152 @@ describe("codex-oauth", () => {
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");
});
it("respects an operator override", () => {
process.env.CODEX_OAUTH_CLIENT_ID = "app_private_test";
expect(getCodexClientId()).toBe("app_private_test");
});
});
describe("buildAuthorizationUrl", () => {
it("targets auth.openai.com with the codex CLI scope set", () => {
const url = buildAuthorizationUrl({
codeChallenge: "test-challenge",
state: "test-state",
redirectUri: "https://example.com/callback",
});
const parsed = new URL(url);
// The previous v1.4.6 pointed at chatgpt.com, which is not an
// OAuth issuer at all. Anchoring this assertion explicitly so a
// future regression to the wrong host fails loudly.
expect(parsed.origin).toBe("https://auth.openai.com");
expect(parsed.pathname).toBe("/oauth/authorize");
expect(parsed.searchParams.get("response_type")).toBe("code");
expect(parsed.searchParams.get("client_id")).toBe(
"app_EMoamEEZ73f0CkXaXp7hrann",
);
expect(parsed.searchParams.get("code_challenge")).toBe("test-challenge");
expect(parsed.searchParams.get("code_challenge_method")).toBe("S256");
expect(parsed.searchParams.get("state")).toBe("test-state");
expect(parsed.searchParams.get("redirect_uri")).toBe(
"https://example.com/callback",
);
expect(parsed.searchParams.get("scope")).toContain("offline_access");
expect(parsed.searchParams.get("id_token_add_organizations")).toBe(
"true",
);
expect(parsed.searchParams.get("codex_cli_simplified_flow")).toBe("true");
});
});
describe("token / api-key exchange", () => {
describe("device-code flow", () => {
let originalFetch: typeof fetch;
beforeEach(() => {
originalFetch = global.fetch;
});
beforeEachInit();
afterEach(() => {
global.fetch = originalFetch;
});
function beforeEachInit() {
originalFetch = global.fetch;
}
it("exchanges the auth code for tokens and trades the id_token for an api key", async () => {
const calls: Array<{ url: string; body: string }> = [];
it("requestDeviceCode returns user code + verification URL", async () => {
global.fetch = vi.fn(
async (url: RequestInfo | URL, init?: RequestInit) => {
calls.push({ url: String(url), body: String(init?.body ?? "") });
if (calls.length === 1) {
// Token endpoint — returns id_token + access_token + refresh_token.
return new Response(
JSON.stringify({
id_token: "id-token-abc",
access_token: "oauth-access",
refresh_token: "oauth-refresh",
expires_in: 3600,
token_type: "Bearer",
}),
{ status: 200, headers: { "content-type": "application/json" } },
);
}
// Api-key exchange.
return new Response(
JSON.stringify({ access_token: "sk-from-codex" }),
{
status: 200,
headers: { "content-type": "application/json" },
},
);
},
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 result = await exchangeCodeForTokens({
code: "auth-code",
codeVerifier: "verifier",
redirectUri: "https://example.com/cb",
});
expect(result.apiKey).toBe("sk-from-codex");
expect(result.refreshToken).toBe("oauth-refresh");
expect(result.expiresAt).toBeInstanceOf(Date);
// Both calls hit auth.openai.com with form-urlencoded bodies.
expect(calls[0].url).toBe("https://auth.openai.com/oauth/token");
expect(calls[0].body).toContain("grant_type=authorization_code");
expect(calls[0].body).toContain("code_verifier=verifier");
expect(calls[1].url).toBe("https://auth.openai.com/oauth/token");
expect(calls[1].body).toContain(
"grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Atoken-exchange",
);
expect(calls[1].body).toContain("subject_token=id-token-abc");
expect(calls[1].body).toContain("requested_token=openai-api-key");
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("refreshTokens re-uses the refresh token and re-runs api-key exchange", async () => {
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({
id_token: "id-fresh",
access_token: "oauth-access-fresh",
refresh_token: "oauth-refresh-fresh",
expires_in: 3600,
token_type: "Bearer",
authorization_code: "auth-code",
code_challenge: "challenge",
code_verifier: "verifier",
}),
{ status: 200, headers: { "content-type": "application/json" } },
);
}
return new Response(JSON.stringify({ access_token: "sk-rotated" }), {
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 result = await refreshTokens("old-refresh");
expect(result.apiKey).toBe("sk-rotated");
expect(result.refreshToken).toBe("oauth-refresh-fresh");
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("obtainApiKey throws when the upstream omits access_token", async () => {
global.fetch = vi.fn(
async () =>
new Response(JSON.stringify({}), {
status: 200,
headers: { "content-type": "application/json" },
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,
}),
) as never;
await expect(obtainApiKey("id-token-x")).rejects.toThrow(
/no access_token/i,
);
{ 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 api key and refresh token", () => {
it("round-trips access token, refresh token, and account id", () => {
const expiresAt = new Date(Date.now() + 3600_000);
const enc = encryptCodexCreds({
apiKey: "sk-test",
accessToken: "oauth-access",
refreshToken: "refresh-test",
accountId: "acct-123",
expiresAt,
});
expect(enc.apiKeyEncrypted).toBe("enc:sk-test");
expect(enc.refreshEncrypted).toBe("enc:refresh-test");
const dec = decryptCodexCreds({
apiKeyEncrypted: enc.apiKeyEncrypted,
accessEncrypted: enc.accessEncrypted,
refreshEncrypted: enc.refreshEncrypted,
});
expect(dec.apiKey).toBe("sk-test");
expect(dec.refreshToken).toBe("refresh-test");
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();
});
});
});
+19 -9
View File
@@ -11,11 +11,13 @@ vi.mock("@/lib/crypto", () => ({
encrypt: vi.fn((v: string) => `encrypted:${v}`),
}));
vi.mock("@/lib/ai/codex-oauth", () => ({
refreshTokens: vi.fn(),
encryptCodexCreds: vi.fn((c: { apiKey: string; refreshToken: string }) => ({
apiKeyEncrypted: `enc:${c.apiKey}`,
refreshEncrypted: `enc:${c.refreshToken}`,
})),
refreshDeviceTokens: vi.fn(),
encryptCodexCreds: vi.fn(
(c: { accessToken: string; refreshToken: string; accountId: string }) => ({
accessEncrypted: `enc:${c.accessToken}|${c.accountId}`,
refreshEncrypted: `enc:${c.refreshToken}`,
}),
),
decryptCodexCreds: vi.fn(),
}));
@@ -39,8 +41,12 @@ describe("resolveProvider", () => {
vi.mocked(decrypt).mockImplementation((v: string) => `decrypted:${v}`);
vi.mocked(encrypt).mockImplementation((v: string) => `encrypted:${v}`);
vi.mocked(encryptCodexCreds).mockImplementation(
(c: { apiKey: string; refreshToken: string }) => ({
apiKeyEncrypted: `enc:${c.apiKey}`,
(c: {
accessToken: string;
refreshToken: string;
accountId: string;
}) => ({
accessEncrypted: `enc:${c.accessToken}|${c.accountId}`,
refreshEncrypted: `enc:${c.refreshToken}`,
}),
);
@@ -48,8 +54,10 @@ describe("resolveProvider", () => {
it("returns codex provider when user has valid tokens (no explicit choice)", async () => {
vi.mocked(decryptCodexCreds).mockReturnValue({
apiKey: "sk-codex-key",
accessToken: "oauth-access",
refreshToken: "decrypted-refresh",
accountId: "acct-123",
expiresAt: new Date(Date.now() + 3600_000),
});
vi.mocked(prisma.user.findUnique)
@@ -265,8 +273,10 @@ describe("resolveProvider", () => {
it("CHATGPT_OAUTH selection routes through Codex when connected", async () => {
vi.mocked(decryptCodexCreds).mockReturnValue({
apiKey: "sk-codex-key",
accessToken: "oauth-access",
refreshToken: "decrypted-refresh",
accountId: "acct-123",
expiresAt: new Date(Date.now() + 3600_000),
});
vi.mocked(prisma.user.findUnique)
+130 -66
View File
@@ -1,20 +1,55 @@
import { randomUUID } from "node:crypto";
import type { AIProvider, CompletionParams, CompletionResult } from "./types";
/**
* Codex backend client — talks to `chatgpt.com/backend-api/codex/responses`
* via the OpenAI Responses API (SSE streaming, ChatGPT-Account-ID
* header, OAuth Bearer auth). Implemented against the spec at
* `docs/codex-protocol-spec.md`, which mirrors the official `openai/codex`
* CLI.
*
* Required headers:
* - `Authorization: Bearer <oauth-access-token>`
* - `ChatGPT-Account-ID: <chatgpt_account_id JWT claim>`
* - `Content-Type: application/json`
* - `Accept: text/event-stream`
* - `originator`, `User-Agent`, `session_id`, `thread_id`
*
* Response is always SSE (server rejects `stream: false` with 400).
*/
const CODEX_ENDPOINT = "https://chatgpt.com/backend-api/codex/responses";
const CODEX_MODEL = "gpt-5.3-codex";
/**
* `gpt-5.3-codex` is a *test placeholder* in the upstream codebase
* (`codex-rs/codex-api/src/sse/responses.rs:1374`), not a real model
* slug. The real CLI default is `gpt-5-codex`. The server safety-routes
* if needed and reports the actual model in the `OpenAI-Model`
* response header.
*/
const CODEX_MODEL = "gpt-5-codex";
const ORIGINATOR = "healthlog";
const USER_AGENT = "HealthLog/1.0 (+https://healthlog.bombeck.io)";
interface CodexClientConfig {
accessToken: string;
onTokenRefresh: () => Promise<string>;
accountId: string;
onTokenRefresh: () => Promise<{ accessToken: string; accountId: string }>;
}
export class CodexClient implements AIProvider {
readonly type = "codex" as const;
private accessToken: string;
private onTokenRefresh: () => Promise<string>;
private accountId: string;
private onTokenRefresh: () => Promise<{
accessToken: string;
accountId: string;
}>;
constructor(config: CodexClientConfig) {
this.accessToken = config.accessToken;
this.accountId = config.accountId;
this.onTokenRefresh = config.onTokenRefresh;
}
@@ -24,7 +59,9 @@ export class CodexClient implements AIProvider {
const firstAttempt = await this.doRequest(params);
if (firstAttempt.status === 401) {
this.accessToken = await this.onTokenRefresh();
const fresh = await this.onTokenRefresh();
this.accessToken = fresh.accessToken;
this.accountId = fresh.accountId;
const retryAttempt = await this.doRequest(params);
if (!retryAttempt.ok) {
throw await this.buildUpstreamError(
@@ -42,13 +79,6 @@ export class CodexClient implements AIProvider {
return this.parseResponse(firstAttempt);
}
/**
* Build a structured error from a non-OK upstream response. Mirrors the
* pattern used in `OpenAIClient.generateCompletion` so logs and Glitchtip
* issues for both providers carry the same fields (httpStatus, upstream,
* model, bodyExcerpt). The body excerpt is truncated to 500 chars and any
* `sk-…` / `Bearer …` token is masked before it reaches log shipping.
*/
private async buildUpstreamError(
res: Response,
message: string,
@@ -69,15 +99,21 @@ export class CodexClient implements AIProvider {
}
private async doRequest(params: CompletionParams): Promise<Response> {
// The chatgpt.com/backend-api/codex/responses endpoint expects the
// OpenAI Responses API shape: `input` is an array of typed message
// items, NOT a raw string. The Codex CLI's `ResponsesApiRequest`
// (`codex-rs/codex-api/src/common.rs`) is the canonical definition.
const sessionId = randomUUID();
const threadId = randomUUID();
return fetch(CODEX_ENDPOINT, {
method: "POST",
headers: {
"Content-Type": "application/json",
Accept: "text/event-stream",
Authorization: `Bearer ${this.accessToken}`,
"ChatGPT-Account-ID": this.accountId,
originator: ORIGINATOR,
"User-Agent": USER_AGENT,
session_id: sessionId,
"session-id": sessionId,
thread_id: threadId,
"thread-id": threadId,
},
body: JSON.stringify({
model: CODEX_MODEL,
@@ -92,37 +128,34 @@ export class CodexClient implements AIProvider {
tools: [],
tool_choice: "auto",
parallel_tool_calls: false,
// Reasoning is required-but-nullable on the wire. We don't ask
// for reasoning summaries — emit `null` so the JSON has the
// field present and the server stops complaining about a
// missing key (it returned 400 in earlier iterations).
reasoning: null,
store: false,
// Codex backend rejects sync responses with
// `Stream must be set to true`. We have to consume SSE.
stream: true,
// Required field; empty array when not asking for reasoning.
include: [],
}),
});
}
/**
* Consume the Codex SSE stream and assemble the final assistant
* message. The Codex backend emits OpenAI-Responses-API events:
* message. Event types per the spec:
*
* event: response.output_text.delta
* data: { "delta": "Hello" }
* - `response.output_item.done` — final assembled item; an
* assistant `Message` carries the canonical reply in
* `item.content[].text`.
* - `response.output_text.delta` — incremental chunks; fallback
* when no done event arrives.
* - `response.completed` — terminal; carries `usage.total_tokens`.
* - `response.failed` / `response.incomplete` — terminal errors.
*
* event: response.output_item.done
* data: { "type": "message", "role": "assistant",
* "content": [{ "type": "output_text", "text": "Hello world" }] }
*
* event: response.completed
* data: { "type": "response.completed",
* "response": { "id": "...", "usage": { "total_tokens": 123 } } }
*
* Strategy:
* - Prefer the assembled text in `response.output_item.done` (any
* `message` item with `assistant` role and `output_text` content).
* This avoids re-stitching `delta` chunks ourselves.
* - Fall back to concatenating all `output_text.delta` chunks if no
* output_item.done arrived before the stream ended.
* - Pick up token usage from `response.completed` when present.
* - Throw on `response.error` events with the upstream message.
* Reasoning deltas (`response.reasoning_*.delta`) are deliberately
* NOT folded into the visible text — they belong to a separate
* channel.
*/
private async parseResponse(res: Response): Promise<CompletionResult> {
if (!res.body) {
@@ -135,6 +168,12 @@ export class CodexClient implements AIProvider {
let assembledText = "";
let deltaText = "";
let tokensUsed: number | null = null;
let serverModel: string | null = null;
// The server reports the actual routed model in the OpenAI-Model
// response header — useful when safety-routing kicks in.
const headerModel = res.headers.get("OpenAI-Model");
if (headerModel) serverModel = headerModel;
try {
while (true) {
@@ -142,7 +181,6 @@ export class CodexClient implements AIProvider {
if (done) break;
buffer += decoder.decode(value, { stream: true });
// SSE messages are separated by a blank line (\n\n).
let separator: number;
while ((separator = buffer.indexOf("\n\n")) !== -1) {
const rawEvent = buffer.slice(0, separator);
@@ -153,7 +191,7 @@ export class CodexClient implements AIProvider {
.find((l) => l.startsWith("data:"));
if (!dataLine) continue;
const payload = dataLine.slice(5).trim();
if (!payload || payload === "[DONE]") continue;
if (!payload) continue;
let parsed: {
type?: string;
@@ -163,8 +201,12 @@ export class CodexClient implements AIProvider {
role?: string;
content?: Array<{ type?: string; text?: string }>;
};
response?: { usage?: { total_tokens?: number } };
error?: { message?: string };
response?: {
usage?: { total_tokens?: number };
error?: { code?: string; message?: string };
incomplete_details?: { reason?: string };
};
error?: { code?: string; message?: string };
};
try {
parsed = JSON.parse(payload);
@@ -172,32 +214,54 @@ export class CodexClient implements AIProvider {
continue;
}
if (parsed.type === "response.output_text.delta" && parsed.delta) {
deltaText += parsed.delta;
continue;
}
if (parsed.type === "response.output_item.done" && parsed.item) {
const item = parsed.item;
if (item.type === "message" && item.role === "assistant") {
const text = item.content
?.filter((c) => c.type === "output_text" && c.text)
.map((c) => c.text!)
.join("");
if (text) assembledText += text;
switch (parsed.type) {
case "response.output_text.delta": {
if (parsed.delta) deltaText += parsed.delta;
break;
}
continue;
}
if (parsed.type === "response.completed") {
tokensUsed = parsed.response?.usage?.total_tokens ?? null;
continue;
}
if (parsed.type === "response.error" || parsed.type === "error") {
const message =
parsed.error?.message ?? "Codex stream returned an error event";
throw new Error(message);
case "response.output_item.done": {
const item = parsed.item;
if (item?.type === "message" && item.role === "assistant") {
const text = item.content
?.filter((c) => c.type === "output_text" && c.text)
.map((c) => c.text!)
.join("");
if (text) assembledText += text;
}
break;
}
case "response.completed": {
tokensUsed = parsed.response?.usage?.total_tokens ?? null;
break;
}
case "response.failed": {
const err = parsed.response?.error;
const message =
err?.message ?? "Codex stream returned response.failed";
const e = new Error(message);
Object.assign(e, {
upstream: "codex",
errorCode: err?.code ?? null,
model: serverModel ?? CODEX_MODEL,
});
throw e;
}
case "response.incomplete": {
const reason =
parsed.response?.incomplete_details?.reason ??
"incomplete response";
throw new Error(`Codex stream incomplete: ${reason}`);
}
case "error":
case "response.error": {
const message =
parsed.error?.message ?? "Codex stream returned an error event";
throw new Error(message);
}
default:
// Unknown event types are tolerated and ignored, same
// as the official client.
break;
}
}
}
@@ -213,7 +277,7 @@ export class CodexClient implements AIProvider {
return {
content,
tokensUsed,
model: CODEX_MODEL,
model: serverModel ?? CODEX_MODEL,
providerType: "codex",
};
}
+196 -250
View File
@@ -2,44 +2,43 @@ import { createHash, randomBytes } from "node:crypto";
import { encrypt, decrypt } from "@/lib/crypto";
/**
* Codex / ChatGPT-OAuth flow — mirrors the official `openai/codex` CLI
* (`codex-rs/login/src/server.rs`).
* Codex / ChatGPT-OAuth flow — implemented against `docs/codex-protocol-spec.md`,
* which mirrors the official `openai/codex` CLI.
*
* The previous v1.4.2-v1.4.6 implementation hit `chatgpt.com/authorize`
* + `chatgpt.com/oauth/token`, which are NOT OAuth endpoints. ChatGPT
* silently rendered its normal web-app and the flow dead-ended. The
* canonical issuer is `auth.openai.com` for both authorize and token
* exchange.
* Production-only path: **device-code flow**. The browser/auth-code flow
* does not work for hosted apps — Hydra's redirect-URI allow-list for the
* public Codex CLI client ID covers only `localhost:1455/1457`. The
* device-code flow side-steps that: the user types a short code on
* `auth.openai.com/codex/device`, we poll, on success we get an
* authorization-code internally and exchange it for OAuth tokens.
*
* Flow:
* 1. PKCE authorize → user consents at auth.openai.com
* 2. Token exchange → returns `{ id_token, access_token, refresh_token }`
* 3. **API-key exchange** (token-exchange grant, RFC 8693) → trades
* the `id_token` for a regular OpenAI API key that bills against
* the user's ChatGPT subscription.
* 4. HealthLog stores the API key (encrypted) and uses it via the
* standard OpenAIClient against `https://api.openai.com/v1`.
* Refresh tokens are stored too so the API key can be re-issued
* when it ages out — the refresh token is the long-lived secret.
* After the exchange we hold three pieces:
* - `accessToken` — the OAuth access token used as `Authorization: Bearer`
* on every Codex request.
* - `refreshToken` — long-lived; rotates on every refresh.
* - `accountId` — the `chatgpt_account_id` claim from the id_token,
* required as the `ChatGPT-Account-ID` header (without it: 401).
*
* All three are persisted (JSON-encoded then encrypted) on the user row
* so we don't need a schema migration.
*/
const ISSUER = "https://auth.openai.com";
/**
* Public PKCE client ID hardcoded in the official `openai/codex` CLI
* (`codex-rs/login/src/auth/manager.rs`). Allows OPS to override via
* env var if a private OAuth app is registered for this deployment.
* (`codex-rs/login/src/auth/manager.rs`). Operators with a private OAuth
* app can override via env var.
*/
const DEFAULT_CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann";
/**
* Scopes the official Codex CLI requests. Without these the
* `id_token` returned has no `chatgpt_subscription_active_until` claim
* and the api-key exchange downstream rejects it.
*/
/** Same scopes the official CLI requests. */
const CODEX_SCOPES =
"openid profile email offline_access api.connectors.read api.connectors.invoke";
const DEVICE_VERIFICATION_URL = `${ISSUER}/codex/device`;
const DEVICE_REDIRECT_URI = `${ISSUER}/deviceauth/callback`;
function base64url(buffer: Buffer): string {
return buffer.toString("base64url");
}
@@ -55,9 +54,9 @@ export function generateState(): string {
}
/**
* Backwards-compatible no-op type. Kept so any existing callers that
* imported the error type don't break — the helpers below now always
* return a value (the public Codex client ID is the safe default).
* Backwards-compatible no-op type. Older callers that imported the error
* type continue to compile. Codex OAuth always considers itself
* configured because we ship the public client ID as the default.
*/
export class CodexOAuthNotConfiguredError extends Error {
constructor() {
@@ -72,181 +71,118 @@ export function getCodexClientId(): string {
return process.env.CODEX_OAUTH_CLIENT_ID?.trim() || DEFAULT_CLIENT_ID;
}
/**
* Codex OAuth is always considered configured because we ship the
* public Codex CLI client ID as a default. Operators can override via
* env var if they have a private OAuth app registered with OpenAI.
*/
export function isCodexOAuthConfigured(): boolean {
return true;
}
export function buildAuthorizationUrl(params: {
codeChallenge: string;
state: string;
redirectUri: string;
}): string {
const url = new URL(`${ISSUER}/oauth/authorize`);
url.searchParams.set("response_type", "code");
url.searchParams.set("client_id", getCodexClientId());
url.searchParams.set("redirect_uri", params.redirectUri);
url.searchParams.set("scope", CODEX_SCOPES);
url.searchParams.set("code_challenge", params.codeChallenge);
url.searchParams.set("code_challenge_method", "S256");
url.searchParams.set("state", params.state);
// Required for the api-key exchange step to work — without these
// claims the id_token cannot be traded for an OpenAI API key.
url.searchParams.set("id_token_add_organizations", "true");
url.searchParams.set("codex_cli_simplified_flow", "true");
return url.toString();
// ─── JWT inspection ──────────────────────────────────────────────────
interface JwtClaims {
exp?: number;
chatgpt_account_id?: string;
/**
* The official Codex CLI also accepts the URI-namespaced variant
* (`https://api.openai.com/auth.chatgpt_account_id`) in the
* `https://api.openai.com/auth` claim object — see
* `codex-rs/login/src/auth/manager.rs:891`.
*/
"https://api.openai.com/auth"?: { chatgpt_account_id?: string };
[key: string]: unknown;
}
function decodeJwtClaims(token: string): JwtClaims | null {
const parts = token.split(".");
if (parts.length < 2) return null;
try {
const padded = parts[1].padEnd(
parts[1].length + ((4 - (parts[1].length % 4)) % 4),
"=",
);
const json = Buffer.from(padded, "base64url").toString("utf8");
return JSON.parse(json) as JwtClaims;
} catch {
return null;
}
}
function extractAccountId(idToken: string): string | null {
const claims = decodeJwtClaims(idToken);
if (!claims) return null;
if (typeof claims.chatgpt_account_id === "string") {
return claims.chatgpt_account_id;
}
const namespaced = claims["https://api.openai.com/auth"];
if (
namespaced &&
typeof namespaced === "object" &&
typeof namespaced.chatgpt_account_id === "string"
) {
return namespaced.chatgpt_account_id;
}
return null;
}
function extractExpiry(idToken: string): Date | null {
const claims = decodeJwtClaims(idToken);
if (!claims || typeof claims.exp !== "number") return null;
return new Date(claims.exp * 1000);
}
// ─── Token shape persisted on the user row ───────────────────────────
export interface CodexCreds {
/** OAuth access token used directly against chatgpt.com/backend-api. */
accessToken: string;
/** OAuth refresh token; rotates on every refresh. */
refreshToken: string;
/**
* `chatgpt_account_id` claim from the id_token. Required as the
* `ChatGPT-Account-ID` header on every Codex backend request.
*/
accountId: string;
/** Wall-clock expiry. Derived from id_token `exp`, fallback expires_in. */
expiresAt: Date;
}
interface RawTokenResponse {
id_token: string;
access_token: string;
refresh_token: string;
expires_in: number;
token_type: string;
refresh_token?: string;
expires_in?: number;
token_type?: string;
}
async function postForm(
endpoint: string,
fields: Record<string, string>,
): Promise<Response> {
const body = new URLSearchParams(fields).toString();
return fetch(endpoint, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body,
});
}
export interface CodexTokens {
/** OpenAI API key obtained via the token-exchange grant. */
apiKey: string;
/** OAuth refresh token; long-lived, re-issues fresh tokens. */
refreshToken: string;
/**
* Wall-clock expiry of the API key. Once this passes we re-run the
* refresh + api-key exchange before the next request.
*/
expiresAt: Date;
}
export async function exchangeCodeForTokens(params: {
code: string;
codeVerifier: string;
redirectUri: string;
}): Promise<CodexTokens> {
const res = await postForm(`${ISSUER}/oauth/token`, {
grant_type: "authorization_code",
client_id: getCodexClientId(),
code: params.code,
redirect_uri: params.redirectUri,
code_verifier: params.codeVerifier,
});
if (!res.ok) {
const body = await res.text().catch(() => "");
throw new Error(`Codex token exchange failed (${res.status}): ${body}`);
function buildCreds(
raw: RawTokenResponse,
prevRefresh: string | null,
): CodexCreds {
const accountId = extractAccountId(raw.id_token);
if (!accountId) {
throw new Error(
"Codex token exchange returned id_token without chatgpt_account_id",
);
}
const tokens = (await res.json()) as RawTokenResponse;
const apiKey = await obtainApiKey(tokens.id_token);
return {
apiKey,
refreshToken: tokens.refresh_token,
expiresAt: new Date(Date.now() + tokens.expires_in * 1000),
};
}
export async function refreshTokens(
refreshToken: string,
): Promise<CodexTokens> {
const res = await postForm(`${ISSUER}/oauth/token`, {
grant_type: "refresh_token",
client_id: getCodexClientId(),
refresh_token: refreshToken,
// The same scopes must be requested on refresh, otherwise the
// re-issued id_token loses the claims the api-key exchange needs.
scope: CODEX_SCOPES,
});
if (!res.ok) {
const body = await res.text().catch(() => "");
throw new Error(`Codex token refresh failed (${res.status}): ${body}`);
const refreshToken = raw.refresh_token ?? prevRefresh ?? "";
if (!refreshToken) {
throw new Error("Codex token exchange returned no refresh_token");
}
const tokens = (await res.json()) as RawTokenResponse;
const apiKey = await obtainApiKey(tokens.id_token);
const jwtExp = extractExpiry(raw.id_token);
const expiresAt =
jwtExp ?? new Date(Date.now() + (raw.expires_in ?? 3600) * 1000);
return {
apiKey,
refreshToken: tokens.refresh_token ?? refreshToken,
expiresAt: new Date(Date.now() + tokens.expires_in * 1000),
};
}
/**
* Trade an OpenID `id_token` for an OpenAI API key via the OAuth
* token-exchange grant (RFC 8693). The resulting key is special: it
* bills against the user's ChatGPT subscription rather than a separate
* API plan, which is the entire point of the Codex flow.
*/
export async function obtainApiKey(idToken: string): Promise<string> {
const res = await postForm(`${ISSUER}/oauth/token`, {
grant_type: "urn:ietf:params:oauth:grant-type:token-exchange",
client_id: getCodexClientId(),
requested_token: "openai-api-key",
subject_token: idToken,
subject_token_type: "urn:ietf:params:oauth:token-type:id_token",
});
if (!res.ok) {
const body = await res.text().catch(() => "");
throw new Error(`Codex api-key exchange failed (${res.status}): ${body}`);
}
const json = (await res.json()) as { access_token?: string };
if (!json.access_token) {
throw new Error("Codex api-key exchange returned no access_token");
}
return json.access_token;
}
export function encryptCodexCreds(creds: {
apiKey: string;
refreshToken: string;
}): { apiKeyEncrypted: string; refreshEncrypted: string } {
return {
apiKeyEncrypted: encrypt(creds.apiKey),
refreshEncrypted: encrypt(creds.refreshToken),
};
}
export function decryptCodexCreds(encrypted: {
apiKeyEncrypted: string;
refreshEncrypted: string;
}): { apiKey: string; refreshToken: string } {
return {
apiKey: decrypt(encrypted.apiKeyEncrypted),
refreshToken: decrypt(encrypted.refreshEncrypted),
accessToken: raw.access_token,
refreshToken,
accountId,
expiresAt,
};
}
// ─── Device-code flow ────────────────────────────────────────────────
//
// For hosted apps (HealthLog), the standard authorization-code redirect
// flow does not work because OpenAI's Hydra only allow-lists localhost
// callbacks for the public Codex CLI client ID. The device-code flow is
// the documented escape hatch: the user goes to chatgpt.com on any
// device, enters a short code, and approves the connection — no
// redirect URI on our domain is involved at all.
const DEVICE_VERIFICATION_URL = `${ISSUER}/codex/device`;
const DEVICE_REDIRECT_URI = `${ISSUER}/deviceauth/callback`;
export interface DeviceCodeStart {
/** Short user-facing code, e.g. "RGRP-N5F7U". */
userCode: string;
/** URL the user opens in their browser to approve. */
verificationUrl: string;
/** Opaque per-attempt id used for polling. Server-side only. */
deviceAuthId: string;
/** Polling interval in seconds. */
intervalSeconds: number;
}
@@ -278,36 +214,15 @@ export async function requestDeviceCode(): Promise<DeviceCodeStart> {
};
}
export interface DeviceTokens {
/**
* OAuth access token. Used directly against
* `https://chatgpt.com/backend-api/codex/responses` — the Codex
* backend that bills against the ChatGPT subscription.
*
* The device-code flow deliberately does NOT call the api-key
* exchange the browser flow uses, because the id_token returned
* here lacks the `organization_id` claim (that claim only gets
* added when the authorize URL was hit with
* `id_token_add_organizations=true`, which we cannot do during
* device-code auth — the user types the code on chatgpt.com
* directly without us controlling the authorize step).
*/
accessToken: string;
/** OAuth refresh token; used to mint a new access token before expiry. */
refreshToken: string;
/** Wall-clock expiry of `accessToken`. */
expiresAt: Date;
}
export type DevicePollResult =
| { status: "pending" }
| { status: "connected"; tokens: DeviceTokens };
| { status: "connected"; creds: CodexCreds };
/**
* Single poll attempt against the device-auth token endpoint. Returns
* "pending" while the user has not yet approved (Hydra answers with
* 403/404), and "connected" with already-exchanged Codex tokens once
* the user finishes the approval flow on chatgpt.com.
* One poll attempt against the device-auth token endpoint. Returns
* `pending` while the user has not yet approved (Hydra answers with
* 403/404), and `connected` with fully resolved Codex credentials
* once the user finishes the approval on chatgpt.com.
*/
export async function pollDeviceCode(params: {
deviceAuthId: string;
@@ -336,16 +251,19 @@ export async function pollDeviceCode(params: {
code_verifier: string;
};
// Standard PKCE exchange — but with the deviceauth callback URI
// that Hydra uses internally for this flow. The user never visits
// this URL; it's just the value Hydra associates with the
// authorization code so the exchange typechecks.
const tokenRes = await postForm(`${ISSUER}/oauth/token`, {
grant_type: "authorization_code",
client_id: getCodexClientId(),
code: json.authorization_code,
redirect_uri: DEVICE_REDIRECT_URI,
code_verifier: json.code_verifier,
// Standard PKCE exchange against the OAuth token endpoint, with the
// device-auth callback URI Hydra associates with this authorization
// code internally.
const tokenRes = await fetch(`${ISSUER}/oauth/token`, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
grant_type: "authorization_code",
client_id: getCodexClientId(),
code: json.authorization_code,
redirect_uri: DEVICE_REDIRECT_URI,
code_verifier: json.code_verifier,
}).toString(),
});
if (!tokenRes.ok) {
const body = await tokenRes.text().catch(() => "");
@@ -356,27 +274,31 @@ export async function pollDeviceCode(params: {
const tokens = (await tokenRes.json()) as RawTokenResponse;
return {
status: "connected",
tokens: {
accessToken: tokens.access_token,
refreshToken: tokens.refresh_token,
expiresAt: new Date(Date.now() + tokens.expires_in * 1000),
},
creds: buildCreds(tokens, null),
};
}
/**
* Refresh an OAuth access token without going through the api-key
* exchange. Used by the device-code path where the resulting id_token
* never has the organization claims the api-key exchange requires.
* Refresh an OAuth access token using the long-lived refresh token.
* Per `docs/codex-protocol-spec.md` §6d, the refresh endpoint takes
* a JSON body (NOT form-urlencoded — that detail bit us once before).
*
* The refresh token rotates on every call; the new value (when
* present in the response) MUST be persisted, otherwise the next
* attempt fails with `refresh_token_reused`.
*/
export async function refreshDeviceTokens(
refreshToken: string,
): Promise<DeviceTokens> {
const res = await postForm(`${ISSUER}/oauth/token`, {
grant_type: "refresh_token",
client_id: getCodexClientId(),
refresh_token: refreshToken,
scope: CODEX_SCOPES,
): Promise<CodexCreds> {
const res = await fetch(`${ISSUER}/oauth/token`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
client_id: getCodexClientId(),
grant_type: "refresh_token",
refresh_token: refreshToken,
scope: CODEX_SCOPES,
}),
});
if (!res.ok) {
const body = await res.text().catch(() => "");
@@ -385,36 +307,60 @@ export async function refreshDeviceTokens(
);
}
const tokens = (await res.json()) as RawTokenResponse;
return {
accessToken: tokens.access_token,
refreshToken: tokens.refresh_token ?? refreshToken,
expiresAt: new Date(Date.now() + tokens.expires_in * 1000),
};
return buildCreds(tokens, refreshToken);
}
// ─── Backwards-compat shims so older callers keep compiling ──────────
// ─── Encrypted-storage codec ────────────────────────────────────────
//
// We re-purpose `codexAccessTokenEncrypted` to store the entire creds
// blob (access_token + accountId + expiresAt) JSON-encoded then
// encrypted. `codexRefreshTokenEncrypted` continues to hold just the
// refresh token (encrypted), so a future migration to a dedicated
// column shape stays simple. Old v1.4.9-v1.4.11 tokens stored without
// the accountId field will fail to parse — the user has to re-connect
// once after the v1.4.12 deploy. Acceptable since those connects
// never produced a working session anyway.
/** @deprecated use `encryptCodexCreds`. */
export function encryptTokens(t: {
interface StoredAccess {
accessToken: string;
refreshToken: string;
}): { accessEncrypted: string; refreshEncrypted: string } {
return {
accessEncrypted: encrypt(t.accessToken),
refreshEncrypted: encrypt(t.refreshToken),
};
accountId: string;
expiresAt: string; // ISO
}
/** @deprecated use `decryptCodexCreds`. */
export function decryptTokens(e: {
export function encryptCodexCreds(creds: CodexCreds): {
accessEncrypted: string;
refreshEncrypted: string;
}): { accessToken: string; refreshToken: string } {
} {
const stored: StoredAccess = {
accessToken: creds.accessToken,
accountId: creds.accountId,
expiresAt: creds.expiresAt.toISOString(),
};
return {
accessToken: decrypt(e.accessEncrypted),
refreshToken: decrypt(e.refreshEncrypted),
accessEncrypted: encrypt(JSON.stringify(stored)),
refreshEncrypted: encrypt(creds.refreshToken),
};
}
/** @deprecated alias for `refreshTokens`. */
export const refreshAccessToken = refreshTokens;
export function decryptCodexCreds(encrypted: {
accessEncrypted: string;
refreshEncrypted: string;
}): CodexCreds | null {
let parsed: StoredAccess;
try {
const raw = decrypt(encrypted.accessEncrypted);
parsed = JSON.parse(raw) as StoredAccess;
} catch {
// Pre-v1.4.12 stored format (raw access token / api key string).
// We cannot recover the accountId from that — caller must treat
// the connection as expired and ask the user to re-link.
return null;
}
if (!parsed.accessToken || !parsed.accountId) return null;
return {
accessToken: parsed.accessToken,
refreshToken: decrypt(encrypted.refreshEncrypted),
accountId: parsed.accountId,
expiresAt: new Date(parsed.expiresAt),
};
}
+63 -37
View File
@@ -1,11 +1,15 @@
import type { AIProvider, CompletionParams, CompletionResult } from "./types";
import { prisma } from "@/lib/db";
import { decrypt, encrypt } from "@/lib/crypto";
import { decrypt } from "@/lib/crypto";
import { CodexClient } from "./codex-client";
import { OpenAIClient } from "./openai-client";
import { AnthropicClient } from "./anthropic-client";
import { LocalOpenAICompatibleClient } from "./local-client";
import { refreshDeviceTokens } from "./codex-oauth";
import {
refreshDeviceTokens,
encryptCodexCreds,
decryptCodexCreds,
} from "./codex-oauth";
import { isPublicUrl } from "@/lib/validations/notifications";
const TOKEN_REFRESH_BUFFER_MS = 5 * 60 * 1000; // 5 minutes
@@ -102,20 +106,18 @@ async function resolveAdminProvider(): Promise<AIProvider> {
}
/**
* Codex flow (v1.4.8+, device-code path): the user authorised against
* chatgpt.com/codex/device, we store the resulting OAuth access_token
* (encrypted) in `codexAccessTokenEncrypted` and the refresh_token in
* `codexRefreshTokenEncrypted`. The active token is forwarded to
* `https://chatgpt.com/backend-api/codex/responses` — the same Codex
* backend the official CLI uses, which bills against the user's
* ChatGPT subscription. When the access token is about to expire we
* refresh it transparently using the long-lived refresh token.
* Codex provider — device-code path.
*
* Note: v1.4.7's authorisation-code-flow path also wrote to these
* columns, but stored an OpenAI API key (post-RFC-8693 exchange)
* rather than an OAuth access token. That path is dead in production
* because Hydra rejects our redirect URI. The device-code path is the
* canonical replacement.
* `codexAccessTokenEncrypted` stores an encrypted JSON blob with the
* OAuth access token AND the `chatgpt_account_id` claim from the
* id_token (the latter is mandatory in the `ChatGPT-Account-ID`
* header). `codexRefreshTokenEncrypted` continues to hold just the
* refresh token. See `codex-oauth.ts` for the storage codec.
*
* Old v1.4.7-v1.4.11 rows that stored a raw token string instead of
* the JSON envelope cannot be revived (the account id was never
* captured), so `decryptCodexCreds` returns null and we treat the
* connection as expired — the user re-runs the connect flow once.
*/
async function resolveCodexProvider(
userId: string,
@@ -125,7 +127,6 @@ async function resolveCodexProvider(
select: {
codexAccessTokenEncrypted: true,
codexRefreshTokenEncrypted: true,
codexTokenExpiresAt: true,
codexConnectionStatus: true,
},
});
@@ -138,54 +139,79 @@ async function resolveCodexProvider(
return null;
}
const storedAccessToken = decrypt(user.codexAccessTokenEncrypted);
const storedRefreshToken = decrypt(user.codexRefreshTokenEncrypted);
const stored = decryptCodexCreds({
accessEncrypted: user.codexAccessTokenEncrypted,
refreshEncrypted: user.codexRefreshTokenEncrypted,
});
if (!stored) {
// Pre-v1.4.12 record without account_id — the token cannot
// satisfy the ChatGPT-Account-ID header. Mark the row as
// disconnected so the UI prompts the user to re-link.
await prisma.user.update({
where: { id: userId },
data: { codexConnectionStatus: "expired" },
});
return null;
}
let activeAccessToken = storedAccessToken;
let active = stored;
// Proactive refresh if the access token expires within 5 minutes.
const expiresAt = user.codexTokenExpiresAt?.getTime() ?? 0;
if (expiresAt < Date.now() + TOKEN_REFRESH_BUFFER_MS) {
// Proactive refresh if the access token is within 5 min of expiry.
if (stored.expiresAt.getTime() < Date.now() + TOKEN_REFRESH_BUFFER_MS) {
try {
const fresh = await refreshDeviceTokens(storedRefreshToken);
activeAccessToken = fresh.accessToken;
const fresh = await refreshDeviceTokens(stored.refreshToken);
active = fresh;
const enc = encryptCodexCreds(fresh);
await prisma.user.update({
where: { id: userId },
data: {
codexAccessTokenEncrypted: encrypt(fresh.accessToken),
codexRefreshTokenEncrypted: encrypt(fresh.refreshToken),
codexAccessTokenEncrypted: enc.accessEncrypted,
codexRefreshTokenEncrypted: enc.refreshEncrypted,
codexTokenExpiresAt: fresh.expiresAt,
},
});
} catch {
// Proactive refresh failures fall through — the CodexClient
// retries via onTokenRefresh on a real 401 and writes the
// updated tokens then.
// Fall through — CodexClient will trigger an on-401 refresh
// and persist there.
}
}
return new CodexClient({
accessToken: activeAccessToken,
accessToken: active.accessToken,
accountId: active.accountId,
onTokenRefresh: async () => {
const freshUser = await prisma.user.findUnique({
where: { id: userId },
select: { codexRefreshTokenEncrypted: true },
select: {
codexAccessTokenEncrypted: true,
codexRefreshTokenEncrypted: true,
},
});
if (!freshUser?.codexRefreshTokenEncrypted) {
if (
!freshUser?.codexAccessTokenEncrypted ||
!freshUser.codexRefreshTokenEncrypted
) {
throw new Error("No refresh token available");
}
const currentRefresh = decrypt(freshUser.codexRefreshTokenEncrypted);
const fresh = await refreshDeviceTokens(currentRefresh);
const decoded = decryptCodexCreds({
accessEncrypted: freshUser.codexAccessTokenEncrypted,
refreshEncrypted: freshUser.codexRefreshTokenEncrypted,
});
if (!decoded) {
throw new Error("Codex token storage corrupt; user must re-link");
}
const fresh = await refreshDeviceTokens(decoded.refreshToken);
const enc = encryptCodexCreds(fresh);
await prisma.user.update({
where: { id: userId },
data: {
codexAccessTokenEncrypted: encrypt(fresh.accessToken),
codexRefreshTokenEncrypted: encrypt(fresh.refreshToken),
codexAccessTokenEncrypted: enc.accessEncrypted,
codexRefreshTokenEncrypted: enc.refreshEncrypted,
codexTokenExpiresAt: fresh.expiresAt,
},
});
return fresh.accessToken;
return { accessToken: fresh.accessToken, accountId: fresh.accountId };
},
});
}