mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-09 13:01:00 +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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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
@@ -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
@@ -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
@@ -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 };
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user