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
This commit is contained in:
Federico Jaramillo Martinez
2026-07-18 20:55:28 +02:00
parent bd99c44286
commit 67f673b227
3 changed files with 45 additions and 1 deletions
+5
View File
@@ -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.
@@ -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<OAuthFlowState>();
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;
+12 -1
View File
@@ -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) {