mirror of
https://github.com/MBombeck/HealthLog.git
synced 2026-11-08 20:42:43 +00:00
Introduce an operator-level ChatGPT-subscription (Codex) credential that the operator connects once and shares server-wide, plus a per-user switch to route AI egress through it. - Schema (migration 0235): three encrypted columns on AppSettings for the operator's central Codex access/refresh tokens and the ChatGPT-Account-ID claim, plus expiry/connected-at/status; a per-user useCentralCodex flag (default false). Register the new encrypted columns in the rotation registry and script. - Resolution: add the admin-codex chain type; append it to a user's resolved chain only when they opted in AND the operator connected it. Tokens refresh server-side against the singleton row (CAS-style), same primitives as the per-user Codex path. - Budget: bill an admin-codex primary against the operator cost cap, not the user plan — it drains the operator's account. - Consent: admin-codex is server-managed external egress, gated by the existing consent machinery (self-snapshot and document surfaces alike). No existing consent path is weakened. - Document order: rank admin-codex with the subscription tier (last), so the document class stays local-first. - UI: admin device-code connect/disconnect card (cookie-only admin routes) with honest copy on account pooling, shared rate limits, and training-by-default; a per-user opt-in switch shown only once the operator has connected it, off by default, behind a one-time honesty confirm. - i18n across all six locales; unit tests for the chain type, budget cap, consent gate, vision capability, and document order; a mocked e2e for both the admin connect surface and the per-user switch. The central credential's tokens are encrypted at rest and never returned to a non-admin API; the opt-in only takes effect once the operator has connected the shared account.
405 lines
13 KiB
TypeScript
405 lines
13 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 new Error(
|
|
`Codex device-token refresh failed (${res.status}): ${body}`,
|
|
);
|
|
}
|
|
const tokens = (await res.json()) as RawTokenResponse;
|
|
return buildCreds(tokens, refreshToken);
|
|
}
|
|
|
|
// ─── 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;
|
|
}
|
|
}
|