From 647e0d17dae4dda7d72731a114f509c26f5d793b Mon Sep 17 00:00:00 2001 From: matamune Date: Tue, 12 May 2026 19:32:18 +0000 Subject: [PATCH] auth flow --- apps/web/src/App.tsx | 316 +++++++++++++-- apps/web/vite.config.ts | 21 + packages/codex-client/README.md | 29 ++ packages/codex-client/package.json | 4 + .../codex-client/scripts/smoke-exports.ts | 1 + packages/codex-client/src/app-server/auth.ts | 362 ++++++++++++++++++ packages/codex-client/src/auth.ts | 22 ++ packages/codex-client/src/browser.ts | 22 ++ packages/codex-client/src/index.ts | 22 ++ packages/codex-client/test/auth.test.ts | 242 ++++++++++++ 10 files changed, 1005 insertions(+), 36 deletions(-) create mode 100644 packages/codex-client/src/app-server/auth.ts create mode 100644 packages/codex-client/src/auth.ts create mode 100644 packages/codex-client/test/auth.test.ts diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index abc5c53..a50317a 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -2,7 +2,10 @@ import { Button } from "@workspace/ui/components/button"; import { AlertCircle, Copy, + ExternalLink, + KeyRound, Loader2, + LogOut, Plug, RefreshCw, Send, @@ -23,6 +26,9 @@ import { import { CodexAppServerClient, JsonRpcError, + createCodexAuthClient, + type CodexAuthClient, + type CodexAuthState, type JsonRpcNotification, type JsonRpcRequest, type v2, @@ -53,6 +59,7 @@ export function App() { function BareCodexApp() { const clientRef = useRef(null); + const authRef = useRef(null); const [wsUrl, setWsUrl] = useState(initialWsUrl); const [connectedUrl, setConnectedUrl] = useState(); const [status, setStatus] = useState("disconnected"); @@ -60,7 +67,7 @@ function BareCodexApp() { const [threads, setThreads] = useState([]); const [selectedThreadId, setSelectedThreadId] = useState(); const [selectedThread, setSelectedThread] = useState(); - const [account, setAccount] = useState(); + const [authState, setAuthState] = useState(); const [prompt, setPrompt] = useState(""); const [cwd, setCwd] = useState(""); const [eventLog, setEventLog] = useState([]); @@ -116,14 +123,14 @@ function BareCodexApp() { [readThread, selectedThreadId], ); - const refreshAccount = useCallback(async (client = clientRef.current) => { - if (!client) { + const refreshAuthState = useCallback(async (auth = authRef.current) => { + if (!auth) { return; } try { - setAccount(await client.getAccount({ refreshToken: false })); + setAuthState(await auth.getState()); } catch { - setAccount(undefined); + setAuthState(undefined); } }, []); @@ -136,7 +143,7 @@ function BareCodexApp() { try { await Promise.all([ refreshThreads(client), - refreshAccount(client), + refreshAuthState(), selectedThreadId ? readThread(selectedThreadId, client) : undefined, ]); } catch (refreshError) { @@ -144,15 +151,23 @@ function BareCodexApp() { } finally { setBusyAction(undefined); } - }, [readThread, refreshAccount, refreshThreads, selectedThreadId]); + }, [readThread, refreshAuthState, refreshThreads, selectedThreadId]); const handleNotification = useCallback( (message: JsonRpcNotification) => { appendEvent({ kind: "notification", title: message.method, - body: previewJson(message.params, 900), + body: previewNotificationParams(message), }); + if ( + message.method === "account/updated" || + message.method === "account/login/completed" + ) { + void refreshAuthState().catch((refreshError) => + setError(errorMessage(refreshError)), + ); + } const threadId = notificationThreadId(message); if (threadId) { if (!selectedThreadId || selectedThreadId === threadId) { @@ -166,7 +181,13 @@ function BareCodexApp() { ); } }, - [appendEvent, readThread, refreshThreads, selectedThreadId], + [ + appendEvent, + readThread, + refreshAuthState, + refreshThreads, + selectedThreadId, + ], ); const connect = useCallback(async () => { @@ -185,6 +206,8 @@ function BareCodexApp() { clientVersion: "0.1.0", }); clientRef.current = client; + const auth = createCodexAuthClient(client); + authRef.current = auth; client.on("notification", handleNotification); client.on("request", (message: JsonRpcRequest) => { appendEvent({ @@ -222,7 +245,7 @@ function BareCodexApp() { setConnectedUrl(url); setStatus("connected"); appendEvent({ kind: "control", title: "connected", body: url }); - await Promise.all([refreshThreads(client), refreshAccount(client)]); + await Promise.all([refreshThreads(client), refreshAuthState(auth)]); } catch (connectError) { if (clientRef.current === client) { clientRef.current = null; @@ -235,7 +258,7 @@ function BareCodexApp() { }, [ appendEvent, handleNotification, - refreshAccount, + refreshAuthState, refreshThreads, wsUrl, ]); @@ -243,8 +266,10 @@ function BareCodexApp() { const disconnect = useCallback(() => { clientRef.current?.close(); clientRef.current = null; + authRef.current = null; setConnectedUrl(undefined); setStatus("disconnected"); + setAuthState(undefined); appendEvent({ kind: "control", title: "disconnected" }); }, [appendEvent]); @@ -322,6 +347,134 @@ function BareCodexApp() { } }; + const startChatGptLogin = async () => { + const auth = authRef.current; + if (!auth) { + return; + } + setBusyAction("auth"); + setError(undefined); + try { + const login = await auth.startChatGptLogin(); + window.open(login.authUrl, "_blank", "noopener,noreferrer"); + setAuthState({ + status: "loginPending", + method: "chatgpt", + loginId: login.loginId, + authMode: null, + planType: null, + usage: null, + }); + appendEvent({ + kind: "control", + title: "chatgpt login started", + body: login.loginId, + }); + } catch (authError) { + setError(errorMessage(authError)); + } finally { + setBusyAction(undefined); + } + }; + + const startDeviceCodeLogin = async () => { + const auth = authRef.current; + if (!auth) { + return; + } + setBusyAction("auth"); + setError(undefined); + try { + const login = await auth.startDeviceCodeLogin(); + if (navigator.clipboard) { + await navigator.clipboard.writeText(login.userCode); + } + window.open(login.verificationUrl, "_blank", "noopener,noreferrer"); + setAuthState({ + status: "loginPending", + method: "chatgptDeviceCode", + loginId: login.loginId, + authMode: null, + planType: null, + usage: null, + }); + appendEvent({ + kind: "control", + title: "device login started", + body: `${login.userCode} / ${login.loginId}`, + }); + } catch (authError) { + setError(errorMessage(authError)); + } finally { + setBusyAction(undefined); + } + }; + + const loginWithApiKey = async () => { + const auth = authRef.current; + const apiKey = window.prompt("OpenAI API key"); + if (!auth || !apiKey?.trim()) { + return; + } + setBusyAction("auth"); + setError(undefined); + try { + await auth.loginWithApiKey(apiKey.trim()); + await refreshAuthState(auth); + appendEvent({ kind: "control", title: "api key login completed" }); + } catch (authError) { + setError(errorMessage(authError)); + } finally { + setBusyAction(undefined); + } + }; + + const loginWithChatGptTokens = async () => { + const auth = authRef.current; + const accessToken = window.prompt("ChatGPT access token"); + if (!auth || !accessToken?.trim()) { + return; + } + const chatgptAccountId = window.prompt("ChatGPT account/workspace id"); + if (!chatgptAccountId?.trim()) { + return; + } + const chatgptPlanType = window.prompt("Plan type (optional)")?.trim() || null; + setBusyAction("auth"); + setError(undefined); + try { + await auth.loginWithChatGptTokens({ + accessToken: accessToken.trim(), + chatgptAccountId: chatgptAccountId.trim(), + chatgptPlanType, + }); + await refreshAuthState(auth); + appendEvent({ kind: "control", title: "token login completed" }); + } catch (authError) { + setError(errorMessage(authError)); + } finally { + setBusyAction(undefined); + } + }; + + const logout = async () => { + const auth = authRef.current; + if (!auth) { + return; + } + setBusyAction("auth"); + setError(undefined); + try { + await auth.logout(); + await refreshAuthState(auth); + appendEvent({ kind: "control", title: "logged out" }); + } catch (authError) { + setError(errorMessage(authError)); + } finally { + setBusyAction(undefined); + } + }; + const selectedItems = useMemo( () => selectedThread?.turns.flatMap((turn) => turn.items) ?? [], [selectedThread], @@ -456,17 +609,69 @@ function BareCodexApp() { -
- - - -
+
+
+ + + + + +
+
+ +
+ + + +
+ +
+
@@ -799,20 +1004,22 @@ function notificationThreadId(message: JsonRpcNotification) { return stringValue(thread.id); } -function accountMode(account: v2.GetAccountResponse | undefined) { - const value = account as unknown; - const item = record(value); - return ( - stringValue(item.authMode) ?? - stringValue(record(item.account).type) ?? - stringValue(record(item.account).authMode) - ); -} - -function accountPlan(account: v2.GetAccountResponse | undefined) { - const value = account as unknown; - const item = record(value); - return stringValue(item.planType) ?? stringValue(record(item.account).planType); +function previewNotificationParams(message: JsonRpcNotification) { + if (message.method === "account/login/completed") { + const params = record(message.params); + return previewJson( + { + loginId: stringValue(params.loginId), + success: params.success === true, + error: stringValue(params.error), + }, + 900, + ); + } + if (message.method === "account/updated") { + return previewJson({ account: "updated" }, 900); + } + return previewJson(message.params, 900); } function optionalText(value: string) { @@ -820,6 +1027,43 @@ function optionalText(value: string) { return trimmed ? trimmed : null; } +function authStatusLabel( + authState: CodexAuthState | undefined, + connected: boolean, +) { + if (!connected) { + return "offline"; + } + if (!authState) { + return "unknown"; + } + if (authState.status === "loginPending") { + return `pending ${authState.method}`; + } + if (authState.status === "authenticated") { + return "signed in"; + } + if (authState.status === "unauthenticated") { + return authState.requiresOpenaiAuth ? "sign in required" : "signed out"; + } + return "error"; +} + +function usageLabel(authState: CodexAuthState | undefined) { + if (authState?.status !== "authenticated") { + return "unknown"; + } + const primary = authState.usage?.primary; + if (!primary) { + return "unknown"; + } + const percent = Math.round(primary.usedPercent); + const reset = primary.resetsAt + ? ` / resets ${formatTime(new Date(primary.resetsAt * 1000).toISOString())}` + : ""; + return `${percent}%${reset}`; +} + function compactPath(path: string | undefined) { if (!path) { return "none"; diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index d267897..a07dd4e 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -16,6 +16,27 @@ export default defineConfig({ resolve: { alias: { "@": path.resolve(__dirname, "./src"), + "@peezy.tech/codex-flows/browser": path.resolve( + __dirname, + "../../packages/codex-client/src/browser.ts", + ), + "@peezy.tech/codex-flows": path.resolve( + __dirname, + "../../packages/codex-client/src/index.ts", + ), + "@workspace/ui/globals.css": path.resolve( + __dirname, + "../../packages/ui/src/styles/globals.css", + ), + "@workspace/ui/components": path.resolve( + __dirname, + "../../packages/ui/src/components", + ), + "@workspace/ui/lib": path.resolve( + __dirname, + "../../packages/ui/src/lib", + ), + "@workspace/ui": path.resolve(__dirname, "../../packages/ui/src"), }, }, server: { diff --git a/packages/codex-client/README.md b/packages/codex-client/README.md index c8eebfd..7f5e440 100644 --- a/packages/codex-client/README.md +++ b/packages/codex-client/README.md @@ -19,6 +19,10 @@ This package owns the low-level JSON-RPC client, transports, framework-agnostic - `CodexFlowClient` - `createCodexFlowClient` - prompt/input normalization and optional turn completion waiting +- `@peezy.tech/codex-flows/auth` + - `CodexAuthClient` + - `createCodexAuthClient` + - privacy-preserving Codex account login, status, and usage helpers - `@peezy.tech/codex-flows/rpc` - JSON-RPC message types and parsing helpers - `@peezy.tech/codex-flows/generated` @@ -74,6 +78,31 @@ const result = await codex.startFlow({ console.log(result.threadId, result.turnId); ``` +Auth helpers: + +```ts +import { + CodexAppServerClient, + createCodexAuthClient, +} from "@peezy.tech/codex-flows"; + +const client = new CodexAppServerClient(); +await client.connect(); + +const auth = createCodexAuthClient(client); +const state = await auth.getState(); + +if (state.status !== "authenticated") { + const login = await auth.startChatGptLogin(); + console.log(login.authUrl); +} +``` + +The high-level auth state intentionally omits email addresses and stable account +identifiers. It exposes anonymous auth mode, plan, and usage data by default. +Call the lower-level app-server client directly only when an application has an +explicit reason to handle raw account details. + ## Scripts ```bash diff --git a/packages/codex-client/package.json b/packages/codex-client/package.json index ecf99a6..9e5043a 100644 --- a/packages/codex-client/package.json +++ b/packages/codex-client/package.json @@ -37,6 +37,10 @@ "types": "./dist/app-server/flows.d.ts", "import": "./dist/app-server/flows.js" }, + "./auth": { + "types": "./dist/auth.d.ts", + "import": "./dist/auth.js" + }, "./rpc": { "types": "./dist/app-server/rpc.d.ts", "import": "./dist/app-server/rpc.js" diff --git a/packages/codex-client/scripts/smoke-exports.ts b/packages/codex-client/scripts/smoke-exports.ts index ad128b6..56afe8b 100644 --- a/packages/codex-client/scripts/smoke-exports.ts +++ b/packages/codex-client/scripts/smoke-exports.ts @@ -2,6 +2,7 @@ const checks = [ ["@peezy.tech/codex-flows", ["CodexAppServerClient"]], ["@peezy.tech/codex-flows/browser", ["CodexAppServerClient"]], ["@peezy.tech/codex-flows/flows", ["CodexFlowClient", "createCodexFlowClient"]], + ["@peezy.tech/codex-flows/auth", ["CodexAuthClient", "createCodexAuthClient"]], ["@peezy.tech/codex-flows/rpc", ["JsonRpcError"]], ["@peezy.tech/codex-flows/generated", ["v2"]], ] as const; diff --git a/packages/codex-client/src/app-server/auth.ts b/packages/codex-client/src/app-server/auth.ts new file mode 100644 index 0000000..5c75d6d --- /dev/null +++ b/packages/codex-client/src/app-server/auth.ts @@ -0,0 +1,362 @@ +import type { PlanType } from "./generated/index.ts"; +import type { v2 } from "./generated/index.ts"; +import type { JsonRpcNotification } from "./rpc.ts"; + +export type CodexAuthClientTransport = { + request(method: string, params?: unknown): Promise; + on(event: "notification", listener: (message: JsonRpcNotification) => void): void; + off(event: "notification", listener: (message: JsonRpcNotification) => void): void; +}; + +export type CodexAuthMode = + | "apiKey" + | "chatgpt" + | "chatgptAuthTokens" + | "amazonBedrock" + | "unknown"; + +export type CodexLoginMethod = + | "apiKey" + | "chatgpt" + | "chatgptDeviceCode" + | "chatgptAuthTokens"; + +export type CodexUsageWindow = { + usedPercent: number; + windowDurationMins: number | null; + resetsAt: number | null; +}; + +export type CodexUsageSnapshot = { + limitId: string | null; + limitName: string | null; + primary: CodexUsageWindow | null; + secondary: CodexUsageWindow | null; + credits: v2.CreditsSnapshot | null; + planType: PlanType | null; + rateLimitReachedType: v2.RateLimitReachedType | null; +}; + +export type CodexAuthState = + | { + status: "unauthenticated"; + requiresOpenaiAuth: boolean; + authMode: null; + planType: null; + usage: null; + } + | { + status: "authenticated"; + authMode: CodexAuthMode; + planType: PlanType | null; + usage: CodexUsageSnapshot | null; + } + | { + status: "loginPending"; + method: CodexLoginMethod; + loginId: string; + authMode: null; + planType: null; + usage: null; + } + | { + status: "error"; + message: string; + authMode: null; + planType: null; + usage: null; + }; + +export type CodexChatGptLoginStart = { + type: "chatgpt"; + loginId: string; + authUrl: string; +}; + +export type CodexDeviceCodeLoginStart = { + type: "chatgptDeviceCode"; + loginId: string; + verificationUrl: string; + userCode: string; +}; + +export type CodexApiKeyLoginStart = { + type: "apiKey"; +}; + +export type CodexAuthTokensLoginStart = { + type: "chatgptAuthTokens"; +}; + +export type CodexLoginStart = + | CodexChatGptLoginStart + | CodexDeviceCodeLoginStart + | CodexApiKeyLoginStart + | CodexAuthTokensLoginStart; + +export type CodexAuthChangeEvent = + | { + type: "accountUpdated"; + state: CodexAuthState; + } + | { + type: "loginCompleted"; + loginId: string | null; + success: boolean; + error: string | null; + state: CodexAuthState | null; + }; + +export type WaitForLoginOptions = { + timeoutMs?: number; + refreshState?: boolean; +}; + +export class CodexAuthTimeoutError extends Error { + constructor(message = "Timed out waiting for Codex login to complete") { + super(message); + this.name = "CodexAuthTimeoutError"; + } +} + +export class CodexAuthClient { + readonly transport: CodexAuthClientTransport; + + constructor(transport: CodexAuthClientTransport) { + this.transport = transport; + } + + async getState(): Promise { + try { + const [account, usage] = await Promise.all([ + this.transport.request("account/read", { + refreshToken: false, + }), + this.getUsage().catch(() => null), + ]); + return accountResponseToAuthState(account, usage); + } catch (error) { + return { + status: "error", + message: errorMessage(error), + authMode: null, + planType: null, + usage: null, + }; + } + } + + async getUsage(limitId?: string): Promise { + const response = + await this.transport.request( + "account/rateLimits/read", + ); + const snapshot = + limitId && response.rateLimitsByLimitId + ? response.rateLimitsByLimitId[limitId] ?? response.rateLimits + : response.rateLimits; + return snapshot ? rateLimitSnapshotToUsage(snapshot) : null; + } + + async startChatGptLogin(options: { + codexStreamlinedLogin?: boolean; + } = {}): Promise { + const response = await this.transport.request( + "account/login/start", + { + type: "chatgpt", + codexStreamlinedLogin: options.codexStreamlinedLogin ?? true, + } satisfies v2.LoginAccountParams, + ); + if (response.type !== "chatgpt") { + throw new Error(`Expected chatgpt login response, received ${response.type}`); + } + return response; + } + + async startDeviceCodeLogin(): Promise { + const response = await this.transport.request( + "account/login/start", + { type: "chatgptDeviceCode" } satisfies v2.LoginAccountParams, + ); + if (response.type !== "chatgptDeviceCode") { + throw new Error( + `Expected chatgptDeviceCode login response, received ${response.type}`, + ); + } + return response; + } + + async loginWithApiKey(apiKey: string): Promise { + const response = await this.transport.request( + "account/login/start", + { type: "apiKey", apiKey } satisfies v2.LoginAccountParams, + ); + if (response.type !== "apiKey") { + throw new Error(`Expected apiKey login response, received ${response.type}`); + } + return response; + } + + async loginWithChatGptTokens(params: { + accessToken: string; + chatgptAccountId: string; + chatgptPlanType?: string | null; + }): Promise { + const response = await this.transport.request( + "account/login/start", + { + type: "chatgptAuthTokens", + accessToken: params.accessToken, + chatgptAccountId: params.chatgptAccountId, + chatgptPlanType: params.chatgptPlanType, + } satisfies v2.LoginAccountParams, + ); + if (response.type !== "chatgptAuthTokens") { + throw new Error( + `Expected chatgptAuthTokens login response, received ${response.type}`, + ); + } + return response; + } + + cancelLogin(loginId: string): Promise { + return this.transport.request( + "account/login/cancel", + { loginId } satisfies v2.CancelLoginAccountParams, + ); + } + + logout(): Promise { + return this.transport.request("account/logout"); + } + + onChange(listener: (event: CodexAuthChangeEvent) => void): () => void { + const handleNotification = (message: JsonRpcNotification) => { + if (message.method === "account/updated") { + void this.getState().then((state) => + listener({ type: "accountUpdated", state }), + ); + return; + } + if (message.method === "account/login/completed") { + const params = message.params as Partial; + void this.getState() + .catch(() => null) + .then((state) => + listener({ + type: "loginCompleted", + loginId: typeof params.loginId === "string" ? params.loginId : null, + success: params.success === true, + error: typeof params.error === "string" ? params.error : null, + state, + }), + ); + } + }; + this.transport.on("notification", handleNotification); + return () => this.transport.off("notification", handleNotification); + } + + waitForLogin( + loginId: string, + options: WaitForLoginOptions = {}, + ): Promise { + const timeoutMs = options.timeoutMs ?? 5 * 60_000; + return new Promise((resolve, reject) => { + let settled = false; + let unsubscribe = () => {}; + const timeout = setTimeout(() => { + if (settled) { + return; + } + settled = true; + unsubscribe(); + reject(new CodexAuthTimeoutError()); + }, timeoutMs); + + unsubscribe = this.onChange((event) => { + if ( + event.type !== "loginCompleted" || + (event.loginId && event.loginId !== loginId) + ) { + return; + } + if (settled) { + return; + } + settled = true; + clearTimeout(timeout); + unsubscribe(); + if (!event.success) { + reject(new Error(event.error ?? "Codex login failed")); + return; + } + if (options.refreshState === false && event.state) { + resolve(event.state); + return; + } + this.getState().then(resolve, reject); + }); + }); + } +} + +export function createCodexAuthClient( + transport: CodexAuthClientTransport, +): CodexAuthClient { + return new CodexAuthClient(transport); +} + +export function accountResponseToAuthState( + response: v2.GetAccountResponse, + usage: CodexUsageSnapshot | null = null, +): CodexAuthState { + const account = response.account; + if (!account) { + return { + status: "unauthenticated", + requiresOpenaiAuth: response.requiresOpenaiAuth, + authMode: null, + planType: null, + usage: null, + }; + } + return { + status: "authenticated", + authMode: accountTypeToAuthMode(account.type), + planType: account.type === "chatgpt" ? account.planType : usage?.planType ?? null, + usage, + }; +} + +export function rateLimitSnapshotToUsage( + snapshot: v2.RateLimitSnapshot, +): CodexUsageSnapshot { + return { + limitId: snapshot.limitId, + limitName: snapshot.limitName, + primary: snapshot.primary ? { ...snapshot.primary } : null, + secondary: snapshot.secondary ? { ...snapshot.secondary } : null, + credits: snapshot.credits, + planType: snapshot.planType, + rateLimitReachedType: snapshot.rateLimitReachedType, + }; +} + +function accountTypeToAuthMode(type: v2.Account["type"]): CodexAuthMode { + switch (type) { + case "apiKey": + return "apiKey"; + case "chatgpt": + return "chatgpt"; + case "amazonBedrock": + return "amazonBedrock"; + default: + return "unknown"; + } +} + +function errorMessage(error: unknown): string { + return error instanceof Error ? error.message : String(error); +} diff --git a/packages/codex-client/src/auth.ts b/packages/codex-client/src/auth.ts new file mode 100644 index 0000000..70d3e67 --- /dev/null +++ b/packages/codex-client/src/auth.ts @@ -0,0 +1,22 @@ +export { + CodexAuthClient, + CodexAuthTimeoutError, + accountResponseToAuthState, + createCodexAuthClient, + rateLimitSnapshotToUsage, +} from "./app-server/auth.ts"; +export type { + CodexApiKeyLoginStart, + CodexAuthChangeEvent, + CodexAuthClientTransport, + CodexAuthMode, + CodexAuthState, + CodexAuthTokensLoginStart, + CodexChatGptLoginStart, + CodexDeviceCodeLoginStart, + CodexLoginMethod, + CodexLoginStart, + CodexUsageSnapshot, + CodexUsageWindow, + WaitForLoginOptions, +} from "./app-server/auth.ts"; diff --git a/packages/codex-client/src/browser.ts b/packages/codex-client/src/browser.ts index 89fad39..ef9f536 100644 --- a/packages/codex-client/src/browser.ts +++ b/packages/codex-client/src/browser.ts @@ -23,3 +23,25 @@ export type { JsonRpcResponse, } from "./app-server/rpc.ts"; export type { v2 } from "./app-server/generated/index.ts"; +export { + CodexAuthClient, + CodexAuthTimeoutError, + accountResponseToAuthState, + createCodexAuthClient, + rateLimitSnapshotToUsage, +} from "./app-server/auth.ts"; +export type { + CodexApiKeyLoginStart, + CodexAuthChangeEvent, + CodexAuthClientTransport, + CodexAuthMode, + CodexAuthState, + CodexAuthTokensLoginStart, + CodexChatGptLoginStart, + CodexDeviceCodeLoginStart, + CodexLoginMethod, + CodexLoginStart, + CodexUsageSnapshot, + CodexUsageWindow, + WaitForLoginOptions, +} from "./app-server/auth.ts"; diff --git a/packages/codex-client/src/index.ts b/packages/codex-client/src/index.ts index 22983a9..c841adb 100644 --- a/packages/codex-client/src/index.ts +++ b/packages/codex-client/src/index.ts @@ -27,3 +27,25 @@ export type { JsonRpcRequest, JsonRpcResponse, } from "./app-server/rpc.ts"; +export { + CodexAuthClient, + CodexAuthTimeoutError, + accountResponseToAuthState, + createCodexAuthClient, + rateLimitSnapshotToUsage, +} from "./app-server/auth.ts"; +export type { + CodexApiKeyLoginStart, + CodexAuthChangeEvent, + CodexAuthClientTransport, + CodexAuthMode, + CodexAuthState, + CodexAuthTokensLoginStart, + CodexChatGptLoginStart, + CodexDeviceCodeLoginStart, + CodexLoginMethod, + CodexLoginStart, + CodexUsageSnapshot, + CodexUsageWindow, + WaitForLoginOptions, +} from "./app-server/auth.ts"; diff --git a/packages/codex-client/test/auth.test.ts b/packages/codex-client/test/auth.test.ts new file mode 100644 index 0000000..9d23783 --- /dev/null +++ b/packages/codex-client/test/auth.test.ts @@ -0,0 +1,242 @@ +import { expect, test } from "bun:test"; +import { + CodexAuthClient, + CodexAuthTimeoutError, + accountResponseToAuthState, + type CodexAuthClientTransport, +} from "../src/app-server/auth.ts"; +import type { v2 } from "../src/app-server/generated/index.ts"; +import type { JsonRpcNotification } from "../src/app-server/rpc.ts"; + +test("normalizes authenticated ChatGPT state without exposing email", () => { + const state = accountResponseToAuthState( + { + requiresOpenaiAuth: false, + account: { + type: "chatgpt", + email: "ada@example.com", + planType: "pro", + }, + }, + usageSnapshot(), + ); + + expect(state).toEqual({ + status: "authenticated", + authMode: "chatgpt", + planType: "pro", + usage: usageSnapshot(), + }); + expect(JSON.stringify(state)).not.toContain("ada@example.com"); +}); + +test("normalizes unauthenticated state", () => { + expect( + accountResponseToAuthState({ + requiresOpenaiAuth: true, + account: null, + }), + ).toEqual({ + status: "unauthenticated", + requiresOpenaiAuth: true, + authMode: null, + planType: null, + usage: null, + }); +}); + +test("starts every Codex login flow through account/login/start", async () => { + const fake = new FakeAuthTransport(); + const auth = new CodexAuthClient(fake); + + await expect(auth.startChatGptLogin()).resolves.toEqual({ + type: "chatgpt", + loginId: "login-chatgpt", + authUrl: "https://example.test/auth", + }); + await expect(auth.startDeviceCodeLogin()).resolves.toEqual({ + type: "chatgptDeviceCode", + loginId: "login-device", + verificationUrl: "https://example.test/device", + userCode: "ABCD-EFGH", + }); + await expect(auth.loginWithApiKey("sk-test")).resolves.toEqual({ + type: "apiKey", + }); + await expect( + auth.loginWithChatGptTokens({ + accessToken: "access", + chatgptAccountId: "workspace", + chatgptPlanType: null, + }), + ).resolves.toEqual({ type: "chatgptAuthTokens" }); + + expect(fake.requests).toEqual([ + [ + "account/login/start", + { type: "chatgpt", codexStreamlinedLogin: true }, + ], + ["account/login/start", { type: "chatgptDeviceCode" }], + ["account/login/start", { type: "apiKey", apiKey: "sk-test" }], + [ + "account/login/start", + { + type: "chatgptAuthTokens", + accessToken: "access", + chatgptAccountId: "workspace", + chatgptPlanType: null, + }, + ], + ]); +}); + +test("getState combines anonymous account and usage state", async () => { + const fake = new FakeAuthTransport(); + const auth = new CodexAuthClient(fake); + + const state = await auth.getState(); + + expect(state.status).toBe("authenticated"); + expect(state.authMode).toBe("chatgpt"); + expect(state.planType).toBe("plus"); + expect(state.usage?.primary?.usedPercent).toBe(27); + expect(JSON.stringify(state)).not.toContain("ada@example.com"); +}); + +test("waits for matching login completion", async () => { + const fake = new FakeAuthTransport(); + const auth = new CodexAuthClient(fake); + const pending = auth.waitForLogin("login-chatgpt", { timeoutMs: 1_000 }); + + fake.emit({ + method: "account/login/completed", + params: { + loginId: "other-login", + success: true, + error: null, + }, + }); + fake.emit({ + method: "account/login/completed", + params: { + loginId: "login-chatgpt", + success: true, + error: null, + }, + }); + + await expect(pending).resolves.toMatchObject({ + status: "authenticated", + authMode: "chatgpt", + }); +}); + +test("waitForLogin times out", async () => { + const auth = new CodexAuthClient(new FakeAuthTransport()); + + await expect(auth.waitForLogin("never", { timeoutMs: 1 })).rejects.toBeInstanceOf( + CodexAuthTimeoutError, + ); +}); + +class FakeAuthTransport implements CodexAuthClientTransport { + requests: Array<[string, unknown]> = []; + #listeners = new Set<(message: JsonRpcNotification) => void>(); + + async request(method: string, params?: unknown): Promise { + this.requests.push([method, params]); + if (method === "account/read") { + return { + requiresOpenaiAuth: false, + account: { + type: "chatgpt", + email: "ada@example.com", + planType: "plus", + }, + } satisfies v2.GetAccountResponse as T; + } + if (method === "account/rateLimits/read") { + return { + rateLimits: { + limitId: "codex", + limitName: "Codex", + primary: { + usedPercent: 27, + windowDurationMins: 300, + resetsAt: 1778611200, + }, + secondary: null, + credits: null, + planType: "plus", + rateLimitReachedType: null, + }, + rateLimitsByLimitId: null, + } satisfies v2.GetAccountRateLimitsResponse as T; + } + if (method === "account/login/start") { + const login = params as v2.LoginAccountParams; + switch (login.type) { + case "chatgpt": + return { + type: "chatgpt", + loginId: "login-chatgpt", + authUrl: "https://example.test/auth", + } satisfies v2.LoginAccountResponse as T; + case "chatgptDeviceCode": + return { + type: "chatgptDeviceCode", + loginId: "login-device", + verificationUrl: "https://example.test/device", + userCode: "ABCD-EFGH", + } satisfies v2.LoginAccountResponse as T; + case "apiKey": + return { type: "apiKey" } satisfies v2.LoginAccountResponse as T; + case "chatgptAuthTokens": + return { + type: "chatgptAuthTokens", + } satisfies v2.LoginAccountResponse as T; + } + } + throw new Error(`Unexpected request ${method}`); + } + + on( + event: "notification", + listener: (message: JsonRpcNotification) => void, + ): void { + if (event === "notification") { + this.#listeners.add(listener); + } + } + + off( + event: "notification", + listener: (message: JsonRpcNotification) => void, + ): void { + if (event === "notification") { + this.#listeners.delete(listener); + } + } + + emit(message: JsonRpcNotification): void { + for (const listener of this.#listeners) { + listener(message); + } + } +} + +function usageSnapshot() { + return { + limitId: "codex", + limitName: "Codex", + primary: { + usedPercent: 27, + windowDurationMins: 300, + resetsAt: 1778611200, + }, + secondary: null, + credits: null, + planType: "plus", + rateLimitReachedType: null, + } satisfies v2.RateLimitSnapshot; +}