Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion src/cli/account-auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ function writeStdoutFully(text: string): void {
const USAGE = `Usage:
ocx account login <provider> [--id <account-id>] [--reauth] [--device] [--code -] [--no-wait] [--json]
ocx account code <provider> [--flow <flow-id>] [--json] (reads the code from stdin)
ocx account cancel <provider> [--flow <flow-id>] [--json]
ocx account cancel <provider> [--flow <flow-id>] [--json] (--flow is required for codex)
ocx account reset-credits <account-id|main> [--consume --yes [--operation-id <uuid>]] [--json]
ocx account grok-reset-coupons [<account-id>] [--consume --yes [--token-id <token-id>] [--operation-id <uuid>]] [--json]

Expand Down Expand Up @@ -266,6 +266,9 @@ async function cancel(argv: string[], deps: RuntimeApiDeps): Promise<void> {
if (!provider) throw new CliUsageError("provider is required", USAGE);
rejectArgs(args, USAGE);
const codex = CODEX_NAMES.has(provider);
if (codex && !flowId) {
throw new CliUsageError("Codex login cancel requires --flow <flow-id> (printed by 'ocx account login').", USAGE);
}
const result = await runtimeRequest(codex ? "/api/codex-auth/login/cancel" : "/api/oauth/login/cancel", {
method: "POST",
body: JSON.stringify(codex ? { flowId } : { provider }),
Expand Down
15 changes: 11 additions & 4 deletions src/codex/auth-api/login-flow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -216,7 +216,7 @@ export async function handleCodexAuthLoginStart(req: Request, config: OcxConfig,
const result = await startLoginFlow("chatgpt", {
forceLogin: true,
...(useDeviceFlow ? { flow: "device" as const } : {}),
});
}, { flowId });

// Open the browser server-side (same pattern as /api/oauth/login in management-api.ts).
// The GUI's window.open is popup-blocked because it runs after an await, not a direct click.
Expand Down Expand Up @@ -527,10 +527,17 @@ export async function handleCodexAuthLoginCode(req: Request): Promise<Response>
}

export async function handleCodexAuthLoginCancel(req: Request): Promise<Response> {
const body = (await req.json().catch(() => ({}))) as { flowId?: string };
const body = (await req.json().catch(() => ({}))) as { flowId?: unknown };
const flowId = typeof body.flowId === "string" ? body.flowId.trim() : "";
if (!flowId) return jsonResponse({ error: "flowId required" }, 400);
const { cancelLoginFlow } = await import("../../oauth");
const cancelled = cancelLoginFlow("chatgpt");
expireCodexAuthFlow(body.flowId ?? null);
// Import may yield; validate afterwards so a stale modal cannot cancel a replacement flow.
const flow = codexAuthLoginState.get(flowId);
if (!flow || flow.status !== "pending") {
return jsonResponse({ error: "login flow expired or unknown" }, 400);
}
const cancelled = cancelLoginFlow("chatgpt", flowId);
expireCodexAuthFlow(flowId);
return jsonResponse({ ok: true, cancelled });
}

Expand Down
19 changes: 10 additions & 9 deletions src/oauth/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -177,6 +177,7 @@ export interface LoginOpts {
}

export interface LoginFlowLifecycle {
flowId?: string;
/** Runs after background credential/config persistence settles, before status becomes done. */
onSettled?: () => void | Promise<void>;
}
Expand Down Expand Up @@ -1711,7 +1712,7 @@ export async function runLogin(
* submitManualLoginCode(), which feeds OAuthController.onManualCodeInput.
*/
const loginState = new Map<string, { error?: string; done: boolean }>();
const loginAbort = new Map<string, AbortController>();
const loginAbort = new Map<string, { controller: AbortController; flowId?: string }>();
const kiroLoginSettling = new Set<string>();

/** Pending paste for a login in progress: either a waiter or a stashed early submission. */
Expand Down Expand Up @@ -1894,17 +1895,17 @@ export function oauthLoginSummary(maskEmails = true): Array<{ provider: string;
}

export function clearLoginState(provider: string): void {
loginAbort.get(provider)?.abort("cleared");
loginAbort.get(provider)?.controller.abort("cleared");
loginAbort.delete(provider);
clearManualCodeSlot(provider);
loginState.delete(provider);
}

export function cancelLoginFlow(provider: string): boolean {
const ctrl = loginAbort.get(provider);
export function cancelLoginFlow(provider: string, flowId?: string): boolean {
const active = loginAbort.get(provider);
const existing = loginState.get(provider);
if (!ctrl && (!existing || existing.done)) return false;
ctrl?.abort("cancelled");
if ((flowId !== undefined && active?.flowId !== flowId) || (!active && (!existing || existing.done))) return false;
active?.controller.abort("cancelled");
loginAbort.delete(provider);
clearManualCodeSlot(provider);
loginState.set(provider, { done: true, error: "Login cancelled" });
Expand All @@ -1925,7 +1926,7 @@ export async function startLoginFlow(
clearManualCodeSlot(provider);
loginState.set(provider, { done: false });
const abort = new AbortController();
loginAbort.set(provider, abort);
loginAbort.set(provider, { controller: abort, flowId: lifecycle?.flowId });
if (provider === "kiro") kiroLoginSettling.add(provider);
return new Promise((resolve, reject) => {
let urlResolved = false;
Expand All @@ -1940,7 +1941,7 @@ export async function startLoginFlow(
signal: abort.signal,
};
const abandonIfNotOwner = (error?: unknown): boolean => {
if (loginAbort.get(provider) === abort) return false;
if (loginAbort.get(provider)?.controller === abort) return false;
if (!urlResolved) reject(error ?? new Error("OAuth login was superseded"));
return true;
};
Expand Down Expand Up @@ -1978,7 +1979,7 @@ export async function startLoginFlow(
// Background: runLogin persists the credential + provider entry to disk. The lifecycle hook
// lets a long-lived server config adopt that settled state before clients observe done=true.
const assertCurrentOwner = (): void => {
if (loginAbort.get(provider) !== abort) throw new OAuthLoginSupersededError();
if (loginAbort.get(provider)?.controller !== abort) throw new OAuthLoginSupersededError();
};
void runLogin(provider, ctrl, opts, { assertCurrentOwner }).then(
() => settle(),
Expand Down
11 changes: 11 additions & 0 deletions tests/cli/cli-account.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1701,6 +1701,17 @@ describe("ocx account CLI (issue #180 matrix)", () => {
expect(result.output).not.toContain("--flow");
});

test("codex cancel without --flow is a usage error, not a server 400", async () => {
// The Codex route requires a flowId; sending { flowId: undefined } used
// to surface as a bare 400. Fail fast with the flag the login printed.
const before = requests.length;
const result = await run(["cancel", "chatgpt"]);

expect(result.code).toBe(2);
expect(result.stderr).toContain("--flow");
expect(requests).toHaveLength(before);
});

test("an empty pipe is a usage error, not an empty credential POST", async () => {
const before = requests.length;
const result = await run(
Expand Down
42 changes: 27 additions & 15 deletions tests/codex-integration/codex-auth-api.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4807,21 +4807,6 @@ describe("codex-auth API", () => {
}
});

test("POST /api/codex-auth/login/cancel expires the pending flow", async () => {
const flowId = "flow-cancel-test";
const req = new Request("http://localhost/api/codex-auth/login/cancel", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ flowId }),
});
const resp = await handleCodexAuthAPI(req, new URL(req.url), {} as any);
expect(resp!.status).toBe(200);
const statusReq = new Request(`http://localhost/api/codex-auth/login-status?flowId=${flowId}`, { method: "GET" });
const statusResp = await handleCodexAuthAPI(statusReq, new URL(statusReq.url), {} as any);
const data = await statusResp!.json() as { status: string; error?: string };
expect(data).toMatchObject({ status: "error", error: "Login cancelled" });
});

