feat: Sign in with ChatGPT — OAuth sign-in, token storage, and settings UI

Lets users connect their ChatGPT Plus/Pro subscription instead of pasting
an API key. Sign-in half only: the codex model client consumes the session
via getChatGPTAccessToken().

- PKCE OAuth against auth.openai.com using the official Codex CLI public
  client id (constants verified against the openai/codex sources); system
  browser + loopback callback server on the pre-registered fixed port
  127.0.0.1:1455, state validated, stale callbacks rejected
- token store (core): single config/chatgpt-auth.json holding non-secret
  display fields (email, accountId, expiresAt) plus the token material
  encrypted via the safeStorage cipher bridge (plaintext-with-marker
  fallback when no keychain, matching the GitHub token path); no token
  value ever written in the clear or logged
- getChatGPTAccessToken(): single-flight refresh within 5 min of expiry;
  refresh rejection (400/401) clears to a clean signed-out state and
  throws typed ChatGPTAuthRequiredError; 5xx/network kept as transient
- sign-out with best-effort token revocation
- IPC chatgpt:getStatus/signIn/cancelSignIn/signOut — raw tokens never
  cross to the renderer; real cancellation settles the in-flight attempt,
  frees the port, and a retry starts a fresh attempt (new PKCE/state)
- settings UI: ChatGPT Subscription section on the OpenAI card
  (sign in / waiting + cancel / connected as {email} + sign out) via a
  useChatGPT hook

Vitest covers the token store, single-flight refresh, and
transient-vs-terminal refresh handling.
This commit is contained in:
Prakhar Pandey 2026-07-15 11:00:01 +05:30 committed by Ramnique Singh
parent 826bce90ad
commit a1db0e395a
10 changed files with 1382 additions and 23 deletions

View file

