From 67f673b227f8b76d9a947d96f1e7b510f58be600 Mon Sep 17 00:00:00 2001 From: Federico Jaramillo Martinez Date: Sat, 18 Jul 2026 20:55:28 +0200 Subject: [PATCH] fix(auth): cancel stale started flows Best-effort cancel a running auth flow when its start response arrives after the browser operation was closed or superseded, so sessiond does not retain orphaned provider polling or callback listeners.\n\nRefs #72 --- .changeset/cancel-stale-auth-flows.md | 5 ++++ .../src/controllers/authController.test.ts | 28 +++++++++++++++++++ src/client/src/controllers/authController.ts | 13 ++++++++- 3 files changed, 45 insertions(+), 1 deletion(-) create mode 100644 .changeset/cancel-stale-auth-flows.md diff --git a/.changeset/cancel-stale-auth-flows.md b/.changeset/cancel-stale-auth-flows.md new file mode 100644 index 0000000..02aad37 --- /dev/null +++ b/.changeset/cancel-stale-auth-flows.md @@ -0,0 +1,5 @@ +--- +"@jmfederico/pi-web": patch +--- + +Cancel auth flows created after their browser start operation becomes stale, preventing abandoned provider polling or callback listeners after the dialog closes. diff --git a/src/client/src/controllers/authController.test.ts b/src/client/src/controllers/authController.test.ts index 05999d5..c25f165 100644 --- a/src/client/src/controllers/authController.test.ts +++ b/src/client/src/controllers/authController.test.ts @@ -191,6 +191,34 @@ describe("AuthController", () => { expect(getState().authDialog).toBeUndefined(); }); + it("best-effort cancels a running flow whose start response arrives after the dialog closes", async () => { + const start = deferred(); + const provider: AuthProviderOption = { ...authProvider("amazon-bedrock", "api_key"), loginFlow: "interactive" }; + const cancelCalls: { flowId: string; machineId: string | undefined }[] = []; + const { controller, getState } = createController( + { + selectedMachine: remoteMachine("remote-1"), + authDialog: { step: "providers", mode: "login", authType: "api_key", providers: [provider] }, + }, + { + startInteractiveApiKeyLogin: () => start.promise, + cancelOAuthFlow: (flowId, machineId) => { + cancelCalls.push({ flowId, machineId }); + return Promise.reject(new Error("Cancel unavailable")); + }, + }, + ); + + const pendingStart = controller.selectLoginProvider(provider.id, provider.authType); + controller.closeDialog(); + start.resolve(oauthFlow({ flowId: "stale-flow", providerId: provider.id, providerName: provider.name })); + await pendingStart; + + expect(cancelCalls).toEqual([{ flowId: "stale-flow", machineId: "remote-1" }]); + expect(getState().authDialog).toBeUndefined(); + expect(getState().error).toBe(""); + }); + it("does not let a stale OAuth response overwrite a newer flow", async () => { vi.stubGlobal("window", { setInterval: () => 1, clearInterval: () => undefined }); const oldPrompt = { requestId: "request-1", message: "Paste callback", kind: "manual" } as const; diff --git a/src/client/src/controllers/authController.ts b/src/client/src/controllers/authController.ts index d9e2ba6..105f202 100644 --- a/src/client/src/controllers/authController.ts +++ b/src/client/src/controllers/authController.ts @@ -205,7 +205,18 @@ export class AuthController { const flow = provider.authType === "oauth" ? await this.api.startOAuthLogin(provider.id, machineId) : await this.api.startInteractiveApiKeyLogin(provider.id, machineId); - if (operationGeneration !== this.oauthOperationGeneration) return; + if (operationGeneration !== this.oauthOperationGeneration) { + // Sessiond has already allocated this flow. Do not orphan its timer, + // provider polling, or callback listener when the UI operation is stale. + if (flow.status === "running") { + try { + await this.api.cancelOAuthFlow(flow.flowId, machineId); + } catch { + // Best-effort cleanup; the obsolete flow must not restore UI state. + } + } + return; + } this.updateOAuthFlow(flow); if (flow.status === "running") this.startPolling(flow.flowId); } catch (error) {