mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-10 05:12:14 +00:00
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:
1 parent
e0de659fdd
commit
e4a07bc2e4
12 files changed
+1311
-680
No files matched your search
@@ -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);
|
||||
});
|
||||
@@ -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`,
|
||||
);
|
||||
}
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user