auth flow

This commit is contained in:
matamune 2026-05-12 19:32:18 +00:00
parent 6e26e4bf09
commit 647e0d17da
Signed by: matamune
GPG key ID: 3BB8E7D3B968A324
10 changed files with 1005 additions and 36 deletions

View file

@ -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<CodexAppServerClient | null>(null);
const authRef = useRef<CodexAuthClient | null>(null);
const [wsUrl, setWsUrl] = useState(initialWsUrl);
const [connectedUrl, setConnectedUrl] = useState<string>();
const [status, setStatus] = useState<ConnectionStatus>("disconnected");
@ -60,7 +67,7 @@ function BareCodexApp() {
const [threads, setThreads] = useState<v2.Thread[]>([]);
const [selectedThreadId, setSelectedThreadId] = useState<string>();
const [selectedThread, setSelectedThread] = useState<v2.Thread>();
const [account, setAccount] = useState<v2.GetAccountResponse>();
const [authState, setAuthState] = useState<CodexAuthState>();
const [prompt, setPrompt] = useState("");
const [cwd, setCwd] = useState("");
const [eventLog, setEventLog] = useState<EventLogEntry[]>([]);
@ -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() {
</Panel>
<Panel title="Account">
<dl className="grid gap-2 text-sm">
<Meta label="Status" value={statusLabel(status)} />
<Meta
label="Mode"
value={accountMode(account) ?? (connected ? "unknown" : "offline")}
/>
<Meta
label="Plan"
value={accountPlan(account) ?? "unknown"}
/>
</dl>
<div className="space-y-3">
<dl className="grid gap-2 text-sm">
<Meta label="Connection" value={statusLabel(status)} />
<Meta label="Auth" value={authStatusLabel(authState, connected)} />
<Meta label="Mode" value={authState?.authMode ?? "none"} />
<Meta label="Plan" value={authState?.planType ?? "unknown"} />
<Meta label="Usage" value={usageLabel(authState)} />
</dl>
<div className="grid gap-2">
<Button
className="w-full"
disabled={!connected || busyAction === "auth"}
onClick={() => void startChatGptLogin()}
size="sm"
type="button"
>
<ExternalLink className="size-4" />
ChatGPT Login
</Button>
<div className="grid grid-cols-3 gap-2">
<Button
disabled={!connected || busyAction === "auth"}
onClick={() => void startDeviceCodeLogin()}
size="sm"
type="button"
variant="outline"
>
<KeyRound className="size-4" />
Device
</Button>
<Button
disabled={!connected || busyAction === "auth"}
onClick={() => void loginWithApiKey()}
size="sm"
type="button"
variant="outline"
>
<KeyRound className="size-4" />
API Key
</Button>
<Button
disabled={!connected || busyAction === "auth"}
onClick={() => void loginWithChatGptTokens()}
size="sm"
type="button"
variant="outline"
>
<KeyRound className="size-4" />
Tokens
</Button>
</div>
<Button
disabled={!connected || busyAction === "auth"}
onClick={() => void logout()}
size="sm"
type="button"
variant="ghost"
>
<LogOut className="size-4" />
Sign Out
</Button>
</div>
</div>
</Panel>
</aside>
@ -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";

View file

@ -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: {

View file

@ -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

View file

@ -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"

View file

@ -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;

View file

@ -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<T = unknown>(method: string, params?: unknown): Promise<T>;
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<CodexAuthState> {
try {
const [account, usage] = await Promise.all([
this.transport.request<v2.GetAccountResponse>("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<CodexUsageSnapshot | null> {
const response =
await this.transport.request<v2.GetAccountRateLimitsResponse>(
"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<CodexChatGptLoginStart> {
const response = await this.transport.request<v2.LoginAccountResponse>(
"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<CodexDeviceCodeLoginStart> {
const response = await this.transport.request<v2.LoginAccountResponse>(
"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<CodexApiKeyLoginStart> {
const response = await this.transport.request<v2.LoginAccountResponse>(
"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<CodexAuthTokensLoginStart> {
const response = await this.transport.request<v2.LoginAccountResponse>(
"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<v2.CancelLoginAccountResponse> {
return this.transport.request<v2.CancelLoginAccountResponse>(
"account/login/cancel",
{ loginId } satisfies v2.CancelLoginAccountParams,
);
}
logout(): Promise<v2.LogoutAccountResponse> {
return this.transport.request<v2.LogoutAccountResponse>("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<v2.AccountLoginCompletedNotification>;
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<CodexAuthState> {
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);
}

View file

@ -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";

View file

@ -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";

View file

@ -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";

View file

@ -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<T = unknown>(method: string, params?: unknown): Promise<T> {
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;
}