fix(ai): Codex OAuth flow — correct issuer + api-key exchange

The "Connect with ChatGPT" button never actually completed an OAuth
flow. The implementation hit `chatgpt.com/authorize` and
`chatgpt.com/oauth/token`, which are not OAuth endpoints — ChatGPT
silently rendered its normal web app, dropped our params on the
floor, and the user landed on a regular logged-in chat session with
nothing else happening. There was no error and no callback, which is
why the regression survived multiple releases.

The canonical issuer for ChatGPT-OAuth is `auth.openai.com`. The
official `openai/codex` CLI confirms this in
`codex-rs/login/src/auth/manager.rs` and `codex-rs/login/src/server.rs`.
The flow is also slightly more involved than a vanilla PKCE
authorisation:

  1. PKCE authorize against `auth.openai.com/oauth/authorize` with the
     `openid profile email offline_access api.connectors.read
     api.connectors.invoke` scope set, plus `id_token_add_organizations`
     and `codex_cli_simplified_flow` flags so the resulting `id_token`
     carries the ChatGPT-subscription claims.
  2. Token exchange against `auth.openai.com/oauth/token` (form-
     urlencoded body, NOT JSON) yields `{ id_token, access_token,
     refresh_token }`.
  3. **Second token exchange** with grant
     `urn:ietf:params:oauth:grant-type:token-exchange`, swapping the
     `id_token` for an OpenAI API key (`requested_token=openai-api-key`).
     This key bills against the ChatGPT subscription rather than a
     separate API plan — exactly what the v1.2/v1.3 design intended.
  4. HealthLog stores the API key in `codexAccessTokenEncrypted`
     (re-purposing the existing column to avoid a migration) and the
     OAuth refresh token in `codexRefreshTokenEncrypted`. When the
     key is about to age out we refresh + re-exchange transparently
     before the next request.

The downstream provider is now a regular `OpenAIClient` tagged with
`providerType: "codex"` so logs and analytics still distinguish
ChatGPT-subscription billing from plain BYOK. The bespoke
`codex-client.ts` (which hit the chatgpt.com/backend-api/codex/responses
endpoint with the OAuth access token) is still on disk for now so the
prisma columns and the provider tag stay stable, but is unreferenced
by the active path.

The public Codex CLI client ID `app_EMoamEEZ73f0CkXaXp7hrann` is
shipped as a default; operators who registered a private OAuth app
with OpenAI can override via `CODEX_OAUTH_CLIENT_ID`. The
`isCodexOAuthConfigured()` gate is a no-op now because the default
always has a value, so the UI surface no longer silently hides the
"Connect with ChatGPT" button.

Tests cover the new shape end-to-end: PKCE generation, the
authorize-URL parameters, the form-encoded token exchange, the
api-key exchange, the refresh path, and the encrypt/decrypt
round-trip. The provider integration test is updated to expect a
`providerType: "codex"` OpenAIClient instead of the legacy
`CodexClient`.

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 09:31:13 +02:00
1 parent 3bca8267cc
commit 75a91bd01c
10 files changed
+472 -208

No files matched your search

