diff --git a/CHANGELOG.md b/CHANGELOG.md index f0ed68d1b..7c47c0643 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,23 @@ # Changelog +## [1.4.9] — 2026-05-09 + +### Fixed — Settings · KI + +- **Device-Code-Flow läuft jetzt komplett durch.** v1.4.8 hat den + Code richtig zugestellt und du konntest auf chatgpt.com + bestätigen, aber der Connect-Schritt am Ende ist mit "missing + organization_id" gestorben. Ursache: der id_token aus dem + Device-Flow trägt das `organization_id`-Claim nicht (das + bekommt nur die Browser-Authorize-Variante via + `id_token_add_organizations=true`-Param), und unsere + RFC-8693-API-Key-Exchange hat genau dieses Claim erwartet. v1.4.9 + übernimmt das Verhalten des offiziellen Codex CLI im Device-Pfad: + den OAuth-Access-Token direkt verwenden und gegen den Codex- + Backend-Endpoint (`chatgpt.com/backend-api/codex/responses`) + schicken — kein API-Key-Tausch nötig. Refresh läuft analog: nur + Token-Rotation, keine Exchange. + ## [1.4.8] — 2026-05-09 ### Fixed — Settings · KI diff --git a/package.json b/package.json index 716fc138e..ea1037162 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "healthlog", - "version": "1.4.8", + "version": "1.4.9", "private": true, "packageManager": "pnpm@10.31.0", "scripts": { diff --git a/src/app/api/auth/codex/device-poll/route.ts b/src/app/api/auth/codex/device-poll/route.ts index b588e51ac..0e96eb475 100644 --- a/src/app/api/auth/codex/device-poll/route.ts +++ b/src/app/api/auth/codex/device-poll/route.ts @@ -1,7 +1,8 @@ import { NextRequest } from "next/server"; import { apiHandler, requireAuth, HttpError } from "@/lib/api-handler"; import { checkRateLimit } from "@/lib/rate-limit"; -import { pollDeviceCode, encryptCodexCreds } from "@/lib/ai/codex-oauth"; +import { pollDeviceCode } from "@/lib/ai/codex-oauth"; +import { encrypt } from "@/lib/crypto"; import { cookies } from "next/headers"; import { decrypt } from "@/lib/crypto"; import { prisma } from "@/lib/db"; @@ -67,16 +68,16 @@ export const POST = apiHandler(async (request: NextRequest) => { return apiSuccess({ status: "pending" }); } - // status === "connected" → persist creds, audit, drop the cookie. - const encrypted = encryptCodexCreds({ - apiKey: result.tokens.apiKey, - refreshToken: result.tokens.refreshToken, - }); + // 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. await prisma.user.update({ where: { id: user.id }, data: { - codexAccessTokenEncrypted: encrypted.apiKeyEncrypted, - codexRefreshTokenEncrypted: encrypted.refreshEncrypted, + codexAccessTokenEncrypted: encrypt(result.tokens.accessToken), + codexRefreshTokenEncrypted: encrypt(result.tokens.refreshToken), codexTokenExpiresAt: result.tokens.expiresAt, codexConnectedAt: new Date(), codexConnectionStatus: "connected", diff --git a/src/lib/ai/codex-oauth.ts b/src/lib/ai/codex-oauth.ts index f7af008ca..ab9232fc9 100644 --- a/src/lib/ai/codex-oauth.ts +++ b/src/lib/ai/codex-oauth.ts @@ -278,9 +278,30 @@ export async function requestDeviceCode(): Promise { }; } +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: CodexTokens }; + | { status: "connected"; tokens: DeviceTokens }; /** * Single poll attempt against the device-auth token endpoint. Returns @@ -333,17 +354,44 @@ export async function pollDeviceCode(params: { ); } const tokens = (await tokenRes.json()) as RawTokenResponse; - const apiKey = await obtainApiKey(tokens.id_token); return { status: "connected", tokens: { - apiKey, + accessToken: tokens.access_token, refreshToken: tokens.refresh_token, expiresAt: new Date(Date.now() + tokens.expires_in * 1000), }, }; } +/** + * 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. + */ +export async function refreshDeviceTokens( + refreshToken: string, +): Promise { + const res = await postForm(`${ISSUER}/oauth/token`, { + grant_type: "refresh_token", + client_id: getCodexClientId(), + refresh_token: refreshToken, + scope: CODEX_SCOPES, + }); + if (!res.ok) { + const body = await res.text().catch(() => ""); + throw new Error( + `Codex device-token refresh failed (${res.status}): ${body}`, + ); + } + 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), + }; +} + // ─── Backwards-compat shims so older callers keep compiling ────────── /** @deprecated use `encryptCodexCreds`. */ diff --git a/src/lib/ai/provider.ts b/src/lib/ai/provider.ts index ed8c5ab7e..6bfbe1597 100644 --- a/src/lib/ai/provider.ts +++ b/src/lib/ai/provider.ts @@ -1,14 +1,11 @@ import type { AIProvider, CompletionParams, CompletionResult } from "./types"; import { prisma } from "@/lib/db"; -import { decrypt } from "@/lib/crypto"; +import { decrypt, encrypt } from "@/lib/crypto"; +import { CodexClient } from "./codex-client"; import { OpenAIClient } from "./openai-client"; import { AnthropicClient } from "./anthropic-client"; import { LocalOpenAICompatibleClient } from "./local-client"; -import { - refreshTokens, - encryptCodexCreds, - decryptCodexCreds, -} from "./codex-oauth"; +import { refreshDeviceTokens } from "./codex-oauth"; import { isPublicUrl } from "@/lib/validations/notifications"; const TOKEN_REFRESH_BUFFER_MS = 5 * 60 * 1000; // 5 minutes @@ -105,14 +102,20 @@ async function resolveAdminProvider(): Promise { } /** - * 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. + * 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. + * + * 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. */ async function resolveCodexProvider( userId: string, @@ -135,46 +138,55 @@ async function resolveCodexProvider( return null; } - const { apiKey: storedApiKey, refreshToken } = decryptCodexCreds({ - apiKeyEncrypted: user.codexAccessTokenEncrypted, - refreshEncrypted: user.codexRefreshTokenEncrypted, - }); + const storedAccessToken = decrypt(user.codexAccessTokenEncrypted); + const storedRefreshToken = decrypt(user.codexRefreshTokenEncrypted); - let activeApiKey = storedApiKey; + let activeAccessToken = storedAccessToken; - // Proactive refresh if the API key expires within 5 minutes. + // Proactive refresh if the access token expires within 5 minutes. const expiresAt = user.codexTokenExpiresAt?.getTime() ?? 0; if (expiresAt < Date.now() + TOKEN_REFRESH_BUFFER_MS) { try { - const fresh = await refreshTokens(refreshToken); - activeApiKey = fresh.apiKey; - - const encrypted = encryptCodexCreds({ - apiKey: fresh.apiKey, - refreshToken: fresh.refreshToken, - }); + const fresh = await refreshDeviceTokens(storedRefreshToken); + activeAccessToken = fresh.accessToken; await prisma.user.update({ where: { id: userId }, data: { - codexAccessTokenEncrypted: encrypted.apiKeyEncrypted, - codexRefreshTokenEncrypted: encrypted.refreshEncrypted, + codexAccessTokenEncrypted: encrypt(fresh.accessToken), + codexRefreshTokenEncrypted: encrypt(fresh.refreshToken), codexTokenExpiresAt: fresh.expiresAt, }, }); } catch { - // 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. + // Proactive refresh failures fall through — the CodexClient + // retries via onTokenRefresh on a real 401 and writes the + // updated tokens then. } } - return new OpenAIClient({ - apiKey: activeApiKey, - model: "gpt-4o-mini", - baseUrl: "https://api.openai.com/v1", - providerType: "codex", + return new CodexClient({ + accessToken: activeAccessToken, + 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 currentRefresh = decrypt(freshUser.codexRefreshTokenEncrypted); + const fresh = await refreshDeviceTokens(currentRefresh); + await prisma.user.update({ + where: { id: userId }, + data: { + codexAccessTokenEncrypted: encrypt(fresh.accessToken), + codexRefreshTokenEncrypted: encrypt(fresh.refreshToken), + codexTokenExpiresAt: fresh.expiresAt, + }, + }); + return fresh.accessToken; + }, }); }