fix(ai): switch ChatGPT-OAuth to device-code flow (v1.4.7-followup)

v1.4.7 corrected the issuer (`auth.openai.com`) and added the
RFC-8693 api-key exchange, but the Connect button still failed in
production with `unknown_error`. Root cause: OpenAI's Hydra
allow-lists only `http://localhost:1455/auth/callback` and
`:1457/auth/callback` for the public Codex CLI client ID. Any hosted
redirect URI gets silently rejected — the v1.4.7 user landed on the
"An error occurred during authentication (unknown_error)" page.

The official Codex CLI ships a parallel **device-code flow** for
exactly this case (`codex-rs/login/src/device_code_auth.rs`):

  1. POST `https://auth.openai.com/api/accounts/deviceauth/usercode`
     with the client id → `{ device_auth_id, user_code, interval }`.
  2. Show the user the code and a link to
     `https://auth.openai.com/codex/device`. They open it on any
     device, log in, and type the code.
  3. POST `https://auth.openai.com/api/accounts/deviceauth/token`
     repeatedly until Hydra returns `{ authorization_code,
     code_challenge, code_verifier }` (otherwise 403 / 404).
  4. Standard PKCE token exchange against `/oauth/token` — but with
     `redirect_uri = https://auth.openai.com/deviceauth/callback`,
     which IS allow-listed for the public client. Then the same
     id_token → API-key exchange that v1.4.7 already added.

This sidesteps the redirect-URI allow-list entirely; HealthLog's
domain never appears in any OAuth round-trip.

Implementation:

- `src/lib/ai/codex-oauth.ts` adds `requestDeviceCode()` and
  `pollDeviceCode()`.
- `POST /api/auth/codex/device-start` initialises the flow, persists
  the `device_auth_id` + `user_code` in an encrypted httpOnly cookie
  (15 min, scoped to the requesting user), and returns the user-
  facing pieces.
- `POST /api/auth/codex/device-poll` does one poll attempt; on
  `pending` it returns `{ status: "pending" }`, on success it
  exchanges + persists the api key + refresh token and returns
  `{ status: "connected" }`.
- The Settings → KI section replaces the "redirect to OAuth"
  button with an inline card showing the user code, the
  verification URL, and a copy button. The page polls every
  `intervalSeconds` (5s default) until status flips to "connected"
  or the user cancels. EN + DE strings under
  `settings.ai.deviceCode*` cover the new copy.
- The legacy authorize / callback routes stay on disk for now —
  they're harmless when the redirect-URI allow-list is updated for
  a private client ID, so operators with an OpenAI partner client
  can still use the original flow via `CODEX_OAUTH_CLIENT_ID`.

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 10:31:30 +02:00
1 parent 2f7f487228
commit 19eb1d95d6
8 files changed
+467 -11

No files matched your search

+101
View File
@@ -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" });
});
@@ -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,
});
});