Files
HealthLog/src/app/api/auth/codex/device-poll/route.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

102 lines
3.5 KiB
TypeScript

import { NextRequest } from "next/server";
import { apiHandler, requireAuth, HttpError } from "@/lib/api-handler";
import { checkRateLimit } from "@/lib/rate-limit";
import { pollDeviceCode, encryptCodexCreds } from "@/lib/ai/codex-oauth";
import { cookies } from "next/headers";
import { decrypt } from "@/lib/crypto";
import { prisma } from "@/lib/db";
import { auditLog } from "@/lib/auth/audit";
import { getClientIp, apiSuccess, apiError } from "@/lib/api-response";
import { annotate } from "@/lib/logging/context";
/**
* v1.4.7.1: Device-code flow polling endpoint.
*
* The client polls this every `intervalSeconds` (returned from
* `device-start`). While the user has not approved on chatgpt.com,
* we return `status: "pending"` and the client retries. Once Hydra
* issues an authorization code we exchange it server-side, swap the
* id_token for an OpenAI API key (RFC 8693), and persist both the
* key and the OAuth refresh token encrypted on the user record.
*
* The cookie that holds the `device_auth_id` + `user_code` lives 15
* minutes — same window Hydra gives the device code. After success
* we delete the cookie immediately so a refresh of the page does not
* try to re-poll a stale device id.
*/
export const POST = apiHandler(async (request: NextRequest) => {
const { user } = await requireAuth();
const rl = await checkRateLimit(`codex-device-poll:${user.id}`, 60, 60_000);
if (!rl.allowed) throw new HttpError(429, "Too many requests");
const cookieStore = await cookies();
const cookie = cookieStore.get("codex_device")?.value;
if (!cookie) {
return apiError("No device-auth attempt in progress", 400);
}
let deviceAuthId: string;
let userCode: string;
try {
const decoded = JSON.parse(decrypt(cookie)) as {
deviceAuthId: string;
userCode: string;
};
deviceAuthId = decoded.deviceAuthId;
userCode = decoded.userCode;
} catch {
cookieStore.delete("codex_device");
return apiError("Invalid device-auth state — restart the flow", 400);
}
let result;
try {
result = await pollDeviceCode({ deviceAuthId, userCode });
} catch (err) {
const message = err instanceof Error ? err.message : "unknown";
annotate({ meta: { codex_device_poll_error: message } });
return apiError("Codex device-poll failed — please restart the flow", 502);
}
if (result.status === "pending") {
annotate({
action: { name: "codex.device.poll" },
meta: { pending: true },
});
return apiSuccess({ status: "pending" });
}
// 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: enc.accessEncrypted,
codexRefreshTokenEncrypted: enc.refreshEncrypted,
codexTokenExpiresAt: result.creds.expiresAt,
codexConnectedAt: new Date(),
codexConnectionStatus: "connected",
insightsCachedAt: null,
insightsCachedText: null,
},
});
cookieStore.delete("codex_device");
await auditLog("codex.oauth.connected", {
userId: user.id,
ipAddress: getClientIp(request),
details: { method: "device_code" },
});
annotate({
action: { name: "codex.device.poll" },
meta: { connected: true },
});
return apiSuccess({ status: "connected" });
});