describe("POST /api/codex-auth/login/code", () => {
async function startPendingFlow() {
const oauth = await import("../../src/oauth");
Expand Down Expand Up @@ -4864,6 +4849,33 @@ describe("codex-auth API", () => {
});
}

test("cancels only the OAuth attempt owned by the pending flow", async () => {
const flow = await startPendingFlow();
const cancelSpy = spyOn(flow.oauth, "cancelLoginFlow").mockReturnValue(true);
try {
const staleReq = new Request("http://localhost/api/codex-auth/login/cancel", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ flowId: "flow-from-old-modal" }),
});
const staleResp = await handleCodexAuthAPI(staleReq, new URL(staleReq.url), makeConfig());
expect(staleResp!.status).toBe(400);
expect(cancelSpy).not.toHaveBeenCalled();

const req = new Request("http://localhost/api/codex-auth/login/cancel", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ flowId: flow.flowId }),
});
const resp = await handleCodexAuthAPI(req, new URL(req.url), makeConfig());
expect(resp!.status).toBe(200);
expect(cancelSpy).toHaveBeenCalledWith("chatgpt", flow.flowId);
} finally {
cancelSpy.mockRestore();
await flow.cleanup();
}
});

test("accepts a manual code only for the pending flow without reflecting it", async () => {
const flow = await startPendingFlow();
const pasted = "http://localhost:1455/auth/callback?code=secret-code&state=expected";
Expand Down
Loading