auth flow
This commit is contained in:
parent
6e26e4bf09
commit
647e0d17da
10 changed files with 1005 additions and 36 deletions
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
362
packages/codex-client/src/app-server/auth.ts
Normal file
362
packages/codex-client/src/app-server/auth.ts
Normal 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);
|
||||
}
|
||||
22
packages/codex-client/src/auth.ts
Normal file
22
packages/codex-client/src/auth.ts
Normal 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";
|
||||
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
242
packages/codex-client/test/auth.test.ts
Normal file
242
packages/codex-client/test/auth.test.ts
Normal 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;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue