Files
HealthLog/src/lib/ai/codex-oauth.ts
T
Marc-André Bombeck 27342ee734 fix(ai): name the real cause of failed briefings and stop retrying what cannot heal
The nightly briefing warm completed every night and read "all-failed",
because one run event held every account's annotations and the last
account won. Each candidate now runs in its own wide event and the run
reports every outcome through `reportJobRun`, so ok, dead sign-in,
withheld and failed accounts show side by side.

A dead Codex refresh token threw a bare error with no status, so the chain
read it as a network failure: the health ledger recorded hard_failed, no
"reconnect" hint appeared, and every night tried the same token. The
refresh now throws `CodexRefreshError` with a 401 for a dead grant
(invalid_grant / invalid_refresh_token on a 400 included), as do a missing
or undecryptable stored credential. The ledger records auth_failed, the
chain marks the primary credential expired, and the nightly pass reports
the account as auth_failed without queuing a retry.

A briefing the outbound screen withheld (a causal claim) failed outright,
and the 45-minute provider retry then asked the same question eight times
a night. The generator now makes one bounded repair that names the broken
rule and keeps the repaired payload only if it passes the same parse,
grounding and screen; a withheld outcome queues no retry.

A provider that keeps failing is paused: from five consecutive hard
failures the skip window starts at 30 minutes and doubles per further
failure, capped at a day. A paused provider is walked last and tried again
when the window lifts; one success resumes it. The operator card shows how
many accounts it is paused for and until when. After a 5xx from a
non-canonical OpenAI-compatible endpoint the client asks the free model
listing, and a configured model the endpoint no longer offers is named in
the error, on the hop line and on the card.

Also:
- The tiered context's live day band read `measured_at AT TIME ZONE tz`,
  which treats the stored UTC as local time; a far-from-UTC reader's
  evening readings landed on the next day. It converts from UTC first now.
