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 {
|
import {
|
||||||
AlertCircle,
|
AlertCircle,
|
||||||
Copy,
|
Copy,
|
||||||
|
ExternalLink,
|
||||||
|
KeyRound,
|
||||||
Loader2,
|
Loader2,
|
||||||
|
LogOut,
|
||||||
Plug,
|
Plug,
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
Send,
|
Send,
|
||||||
|
|
@ -23,6 +26,9 @@ import {
|
||||||
import {
|
import {
|
||||||
CodexAppServerClient,
|
CodexAppServerClient,
|
||||||
JsonRpcError,
|
JsonRpcError,
|
||||||
|
createCodexAuthClient,
|
||||||
|
type CodexAuthClient,
|
||||||
|
type CodexAuthState,
|
||||||
type JsonRpcNotification,
|
type JsonRpcNotification,
|
||||||
type JsonRpcRequest,
|
type JsonRpcRequest,
|
||||||
type v2,
|
type v2,
|
||||||
|
|
@ -53,6 +59,7 @@ export function App() {
|
||||||
|
|
||||||
function BareCodexApp() {
|
function BareCodexApp() {
|
||||||
const clientRef = useRef<CodexAppServerClient | null>(null);
|
const clientRef = useRef<CodexAppServerClient | null>(null);
|
||||||
|
const authRef = useRef<CodexAuthClient | null>(null);
|
||||||
const [wsUrl, setWsUrl] = useState(initialWsUrl);
|
const [wsUrl, setWsUrl] = useState(initialWsUrl);
|
||||||
const [connectedUrl, setConnectedUrl] = useState<string>();
|
const [connectedUrl, setConnectedUrl] = useState<string>();
|
||||||
const [status, setStatus] = useState<ConnectionStatus>("disconnected");
|
const [status, setStatus] = useState<ConnectionStatus>("disconnected");
|
||||||
|
|
@ -60,7 +67,7 @@ function BareCodexApp() {
|
||||||
const [threads, setThreads] = useState<v2.Thread[]>([]);
|
const [threads, setThreads] = useState<v2.Thread[]>([]);
|
||||||
const [selectedThreadId, setSelectedThreadId] = useState<string>();
|
const [selectedThreadId, setSelectedThreadId] = useState<string>();
|
||||||
const [selectedThread, setSelectedThread] = useState<v2.Thread>();
|
const [selectedThread, setSelectedThread] = useState<v2.Thread>();
|
||||||
const [account, setAccount] = useState<v2.GetAccountResponse>();
|
const [authState, setAuthState] = useState<CodexAuthState>();
|
||||||
const [prompt, setPrompt] = useState("");
|
const [prompt, setPrompt] = useState("");
|
||||||
const [cwd, setCwd] = useState("");
|
const [cwd, setCwd] = useState("");
|
||||||
const [eventLog, setEventLog] = useState<EventLogEntry[]>([]);
|
const [eventLog, setEventLog] = useState<EventLogEntry[]>([]);
|
||||||
|
|
@ -116,14 +123,14 @@ function BareCodexApp() {
|
||||||
[readThread, selectedThreadId],
|
[readThread, selectedThreadId],
|
||||||
);
|
);
|
||||||
|
|
||||||
const refreshAccount = useCallback(async (client = clientRef.current) => {
|
const refreshAuthState = useCallback(async (auth = authRef.current) => {
|
||||||
if (!client) {
|
if (!auth) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
setAccount(await client.getAccount({ refreshToken: false }));
|
setAuthState(await auth.getState());
|
||||||
} catch {
|
} catch {
|
||||||
setAccount(undefined);
|
setAuthState(undefined);
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
|
@ -136,7 +143,7 @@ function BareCodexApp() {
|
||||||
try {
|
try {
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
refreshThreads(client),
|
refreshThreads(client),
|
||||||
refreshAccount(client),
|
refreshAuthState(),
|
||||||
selectedThreadId ? readThread(selectedThreadId, client) : undefined,
|
selectedThreadId ? readThread(selectedThreadId, client) : undefined,
|
||||||
]);
|
]);
|
||||||
} catch (refreshError) {
|
} catch (refreshError) {
|
||||||
|
|
@ -144,15 +151,23 @@ function BareCodexApp() {
|
||||||
} finally {
|
} finally {
|
||||||
setBusyAction(undefined);
|
setBusyAction(undefined);
|
||||||
}
|
}
|
||||||
}, [readThread, refreshAccount, refreshThreads, selectedThreadId]);
|
}, [readThread, refreshAuthState, refreshThreads, selectedThreadId]);
|
||||||
|
|
||||||
const handleNotification = useCallback(
|
const handleNotification = useCallback(
|
||||||
(message: JsonRpcNotification) => {
|
(message: JsonRpcNotification) => {
|
||||||
appendEvent({
|
appendEvent({
|
||||||
kind: "notification",
|
kind: "notification",
|
||||||
title: message.method,
|
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);
|
const threadId = notificationThreadId(message);
|
||||||
if (threadId) {
|
if (threadId) {
|
||||||
if (!selectedThreadId || selectedThreadId === threadId) {
|
if (!selectedThreadId || selectedThreadId === threadId) {
|
||||||
|
|
@ -166,7 +181,13 @@ function BareCodexApp() {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[appendEvent, readThread, refreshThreads, selectedThreadId],
|
[
|
||||||
|
appendEvent,
|
||||||
|
readThread,
|
||||||
|
refreshAuthState,
|
||||||
|
refreshThreads,
|
||||||
|
selectedThreadId,
|
||||||
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
const connect = useCallback(async () => {
|
const connect = useCallback(async () => {
|
||||||
|
|
@ -185,6 +206,8 @@ function BareCodexApp() {
|
||||||
clientVersion: "0.1.0",
|
clientVersion: "0.1.0",
|
||||||
});
|
});
|
||||||
clientRef.current = client;
|
clientRef.current = client;
|
||||||
|
const auth = createCodexAuthClient(client);
|
||||||
|
authRef.current = auth;
|
||||||
client.on("notification", handleNotification);
|
client.on("notification", handleNotification);
|
||||||
client.on("request", (message: JsonRpcRequest) => {
|
client.on("request", (message: JsonRpcRequest) => {
|
||||||
appendEvent({
|
appendEvent({
|
||||||
|
|
@ -222,7 +245,7 @@ function BareCodexApp() {
|
||||||
setConnectedUrl(url);
|
setConnectedUrl(url);
|
||||||
setStatus("connected");
|
setStatus("connected");
|
||||||
appendEvent({ kind: "control", title: "connected", body: url });
|
appendEvent({ kind: "control", title: "connected", body: url });
|
||||||
await Promise.all([refreshThreads(client), refreshAccount(client)]);
|
await Promise.all([refreshThreads(client), refreshAuthState(auth)]);
|
||||||
} catch (connectError) {
|
} catch (connectError) {
|
||||||
if (clientRef.current === client) {
|
if (clientRef.current === client) {
|
||||||
clientRef.current = null;
|
clientRef.current = null;
|
||||||
|
|
@ -235,7 +258,7 @@ function BareCodexApp() {
|
||||||
}, [
|
}, [
|
||||||
appendEvent,
|
appendEvent,
|
||||||
handleNotification,
|
handleNotification,
|
||||||
refreshAccount,
|
refreshAuthState,
|
||||||
refreshThreads,
|
refreshThreads,
|
||||||
wsUrl,
|
wsUrl,
|
||||||
]);
|
]);
|
||||||
|
|
@ -243,8 +266,10 @@ function BareCodexApp() {
|
||||||
const disconnect = useCallback(() => {
|
const disconnect = useCallback(() => {
|
||||||
clientRef.current?.close();
|
clientRef.current?.close();
|
||||||
clientRef.current = null;
|
clientRef.current = null;
|
||||||
|
authRef.current = null;
|
||||||
setConnectedUrl(undefined);
|
setConnectedUrl(undefined);
|
||||||
setStatus("disconnected");
|
setStatus("disconnected");
|
||||||
|
setAuthState(undefined);
|
||||||
appendEvent({ kind: "control", title: "disconnected" });
|
appendEvent({ kind: "control", title: "disconnected" });
|
||||||
}, [appendEvent]);
|
}, [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(
|
const selectedItems = useMemo(
|
||||||
() => selectedThread?.turns.flatMap((turn) => turn.items) ?? [],
|
() => selectedThread?.turns.flatMap((turn) => turn.items) ?? [],
|
||||||
[selectedThread],
|
[selectedThread],
|
||||||
|
|
@ -456,17 +609,69 @@ function BareCodexApp() {
|
||||||
</Panel>
|
</Panel>
|
||||||
|
|
||||||
<Panel title="Account">
|
<Panel title="Account">
|
||||||
|
<div className="space-y-3">
|
||||||
<dl className="grid gap-2 text-sm">
|
<dl className="grid gap-2 text-sm">
|
||||||
<Meta label="Status" value={statusLabel(status)} />
|
<Meta label="Connection" value={statusLabel(status)} />
|
||||||
<Meta
|
<Meta label="Auth" value={authStatusLabel(authState, connected)} />
|
||||||
label="Mode"
|
<Meta label="Mode" value={authState?.authMode ?? "none"} />
|
||||||
value={accountMode(account) ?? (connected ? "unknown" : "offline")}
|
<Meta label="Plan" value={authState?.planType ?? "unknown"} />
|
||||||
/>
|
<Meta label="Usage" value={usageLabel(authState)} />
|
||||||
<Meta
|
|
||||||
label="Plan"
|
|
||||||
value={accountPlan(account) ?? "unknown"}
|
|
||||||
/>
|
|
||||||
</dl>
|
</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>
|
</Panel>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
|
|
@ -799,20 +1004,22 @@ function notificationThreadId(message: JsonRpcNotification) {
|
||||||
return stringValue(thread.id);
|
return stringValue(thread.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
function accountMode(account: v2.GetAccountResponse | undefined) {
|
function previewNotificationParams(message: JsonRpcNotification) {
|
||||||
const value = account as unknown;
|
if (message.method === "account/login/completed") {
|
||||||
const item = record(value);
|
const params = record(message.params);
|
||||||
return (
|
return previewJson(
|
||||||
stringValue(item.authMode) ??
|
{
|
||||||
stringValue(record(item.account).type) ??
|
loginId: stringValue(params.loginId),
|
||||||
stringValue(record(item.account).authMode)
|
success: params.success === true,
|
||||||
|
error: stringValue(params.error),
|
||||||
|
},
|
||||||
|
900,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
if (message.method === "account/updated") {
|
||||||
function accountPlan(account: v2.GetAccountResponse | undefined) {
|
return previewJson({ account: "updated" }, 900);
|
||||||
const value = account as unknown;
|
}
|
||||||
const item = record(value);
|
return previewJson(message.params, 900);
|
||||||
return stringValue(item.planType) ?? stringValue(record(item.account).planType);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function optionalText(value: string) {
|
function optionalText(value: string) {
|
||||||
|
|
@ -820,6 +1027,43 @@ function optionalText(value: string) {
|
||||||
return trimmed ? trimmed : null;
|
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) {
|
function compactPath(path: string | undefined) {
|
||||||
if (!path) {
|
if (!path) {
|
||||||
return "none";
|
return "none";
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,27 @@ export default defineConfig({
|
||||||
resolve: {
|
resolve: {
|
||||||
alias: {
|
alias: {
|
||||||
"@": path.resolve(__dirname, "./src"),
|
"@": 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: {
|
server: {
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,10 @@ This package owns the low-level JSON-RPC client, transports, framework-agnostic
|
||||||
- `CodexFlowClient`
|
- `CodexFlowClient`
|
||||||
- `createCodexFlowClient`
|
- `createCodexFlowClient`
|
||||||
- prompt/input normalization and optional turn completion waiting
|
- 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`
|
- `@peezy.tech/codex-flows/rpc`
|
||||||
- JSON-RPC message types and parsing helpers
|
- JSON-RPC message types and parsing helpers
|
||||||
- `@peezy.tech/codex-flows/generated`
|
- `@peezy.tech/codex-flows/generated`
|
||||||
|
|
@ -74,6 +78,31 @@ const result = await codex.startFlow({
|
||||||
console.log(result.threadId, result.turnId);
|
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
|
## Scripts
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
|
|
|
||||||
|
|
@ -37,6 +37,10 @@
|
||||||
"types": "./dist/app-server/flows.d.ts",
|
"types": "./dist/app-server/flows.d.ts",
|
||||||
"import": "./dist/app-server/flows.js"
|
"import": "./dist/app-server/flows.js"
|
||||||
},
|
},
|
||||||
|
"./auth": {
|
||||||
|
"types": "./dist/auth.d.ts",
|
||||||
|
"import": "./dist/auth.js"
|
||||||
|
},
|
||||||
"./rpc": {
|
"./rpc": {
|
||||||
"types": "./dist/app-server/rpc.d.ts",
|
"types": "./dist/app-server/rpc.d.ts",
|
||||||
"import": "./dist/app-server/rpc.js"
|
"import": "./dist/app-server/rpc.js"
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ const checks = [
|
||||||
["@peezy.tech/codex-flows", ["CodexAppServerClient"]],
|
["@peezy.tech/codex-flows", ["CodexAppServerClient"]],
|
||||||
["@peezy.tech/codex-flows/browser", ["CodexAppServerClient"]],
|
["@peezy.tech/codex-flows/browser", ["CodexAppServerClient"]],
|
||||||
["@peezy.tech/codex-flows/flows", ["CodexFlowClient", "createCodexFlowClient"]],
|
["@peezy.tech/codex-flows/flows", ["CodexFlowClient", "createCodexFlowClient"]],
|
||||||
|
["@peezy.tech/codex-flows/auth", ["CodexAuthClient", "createCodexAuthClient"]],
|
||||||
["@peezy.tech/codex-flows/rpc", ["JsonRpcError"]],
|
["@peezy.tech/codex-flows/rpc", ["JsonRpcError"]],
|
||||||
["@peezy.tech/codex-flows/generated", ["v2"]],
|
["@peezy.tech/codex-flows/generated", ["v2"]],
|
||||||
] as const;
|
] 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,
|
JsonRpcResponse,
|
||||||
} from "./app-server/rpc.ts";
|
} from "./app-server/rpc.ts";
|
||||||
export type { v2 } from "./app-server/generated/index.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,
|
JsonRpcRequest,
|
||||||
JsonRpcResponse,
|
JsonRpcResponse,
|
||||||
} from "./app-server/rpc.ts";
|
} 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