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

+18
View File
@@ -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
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "healthlog",
"version": "1.4.8",
"version": "1.4.9",
"private": true,
"packageManager": "pnpm@10.31.0",
"scripts": {
+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",
+51 -3
View File
@@ -278,9 +278,30 @@ export async function requestDeviceCode(): Promise<DeviceCodeStart> {
};
}
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<DeviceTokens> {
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`. */
+50 -38
View File
@@ -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<AIProvider> {
}
/**
* 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;
},
});
}