- Google Health intraday heart rate re-reads two hours per sync instead of
  a day; daily summaries and sleep keep the day (#1023).
- The first filtered daily read that answers 400 and triggers the
  snake_case retry is recorded as a note, not an error (#1023).
- The single derived-score read goes through the derived cache with
  stale-while-revalidate, like the batch beside it.
2026-10-08 09:06:09 +02:00

499 lines
17 KiB
TypeScript

import { createHash, randomBytes } from "node:crypto";
import { encrypt, decrypt } from "@/lib/crypto";
import { safeFetch } from "@/lib/safe-fetch";
/**
* Codex / ChatGPT-OAuth flow — implemented against `docs/codex-protocol-spec.md`,
* which mirrors the official `openai/codex` CLI.
*
* Production-only path: **device-code flow**. The browser/auth-code flow
* does not work for hosted apps — Hydra's redirect-URI allow-list for the
* public Codex CLI client ID covers only `localhost:1455/1457`. The
* device-code flow side-steps that: the user types a short code on
* `auth.openai.com/codex/device`, we poll, on success we get an
* authorization-code internally and exchange it for OAuth tokens.
*
* After the exchange we hold three pieces:
* - `accessToken` — the OAuth access token used as `Authorization: Bearer`
* on every Codex request.
* - `refreshToken` — long-lived; rotates on every refresh.
* - `accountId` — the `chatgpt_account_id` claim from the id_token,
* required as the `ChatGPT-Account-ID` header (without it: 401).
*
* All three are persisted (JSON-encoded then encrypted) on the user row
* so we don't need a schema migration.
*/
const ISSUER = "https://auth.openai.com";
/**
* Public PKCE client ID hardcoded in the official `openai/codex` CLI
* (`codex-rs/login/src/auth/manager.rs`). Operators with a private OAuth
* app can override via env var.
*/
const DEFAULT_CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann";
/** Same scopes the official CLI requests. */
const CODEX_SCOPES =
"openid profile email offline_access api.connectors.read api.connectors.invoke";
const DEVICE_VERIFICATION_URL = `${ISSUER}/codex/device`;
const DEVICE_REDIRECT_URI = `${ISSUER}/deviceauth/callback`;
function base64url(buffer: Buffer): string {
return buffer.toString("base64url");
}
export function generatePKCE(): { verifier: string; challenge: string } {
const verifier = base64url(randomBytes(96));
const challenge = base64url(createHash("sha256").update(verifier).digest());
return { verifier, challenge };
}
export function generateState(): string {
return base64url(randomBytes(32));
}
export function getCodexClientId(): string {
return process.env.CODEX_OAUTH_CLIENT_ID?.trim() || DEFAULT_CLIENT_ID;
}
export function isCodexOAuthConfigured(): boolean {
return true;
}
// ─── JWT inspection ──────────────────────────────────────────────────
interface JwtClaims {
exp?: number;
chatgpt_account_id?: string;
/**
* The official Codex CLI also accepts the URI-namespaced variant
* (`https://api.openai.com/auth.chatgpt_account_id`) in the
* `https://api.openai.com/auth` claim object — see
* `codex-rs/login/src/auth/manager.rs:891`.
*/
"https://api.openai.com/auth"?: { chatgpt_account_id?: string };
[key: string]: unknown;
}
function decodeJwtClaims(token: string): JwtClaims | null {
const parts = token.split(".");
if (parts.length < 2) return null;
try {
const padded = parts[1].padEnd(
parts[1].length + ((4 - (parts[1].length % 4)) % 4),
"=",
);
const json = Buffer.from(padded, "base64url").toString("utf8");
return JSON.parse(json) as JwtClaims;
} catch {
return null;
}
}
function extractAccountId(idToken: string): string | null {
const claims = decodeJwtClaims(idToken);
if (!claims) return null;
if (typeof claims.chatgpt_account_id === "string") {
return claims.chatgpt_account_id;
}
const namespaced = claims["https://api.openai.com/auth"];
if (
namespaced &&
typeof namespaced === "object" &&
typeof namespaced.chatgpt_account_id === "string"
) {
return namespaced.chatgpt_account_id;
}
return null;
}
function extractExpiry(idToken: string): Date | null {
const claims = decodeJwtClaims(idToken);
if (!claims || typeof claims.exp !== "number") return null;
return new Date(claims.exp * 1000);
}
// ─── Token shape persisted on the user row ───────────────────────────
export interface CodexCreds {
/** OAuth access token used directly against chatgpt.com/backend-api. */
accessToken: string;
/** OAuth refresh token; rotates on every refresh. */
refreshToken: string;
/**
* `chatgpt_account_id` claim from the id_token. Required as the
* `ChatGPT-Account-ID` header on every Codex backend request.
*/
accountId: string;
/** Wall-clock expiry. Derived from id_token `exp`, fallback expires_in. */
expiresAt: Date;
}
interface RawTokenResponse {
id_token: string;
access_token: string;
refresh_token?: string;
expires_in?: number;
token_type?: string;
}
function buildCreds(
raw: RawTokenResponse,
prevRefresh: string | null,
): CodexCreds {
const accountId = extractAccountId(raw.id_token);
if (!accountId) {
throw new Error(
"Codex token exchange returned id_token without chatgpt_account_id",
);
}
const refreshToken = raw.refresh_token ?? prevRefresh ?? "";
if (!refreshToken) {
throw new Error("Codex token exchange returned no refresh_token");
}
const jwtExp = extractExpiry(raw.id_token);
const expiresAt =
jwtExp ?? new Date(Date.now() + (raw.expires_in ?? 3600) * 1000);
return {
accessToken: raw.access_token,
refreshToken,
accountId,
expiresAt,
};
}
// ─── Device-code flow ────────────────────────────────────────────────
export interface DeviceCodeStart {
userCode: string;
verificationUrl: string;
deviceAuthId: string;
intervalSeconds: number;
}
export async function requestDeviceCode(): Promise<DeviceCodeStart> {
const res = await safeFetch(`${ISSUER}/api/accounts/deviceauth/usercode`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ client_id: getCodexClientId() }),
});
if (!res.ok) {
const body = await res.text().catch(() => "");
throw new Error(
`Codex device-code request failed (${res.status}): ${body}`,
);
}
const json = (await res.json()) as {
device_auth_id: string;
user_code: string;
interval: string | number;
};
return {
userCode: json.user_code,
verificationUrl: DEVICE_VERIFICATION_URL,
deviceAuthId: json.device_auth_id,
intervalSeconds:
typeof json.interval === "string"
? Number(json.interval) || 5
: json.interval || 5,
};
}
export type DevicePollResult =
{ status: "pending" } | { status: "connected"; creds: CodexCreds };
/**
* One poll attempt against the device-auth token endpoint. Returns
* `pending` while the user has not yet approved (Hydra answers with
* 403/404), and `connected` with fully resolved Codex credentials
* once the user finishes the approval on chatgpt.com.
*/
export async function pollDeviceCode(params: {
deviceAuthId: string;
userCode: string;
}): Promise<DevicePollResult> {
const res = await safeFetch(`${ISSUER}/api/accounts/deviceauth/token`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
device_auth_id: params.deviceAuthId,
user_code: params.userCode,
}),
});
if (res.status === 403 || res.status === 404) {
return { status: "pending" };
}
if (!res.ok) {
const body = await res.text().catch(() => "");
throw new Error(`Codex device-code poll failed (${res.status}): ${body}`);
}
const json = (await res.json()) as {
authorization_code: string;
code_challenge: string;
code_verifier: string;
};
// Standard PKCE exchange against the OAuth token endpoint, with the
// device-auth callback URI Hydra associates with this authorization
// code internally.
const tokenRes = await safeFetch(`${ISSUER}/oauth/token`, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
grant_type: "authorization_code",
client_id: getCodexClientId(),
code: json.authorization_code,
redirect_uri: DEVICE_REDIRECT_URI,
code_verifier: json.code_verifier,
}).toString(),
});
if (!tokenRes.ok) {
const body = await tokenRes.text().catch(() => "");
throw new Error(
`Codex device-code token exchange failed (${tokenRes.status}): ${body}`,
);
}
const tokens = (await tokenRes.json()) as RawTokenResponse;
return {
status: "connected",
creds: buildCreds(tokens, null),
};
}
/**
* Refresh an OAuth access token using the long-lived refresh token.
* Per `docs/codex-protocol-spec.md` §6d, the refresh endpoint takes
* a JSON body (NOT form-urlencoded — that detail bit us once before).
*
* The refresh token rotates on every call; the new value (when
* present in the response) MUST be persisted, otherwise the next
* attempt fails with `refresh_token_reused`.
*/
export async function refreshDeviceTokens(
refreshToken: string,
): Promise<CodexCreds> {
const res = await safeFetch(`${ISSUER}/oauth/token`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
client_id: getCodexClientId(),
grant_type: "refresh_token",
refresh_token: refreshToken,
scope: CODEX_SCOPES,
}),
});
if (!res.ok) {
const body = await res.text().catch(() => "");
throw codexRefreshError(res.status, body);
}
const tokens = (await res.json()) as RawTokenResponse;
return buildCreds(tokens, refreshToken);
}
/**
* OAuth error codes on the refresh grant that mean the stored refresh token
* is dead for good: revoked, expired, already rotated, or issued to another
* client. Retrying cannot help; only signing in again can.
*/
const DEAD_REFRESH_TOKEN_CODES: ReadonlySet<string> = new Set([
"invalid_grant",
"invalid_refresh_token",
"refresh_token_expired",
"refresh_token_reused",
"refresh_token_invalidated",
"invalid_token",
]);
/**
* A failed refresh of the Codex OAuth token.
*
* The refresh used to throw a bare `Error` with the status only in its
* message. The chain runner reads `httpStatus` to classify a failure, so a
* dead refresh token read as a network error: the health ledger recorded
* `hard_failed` instead of `auth_failed`, nothing told the person to
* reconnect, and every night tried the same dead token again.
*
* `httpStatus` is 401 whenever the grant itself is dead (whatever status the
* token endpoint chose to send it with, 400 for `invalid_grant` included), so
* every classifier downstream (`classifyFailure`, `AllProvidersFailedError`,
* the briefing failure marker) reads it as an expired credential. Any other
* refusal keeps the endpoint's status. The body is not quoted: only the
* OAuth error code, which is the endpoint's vocabulary, rides on the error.
*/
export class CodexRefreshError extends Error {
readonly httpStatus: number;
readonly upstream = "codex";
/** True when the person has to sign in to ChatGPT again. */
readonly credentialExpired: boolean;
/** The OAuth `error` code from the response body, when it had one. */
readonly oauthError: string | null;
constructor(status: number, oauthError: string | null) {
const credentialExpired =
status === 401 ||
status === 403 ||
(oauthError !== null && DEAD_REFRESH_TOKEN_CODES.has(oauthError));
super(
credentialExpired
? `Codex sign-in expired (${status}${oauthError ? ` ${oauthError}` : ""}); reconnect ChatGPT`
: `Codex device-token refresh failed (${status}${oauthError ? ` ${oauthError}` : ""})`,
);
this.name = "CodexRefreshError";
this.credentialExpired = credentialExpired;
this.oauthError = oauthError;
this.httpStatus = credentialExpired && status !== 403 ? 401 : status;
}
}
/**
* The stored Codex credential cannot be used at all (missing refresh token,
* or storage that no longer decrypts). Same contract as a dead refresh token:
* the person has to reconnect.
*/
export function codexReconnectRequired(reason: string): CodexRefreshError {
const err = new CodexRefreshError(401, null);
err.message = `Codex sign-in unusable (${reason}); reconnect ChatGPT`;
return err;
}
/** Read the OAuth error code from a token-endpoint error body. */
function readOAuthErrorCode(body: string): string | null {
try {
const parsed = JSON.parse(body) as {
error?: unknown;
code?: unknown;
};
const error = parsed.error;
if (typeof error === "string") return normaliseCode(error);
if (error && typeof error === "object") {
const code = (error as { code?: unknown; type?: unknown }).code;
if (typeof code === "string") return normaliseCode(code);
}
if (typeof parsed.code === "string") return normaliseCode(parsed.code);
} catch {
// Not JSON: no code to read. The status still classifies the failure.
}
return null;
}
/** Codes are the endpoint's own vocabulary; keep them short and plain. */
function normaliseCode(code: string): string | null {
const trimmed = code.trim().toLowerCase();
return /^[a-z0-9_.-]{1,64}$/.test(trimmed) ? trimmed : null;
}
function codexRefreshError(status: number, body: string): CodexRefreshError {
return new CodexRefreshError(status, readOAuthErrorCode(body));
}
// ─── Encrypted-storage codec ────────────────────────────────────────
//
// We re-purpose `codexAccessTokenEncrypted` to store the entire creds
// blob (access_token + accountId + expiresAt) JSON-encoded then
// encrypted. `codexRefreshTokenEncrypted` continues to hold just the
// refresh token (encrypted), so a future migration to a dedicated
// column shape stays simple. Old v1.4.9-v1.4.11 tokens stored without
// the accountId field will fail to parse — the user has to re-connect
// once after the v1.4.12 deploy. Acceptable since those connects
// never produced a working session anyway.
interface StoredAccess {
accessToken: string;
accountId: string;
expiresAt: string; // ISO
}
export function encryptCodexCreds(creds: CodexCreds): {
accessEncrypted: string;
refreshEncrypted: string;
} {
const stored: StoredAccess = {
accessToken: creds.accessToken,
accountId: creds.accountId,
expiresAt: creds.expiresAt.toISOString(),
};
return {
accessEncrypted: encrypt(JSON.stringify(stored)),
refreshEncrypted: encrypt(creds.refreshToken),
};
}
export function decryptCodexCreds(encrypted: {
accessEncrypted: string;
refreshEncrypted: string;
}): CodexCreds | null {
let parsed: StoredAccess;
try {
const raw = decrypt(encrypted.accessEncrypted);
parsed = JSON.parse(raw) as StoredAccess;
} catch {
// Pre-v1.4.12 stored format (raw access token / api key string).
// We cannot recover the accountId from that — caller must treat
// the connection as expired and ask the user to re-link.
return null;
}
if (!parsed.accessToken || !parsed.accountId) return null;
return {
accessToken: parsed.accessToken,
refreshToken: decrypt(encrypted.refreshEncrypted),
accountId: parsed.accountId,
expiresAt: new Date(parsed.expiresAt),
};
}
// ─── Operator-shared central-codex storage codec (AppSettings) ───────
//
// The operator-level central Codex credential lives on `AppSettings` in three
// dedicated encrypted columns plus a plaintext expiry `DateTime`, rather than
// the per-user packed JSON blob. Three separate columns keep each token piece
// independently rotatable and make the `ChatGPT-Account-ID` claim an explicit,
// registered encrypted column (`admin_codex_account_id_encrypted`).
export interface EncryptedAdminCodexCreds {
accessEncrypted: string;
refreshEncrypted: string;
accountIdEncrypted: string;
expiresAt: Date;
}
export function encryptAdminCodexCreds(
creds: CodexCreds,
): EncryptedAdminCodexCreds {
return {
accessEncrypted: encrypt(creds.accessToken),
refreshEncrypted: encrypt(creds.refreshToken),
accountIdEncrypted: encrypt(creds.accountId),
expiresAt: creds.expiresAt,
};
}
export function decryptAdminCodexCreds(encrypted: {
accessEncrypted: string;
refreshEncrypted: string;
accountIdEncrypted: string;
expiresAt: Date | null;
}): CodexCreds | null {
try {
const accessToken = decrypt(encrypted.accessEncrypted);
const refreshToken = decrypt(encrypted.refreshEncrypted);
const accountId = decrypt(encrypted.accountIdEncrypted);
if (!accessToken || !refreshToken || !accountId) return null;
return {
accessToken,
refreshToken,
accountId,
// A missing expiry column (shouldn't happen once connected) is treated as
// already expired so the caller proactively refreshes before first use.
expiresAt: encrypted.expiresAt ?? new Date(0),
};
} catch {
// Fail closed: a decrypt failure (missing / rotated-out key) surfaces as a
// corrupt connection the operator must re-link, never as plaintext.
return null;
}
}