@ -20,9 +20,46 @@ export interface AuthServerResult {
port: number;
}
interface CallbackHandlingOpts {
callbackPath: string;
/** Invoked when the provider redirects back with an `error` param. */
onError?: (error: string) => void;
/**
* Gatekeeper run BEFORE the error/callback handling. Return a message to
* reject the request with a polite close-this-tab error page without
* invoking onCallback/onError. Lets a caller drop stale callbacks (e.g. the
* browser tab of a cancelled sign-in attempt carrying an old `state`)
* without disturbing the live flow.
*/
validateCallback?: (url: URL) => string | null;
}
function renderErrorPage(res: import('http').ServerResponse, message: string): void {
res.writeHead(200, { 'Content-Type': 'text/html' });
res.end(`
<!DOCTYPE html>
<html>
<head>
<title>OAuth Error</title>
<style>
body { font-family: Arial, sans-serif; text-align: center; padding: 50px; }
.error { color: #d32f2f; }
</style>
</head>
<body>
<h1 class="error">Authorization Failed</h1>
<p>${escapeHtml(message)}</p>
<p>You can close this window.</p>
<script>setTimeout(() => window.close(), 3000);</script>
</body>
</html>
`);
}
function tryBindPort(
port: number,
onCallback: (callbackUrl: URL) => void | Promise<void>
onCallback: (callbackUrl: URL) => void | Promise<void>,
opts: CallbackHandlingOpts,
): Promise<AuthServerResult> {
return new Promise((resolve, reject) => {
const server = createServer((req, res) => {
@ -34,29 +71,24 @@ function tryBindPort(
const url = new URL(req.url, `http://localhost:${port}`);
if (url.pathname === OAUTH_CALLBACK_PATH) {
if (url.pathname === opts.callbackPath) {
// Gatekeeper first: stale/foreign requests must not reach onError or
// onCallback (a stale tab's redirect must never settle a live flow).
const rejection = opts.validateCallback?.(url) ?? null;
if (rejection) {
renderErrorPage(res, rejection);
return;
}
const error = url.searchParams.get('error');
if (error) {
res.writeHead(200, { 'Content-Type': 'text/html' });
res.end(`
<!DOCTYPE html>
<html>
<head>
<title>OAuth Error</title>
<style>
body { font-family: Arial, sans-serif; text-align: center; padding: 50px; }
.error { color: #d32f2f; }
</style>
</head>
<body>
<h1 class="error">Authorization Failed</h1>
<p>Error: ${escapeHtml(error)}</p>
<p>You can close this window.</p>
<script>setTimeout(() => window.close(), 3000);</script>
</body>
</html>
`);
// Surface the provider error (e.g. access_denied when the user
// cancels consent) so the caller can settle its flow instead of
// waiting for the timeout. Callers that don't opt in keep the old
// behaviour: the error page renders and the flow times out.
opts.onError?.(error);
renderErrorPage(res, `Error: ${error}`);
return;
}
@ -117,18 +149,29 @@ function tryBindPort(
* through `port + PORT_RANGE_SIZE - 1` and binds the first available, handling
* both EADDRINUSE and EACCES (the latter is common on Windows when
* Hyper-V/WSL2 reserve the port).
*
* `callbackPath` overrides the served path for providers whose registered
* redirect URI differs (ChatGPT/Codex uses /auth/callback). `onError` is
* invoked when the provider redirects back with an `error` param (e.g.
* access_denied), letting the caller settle instead of waiting for timeout.
* `validateCallback` runs before both see CallbackHandlingOpts.
*/
export async function createAuthServer(
port: number = DEFAULT_PORT,
onCallback: (callbackUrl: URL) => void | Promise<void>,
opts: { fallback?: boolean } = {},
opts: { fallback?: boolean } & Partial<CallbackHandlingOpts> = {},
): Promise<AuthServerResult> {
const fallback = opts.fallback === true;
const handlingOpts: CallbackHandlingOpts = {
callbackPath: opts.callbackPath ?? OAUTH_CALLBACK_PATH,
onError: opts.onError,
validateCallback: opts.validateCallback,
};
const limit = fallback ? port + PORT_RANGE_SIZE - 1 : port;
for (let p = port; p <= limit; p++) {
try {
return await tryBindPort(p, onCallback);
return await tryBindPort(p, onCallback, handlingOpts);
} catch (err) {
const code = (err as NodeJS.ErrnoException).code;
if (fallback && (code === 'EADDRINUSE' || code === 'EACCES') && p < limit) {

View file

@ -0,0 +1,247 @@
import { shell } from 'electron';
import type { Server } from 'http';
import { createAuthServer } from './auth-server.js';
import * as oauthClient from '@x/core/dist/auth/oauth-client.js';
import { exchangeChatGPTCode, getChatGPTStatus } from '@x/core/dist/auth/chatgpt-auth.js';
import {
CHATGPT_AUTHORIZE_URL,
CHATGPT_CALLBACK_PATH,
CHATGPT_CALLBACK_PORT,
CHATGPT_CLIENT_ID,
CHATGPT_EXTRA_AUTHORIZE_PARAMS,
CHATGPT_REDIRECT_URI,
CHATGPT_SCOPES,
} from '@x/core/dist/auth/chatgpt-constants.js';
// Interactive "Sign in with ChatGPT" flow (OAuth 2.0 + PKCE, Codex CLI client
// — see chatgpt-constants.ts). Orchestration only: PKCE/state generation and
// all token-endpoint traffic + storage live in core; this module owns the
// system browser, the loopback callback server on 127.0.0.1:1455, and flow
// lifecycle. The port is FIXED — the redirect URI is pre-registered at OpenAI
// for the Codex client id, so there is no scan-to-next-port fallback.
export type ChatGPTSignInResult = {
signedIn: boolean;
email?: string;
accountId?: string;
/** True when the attempt was cancelled (Cancel button or superseded). */
cancelled?: boolean;
error?: string;
};
/** Generous, mirrors the Google flow's abandoned-flow cleanup ceiling. */
const SIGN_IN_TIMEOUT_MS = 5 * 60 * 1000;
type ActiveAttempt = {
promise: Promise<ChatGPTSignInResult>;
/**
* Settle the attempt with a cancelled outcome. Resolves once the loopback
* server has fully closed (listener + keep-alive connections), so a
* follow-up attempt can rebind 1455 immediately.
*/
cancel: (reason: string) => Promise<void>;
};
let activeAttempt: ActiveAttempt | null = null;
/**
* Start a sign-in attempt. If one is already pending it is stale by
* definition the user is clicking Sign In again precisely because no
* browser flow is visibly in progress (e.g. they closed the tab and hit
* Cancel) so cancel it and start FRESH (new PKCE verifier/state, new
* loopback server, new browser window). Awaiting the cancel preserves the
* one-server-at-a-time invariant: 1455 is fully released before rebinding.
*/
export async function signInWithChatGPT(): Promise<ChatGPTSignInResult> {
if (activeAttempt) {
const stale = activeAttempt;
activeAttempt = null;
console.log('[ChatGPTAuth] Cancelling stale sign-in attempt before starting a new one');
await stale.cancel('Superseded by a new sign-in attempt.');
}
const attempt = startAttempt();
activeAttempt = attempt;
void attempt.promise.finally(() => {
if (activeAttempt === attempt) activeAttempt = null;
});
return attempt.promise;
}
/**
* Abort the pending attempt (renderer Cancel button): stops the loopback
* server, clears pending state, settles the in-flight signIn promise with a
* cancelled outcome. No-op when nothing is pending. Never touches stored
* tokens after cancel, chatgpt:getStatus reports signed-out (unless an
* earlier sign-in already completed).
*/
export async function cancelChatGPTSignIn(): Promise<void> {
const attempt = activeAttempt;
if (!attempt) return;
activeAttempt = null;
await attempt.cancel('Sign-in cancelled.');
}
/**
* One sign-in attempt. The returned promise always RESOLVES (never rejects),
* and every exit path success, denial, timeout, port busy, exchange
* failure, cancellation tears down the loopback server and the timeout
* exactly once via the settle-once `finish`.
*/
function startAttempt(): ActiveAttempt {
let settle!: (result: ChatGPTSignInResult) => void;
const promise = new Promise<ChatGPTSignInResult>((resolve) => {
settle = resolve;
});
let settled = false;
let server: Server | null = null;
let timeoutHandle: NodeJS.Timeout | null = null;
let serverClosed: Promise<void> | null = null;
// Close the listening socket AND any keep-alive connections (the browser
// holds one open after the callback response) so 1455 frees immediately.
const closeServer = (): Promise<void> => {
if (serverClosed) return serverClosed;
const s = server;
server = null;
serverClosed = !s
? Promise.resolve()
: new Promise<void>((resolve) => {
s.close(() => resolve());
s.closeAllConnections();
});
return serverClosed;
};
const finish = (result: ChatGPTSignInResult): Promise<void> => {
if (settled) return serverClosed ?? Promise.resolve();
settled = true;
if (timeoutHandle) clearTimeout(timeoutHandle);
const closed = closeServer();
if (!result.signedIn) {
console.log(`[ChatGPTAuth] Sign-in did not complete: ${result.error ?? 'unknown'}`);
}
settle(result);
return closed;
};
const cancel = (reason: string): Promise<void> =>
finish({ signedIn: false, cancelled: true, error: reason });
void run();
return { promise, cancel };
async function run(): Promise<void> {
console.log('[ChatGPTAuth] Starting sign-in flow...');
try {
const { verifier, challenge } = await oauthClient.generatePKCE();
const state = oauthClient.generateState();
if (settled) return; // cancelled while generating PKCE — nothing bound yet
// Guard against duplicate callbacks (browser may send multiple requests).
let callbackHandled = false;
const onCallback = async (callbackUrl: URL) => {
if (settled || callbackHandled) return;
callbackHandled = true;
try {
// State already verified by validateCallback below.
const code = callbackUrl.searchParams.get('code');
if (!code) {
void finish({ signedIn: false, error: 'Sign-in failed: callback is missing the authorization code.' });
return;
}
// Exchange + persistence live in core (never log token values).
await exchangeChatGPTCode(code, verifier);
const status = await getChatGPTStatus();
console.log('[ChatGPTAuth] Sign-in complete');
void finish({ ...status });
} catch (error) {
console.error('[ChatGPTAuth] Token exchange failed:', error);
void finish({
signedIn: false,
error: error instanceof Error ? error.message : 'Token exchange failed',
});
}
};
// Bind the loopback server FIRST so a busy port fails fast, before any
// browser tab opens. Fixed port — createAuthServer's no-fallback error
// message tells the user to free the port.
let boundServer: Server;
try {
({ server: boundServer } = await createAuthServer(CHATGPT_CALLBACK_PORT, onCallback, {
callbackPath: CHATGPT_CALLBACK_PATH,
onError: (error) => {
void finish({
signedIn: false,
error: error === 'access_denied'
? 'Sign-in was cancelled in the browser.'
: `Sign-in failed: ${error}`,
});
},
// Stale callbacks — a tab left over from an earlier, cancelled
// attempt carries that attempt's `state` — get a polite
// close-this-tab page and never reach onError/onCallback, so they
// can neither complete sign-in nor settle the live attempt.
validateCallback: (url) => {
if (settled) {
return 'This sign-in attempt is no longer active. Close this tab and try again from Rowboat.';
}
if (url.searchParams.get('state') !== state) {
return 'This sign-in link has expired. Close this tab and try again from Rowboat.';
}
return null;
},
}));
} catch (error) {
void finish({
signedIn: false,
error: error instanceof Error ? error.message : 'Failed to start the sign-in callback server',
});
return;
}
if (settled) {
// Cancelled while the bind was in flight — release the port we just
// grabbed (finish() already ran with no server to close).
boundServer.closeAllConnections();
boundServer.close();
return;
}
server = boundServer;
timeoutHandle = setTimeout(() => {
void finish({ signedIn: false, error: 'Sign-in timed out. Please try again.' });
}, SIGN_IN_TIMEOUT_MS);
const authUrl = new URL(CHATGPT_AUTHORIZE_URL);
authUrl.search = new URLSearchParams({
response_type: 'code',
client_id: CHATGPT_CLIENT_ID,
redirect_uri: CHATGPT_REDIRECT_URI,
scope: CHATGPT_SCOPES.join(' '),
code_challenge: challenge,
code_challenge_method: 'S256',
state,
...CHATGPT_EXTRA_AUTHORIZE_PARAMS,
}).toString();
try {
// System browser: shares the user's existing ChatGPT session cookies.
await shell.openExternal(authUrl.toString());
} catch (error) {
void finish({
signedIn: false,
error: error instanceof Error ? `Failed to open browser: ${error.message}` : 'Failed to open browser',
});
}
} catch (error) {
console.error('[ChatGPTAuth] Sign-in flow error:', error);
void finish({
signedIn: false,
error: error instanceof Error ? error.message : 'Sign-in failed',
});
}
}
}

View file

@ -40,6 +40,8 @@ import { isSignedIn } from '@x/core/dist/account/account.js';
import { listGatewayModels } from '@x/core/dist/models/gateway.js';
import type { IModelConfigRepo } from '@x/core/dist/models/repo.js';
import type { IOAuthRepo } from '@x/core/dist/auth/repo.js';
import { getChatGPTStatus, signOutChatGPT } from '@x/core/dist/auth/chatgpt-auth.js';
import { signInWithChatGPT, cancelChatGPTSignIn } from './chatgpt-signin.js';
import { IGranolaConfigRepo } from '@x/core/dist/knowledge/granola/repo.js';
import { ICodeModeConfigRepo } from '@x/core/dist/code-mode/repo.js';
import { CodePermissionRegistry } from '@x/core/dist/code-mode/acp/permission-registry.js';
@ -1285,6 +1287,25 @@ export function setupIpcHandlers() {
const config = await repo.getClientFacingConfig();
return { config };
},
'chatgpt:getStatus': async () => {
return await getChatGPTStatus();
},
'chatgpt:signIn': async () => {
return await signInWithChatGPT();
},
'chatgpt:cancelSignIn': async () => {
await cancelChatGPTSignIn();
return { success: true };
},
'chatgpt:signOut': async () => {
try {
await signOutChatGPT();
return { success: true };
} catch (error) {
console.error('[ChatGPTAuth] Sign-out failed:', error);
return { success: false };
}
},
'account:getRowboat': async () => {
const signedIn = await isSignedIn();
if (!signedIn) {

View file

@ -40,6 +40,7 @@ import { startSkillsWatcher, stopSkillsWatcher } from "@x/core/dist/runtime/asse
import { init as initAppsServer, shutdown as shutdownAppsServer } from "@x/core/dist/apps/server.js";
import { registerAppsHostApi } from "@x/core/dist/apps/host-api.js";
import { setTokenCipher as setGithubTokenCipher } from "@x/core/dist/apps/github-auth.js";
import { setTokenCipher as setChatGPTTokenCipher } from "@x/core/dist/auth/chatgpt-auth.js";
import { shutdown as shutdownAnalytics } from "@x/core/dist/analytics/posthog.js";
import { identifyIfSignedIn } from "@x/core/dist/analytics/identify.js";
import { migrateRuns } from "@x/core/dist/migrations/runs/migrate.js";
@ -498,6 +499,12 @@ app.whenReady().then(async () => {
encrypt: (plain) => safeStorage.encryptString(plain).toString('base64'),
decrypt: (encrypted) => safeStorage.decryptString(Buffer.from(encrypted, 'base64')),
});
// ChatGPT subscription tokens at rest: same keychain-backed cipher.
setChatGPTTokenCipher({
isAvailable: () => safeStorage.isEncryptionAvailable(),
encrypt: (plain) => safeStorage.encryptString(plain).toString('base64'),
decrypt: (encrypted) => safeStorage.decryptString(Buffer.from(encrypted, 'base64')),
});
initAppsServer().catch((error) => {
console.error('[Apps] Failed to start:', error);
});

View file

@ -32,6 +32,7 @@ import type { ApprovalPolicy } from "@x/shared/src/code-mode.js"
import type { ipc as ipcShared } from "@x/shared"
import { startProvisioning, useProvisioning, enabledOptimistic, type AgentStatus, type CodeModeAgentStatus } from "@/lib/code-mode-provisioning"
import { useProviderModels } from "@/hooks/use-provider-models"
import { useChatGPT } from "@/hooks/useChatGPT"
type ConfigTab = "account" | "connections" | "mobile" | "models" | "mcp" | "security" | "code-mode" | "appearance" | "notifications" | "note-tagging" | "help"
@ -554,6 +555,9 @@ function ModelSettings({ dialogOpen, rowboatConnected = false }: { dialogOpen: b
apiKey: activeConfig.apiKey,
baseURL: activeConfig.baseURL,
})
// "Sign in with ChatGPT" subscription state — only rendered on the OpenAI
// card; independent of the API-key providerConfigs state above.
const chatgpt = useChatGPT()
const showApiKey = provider === "openai" || provider === "anthropic" || provider === "google" || provider === "openrouter" || provider === "aigateway" || provider === "openai-compatible"
const requiresApiKey = provider === "openai" || provider === "anthropic" || provider === "google" || provider === "openrouter" || provider === "aigateway"
const showBaseURL = provider === "ollama" || provider === "openai-compatible" || provider === "aigateway"
@ -1081,6 +1085,53 @@ function ModelSettings({ dialogOpen, rowboatConnected = false }: { dialogOpen: b
</div>
)}
{/* ChatGPT subscription OAuth sign-in, independent of the API key and
of models.json (the Codex model client consumes the token via core) */}
{provider === "openai" && (
<div className="space-y-2">
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
ChatGPT Subscription
</span>
{chatgpt.status.signedIn ? (
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-1.5 text-sm text-green-600 min-w-0">
<CheckCircle2 className="size-4 shrink-0" />
<span className="truncate">
Connected as {chatgpt.status.email ?? "your ChatGPT account"}
</span>
</div>
<Button
variant="outline"
size="sm"
className="shrink-0 border-destructive/40 text-destructive hover:bg-destructive/10 hover:text-destructive"
onClick={chatgpt.signOut}
>
Sign Out
</Button>
</div>
) : chatgpt.isSigningIn ? (
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Loader2 className="size-4 animate-spin" />
Waiting for browser
</div>
<Button variant="outline" size="sm" className="shrink-0" onClick={chatgpt.cancelSignIn}>
Cancel
</Button>
</div>
) : (
<div className="flex items-center justify-between gap-2">
<span className="text-sm text-muted-foreground">
Use your ChatGPT Plus/Pro subscription
</span>
<Button variant="outline" size="sm" className="shrink-0" onClick={chatgpt.signIn}>
Sign In
</Button>
</div>
)}
</div>
)}
{/* Base URL */}
{showBaseURL && (
<div className="space-y-2">

View file

@ -0,0 +1,114 @@
import { useState, useEffect, useCallback, useRef } from 'react';
import { toast } from 'sonner';
// "Sign in with ChatGPT" state, modeled on useOAuth.ts but against the
// dedicated chatgpt:* IPC surface: status is fetched on mount and re-derived
// from action results (chatgpt:signIn resolves with the final status — no
// broadcast event to listen for).
type ChatGPTStatus = {
signedIn: boolean;
email?: string;
accountId?: string;
};
export function useChatGPT() {
const [status, setStatus] = useState<ChatGPTStatus>({ signedIn: false });
const [isLoading, setIsLoading] = useState(true);
const [isSigningIn, setIsSigningIn] = useState(false);
// Cancelled flag + attempt sequence: a cancelled or superseded attempt's
// invoke still resolves later (main settles it with `cancelled: true`);
// only the CURRENT attempt may touch isSigningIn or show toasts, so a
// stale resolution can't clobber a fresh attempt's waiting UI.
const cancelledRef = useRef(false);
const attemptSeqRef = useRef(0);
const refresh = useCallback(async () => {
try {
setIsLoading(true);
setStatus(await window.ipc.invoke('chatgpt:getStatus', null));
} catch (error) {
console.error('Failed to fetch ChatGPT status:', error);
} finally {
setIsLoading(false);
}
}, []);
useEffect(() => {
refresh();
}, [refresh]);
const signIn = useCallback(async () => {
if (isSigningIn) return;
const attempt = ++attemptSeqRef.current;
cancelledRef.current = false;
setIsSigningIn(true);
// True only for the attempt whose result should drive the UI.
const isCurrent = () => attempt === attemptSeqRef.current && !cancelledRef.current;
try {
const result = await window.ipc.invoke('chatgpt:signIn', null);
if (result.signedIn) {
// Always reflect a successful sign-in, even if this attempt was
// cancelled client-side after the browser flow completed.
setStatus({
signedIn: true,
...(result.email ? { email: result.email } : {}),
...(result.accountId ? { accountId: result.accountId } : {}),
});
if (isCurrent()) {
toast.success(result.email ? `Signed in as ${result.email}` : 'Signed in with ChatGPT');
}
} else if (isCurrent() && !result.cancelled) {
toast.error(result.error || 'ChatGPT sign-in failed');
}
} catch (error) {
console.error('ChatGPT sign-in failed:', error);
if (isCurrent()) {
toast.error('ChatGPT sign-in failed');
}
} finally {
if (isCurrent()) {
setIsSigningIn(false);
}
}
}, [isSigningIn]);
const cancelSignIn = useCallback(() => {
cancelledRef.current = true;
setIsSigningIn(false);
// Really abort the main-process attempt (stops the loopback server and
// settles the pending signIn invoke) — otherwise the next Sign In click
// would join a dead attempt and never re-open the browser.
window.ipc.invoke('chatgpt:cancelSignIn', null).catch((error) => {
console.error('Failed to cancel ChatGPT sign-in:', error);
});
}, []);
const signOut = useCallback(async () => {
try {
setIsLoading(true);
const result = await window.ipc.invoke('chatgpt:signOut', null);
if (result.success) {
setStatus({ signedIn: false });
toast.success('Signed out of ChatGPT');
} else {
toast.error('Failed to sign out of ChatGPT');
}
} catch (error) {
console.error('ChatGPT sign-out failed:', error);
toast.error('Failed to sign out of ChatGPT');
} finally {
setIsLoading(false);
}
}, []);
return {
status,
isLoading,
isSigningIn,
signIn,
cancelSignIn,
signOut,
refresh,
};
}

View file

@ -0,0 +1,352 @@
import { describe, it, expect, beforeEach, afterEach, afterAll, vi } from 'vitest';
import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
// Isolate the store from the real home dir BEFORE importing the module —
// WorkDir is resolved at config.ts import time (same pattern as
// classification_stamp.test.ts).
const tmpWorkDir = fs.mkdtempSync(path.join(os.tmpdir(), 'x-chatgpt-auth-test-'));
process.env.ROWBOAT_WORKDIR = tmpWorkDir;
const chatgptAuth = await import('./chatgpt-auth.js');
const {
saveChatGPTTokens,
getChatGPTAccessToken,
getChatGPTStatus,
signOutChatGPT,
setTokenCipher,
decodeJwtClaims,
ChatGPTAuthRequiredError,
} = chatgptAuth;
const {
CHATGPT_CLIENT_ID,
CHATGPT_TOKEN_URL,
CHATGPT_REVOKE_URL,
CHATGPT_AUTH_CLAIM_NAMESPACE,
CHATGPT_PROFILE_CLAIM_NAMESPACE,
} = await import('./chatgpt-constants.js');
const AUTH_FILE = path.join(tmpWorkDir, 'config', 'chatgpt-auth.json');
// Fixed clock so expiry math is deterministic.
const NOW_MS = new Date('2026-07-15T12:00:00Z').getTime();
const NOW = Math.floor(NOW_MS / 1000);
/** Unsigned JWT with the given payload — decodeJwtClaims only reads part [1]. */
function makeJwt(claims: Record<string, unknown>): string {
const b64 = (obj: unknown) => Buffer.from(JSON.stringify(obj)).toString('base64url');
return `${b64({ alg: 'none' })}.${b64(claims)}.sig`;
}
function makeIdToken(overrides: Record<string, unknown> = {}): string {
return makeJwt({
email: 'user@example.com',
[CHATGPT_AUTH_CLAIM_NAMESPACE]: { chatgpt_account_id: 'acct_123' },
...overrides,
});
}
function makeAccessToken(expiresInSeconds: number, extra: Record<string, unknown> = {}): string {
return makeJwt({ exp: NOW + expiresInSeconds, ...extra });
}
/** Reversible fake cipher with a toggleable availability flag. */
const fakeCipher = {
available: true,
isAvailable() { return this.available; },
encrypt(plain: string) { return 'enc:' + Buffer.from(plain).toString('base64'); },
decrypt(encrypted: string) {
if (!encrypted.startsWith('enc:')) throw new Error('bad ciphertext');
return Buffer.from(encrypted.slice(4), 'base64').toString('utf8');
},
};
function readStoredFile(): Record<string, unknown> {
return JSON.parse(fs.readFileSync(AUTH_FILE, 'utf-8')) as Record<string, unknown>;
}
function jsonResponse(body: unknown, status = 200): Response {
return new Response(JSON.stringify(body), {
status,
headers: { 'Content-Type': 'application/json' },
});
}
let fetchMock: ReturnType<typeof vi.fn>;
beforeEach(() => {
vi.useFakeTimers();
vi.setSystemTime(NOW_MS);
fakeCipher.available = true;
setTokenCipher(fakeCipher);
fetchMock = vi.fn();
vi.stubGlobal('fetch', fetchMock);
fs.rmSync(AUTH_FILE, { force: true });
});
afterEach(() => {
vi.unstubAllGlobals();
vi.useRealTimers();
});
afterAll(() => {
fs.rmSync(tmpWorkDir, { recursive: true, force: true });
});
describe('decodeJwtClaims', () => {
it('decodes the payload with a pure base64url decode', () => {
expect(decodeJwtClaims(makeJwt({ exp: 42, foo: 'bar' }))).toEqual({ exp: 42, foo: 'bar' });
});
it('returns null for malformed input', () => {
expect(decodeJwtClaims('not-a-jwt')).toBeNull();
expect(decodeJwtClaims('a.!!!.c')).toBeNull();
expect(decodeJwtClaims(`a.${Buffer.from('[1,2]').toString('base64url')}.c`)).toBeNull();
});
});
describe('token store', () => {
it('persists tokens encrypted at rest and never writes token values in the clear', async () => {
const accessToken = makeAccessToken(3600);
const identity = await saveChatGPTTokens({
accessToken,
refreshToken: 'rt_secret_1',
idToken: makeIdToken(),
});
expect(identity).toEqual({ accountId: 'acct_123', email: 'user@example.com' });
const stored = readStoredFile();
expect(stored.tokensEncrypted).toMatch(/^enc:/);
expect(stored.tokens).toBeUndefined();
expect(stored.plaintext).toBeUndefined();
expect(stored.expiresAt).toBe(NOW + 3600);
// No raw token material anywhere in the file.
const raw = fs.readFileSync(AUTH_FILE, 'utf-8');
expect(raw).not.toContain(accessToken);
expect(raw).not.toContain('rt_secret_1');
await expect(getChatGPTStatus()).resolves.toEqual({
signedIn: true,
email: 'user@example.com',
accountId: 'acct_123',
});
});
it('falls back to plaintext with a marker when the cipher is unavailable', async () => {
fakeCipher.available = false;
await saveChatGPTTokens({
accessToken: makeAccessToken(3600),
refreshToken: 'rt_1',
idToken: makeIdToken(),
});
const stored = readStoredFile();
expect(stored.tokensEncrypted).toBeUndefined();
expect(stored.plaintext).toBe(true);
expect((stored.tokens as { refreshToken: string }).refreshToken).toBe('rt_1');
await expect(getChatGPTAccessToken()).resolves.toBe(stored && (stored.tokens as { accessToken: string }).accessToken);
expect(fetchMock).not.toHaveBeenCalled();
});
it('parses accountId from the access token and email from the profile namespace as fallbacks', async () => {
// No id_token; access token carries the auth namespace + profile email.
await saveChatGPTTokens({
accessToken: makeAccessToken(3600, {
[CHATGPT_AUTH_CLAIM_NAMESPACE]: { chatgpt_account_id: 'acct_from_at' },
[CHATGPT_PROFILE_CLAIM_NAMESPACE]: { email: 'profile@example.com' },
}),
refreshToken: 'rt_1',
});
await expect(getChatGPTStatus()).resolves.toEqual({
signedIn: true,
email: 'profile@example.com',
accountId: 'acct_from_at',
});
});
it('preserves existing identity when a refresh response carries no claims', async () => {
await saveChatGPTTokens({
accessToken: makeAccessToken(3600),
refreshToken: 'rt_1',
idToken: makeIdToken(),
});
// Rotated tokens with no identity claims at all.
await saveChatGPTTokens({
accessToken: makeAccessToken(7200),
refreshToken: 'rt_2',
});
await expect(getChatGPTStatus()).resolves.toEqual({
signedIn: true,
email: 'user@example.com',
accountId: 'acct_123',
});
});
it('reports signed out when nothing is stored', async () => {
await expect(getChatGPTStatus()).resolves.toEqual({ signedIn: false });
await expect(getChatGPTAccessToken()).rejects.toBeInstanceOf(ChatGPTAuthRequiredError);
});
});
describe('getChatGPTAccessToken', () => {
it('returns the cached token without refreshing when >5 min from expiry', async () => {
const accessToken = makeAccessToken(3600);
await saveChatGPTTokens({ accessToken, refreshToken: 'rt_1', idToken: makeIdToken() });
await expect(getChatGPTAccessToken()).resolves.toBe(accessToken);
expect(fetchMock).not.toHaveBeenCalled();
});
it('refreshes within the 5-min window using the verified JSON request shape and persists rotation', async () => {
await saveChatGPTTokens({
accessToken: makeAccessToken(60), // inside the 5-min margin
refreshToken: 'rt_old',
idToken: makeIdToken(),
});
const newAccessToken = makeAccessToken(3600);
fetchMock.mockResolvedValueOnce(jsonResponse({
access_token: newAccessToken,
refresh_token: 'rt_new',
}));
await expect(getChatGPTAccessToken()).resolves.toBe(newAccessToken);
// Request shape verified against codex-rs/login/src/auth/manager.rs.
expect(fetchMock).toHaveBeenCalledTimes(1);
const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit];
expect(url).toBe(CHATGPT_TOKEN_URL);
expect(init.method).toBe('POST');
expect((init.headers as Record<string, string>)['Content-Type']).toBe('application/json');
expect(JSON.parse(init.body as string)).toEqual({
client_id: CHATGPT_CLIENT_ID,
grant_type: 'refresh_token',
refresh_token: 'rt_old',
});
// Rotated material persisted (decrypt the file with the fake cipher).
const stored = readStoredFile();
const material = JSON.parse(fakeCipher.decrypt(stored.tokensEncrypted as string)) as Record<string, string>;
expect(material.accessToken).toBe(newAccessToken);
expect(material.refreshToken).toBe('rt_new');
expect(stored.expiresAt).toBe(NOW + 3600);
// Now fresh — no second refresh.
await expect(getChatGPTAccessToken()).resolves.toBe(newAccessToken);
expect(fetchMock).toHaveBeenCalledTimes(1);
});
it('keeps the old refresh token when the response omits one', async () => {
await saveChatGPTTokens({ accessToken: makeAccessToken(60), refreshToken: 'rt_keep' });
fetchMock.mockResolvedValueOnce(jsonResponse({ access_token: makeAccessToken(3600) }));
await getChatGPTAccessToken();
const stored = readStoredFile();
const material = JSON.parse(fakeCipher.decrypt(stored.tokensEncrypted as string)) as Record<string, string>;
expect(material.refreshToken).toBe('rt_keep');
});
it('shares a single in-flight refresh across concurrent callers', async () => {
await saveChatGPTTokens({ accessToken: makeAccessToken(60), refreshToken: 'rt_1' });
const newAccessToken = makeAccessToken(3600);
let release!: (r: Response) => void;
fetchMock.mockReturnValueOnce(new Promise<Response>((resolve) => { release = resolve; }));
const a = getChatGPTAccessToken();
const b = getChatGPTAccessToken();
// Let both callers reach the refresh gate before releasing the response.
await Promise.resolve();
release(jsonResponse({ access_token: newAccessToken }));
await expect(a).resolves.toBe(newAccessToken);
await expect(b).resolves.toBe(newAccessToken);
expect(fetchMock).toHaveBeenCalledTimes(1);
});
it('clears to a clean signed-out state and throws the typed error when the refresh token is rejected', async () => {
await saveChatGPTTokens({ accessToken: makeAccessToken(60), refreshToken: 'rt_revoked' });
fetchMock.mockResolvedValueOnce(jsonResponse({ error: 'invalid_grant' }, 401));
await expect(getChatGPTAccessToken()).rejects.toBeInstanceOf(ChatGPTAuthRequiredError);
expect(fs.existsSync(AUTH_FILE)).toBe(false);
await expect(getChatGPTStatus()).resolves.toEqual({ signedIn: false });
});
it('treats 5xx as transient: throws a plain error and keeps the stored tokens', async () => {
await saveChatGPTTokens({ accessToken: makeAccessToken(60), refreshToken: 'rt_1', idToken: makeIdToken() });
fetchMock.mockResolvedValueOnce(jsonResponse({ error: 'server_error' }, 503));
const err = await getChatGPTAccessToken().catch((e: unknown) => e);
expect(err).toBeInstanceOf(Error);
expect(err).not.toBeInstanceOf(ChatGPTAuthRequiredError);
await expect(getChatGPTStatus()).resolves.toMatchObject({ signedIn: true });
});
it('treats network failure as transient and keeps the stored tokens', async () => {
await saveChatGPTTokens({ accessToken: makeAccessToken(60), refreshToken: 'rt_1' });
fetchMock.mockRejectedValueOnce(new Error('fetch failed'));
const err = await getChatGPTAccessToken().catch((e: unknown) => e);
expect(err).toBeInstanceOf(Error);
expect(err).not.toBeInstanceOf(ChatGPTAuthRequiredError);
await expect(getChatGPTStatus()).resolves.toMatchObject({ signedIn: true });
});
it('clears the store and requires sign-in when decryption fails', async () => {
await saveChatGPTTokens({ accessToken: makeAccessToken(3600), refreshToken: 'rt_1' });
const stored = readStoredFile();
stored.tokensEncrypted = 'corrupted';
fs.writeFileSync(AUTH_FILE, JSON.stringify(stored));
await expect(getChatGPTAccessToken()).rejects.toBeInstanceOf(ChatGPTAuthRequiredError);
expect(fs.existsSync(AUTH_FILE)).toBe(false);
});
});
describe('signOutChatGPT', () => {
it('best-effort revokes the refresh token (with client_id) then clears the store', async () => {
await saveChatGPTTokens({
accessToken: makeAccessToken(3600),
refreshToken: 'rt_1',
idToken: makeIdToken(),
});
fetchMock.mockResolvedValueOnce(jsonResponse({}));
await signOutChatGPT();
// Revoke shape verified against codex-rs/login/src/auth/revoke.rs.
expect(fetchMock).toHaveBeenCalledTimes(1);
const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit];
expect(url).toBe(CHATGPT_REVOKE_URL);
expect(JSON.parse(init.body as string)).toEqual({
token: 'rt_1',
token_type_hint: 'refresh_token',
client_id: CHATGPT_CLIENT_ID,
});
expect(fs.existsSync(AUTH_FILE)).toBe(false);
await expect(getChatGPTStatus()).resolves.toEqual({ signedIn: false });
});
it('still clears the store when revocation fails', async () => {
await saveChatGPTTokens({ accessToken: makeAccessToken(3600), refreshToken: 'rt_1' });
fetchMock.mockRejectedValueOnce(new Error('offline'));
await signOutChatGPT();
expect(fs.existsSync(AUTH_FILE)).toBe(false);
});
it('is a no-op-safe local clear when nothing is stored', async () => {
await signOutChatGPT();
expect(fetchMock).not.toHaveBeenCalled();
await expect(getChatGPTStatus()).resolves.toEqual({ signedIn: false });
});
});

View file

@ -0,0 +1,389 @@
import fs from 'node:fs/promises';
import path from 'node:path';
import { WorkDir } from '../config/config.js';
import {
CHATGPT_AUTH_CLAIM_NAMESPACE,
CHATGPT_CLIENT_ID,
CHATGPT_PROFILE_CLAIM_NAMESPACE,
CHATGPT_REDIRECT_URI,
CHATGPT_REFRESH_MARGIN_SECONDS,
CHATGPT_REVOKE_URL,
CHATGPT_TOKEN_URL,
} from './chatgpt-constants.js';
// "Sign in with ChatGPT" token layer. Owns storage + refresh of the OAuth
// tokens acquired via the Codex CLI client (see chatgpt-constants.ts). The
// interactive sign-in flow (PKCE + loopback server on 127.0.0.1:1455) lives
// in the Electron main process and lands in Phase 2; it persists tokens here
// via saveChatGPTTokens(). Consumers (the Codex Responses model client) must
// go through getChatGPTAccessToken() and never read the store directly.
//
// IMPORTANT: never log token values — log events only.
const AUTH_FILE = path.join(WorkDir, 'config', 'chatgpt-auth.json');
// Token-at-rest encryption is provided by the Electron main process
// (safeStorage) — core stays electron-free. When no cipher is wired (or the
// OS keychain is unavailable) tokens are stored plaintext with a marker,
// matching the existing GitHub token storage in apps/github-auth.ts.
export interface TokenCipher {
isAvailable(): boolean;
encrypt(plain: string): string; // returns base64
decrypt(encrypted: string): string;
}
let cipher: TokenCipher | null = null;
export function setTokenCipher(c: TokenCipher): void {
cipher = c;
}
/** The sensitive material — stored encrypted when the cipher is available. */
type TokenMaterial = {
accessToken: string;
refreshToken: string;
};
type StoredChatGPTAuth = {
/** ChatGPT account id, parsed from the id_token (see extractIdentity). */
accountId?: string;
email?: string;
/** Unix seconds — the access token's `exp` claim. */
expiresAt: number;
createdAt: string;
/** base64 ciphertext of JSON TokenMaterial, via the injected cipher. */
tokensEncrypted?: string;
/** Plaintext fallback when no cipher/keychain is available. */
tokens?: TokenMaterial;
plaintext?: boolean;
};
/**
* Thrown when there is no usable ChatGPT session never signed in, refresh
* token revoked/expired, or the stored tokens are unreadable. Callers should
* surface "Sign in with ChatGPT" and must not retry.
*/
export class ChatGPTAuthRequiredError extends Error {
constructor(message = 'ChatGPT sign-in required') {
super(message);
this.name = 'ChatGPTAuthRequiredError';
}
}
/**
* Decode a JWT's payload claims with a pure base64url decode no signature
* verification. Fine for our use: we only mine identity/expiry hints from
* tokens we received directly from the token endpoint over TLS.
*/
export function decodeJwtClaims(jwt: string): Record<string, unknown> | null {
const parts = jwt.split('.');
if (parts.length < 2 || !parts[1]) return null;
try {
const payload = Buffer.from(parts[1], 'base64url').toString('utf8');
const parsed: unknown = JSON.parse(payload);
return parsed !== null && typeof parsed === 'object' && !Array.isArray(parsed)
? parsed as Record<string, unknown>
: null;
} catch {
return null;
}
}
function claimString(source: unknown, key: string): string | undefined {
if (source !== null && typeof source === 'object') {
const value = (source as Record<string, unknown>)[key];
if (typeof value === 'string' && value.length > 0) return value;
}
return undefined;
}
/**
* Identity claims, per codex-rs/login/src/token_data.rs:
* - accountId: `chatgpt_account_id` inside "https://api.openai.com/auth"
* - email: root `email` claim, falling back to `email` inside
* "https://api.openai.com/profile"
* We check the id_token first (codex parses identity from it), then the
* access token, which carries the same auth claim namespace.
*/
function extractIdentity(tokens: Array<string | undefined>): { accountId?: string; email?: string } {
let accountId: string | undefined;
let email: string | undefined;
for (const token of tokens) {
if (!token) continue;
const claims = decodeJwtClaims(token);
if (!claims) continue;
accountId ??= claimString(claims[CHATGPT_AUTH_CLAIM_NAMESPACE], 'chatgpt_account_id');
email ??= claimString(claims, 'email')
?? claimString(claims[CHATGPT_PROFILE_CLAIM_NAMESPACE], 'email');
}
return { accountId, email };
}
async function readAuth(): Promise<StoredChatGPTAuth | null> {
try {
return JSON.parse(await fs.readFile(AUTH_FILE, 'utf-8')) as StoredChatGPTAuth;
} catch {
return null;
}
}
async function writeAuth(auth: StoredChatGPTAuth): Promise<void> {
await fs.mkdir(path.dirname(AUTH_FILE), { recursive: true });
await fs.writeFile(AUTH_FILE, JSON.stringify(auth, null, 2), { mode: 0o600 });
}
async function clearStore(): Promise<void> {
await fs.rm(AUTH_FILE, { force: true });
}
/**
* Read the sensitive token material from a stored entry. Returns null when it
* cannot be read; a failed DECRYPT additionally clears the store (keychain
* changed / corrupt ciphertext is unrecoverable force a clean re-sign-in,
* mirroring the GitHub token path).
*/
async function getTokenMaterial(auth: StoredChatGPTAuth): Promise<TokenMaterial | null> {
if (auth.tokensEncrypted && cipher?.isAvailable()) {
try {
const material = JSON.parse(cipher.decrypt(auth.tokensEncrypted)) as TokenMaterial;
if (typeof material.accessToken === 'string' && typeof material.refreshToken === 'string') {
return material;
}
throw new Error('malformed token material');
} catch {
console.warn('[ChatGPTAuth] Failed to decrypt stored tokens; clearing auth');
await clearStore();
return null;
}
}
if (auth.tokens) return auth.tokens;
if (auth.tokensEncrypted) {
// Encrypted store but no cipher wired (e.g. keychain unavailable this
// launch). Don't clear — the tokens may become readable again.
console.warn('[ChatGPTAuth] Stored tokens are encrypted but no cipher is available');
}
return null;
}
/**
* Persist tokens (called by the Phase 2 sign-in flow and by refresh).
* Derives expiry from the access token's `exp` claim and identity from the
* id_token / access token claims; existing identity is preserved when a
* refresh response doesn't carry the claims.
*/
export async function saveChatGPTTokens(input: {
accessToken: string;
refreshToken: string;
idToken?: string;
}): Promise<{ accountId?: string; email?: string }> {
const existing = await readAuth();
const identity = extractIdentity([input.idToken, input.accessToken]);
const accountId = identity.accountId ?? existing?.accountId;
const email = identity.email ?? existing?.email;
const exp = decodeJwtClaims(input.accessToken)?.exp;
let expiresAt: number;
if (typeof exp === 'number') {
expiresAt = exp;
} else {
// The token endpoint returns no expires_in (verified against
// codex-rs/login/src/auth/manager.rs) — the JWT exp claim is the only
// expiry source. Without it, assume a conservative 1h lifetime.
console.warn('[ChatGPTAuth] Access token has no exp claim; assuming 1h lifetime');
expiresAt = Math.floor(Date.now() / 1000) + 3600;
}
const auth: StoredChatGPTAuth = {
...(accountId ? { accountId } : {}),
...(email ? { email } : {}),
expiresAt,
createdAt: existing?.createdAt ?? new Date().toISOString(),
};
const material: TokenMaterial = {
accessToken: input.accessToken,
refreshToken: input.refreshToken,
};
if (cipher?.isAvailable()) {
auth.tokensEncrypted = cipher.encrypt(JSON.stringify(material));
} else {
auth.tokens = material;
auth.plaintext = true;
}
await writeAuth(auth);
return { accountId, email };
}
/**
* Exchange an authorization code for tokens and persist them (called by the
* main process sign-in flow after the loopback callback). Request/response
* shape verified against codex-rs/login/src/server.rs
* (`exchange_code_for_tokens`): form-encoded POST unlike the JSON refresh
* response `{ id_token, access_token, refresh_token }`, no expires_in.
* Returns the parsed identity for the caller to surface.
*/
export async function exchangeChatGPTCode(
code: string,
codeVerifier: string,
): Promise<{ accountId?: string; email?: string }> {
const res = await fetch(CHATGPT_TOKEN_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'authorization_code',
code,
redirect_uri: CHATGPT_REDIRECT_URI,
client_id: CHATGPT_CLIENT_ID,
code_verifier: codeVerifier,
}).toString(),
signal: AbortSignal.timeout(30_000),
});
if (!res.ok) {
throw new Error(`ChatGPT token exchange failed: HTTP ${res.status}`);
}
const body = await res.json() as {
id_token?: string;
access_token?: string;
refresh_token?: string;
};
if (!body.access_token || !body.refresh_token) {
throw new Error('ChatGPT token exchange response is missing tokens');
}
const identity = await saveChatGPTTokens({
accessToken: body.access_token,
refreshToken: body.refresh_token,
...(body.id_token ? { idToken: body.id_token } : {}),
});
console.log('[ChatGPTAuth] Sign-in token exchange complete');
return identity;
}
// Single-flight refresh: concurrent expired-token callers share one request
// (same pattern as the Rowboat gateway token in auth/tokens.ts). One refresh
// owner matters here — parallel refreshes racing on one refresh_token can
// invalidate each other's grant.
let refreshInFlight: Promise<string> | null = null;
async function performRefresh(refreshToken: string): Promise<string> {
// Request/response shape verified against codex-rs/login/src/auth/manager.rs:
// JSON body (not form-encoded), response may omit refresh_token (keep the
// old one) and never carries expires_in.
let res: Response;
try {
res = await fetch(CHATGPT_TOKEN_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
client_id: CHATGPT_CLIENT_ID,
grant_type: 'refresh_token',
refresh_token: refreshToken,
}),
signal: AbortSignal.timeout(30_000),
});
} catch (error) {
// Network failure: transient — keep the stored tokens so the next
// call retries instead of forcing a re-sign-in.
const message = error instanceof Error ? error.message : String(error);
throw new Error(`ChatGPT token refresh failed: ${message}`);
}
if (res.status === 400 || res.status === 401) {
// Refresh token revoked or expired — unrecoverable without the user.
console.log(`[ChatGPTAuth] Refresh rejected (HTTP ${res.status}); signing out`);
await clearStore();
throw new ChatGPTAuthRequiredError('ChatGPT session expired. Please sign in again.');
}
if (!res.ok) {
// 5xx / rate limit: transient — keep the stored tokens.
throw new Error(`ChatGPT token refresh failed: HTTP ${res.status}`);
}
const body = await res.json() as {
id_token?: string;
access_token?: string;
refresh_token?: string;
};
if (!body.access_token) {
throw new Error('ChatGPT token refresh returned no access token');
}
await saveChatGPTTokens({
accessToken: body.access_token,
refreshToken: body.refresh_token || refreshToken,
...(body.id_token ? { idToken: body.id_token } : {}),
});
console.log('[ChatGPTAuth] Access token refreshed');
return body.access_token;
}
/**
* The one seam for consumers (the Codex Responses model client): returns a
* valid access token, transparently refreshing when within 5 minutes of
* expiry. Throws ChatGPTAuthRequiredError when there is no usable session.
*/
export async function getChatGPTAccessToken(): Promise<string> {
const auth = await readAuth();
if (!auth) {
throw new ChatGPTAuthRequiredError();
}
const material = await getTokenMaterial(auth);
if (!material) {
throw new ChatGPTAuthRequiredError();
}
const now = Math.floor(Date.now() / 1000);
if (auth.expiresAt - now > CHATGPT_REFRESH_MARGIN_SECONDS) {
return material.accessToken;
}
if (!refreshInFlight) {
refreshInFlight = performRefresh(material.refreshToken).finally(() => {
refreshInFlight = null;
});
}
return refreshInFlight;
}
/** Connection state for the UI. Never returns token values. */
export async function getChatGPTStatus(): Promise<{ signedIn: boolean; email?: string; accountId?: string }> {
const auth = await readAuth();
if (!auth || (!auth.tokensEncrypted && !auth.tokens)) {
return { signedIn: false };
}
return {
signedIn: true,
...(auth.email ? { email: auth.email } : {}),
...(auth.accountId ? { accountId: auth.accountId } : {}),
};
}
/**
* Sign out: best-effort revocation at auth.openai.com (endpoint + request
* shape verified in codex-rs/login/src/auth/revoke.rs refresh token first,
* with client_id; access token as fallback, without), then clear the local
* store. Revocation failure never blocks the local sign-out.
*/
export async function signOutChatGPT(): Promise<void> {
const auth = await readAuth();
if (auth) {
const material = await getTokenMaterial(auth);
if (material) {
const body = material.refreshToken
? { token: material.refreshToken, token_type_hint: 'refresh_token', client_id: CHATGPT_CLIENT_ID }
: { token: material.accessToken, token_type_hint: 'access_token' };
try {
const res = await fetch(CHATGPT_REVOKE_URL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
signal: AbortSignal.timeout(10_000),
});
if (!res.ok) {
console.warn(`[ChatGPTAuth] Token revocation returned HTTP ${res.status}; continuing with local sign-out`);
}
} catch {
console.warn('[ChatGPTAuth] Token revocation failed; continuing with local sign-out');
}
}
}
await clearStore();
console.log('[ChatGPTAuth] Signed out');
}

View file

@ -0,0 +1,97 @@
// OAuth constants for "Sign in with ChatGPT" (ChatGPT subscription auth).
//
// Rowboat authenticates against OpenAI's auth server using the SAME public
// client the open-source Codex CLI uses. Every value below was verified
// against the openai/codex sources on 2026-07-15 — do not edit from memory;
// re-check the linked files instead.
//
// Sources (github.com/openai/codex, main branch):
// - codex-rs/login/src/auth/manager.rs → CLIENT_ID, refresh request shape,
// 5-minute access-token refresh window
// - codex-rs/login/src/server.rs → DEFAULT_ISSUER, /oauth/authorize,
// /oauth/token, DEFAULT_PORT 1455, /auth/callback, scopes, extra
// authorize params
// - codex-rs/login/src/auth/revoke.rs → /oauth/revoke request shape
// - codex-rs/login/src/token_data.rs → JWT claim names for account id
// and email
/**
* OAuth client id of the official Codex CLI.
* Source: codex-rs/login/src/auth/manager.rs (`pub const CLIENT_ID`).
* Client ids are public identifiers, not secrets.
*/
export const CHATGPT_CLIENT_ID = 'app_EMoamEEZ73f0CkXaXp7hrann';
/** Source: codex-rs/login/src/server.rs (`DEFAULT_ISSUER`). */
export const CHATGPT_ISSUER = 'https://auth.openai.com';
/** Source: codex-rs/login/src/server.rs (`build_authorize_url`: `{issuer}/oauth/authorize`). */
export const CHATGPT_AUTHORIZE_URL = `${CHATGPT_ISSUER}/oauth/authorize`;
/**
* Token endpoint, used for both code exchange and refresh.
* Source: codex-rs/login/src/server.rs (`exchange_code_for_tokens`) and
* codex-rs/login/src/auth/manager.rs (refresh POSTs here).
*
* NOTE (verified in manager.rs): the refresh request is a JSON body
* `{ client_id, grant_type: "refresh_token", refresh_token }` with
* Content-Type application/json, NOT the form-encoded body standard OAuth
* clients send. The response is `{ id_token?, access_token?, refresh_token? }`
* and carries NO `expires_in` expiry must be read from the new access
* token's `exp` claim.
*/
export const CHATGPT_TOKEN_URL = `${CHATGPT_ISSUER}/oauth/token`;
/**
* Revocation endpoint. Source: codex-rs/login/src/auth/revoke.rs JSON POST
* `{ token, token_type_hint: "refresh_token"|"access_token", client_id }`
* (client_id only when revoking a refresh token), 10s timeout.
*/
export const CHATGPT_REVOKE_URL = `${CHATGPT_ISSUER}/oauth/revoke`;
/**
* The loopback callback the Codex client id is registered for. The port is
* fixed unlike Rowboat's DCR providers there is no scan-to-next-port
* fallback, because the redirect URI is pre-registered at OpenAI.
* Source: codex-rs/login/src/server.rs (`DEFAULT_PORT: u16 = 1455`,
* `http://localhost:{port}/auth/callback`).
*/
export const CHATGPT_CALLBACK_PORT = 1455;
export const CHATGPT_CALLBACK_PATH = '/auth/callback';
export const CHATGPT_REDIRECT_URI = `http://localhost:${CHATGPT_CALLBACK_PORT}${CHATGPT_CALLBACK_PATH}`;
/**
* Scopes to request at authorize time (Phase 2).
* Source: codex-rs/login/src/server.rs requests
* "openid profile email offline_access api.connectors.read api.connectors.invoke";
* we deliberately drop the two `api.connectors.*` scopes Rowboat only needs
* identity + refresh (`offline_access`) for model calls, matching what Zed's
* ChatGPT provider requests.
*/
export const CHATGPT_SCOPES = ['openid', 'profile', 'email', 'offline_access'];
/**
* Extra authorize-URL query params the Codex flow sends (Phase 2).
* Source: codex-rs/login/src/server.rs (`build_authorize_url`).
*/
export const CHATGPT_EXTRA_AUTHORIZE_PARAMS: Record<string, string> = {
id_token_add_organizations: 'true',
codex_cli_simplified_flow: 'true',
};
/**
* Refresh the access token when it is within this margin of expiry.
* Source: codex-rs/login/src/auth/manager.rs
* (`CHATGPT_ACCESS_TOKEN_REFRESH_WINDOW_MINUTES: i64 = 5`).
*/
export const CHATGPT_REFRESH_MARGIN_SECONDS = 5 * 60;
/**
* JWT claim namespaces. Source: codex-rs/login/src/token_data.rs the
* ChatGPT account id is `chatgpt_account_id` inside the
* "https://api.openai.com/auth" claim of the id_token; email is the root
* `email` claim with a fallback to `email` inside
* "https://api.openai.com/profile".
*/
export const CHATGPT_AUTH_CLAIM_NAMESPACE = 'https://api.openai.com/auth';
export const CHATGPT_PROFILE_CLAIM_NAMESPACE = 'https://api.openai.com/profile';

View file

@ -783,6 +783,44 @@ const ipcSchemas = {
}),
res: z.null(),
},
// --- "Sign in with ChatGPT" (subscription OAuth via the Codex CLI client) ---
// Raw tokens are never exposed over IPC — the renderer only sees identity
// and connection state.
'chatgpt:getStatus': {
req: z.null(),
res: z.object({
signedIn: z.boolean(),
email: z.string().optional(),
accountId: z.string().optional(),
}),
},
// Resolves when the browser flow settles (success, denial, timeout, port
// busy, exchange failure, cancellation) — same shape as getStatus plus an
// error string; `cancelled` marks expected teardown (no error toast).
'chatgpt:signIn': {
req: z.null(),
res: z.object({
signedIn: z.boolean(),
email: z.string().optional(),
accountId: z.string().optional(),
cancelled: z.boolean().optional(),
error: z.string().optional(),
}),
},
// Abort the pending sign-in attempt: stops the loopback server and settles
// the in-flight chatgpt:signIn with a cancelled outcome. No-op when idle.
'chatgpt:cancelSignIn': {
req: z.null(),
res: z.object({
success: z.boolean(),
}),
},
'chatgpt:signOut': {
req: z.null(),
res: z.object({
success: z.boolean(),
}),
},
'app:openUrl': {
req: z.object({
url: z.string(),