diff --git a/CHANGELOG.md b/CHANGELOG.md index f069ae588..f0ed68d1b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,23 @@ # Changelog +## [1.4.8] — 2026-05-09 + +### Fixed — Settings · KI + +- **"Mit ChatGPT verbinden" funktioniert jetzt wirklich.** v1.4.7 hat + zwar die OAuth-Endpoints korrigiert, ist aber an OpenAIs Hydra- + Allow-List gescheitert: die Public-Codex-Client-ID hat in der + Server-Allow-List nur `localhost:1455/1457` als Redirect-URI, jeder + hosted-Domain-Callback wird mit "An error occurred during + authentication (unknown_error)" abgelehnt. v1.4.8 schaltet auf den + **Device-Code-Flow** um — den selben Mechanismus den der offizielle + Codex CLI für Headless-/Hosted-Umgebungen nutzt: HealthLog zeigt + einen kurzen Code (`RGRP-N5F7U`-Stil), du gehst auf + https://auth.openai.com/codex/device, gibst den Code ein, bestätigst + bei dir im Browser, fertig. Kein Redirect zurück nötig, also keine + Allow-List-Konflikte. Die Settings-Seite pollt im Hintergrund und + schließt das Modal sobald deine Approval durch ist. + ## [1.4.7] — 2026-05-09 ### Fixed — Settings · KI diff --git a/messages/de.json b/messages/de.json index 6140e8ca9..3e8768f69 100644 --- a/messages/de.json +++ b/messages/de.json @@ -1038,6 +1038,13 @@ "connectChatgptHeading": "Mit ChatGPT verbinden", "connectChatgptBody": "Verbinde dein ChatGPT Pro/Max-Konto um KI-gestützte Gesundheitsanalysen basierend auf aktuellen medizinischen Leitlinien zu erhalten. Keine zusätzlichen API-Kosten.", "connectChatgptCta": "Mit ChatGPT verbinden", + "deviceCodeHeading": "Verbindung auf chatgpt.com abschließen", + "deviceCodeStep1": "Öffne diesen Link auf einem beliebigen Gerät:", + "deviceCodeStep2": "Gib diesen Einmalcode ein:", + "deviceCodeStep3": "Bestätige die Verbindung — diese Seite aktualisiert sich automatisch.", + "deviceCodeCopy": "kopieren", + "deviceCodeWaiting": "Warte auf Bestätigung…", + "deviceCodeCancel": "Abbrechen", "oauthNotConfigured": "ChatGPT-OAuth ist auf dieser Instanz nicht konfiguriert — nutze stattdessen einen eigenen API-Key unten.", "adminFallback": "Alternativ nutzt HealthLog den vom Administrator konfigurierten KI-Anbieter.", "personalProviderHeading": "KI-Provider (persönlich)", diff --git a/messages/en.json b/messages/en.json index 12be0dc7d..f93278d39 100644 --- a/messages/en.json +++ b/messages/en.json @@ -1038,6 +1038,13 @@ "connectChatgptHeading": "Connect with ChatGPT", "connectChatgptBody": "Link your ChatGPT Pro/Max account to get AI-powered health analyses based on current medical guidelines. No additional API costs.", "connectChatgptCta": "Connect with ChatGPT", + "deviceCodeHeading": "Finish connecting on chatgpt.com", + "deviceCodeStep1": "Open this link on any device:", + "deviceCodeStep2": "Enter this one-time code:", + "deviceCodeStep3": "Approve the connection — this page updates automatically.", + "deviceCodeCopy": "copy", + "deviceCodeWaiting": "Waiting for approval…", + "deviceCodeCancel": "Cancel", "oauthNotConfigured": "ChatGPT OAuth is not configured on this instance — use your own API key below instead.", "adminFallback": "Alternatively, HealthLog will use the AI provider configured by the administrator.", "personalProviderHeading": "AI provider (personal)", diff --git a/package.json b/package.json index 5f32ca8ea..716fc138e 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "healthlog", - "version": "1.4.7", + "version": "1.4.8", "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 new file mode 100644 index 000000000..b588e51ac --- /dev/null +++ b/src/app/api/auth/codex/device-poll/route.ts @@ -0,0 +1,101 @@ +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 { cookies } from "next/headers"; +import { decrypt } from "@/lib/crypto"; +import { prisma } from "@/lib/db"; +import { auditLog } from "@/lib/auth/audit"; +import { getClientIp, apiSuccess, apiError } from "@/lib/api-response"; +import { annotate } from "@/lib/logging/context"; + +/** + * v1.4.7.1: Device-code flow polling endpoint. + * + * The client polls this every `intervalSeconds` (returned from + * `device-start`). While the user has not approved on chatgpt.com, + * we return `status: "pending"` and the client retries. Once Hydra + * issues an authorization code we exchange it server-side, swap the + * id_token for an OpenAI API key (RFC 8693), and persist both the + * key and the OAuth refresh token encrypted on the user record. + * + * The cookie that holds the `device_auth_id` + `user_code` lives 15 + * minutes — same window Hydra gives the device code. After success + * we delete the cookie immediately so a refresh of the page does not + * try to re-poll a stale device id. + */ +export const POST = apiHandler(async (request: NextRequest) => { + const { user } = await requireAuth(); + + const rl = await checkRateLimit(`codex-device-poll:${user.id}`, 60, 60_000); + if (!rl.allowed) throw new HttpError(429, "Too many requests"); + + const cookieStore = await cookies(); + const cookie = cookieStore.get("codex_device")?.value; + if (!cookie) { + return apiError("No device-auth attempt in progress", 400); + } + + let deviceAuthId: string; + let userCode: string; + try { + const decoded = JSON.parse(decrypt(cookie)) as { + deviceAuthId: string; + userCode: string; + }; + deviceAuthId = decoded.deviceAuthId; + userCode = decoded.userCode; + } catch { + cookieStore.delete("codex_device"); + return apiError("Invalid device-auth state — restart the flow", 400); + } + + let result; + try { + result = await pollDeviceCode({ deviceAuthId, userCode }); + } catch (err) { + const message = err instanceof Error ? err.message : "unknown"; + annotate({ meta: { codex_device_poll_error: message } }); + return apiError("Codex device-poll failed — please restart the flow", 502); + } + + if (result.status === "pending") { + annotate({ + action: { name: "codex.device.poll" }, + meta: { pending: true }, + }); + return apiSuccess({ status: "pending" }); + } + + // status === "connected" → persist creds, audit, drop the cookie. + const encrypted = encryptCodexCreds({ + apiKey: result.tokens.apiKey, + refreshToken: result.tokens.refreshToken, + }); + await prisma.user.update({ + where: { id: user.id }, + data: { + codexAccessTokenEncrypted: encrypted.apiKeyEncrypted, + codexRefreshTokenEncrypted: encrypted.refreshEncrypted, + codexTokenExpiresAt: result.tokens.expiresAt, + codexConnectedAt: new Date(), + codexConnectionStatus: "connected", + insightsCachedAt: null, + insightsCachedText: null, + }, + }); + + cookieStore.delete("codex_device"); + + await auditLog("codex.oauth.connected", { + userId: user.id, + ipAddress: getClientIp(request), + details: { method: "device_code" }, + }); + annotate({ + action: { name: "codex.device.poll" }, + meta: { connected: true }, + }); + + return apiSuccess({ status: "connected" }); +}); diff --git a/src/app/api/auth/codex/device-start/route.ts b/src/app/api/auth/codex/device-start/route.ts new file mode 100644 index 000000000..fa252f88a --- /dev/null +++ b/src/app/api/auth/codex/device-start/route.ts @@ -0,0 +1,61 @@ +import { NextRequest } from "next/server"; +import { apiHandler, requireAuth, HttpError } from "@/lib/api-handler"; +import { checkRateLimit } from "@/lib/rate-limit"; +import { requestDeviceCode } from "@/lib/ai/codex-oauth"; +import { cookies } from "next/headers"; +import { annotate } from "@/lib/logging/context"; +import { encrypt } from "@/lib/crypto"; +import { apiSuccess } from "@/lib/api-response"; + +/** + * v1.4.7.1: Device-code flow for ChatGPT-OAuth. + * + * The previous redirect-based authorization-code flow fails on hosted + * deployments because OpenAI's Hydra only allow-lists localhost + * callbacks for the public Codex CLI client ID. Device-code is the + * documented alternative — the user goes to chatgpt.com on any device, + * types a short code, approves there. No redirect to our domain. + * + * This endpoint kicks the flow off: it asks Hydra for a `user_code` / + * `device_auth_id` pair and returns the user-facing pieces. The + * `device_auth_id` is the secret the poll endpoint needs and is + * persisted server-side via an encrypted httpOnly cookie scoped to a + * 15-minute lifetime — the same window Hydra gives the code. + */ +export const POST = apiHandler(async (_request: NextRequest) => { + const { user } = await requireAuth(); + + const rl = await checkRateLimit(`codex-device-start:${user.id}`, 5, 60_000); + if (!rl.allowed) throw new HttpError(429, "Too many requests"); + + const code = await requestDeviceCode(); + + const cookieStore = await cookies(); + cookieStore.set( + "codex_device", + encrypt( + JSON.stringify({ + deviceAuthId: code.deviceAuthId, + userCode: code.userCode, + }), + ), + { + httpOnly: true, + secure: process.env.NODE_ENV === "production", + sameSite: "lax", + path: "/", + maxAge: 15 * 60, + }, + ); + + annotate({ + action: { name: "codex.device.start" }, + meta: { interval_seconds: code.intervalSeconds }, + }); + + return apiSuccess({ + userCode: code.userCode, + verificationUrl: code.verificationUrl, + intervalSeconds: code.intervalSeconds, + }); +}); diff --git a/src/components/settings/ai-section.tsx b/src/components/settings/ai-section.tsx index 0b6a78517..6981433ad 100644 --- a/src/components/settings/ai-section.tsx +++ b/src/components/settings/ai-section.tsx @@ -166,8 +166,94 @@ function InsightsSettingsCard({ const hasProvider = settings?.codexStatus === "connected" || settings?.hasAdminKey; - function handleConnect() { - window.location.href = "/api/auth/codex/authorize"; + // v1.4.7.1: device-code flow. The earlier "redirect to + // auth.openai.com" path fails because the public Codex CLI client + // ID's redirect-URI allow-list only covers localhost. The device + // flow side-steps that — the user types a short code on chatgpt.com + // and we poll for confirmation. + const [deviceCode, setDeviceCode] = useState<{ + userCode: string; + verificationUrl: string; + intervalSeconds: number; + } | null>(null); + const [devicePolling, setDevicePolling] = useState(false); + + async function handleConnect() { + setMsg(null); + setDevicePolling(true); + try { + const res = await fetch("/api/auth/codex/device-start", { + method: "POST", + }); + const json = await res.json(); + if (!res.ok) throw new Error(json.error || t("settings.savingError")); + setDeviceCode({ + userCode: json.data.userCode, + verificationUrl: json.data.verificationUrl, + intervalSeconds: json.data.intervalSeconds, + }); + } catch (err) { + setMsg(err instanceof Error ? err.message : t("settings.savingError")); + setMsgType("error"); + setDevicePolling(false); + } + } + + // Poll until the user finishes the approval at chatgpt.com or the + // 15-minute device-code window expires. Polling stops as soon as + // `deviceCode` is cleared (success / cancel / unmount). + useEffect(() => { + if (!deviceCode) { + // No active device code → make sure the spinner is off. setState + // inside an effect is intentional here; the alternative (deriving + // `devicePolling` from `deviceCode`) would force every error + // path to also reset `deviceCode`, which we don't want — the + // start handler resets `devicePolling` itself when fetch fails. + return; + } + let cancelled = false; + const intervalMs = Math.max(deviceCode.intervalSeconds, 3) * 1000; + + async function tick() { + try { + const res = await fetch("/api/auth/codex/device-poll", { + method: "POST", + }); + const json = await res.json(); + if (cancelled) return; + if (!res.ok) { + throw new Error(json.error || t("settings.savingError")); + } + if (json.data?.status === "connected") { + setDeviceCode(null); + setDevicePolling(false); + setMsg(t("settings.codexConnected")); + setMsgType("success"); + queryClient.invalidateQueries({ queryKey: ["insights"] }); + return; + } + // Pending → schedule next tick. + if (!cancelled) setTimeout(tick, intervalMs); + } catch (err) { + if (cancelled) return; + setMsg(err instanceof Error ? err.message : t("settings.savingError")); + setMsgType("error"); + setDeviceCode(null); + setDevicePolling(false); + } + } + + const handle = setTimeout(tick, intervalMs); + return () => { + cancelled = true; + clearTimeout(handle); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [deviceCode]); + + function handleCancelDevice() { + setDeviceCode(null); + setDevicePolling(false); } async function handleDisconnect() { @@ -312,14 +398,73 @@ function InsightsSettingsCard({

