fix(ai): device-code flow uses OAuth access token directly (v1.4.8 follow-up)

v1.4.8's device-code flow got the user as far as the chatgpt.com
approval, but the connect step at the end still failed with:

  Codex api-key exchange failed (401): "Invalid ID token: missing
  organization_id"

Root cause: the api-key exchange (RFC 8693, swap id_token for an
OpenAI API key) requires the id_token to carry an `organization_id`
claim. That claim only gets added when the authorize URL is hit with
`id_token_add_organizations=true` — which the browser/auth-code
flow does, but the device-code flow does NOT (the user types the
code on chatgpt.com's own page; we never control the authorize URL).

The official Codex CLI handles this by NOT calling the api-key
exchange in the device path: it persists the OAuth access_token /
refresh_token directly and uses them against
`https://chatgpt.com/backend-api/codex/responses` (the Codex backend
that bills against the ChatGPT subscription). v1.4.9 does the same.

`pollDeviceCode` no longer calls `obtainApiKey`; it returns the raw
OAuth access_token instead. New `refreshDeviceTokens()` mirrors that
on the refresh path. The provider rewires Codex back through the
existing `CodexClient` (bg backend-api), with proactive refresh
+ on-401 refresh both going through `refreshDeviceTokens`.

The browser/auth-code flow path is left in place — operators who
register a private OAuth app with OpenAI (e.g. for an enterprise
deployment) can still use the api-key exchange via the
`exchangeCodeForTokens` helper. For the public Codex client ID on
hosted instances, device-code is the only working path.

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 11:25:50 +02:00
1 parent 19eb1d95d6
commit 3dbc8af627
5 files changed
+129 -50

No files matched your search

+9 -8
View File
@@ -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",