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")} +
+
{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