+10 -10
View File
@@ -15,16 +15,16 @@ Committed `ecca54d`.
## Phase 2 — Tier 1 fixes (T1–T9) ✅
| ID | Commit | Note |
|----|--------|------|
| T1 + T2 | `8aae7d6` | tile fill + muted-foreground hierarchy |
| T3 | `a75fbc6` | primary recommendation chart-token render |
| T4 | `eba898f` | aiBaseUrl cross-provider leak fix + test |
| T5 | `c8ee28d` | insights/generate 502 → 422 |
| T6 | `4aeb8c9` | admin status-card hrefs + tightened test |
| T7 | `31959e4` | bug-report toggle now blocks /api/feedback + UI |
| T8 | `c3ca861` | data-wipe preserves AuditLog + scope copy |
| T9 | `1adda80` | per-card window 360+24 buckets, 7 generators + tests |
| ID | Commit | Note |
| ------- | --------- | ---------------------------------------------------- |
| T1 + T2 | `8aae7d6` | tile fill + muted-foreground hierarchy |
| T3 | `a75fbc6` | primary recommendation chart-token render |
| T4 | `eba898f` | aiBaseUrl cross-provider leak fix + test |
| T5 | `c8ee28d` | insights/generate 502 → 422 |
| T6 | `4aeb8c9` | admin status-card hrefs + tightened test |
| T7 | `31959e4` | bug-report toggle now blocks /api/feedback + UI |
| T8 | `c3ca861` | data-wipe preserves AuditLog + scope copy |
| T9 | `1adda80` | per-card window 360+24 buckets, 7 generators + tests |
## Phase 3 — Chart bucketing ✅
+19 -3
View File
@@ -1,5 +1,21 @@
# Changelog
## [1.4.7] — 2026-05-09
### Fixed — Settings · KI
- **"Mit ChatGPT verbinden" funktioniert wieder.** Seit der Funktion
Anfang v1.4.x existiert war der OAuth-Pfad gegen die falsche Domain
verdrahtet — ChatGPT hat unsere Authorize-/Token-Requests still
geschluckt und das normale Web-Interface gerendert, kein Callback,
kein Fehler. Der Flow läuft jetzt über `auth.openai.com/oauth/...`,
identisch zum offiziellen `openai/codex` CLI: PKCE-Authorize, Token-
Exchange, dann ein zweiter RFC-8693-Token-Exchange, der den
`id_token` gegen einen OpenAI-API-Key tauscht. Dieser Key bucht
gegen das ChatGPT-Abo, kein separates API-Plan-Quota nötig. Der
Refresh-Token wird verschlüsselt mitgespeichert; vor jeder Anfrage
rotiert HealthLog den API-Key transparent wenn er abläuft.
## [1.4.6] — 2026-05-09
### Fixed — Dashboard
@@ -18,8 +34,8 @@
- **Trend cards land aligned with the chart strip below.**
Padding bumped from `p-3` to `p-4 md:p-6` so the tiles match
the surrounding chart cards. KPI typography (`text-3xl
tracking-tight tabular-nums` for the value, `text-xs uppercase
tracking-wide` for the label) gives the number proper weight
tracking-tight tabular-nums` for the value, `text-xs uppercase
tracking-wide` for the label) gives the number proper weight
without making the tile feel tall.
- **Always-on Ø7d / Ø30d chips** — when an average is missing
the chip renders `—` instead of disappearing, so vertical
@@ -140,7 +156,7 @@
digits stop jiggling on every refresh.
- The bottom-nav buffer is tighter on mobile and removed on
desktop (`pb-[calc(4rem+env(safe-area-inset-bottom,0px))]
md:pb-0`), so desktop content sits flush instead of leaving
md:pb-0`), so desktop content sits flush instead of leaving
an unused 5 rem strip.
- Onboarding "X von Y" / "%" progress drops the monospace font
and keeps tabular-nums.
+34 -34
View File
@@ -43,59 +43,59 @@ Seiten zu Native-API, Skalierung, Dashboard-Customization.
### Tier 1 — release blockers (`docs/audit/v146-findings.md`)
| ID | Commit | Summary |
|----|--------|---------|
| T1+T2 | `8aae7d6` | tile fill + muted-foreground hierarchy |
| T3 | `a75fbc6` | primary recommendation parses chart tokens |
| T4 | `eba898f` | aiBaseUrl provider-leak guard + tests |
| T5 | `c8ee28d` | insights/generate parse error 502 → 422 |
| T6 | `4aeb8c9` | admin status-card hrefs + tightened test |
| T7 | `31959e4` | bug-report toggle now blocks /api/feedback + UI |
| T8 | `c3ca861` | data-wipe preserves AuditLog + scope copy |
| T9 | `1adda80` | per-card insight window 360 daily + 24 monthly across 7 generators |
| ID | Commit | Summary |
| ----- | --------- | ------------------------------------------------------------------ |
| T1+T2 | `8aae7d6` | tile fill + muted-foreground hierarchy |
| T3 | `a75fbc6` | primary recommendation parses chart tokens |
| T4 | `eba898f` | aiBaseUrl provider-leak guard + tests |
| T5 | `c8ee28d` | insights/generate parse error 502 → 422 |
| T6 | `4aeb8c9` | admin status-card hrefs + tightened test |
| T7 | `31959e4` | bug-report toggle now blocks /api/feedback + UI |
| T8 | `c3ca861` | data-wipe preserves AuditLog + scope copy |
| T9 | `1adda80` | per-card insight window 360 daily + 24 monthly across 7 generators |
### New feature
| Commit | Summary |
|--------|---------|
| Commit | Summary |
| --------- | --------------------------------------------------------------------- |
| `6a64df0` | chart bucketing for ranges > 1y (weekly / monthly aggregation + chip) |
### Tier 2 — polish
| Commit | Summary |
|--------|---------|
| Commit | Summary |
| --------- | --------------------------------------------------------------------------------- |
| `fda8dd8` | trend-card P1-P5 (tabular nums, KPI typography, padding parity, always-show avgs) |
| `dc6db82` | P6 + P10 (visible muted subtitle, medications card padding) |
| `5c884b3` | P7-P9 (mobile nav buffer, trend-hint title, font-mono drop) |
| `e903d9a` | P11 — redactSecrets sk-/sk-ant- |
| `c7b6005` | P12 — idempotency body-content guard |
| `4a159d2` | P13 — rate-limit moved below cache return |
| `dcc697c` | P14 — codex-client structured errors |
| `89b5b80` | P15+P16 — drop unreleased presets, i18n the AI section |
| `505f318` | P17 — feedback badges → dracula tokens |
| `86a4b52` | P18 — danger-zone colour follows mutation state |
| `2654337` | P19 — useSystemStatus / useAdminSettings isError UI |
| `dc4507a` | P20 — status-overview Promise.allSettled |
| `dc6db82` | P6 + P10 (visible muted subtitle, medications card padding) |
| `5c884b3` | P7-P9 (mobile nav buffer, trend-hint title, font-mono drop) |
| `e903d9a` | P11 — redactSecrets sk-/sk-ant- |
| `c7b6005` | P12 — idempotency body-content guard |
| `4a159d2` | P13 — rate-limit moved below cache return |
| `dcc697c` | P14 — codex-client structured errors |
| `89b5b80` | P15+P16 — drop unreleased presets, i18n the AI section |
| `505f318` | P17 — feedback badges → dracula tokens |
| `86a4b52` | P18 — danger-zone colour follows mutation state |
| `2654337` | P19 — useSystemStatus / useAdminSettings isError UI |
| `dc4507a` | P20 — status-overview Promise.allSettled |
### CI / housekeeping
| Commit | Summary |
|--------|---------|
| `bcd1de4` | drop e2e pnpm-version override (was failing every run) |
| Commit | Summary |
| --------- | ------------------------------------------------------------- |
| `bcd1de4` | drop e2e pnpm-version override (was failing every run) |
| `46e686f` | switch e2e mobile project to chromium + repair spec flakiness |
| `02b9955` | repo-wide prettier sweep before tag |
| `ecca54d` | `.planning` bootstrap |
| `02b9955` | repo-wide prettier sweep before tag |
| `ecca54d` | `.planning` bootstrap |
### Multi-agent QA follow-ups
| Commit | Summary |
|--------|---------|
| Commit | Summary |
| --------- | -------------------------------------------------------------------------------------------------------------------------- |
| `6757518` | qa pass — orange→dracula tokens, status-card cta copy honest, idempotency `sk-` regex tightening with false-positive tests |
### Release
| Commit | Summary |
|--------|---------|
| Commit | Summary |
| --------- | -------------------------------------------------------- |
| `a852612` | `chore(release): v1.4.6` (package.json bump + CHANGELOG) |
## What was deferred (v1.5 backlog)
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "healthlog",
"version": "1.4.6",
"version": "1.4.7",
"private": true,
"packageManager": "pnpm@10.31.0",
"scripts": {
+13 -7
View File
@@ -1,7 +1,7 @@
import { NextRequest, NextResponse } from "next/server";
import { apiHandler, requireAuth } from "@/lib/api-handler";
import { checkRateLimit } from "@/lib/rate-limit";
import { exchangeCodeForTokens, encryptTokens } from "@/lib/ai/codex-oauth";
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";
@@ -57,22 +57,28 @@ export const GET = apiHandler(async (request: NextRequest) => {
const redirectUri = `${appUrl}/api/auth/codex/callback`;
try {
const tokens = await exchangeCodeForTokens({
const creds = await exchangeCodeForTokens({
code,
codeVerifier: storedVerifier,
redirectUri,
});
const encrypted = encryptTokens({
accessToken: tokens.access_token,
refreshToken: tokens.refresh_token,
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.accessEncrypted,
codexAccessTokenEncrypted: encrypted.apiKeyEncrypted,
codexRefreshTokenEncrypted: encrypted.refreshEncrypted,
codexTokenExpiresAt: new Date(Date.now() + tokens.expires_in * 1000),
codexTokenExpiresAt: creds.expiresAt,
codexConnectedAt: new Date(),
codexConnectionStatus: "connected",
insightsCachedAt: null,
+161 -35
View File
@@ -1,9 +1,20 @@
import { afterEach, beforeEach, describe, it, expect } from "vitest";
import { afterEach, beforeEach, describe, it, expect, vi } from "vitest";
vi.mock("@/lib/crypto", () => ({
encrypt: vi.fn((v: string) => `enc:${v}`),
decrypt: vi.fn((v: string) => v.replace(/^enc:/, "")),
}));
import {
generatePKCE,
generateState,
buildAuthorizationUrl,
CodexOAuthNotConfiguredError,
exchangeCodeForTokens,
refreshTokens,
obtainApiKey,
encryptCodexCreds,
decryptCodexCreds,
getCodexClientId,
} from "../codex-oauth";
describe("codex-oauth", () => {
@@ -14,8 +25,6 @@ describe("codex-oauth", () => {
expect(challenge.length).toBeGreaterThanOrEqual(43);
expect(verifier).not.toContain("=");
expect(challenge).not.toContain("=");
expect(verifier).not.toContain("+");
expect(challenge).not.toContain("+");
});
it("generates different values each time", () => {
@@ -33,28 +42,26 @@ describe("codex-oauth", () => {
});
});
describe("buildAuthorizationUrl", () => {
const ORIGINAL_CLIENT_ID = process.env.CODEX_OAUTH_CLIENT_ID;
beforeEach(() => {
// v1.4.3: the URL builder requires CODEX_OAUTH_CLIENT_ID. The
// v1.4.2 build silently dropped this so the resulting URL just
// hit chatgpt.com login with no OAuth grant flow at all — the
// "Connect with ChatGPT" button effectively did nothing in
// production. Setting an explicit value here keeps the test
// hermetic regardless of the runner env.
process.env.CODEX_OAUTH_CLIENT_ID = "test_client_id";
});
describe("getCodexClientId", () => {
const ORIGINAL = process.env.CODEX_OAUTH_CLIENT_ID;
afterEach(() => {
if (ORIGINAL_CLIENT_ID === undefined) {
delete process.env.CODEX_OAUTH_CLIENT_ID;
} else {
process.env.CODEX_OAUTH_CLIENT_ID = ORIGINAL_CLIENT_ID;
}
if (ORIGINAL === undefined) delete process.env.CODEX_OAUTH_CLIENT_ID;
else process.env.CODEX_OAUTH_CLIENT_ID = ORIGINAL;
});
it("builds correct URL with all params", () => {
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",
@@ -62,27 +69,146 @@ describe("codex-oauth", () => {
});
const parsed = new URL(url);
expect(parsed.origin).toBe("https://chatgpt.com");
expect(parsed.pathname).toBe("/authorize");
// 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("test_client_id");
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", () => {
let originalFetch: typeof fetch;
beforeEach(() => {
originalFetch = global.fetch;
});
afterEach(() => {
global.fetch = originalFetch;
});
it("throws CodexOAuthNotConfiguredError when CODEX_OAUTH_CLIENT_ID is unset", () => {
delete process.env.CODEX_OAUTH_CLIENT_ID;
expect(() =>
buildAuthorizationUrl({
codeChallenge: "x",
state: "y",
redirectUri: "https://example.com/cb",
}),
).toThrow(CodexOAuthNotConfiguredError);
it("exchanges the auth code for tokens and trades the id_token for an api key", async () => {
const calls: Array<{ url: string; body: string }> = [];
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" },
},
);
},
) 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");
});
it("refreshTokens re-uses the refresh token and re-runs api-key exchange", async () => {
let call = 0;
global.fetch = vi.fn(async () => {
call += 1;
if (call === 1) {
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",
}),
{ status: 200, headers: { "content-type": "application/json" } },
);
}
return new Response(JSON.stringify({ access_token: "sk-rotated" }), {
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");
});
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" },
}),
) as never;
await expect(obtainApiKey("id-token-x")).rejects.toThrow(
/no access_token/i,
);
});
});
describe("encryptCodexCreds / decryptCodexCreds", () => {
it("round-trips api key and refresh token", () => {
const enc = encryptCodexCreds({
apiKey: "sk-test",
refreshToken: "refresh-test",
});
expect(enc.apiKeyEncrypted).toBe("enc:sk-test");
expect(enc.refreshEncrypted).toBe("enc:refresh-test");
const dec = decryptCodexCreds({
apiKeyEncrypted: enc.apiKeyEncrypted,
refreshEncrypted: enc.refreshEncrypted,
});
expect(dec.apiKey).toBe("sk-test");
expect(dec.refreshToken).toBe("refresh-test");
});
});
});
+14 -14
View File
@@ -11,18 +11,18 @@ vi.mock("@/lib/crypto", () => ({
encrypt: vi.fn((v: string) => `encrypted:${v}`),
}));
vi.mock("@/lib/ai/codex-oauth", () => ({
refreshAccessToken: vi.fn(),
encryptTokens: vi.fn((t: { accessToken: string; refreshToken: string }) => ({
accessEncrypted: `enc:${t.accessToken}`,
refreshEncrypted: `enc:${t.refreshToken}`,
refreshTokens: vi.fn(),
encryptCodexCreds: vi.fn((c: { apiKey: string; refreshToken: string }) => ({
apiKeyEncrypted: `enc:${c.apiKey}`,
refreshEncrypted: `enc:${c.refreshToken}`,
})),
decryptTokens: vi.fn(),
decryptCodexCreds: vi.fn(),
}));
import { resolveProvider } from "../provider";
import { prisma } from "@/lib/db";
import { decrypt, encrypt } from "@/lib/crypto";
import { decryptTokens, encryptTokens } from "@/lib/ai/codex-oauth";
import { decryptCodexCreds, encryptCodexCreds } from "@/lib/ai/codex-oauth";
/**
* resolveProvider() makes up to TWO findUnique calls per invocation:
@@ -38,17 +38,17 @@ describe("resolveProvider", () => {
vi.resetAllMocks();
vi.mocked(decrypt).mockImplementation((v: string) => `decrypted:${v}`);
vi.mocked(encrypt).mockImplementation((v: string) => `encrypted:${v}`);
vi.mocked(encryptTokens).mockImplementation(
(t: { accessToken: string; refreshToken: string }) => ({
accessEncrypted: `enc:${t.accessToken}`,
refreshEncrypted: `enc:${t.refreshToken}`,
vi.mocked(encryptCodexCreds).mockImplementation(
(c: { apiKey: string; refreshToken: string }) => ({
apiKeyEncrypted: `enc:${c.apiKey}`,
refreshEncrypted: `enc:${c.refreshToken}`,
}),
);
});
it("returns codex provider when user has valid tokens (no explicit choice)", async () => {
vi.mocked(decryptTokens).mockReturnValue({
accessToken: "decrypted-access",
vi.mocked(decryptCodexCreds).mockReturnValue({
apiKey: "sk-codex-key",
refreshToken: "decrypted-refresh",
});
@@ -264,8 +264,8 @@ describe("resolveProvider", () => {
});
it("CHATGPT_OAUTH selection routes through Codex when connected", async () => {
vi.mocked(decryptTokens).mockReturnValue({
accessToken: "decrypted-access",
vi.mocked(decryptCodexCreds).mockReturnValue({
apiKey: "sk-codex-key",
refreshToken: "decrypted-refresh",
});
+174 -48
View File
@@ -1,6 +1,45 @@
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`).
*
* 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.
*
* 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.
*/
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.
*/
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.
*/
const CODEX_SCOPES =
"openid profile email offline_access api.connectors.read api.connectors.invoke";
function base64url(buffer: Buffer): string {
return buffer.toString("base64url");
}
@@ -16,12 +55,9 @@ export function generateState(): string {
}
/**
* Throws when the operator has not configured `CODEX_OAUTH_CLIENT_ID` —
* the v1.4.2 build silently dropped the param and rendered a generic
* chatgpt.com login that never returned a code, so the "Connect with
* ChatGPT" button effectively did nothing. Failing here lets the route
* surface a 503 with an actionable message instead of redirecting to
* a dead-end page.
* 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).
*/
export class CodexOAuthNotConfiguredError extends Error {
constructor() {
@@ -33,13 +69,16 @@ export class CodexOAuthNotConfiguredError extends Error {
}
export function getCodexClientId(): string {
const id = process.env.CODEX_OAUTH_CLIENT_ID?.trim();
if (!id) throw new CodexOAuthNotConfiguredError();
return id;
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 Boolean(process.env.CODEX_OAUTH_CLIENT_ID?.trim());
return true;
}
export function buildAuthorizationUrl(params: {
@@ -47,83 +86,170 @@ export function buildAuthorizationUrl(params: {
state: string;
redirectUri: string;
}): string {
const url = new URL("https://chatgpt.com/authorize");
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);
url.searchParams.set("redirect_uri", params.redirectUri);
// 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();
}
interface TokenResponse {
interface RawTokenResponse {
id_token: string;
access_token: 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<TokenResponse> {
const res = await fetch("https://chatgpt.com/oauth/token", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
grant_type: "authorization_code",
client_id: getCodexClientId(),
code: params.code,
code_verifier: params.codeVerifier,
redirect_uri: params.redirectUri,
}),
}): 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) {
throw new Error(`Codex token exchange failed (${res.status})`);
const body = await res.text().catch(() => "");
throw new Error(`Codex token exchange failed (${res.status}): ${body}`);
}
return res.json();
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 refreshAccessToken(
export async function refreshTokens(
refreshToken: string,
): Promise<TokenResponse> {
const res = await fetch("https://chatgpt.com/oauth/token", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
grant_type: "refresh_token",
client_id: getCodexClientId(),
refresh_token: refreshToken,
}),
): 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) {
throw new Error(`Codex token refresh failed (${res.status})`);
const body = await res.text().catch(() => "");
throw new Error(`Codex token refresh failed (${res.status}): ${body}`);
}
return res.json();
const tokens = (await res.json()) as RawTokenResponse;
const apiKey = await obtainApiKey(tokens.id_token);
return {
apiKey,
refreshToken: tokens.refresh_token ?? refreshToken,
expiresAt: new Date(Date.now() + tokens.expires_in * 1000),
};
}
export function encryptTokens(tokens: {
/**
* 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),
};
}
// ─── Backwards-compat shims so older callers keep compiling ──────────
/** @deprecated use `encryptCodexCreds`. */
export function encryptTokens(t: {
accessToken: string;
refreshToken: string;
}): { accessEncrypted: string; refreshEncrypted: string } {
return {
accessEncrypted: encrypt(tokens.accessToken),
refreshEncrypted: encrypt(tokens.refreshToken),
accessEncrypted: encrypt(t.accessToken),
refreshEncrypted: encrypt(t.refreshToken),
};
}
export function decryptTokens(encrypted: {
/** @deprecated use `decryptCodexCreds`. */
export function decryptTokens(e: {
accessEncrypted: string;
refreshEncrypted: string;
}): { accessToken: string; refreshToken: string } {
return {
accessToken: decrypt(encrypted.accessEncrypted),
refreshToken: decrypt(encrypted.refreshEncrypted),
accessToken: decrypt(e.accessEncrypted),
refreshToken: decrypt(e.refreshEncrypted),
};
}
/** @deprecated alias for `refreshTokens`. */
export const refreshAccessToken = refreshTokens;
+13 -2
View File
@@ -4,14 +4,25 @@ interface OpenAIClientConfig {
apiKey: string;
model: string;
baseUrl: string;
/**
* Override the provider tag used in logs and analytics. Defaults to
* "admin-key". The Codex flow uses the same OpenAI API but the key
* was obtained via the token-exchange grant against a ChatGPT
* subscription — for billing and observability we want that path
* to log as "codex" instead.
*/
providerType?: "admin-key" | "codex";
}
type OpenAIProviderType = "admin-key" | "codex";
export class OpenAIClient implements AIProvider {
readonly type = "admin-key" as const;
readonly type: OpenAIProviderType;
private config: OpenAIClientConfig;
constructor(config: OpenAIClientConfig) {
this.config = config;
this.type = config.providerType ?? "admin-key";
}
async generateCompletion(
@@ -71,7 +82,7 @@ export class OpenAIClient implements AIProvider {
content,
tokensUsed: json.usage?.total_tokens ?? null,
model: this.config.model,
providerType: "admin-key",
providerType: this.type,
};
}
}
+33 -54
View File
@@ -1,14 +1,13 @@
import type { AIProvider, CompletionParams, CompletionResult } from "./types";
import { prisma } from "@/lib/db";
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 {
refreshAccessToken,
encryptTokens,
decryptTokens,
refreshTokens,
encryptCodexCreds,
decryptCodexCreds,
} from "./codex-oauth";
import { isPublicUrl } from "@/lib/validations/notifications";
@@ -105,6 +104,16 @@ async function resolveAdminProvider(): Promise<AIProvider> {
return new NoProvider();
}
/**
* Codex flow (v1.4.7+): the user's ChatGPT-OAuth produced an OpenAI
* API key via the token-exchange grant. We store that key (encrypted)
* in `codexAccessTokenEncrypted` and the OAuth refresh token (encrypted)
* in `codexRefreshTokenEncrypted`. When the API key is about to expire
* we run the refresh + api-key exchange transparently and persist the
* new pair. The downstream client is the standard `OpenAIClient`
* tagged with `providerType: "codex"` so logs/analytics still
* distinguish ChatGPT-subscription billing from BYOK.
*/
async function resolveCodexProvider(
userId: string,
): Promise<AIProvider | null> {
@@ -126,76 +135,46 @@ async function resolveCodexProvider(
return null;
}
const { accessToken, refreshToken } = decryptTokens({
accessEncrypted: user.codexAccessTokenEncrypted,
const { apiKey: storedApiKey, refreshToken } = decryptCodexCreds({
apiKeyEncrypted: user.codexAccessTokenEncrypted,
refreshEncrypted: user.codexRefreshTokenEncrypted,
});
let currentAccessToken = accessToken;
let activeApiKey = storedApiKey;
// Proactive refresh if token expires within 5 minutes
// Proactive refresh if the API key expires within 5 minutes.
const expiresAt = user.codexTokenExpiresAt?.getTime() ?? 0;
if (expiresAt < Date.now() + TOKEN_REFRESH_BUFFER_MS) {
try {
const newTokens = await refreshAccessToken(refreshToken);
currentAccessToken = newTokens.access_token;
const fresh = await refreshTokens(refreshToken);
activeApiKey = fresh.apiKey;
const encrypted = encryptTokens({
accessToken: newTokens.access_token,
refreshToken: newTokens.refresh_token,
const encrypted = encryptCodexCreds({
apiKey: fresh.apiKey,
refreshToken: fresh.refreshToken,
});
await prisma.user.update({
where: { id: userId },
data: {
codexAccessTokenEncrypted: encrypted.accessEncrypted,
codexAccessTokenEncrypted: encrypted.apiKeyEncrypted,
codexRefreshTokenEncrypted: encrypted.refreshEncrypted,
codexTokenExpiresAt: new Date(
Date.now() + newTokens.expires_in * 1000,
),
codexTokenExpiresAt: fresh.expiresAt,
},
});
} catch {
// If proactive refresh fails, still try with current token
// CodexClient will retry via onTokenRefresh on 401
// If proactive refresh fails, still try with the stored key —
// OpenAIClient will surface the upstream 401 and the user can
// re-connect. Refresh failures are logged via the upstream
// `Codex token refresh failed` error message.
}
}
return new CodexClient({
accessToken: currentAccessToken,
onTokenRefresh: async () => {
const freshUser = await prisma.user.findUnique({
where: { id: userId },
select: {
codexRefreshTokenEncrypted: true,
},
});
if (!freshUser?.codexRefreshTokenEncrypted) {
throw new Error("No refresh token available");
}
const currentRefreshToken = decrypt(freshUser.codexRefreshTokenEncrypted);
const newTokens = await refreshAccessToken(currentRefreshToken);
const encrypted = encryptTokens({
accessToken: newTokens.access_token,
refreshToken: newTokens.refresh_token,
});
await prisma.user.update({
where: { id: userId },
data: {
codexAccessTokenEncrypted: encrypted.accessEncrypted,
codexRefreshTokenEncrypted: encrypted.refreshEncrypted,
codexTokenExpiresAt: new Date(
Date.now() + newTokens.expires_in * 1000,
),
},
});
return newTokens.access_token;
},
return new OpenAIClient({
apiKey: activeApiKey,
model: "gpt-4o-mini",
baseUrl: "https://api.openai.com/v1",
providerType: "codex",
});
}