From af30626676f37db010e2935241ddf563805ceafa Mon Sep 17 00:00:00 2001 From: lihbr Date: Fri, 13 Nov 2026 12:08:52 +0900 Subject: [PATCH] fix(auth): don't call a login cancelled when Ctrl-C lands after it saved saveLogin() holds the credentials lock while it writes the new login and revokes the previous session. A Ctrl-C in that window waited for the lock, found the pending login already replaced, and still printed "Login cancelled." with telemetry "cancelled", although the login was saved. The handler now checks whether the login saved and says so. Co-Authored-By: Claude Opus 5.5 --- src/auth.ts | 18 +++++++++++++++--- test/auth0.ts | 4 +++- test/login.test.ts | 17 +++++++++++++++++ 3 files changed, 35 insertions(+), 4 deletions(-) diff --git a/src/auth.ts b/src/auth.ts index 2670e5ed..66503b6a 100644 --- a/src/auth.ts +++ b/src/auth.ts @@ -58,6 +58,8 @@ type Login = { pending?: PendingLogin; /** Set on Ctrl-C, so its cleanup doesn't read as a logout. */ cancelled?: boolean; + /** Set once the login is saved, so a later Ctrl-C doesn't call it cancelled. */ + saved?: SavedLogin; }; type SavedLogin = Pick & { token: string }; @@ -214,9 +216,18 @@ export async function logIn(options: { browser?: boolean } = {}): Promise { login.cancelled = true; console.error(); + // Waits for a save in progress, which holds the lock. if (login.pending) await setPendingLogin(login.pending, undefined); - recordLogin({ result: "cancelled", resumed: login.resumed, startedAt: login.startedAt }); - await endCommand("Login cancelled.", 130); + const { resumed, startedAt, saved } = login; + const result = saved ? "success" : "cancelled"; + recordLogin({ + result, + resumed, + startedAt, + store: saved?.store, + storeReason: saved?.storeReason, + }); + await endCommand(saved ? "Logged in to Prismic." : "Login cancelled.", 130); }); let outcome: LoginOutcome; @@ -464,13 +475,14 @@ async function saveLogin( : await prepareLogin(lock); const session = { host: login.host, ...login.auth0, ...tokens }; await writeCredentials(lock, { ...store, session }); + login.saved = { ...store, token: tokens.accessToken }; // The new login is saved first, so a failed revoke only leaves the // previous session to expire. const previous = stored?.login?.session; if (previous?.refreshToken) { await revokeRefreshToken(previous.refreshToken, previous).catch(() => {}); } - return { ...store, token: tokens.accessToken }; + return login.saved; }); } diff --git a/test/auth0.ts b/test/auth0.ts index 05a2082c..865ea25f 100644 --- a/test/auth0.ts +++ b/test/auth0.ts @@ -1,13 +1,14 @@ import { randomBytes } from "node:crypto"; import { createServer, type ServerResponse } from "node:http"; import type { AddressInfo } from "node:net"; +import { setTimeout as sleep } from "node:timers/promises"; // A fake Auth0 tenant for login tests. A real device login needs a person to // approve it in a browser, so automated tests can't use Auth0 itself. const INTERVAL = 0.1; -type FakeAuth0Response = { status: number; body?: unknown }; +type FakeAuth0Response = { status: number; body?: unknown; delayMs?: number }; export type FakeAuth0 = { auth0Domain: string; @@ -48,6 +49,7 @@ export async function createFakeAuth0(options: { accessToken: string }): Promise const response = responses.get(path)?.shift(); if (response) { + await sleep(response.delayMs ?? 0); send(res, response.status, response.body); return; } diff --git a/test/login.test.ts b/test/login.test.ts index e7c357b5..34da8bc8 100644 --- a/test/login.test.ts +++ b/test/login.test.ts @@ -88,6 +88,23 @@ it.skipIf(process.platform === "win32")( }, ); +it.skipIf(process.platform === "win32")( + "reports a login that saved before Ctrl-C as saved", + async ({ expect, prismic, login, auth0 }) => { + // The new login revokes this session while it still holds the lock after saving. + await login({ refreshToken: auth0.issueRefreshToken() }); + auth0.respondOnce("/oauth/revoke", { status: 200, delayMs: 2000 }); + const proc = prismic("login"); + const output = captureOutput(proc); + await approveLogin(expect, output, auth0); + await expect.poll(() => auth0.requestsTo("/oauth/revoke")).toHaveLength(1); + proc.kill("SIGINT"); + expect((await proc).exitCode).toBe(130); + expect(output()).toContain("Logged in to Prismic."); + expect(output()).not.toContain("Login cancelled."); + }, +); + it("makes another command wait for the login in progress", async ({ expect, prismic,