{settings?.codexOauthConfigured ? ( - + deviceCode ? ( +
+

+ {t("settings.ai.deviceCodeHeading")} +

+
    +
  1. + {t("settings.ai.deviceCodeStep1")}{" "} + + {deviceCode.verificationUrl} + +
  2. +
  3. + {t("settings.ai.deviceCodeStep2")} +
    + {deviceCode.userCode} + +
    +
  4. +
  5. {t("settings.ai.deviceCodeStep3")}
  6. +
+
+ {devicePolling && ( + + + {t("settings.ai.deviceCodeWaiting")} + + )} + +
+
+ ) : ( + + ) ) : (

{t("settings.ai.oauthNotConfigured")} diff --git a/src/lib/ai/codex-oauth.ts b/src/lib/ai/codex-oauth.ts index 25aaced9f..f7af008ca 100644 --- a/src/lib/ai/codex-oauth.ts +++ b/src/lib/ai/codex-oauth.ts @@ -227,6 +227,123 @@ export function decryptCodexCreds(encrypted: { }; } +// ─── 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; +} + +export async function requestDeviceCode(): Promise { + const res = await fetch(`${ISSUER}/api/accounts/deviceauth/usercode`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ client_id: getCodexClientId() }), + }); + if (!res.ok) { + const body = await res.text().catch(() => ""); + throw new Error( + `Codex device-code request failed (${res.status}): ${body}`, + ); + } + const json = (await res.json()) as { + device_auth_id: string; + user_code: string; + interval: string | number; + }; + return { + userCode: json.user_code, + verificationUrl: DEVICE_VERIFICATION_URL, + deviceAuthId: json.device_auth_id, + intervalSeconds: + typeof json.interval === "string" + ? Number(json.interval) || 5 + : json.interval || 5, + }; +} + +export type DevicePollResult = + | { status: "pending" } + | { status: "connected"; tokens: CodexTokens }; + +/** + * 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. + */ +export async function pollDeviceCode(params: { + deviceAuthId: string; + userCode: string; +}): Promise { + const res = await fetch(`${ISSUER}/api/accounts/deviceauth/token`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + device_auth_id: params.deviceAuthId, + user_code: params.userCode, + }), + }); + + if (res.status === 403 || res.status === 404) { + return { status: "pending" }; + } + if (!res.ok) { + const body = await res.text().catch(() => ""); + throw new Error(`Codex device-code poll failed (${res.status}): ${body}`); + } + + const json = (await res.json()) as { + authorization_code: string; + code_challenge: string; + 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, + }); + if (!tokenRes.ok) { + const body = await tokenRes.text().catch(() => ""); + throw new Error( + `Codex device-code token exchange failed (${tokenRes.status}): ${body}`, + ); + } + const tokens = (await tokenRes.json()) as RawTokenResponse; + const apiKey = await obtainApiKey(tokens.id_token); + return { + status: "connected", + tokens: { + apiKey, + refreshToken: tokens.refresh_token, + expiresAt: new Date(Date.now() + tokens.expires_in * 1000), + }, + }; +} + // ─── Backwards-compat shims so older callers keep compiling ────────── /** @deprecated use `encryptCodexCreds`. */