mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 12:32:43 +00:00
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:
1 parent
19eb1d95d6
commit
3dbc8af627
5 files changed
+129
-50
No files matched your search
@@ -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
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "healthlog",
|
||||
"version": "1.4.8",
|
||||
"version": "1.4.9",
|
||||
"private": true,
|
||||
"packageManager": "pnpm@10.31.0",
|
||||
"scripts": {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user