diff --git a/.planning/STATE.md b/.planning/STATE.md index 4dcfba1b5..bef555980 100644 --- a/.planning/STATE.md +++ b/.planning/STATE.md @@ -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 ✅ diff --git a/CHANGELOG.md b/CHANGELOG.md index 16d762237..f069ae588 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/docs/audit/v146-summary.md b/docs/audit/v146-summary.md index ad2e81bcc..13e03ffdc 100644 --- a/docs/audit/v146-summary.md +++ b/docs/audit/v146-summary.md @@ -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) diff --git a/package.json b/package.json index 22299d6b4..5f32ca8ea 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "healthlog", - "version": "1.4.6", + "version": "1.4.7", "private": true, "packageManager": "pnpm@10.31.0", "scripts": { diff --git a/src/app/api/auth/codex/callback/route.ts b/src/app/api/auth/codex/callback/route.ts index 72f062cfd..933daad8f 100644 --- a/src/app/api/auth/codex/callback/route.ts +++ b/src/app/api/auth/codex/callback/route.ts @@ -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, diff --git a/src/lib/ai/__tests__/codex-oauth.test.ts b/src/lib/ai/__tests__/codex-oauth.test.ts index 4f78cfdb8..ad3f56ed7 100644 --- a/src/lib/ai/__tests__/codex-oauth.test.ts +++ b/src/lib/ai/__tests__/codex-oauth.test.ts @@ -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"); }); }); }); diff --git a/src/lib/ai/__tests__/provider.test.ts b/src/lib/ai/__tests__/provider.test.ts index cf7335377..bbb08557f 100644 --- a/src/lib/ai/__tests__/provider.test.ts +++ b/src/lib/ai/__tests__/provider.test.ts @@ -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", }); diff --git a/src/lib/ai/codex-oauth.ts b/src/lib/ai/codex-oauth.ts index 88adb5e50..25aaced9f 100644 --- a/src/lib/ai/codex-oauth.ts +++ b/src/lib/ai/codex-oauth.ts @@ -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, +): Promise { + 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 { - 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 { + 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 { - 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 { + 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 { + 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; diff --git a/src/lib/ai/openai-client.ts b/src/lib/ai/openai-client.ts index eb90dc5b5..5c0882813 100644 --- a/src/lib/ai/openai-client.ts +++ b/src/lib/ai/openai-client.ts @@ -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, }; } } diff --git a/src/lib/ai/provider.ts b/src/lib/ai/provider.ts index 4b988c6f9..ed8c5ab7e 100644 --- a/src/lib/ai/provider.ts +++ b/src/lib/ai/provider.ts @@ -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 { 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 { @@ -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", }); }