Skip to content

Commit 94039e6

Browse files
fix(auth): prevent ERR_SERVER_NOT_RUNNING on magic-link login; harden stopServer (#474)
1 parent 2b94a87 commit 94039e6

2 files changed

Lines changed: 93 additions & 8 deletions

File tree

src/commands/auth/login.ts

Lines changed: 54 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,14 +6,15 @@ import { Command } from '@oclif/core';
66
import { ensureUserSetup } from '../../api/user-setup.client.ts';
77
import { persistTokenResponse } from '../../service/auth.svc.ts';
88
import { getClientId, getRealmUrl } from '../../service/auth-config.svc.ts';
9-
import { getErrorMessage } from '../../service/log.svc.ts';
9+
import { debugLogger, getErrorMessage } from '../../service/log.svc.ts';
1010
import type { TokenResponse } from '../../types/auth.ts';
1111
import { openInBrowser } from '../../utils/open-in-browser.ts';
1212

1313
export default class AuthLogin extends Command {
1414
static description = 'OAuth CLI login';
1515

1616
private server?: http.Server;
17+
private stopServerPromise?: Promise<void>;
1718
private readonly port = parseInt(process.env.OAUTH_CALLBACK_PORT || '4000', 10);
1819
private readonly redirectUri = process.env.OAUTH_CALLBACK_REDIRECT || `http://localhost:${this.port}/oauth2/callback`;
1920
private readonly realmUrl = getRealmUrl();
@@ -136,11 +137,59 @@ export default class AuthLogin extends Command {
136137
});
137138
}
138139

139-
private async stopServer() {
140-
if (this.server) {
141-
await new Promise<void>((resolve, reject) => this.server?.close((err) => (err ? reject(err) : resolve())));
142-
this.server = undefined;
140+
private stopServer(): Promise<void> {
141+
if (this.stopServerPromise) {
142+
return this.stopServerPromise;
143143
}
144+
145+
const server = this.server;
146+
this.server = undefined;
147+
148+
if (!server) {
149+
return Promise.resolve();
150+
}
151+
152+
const stopPromise = new Promise<void>((resolve) => {
153+
const timeoutMs = 1000;
154+
let settled = false;
155+
let timeout: ReturnType<typeof setTimeout> | undefined;
156+
157+
const complete = (err?: Error) => {
158+
if (settled) {
159+
return;
160+
}
161+
162+
settled = true;
163+
if (timeout) {
164+
clearTimeout(timeout);
165+
timeout = undefined;
166+
}
167+
168+
const code = (err as NodeJS.ErrnoException | undefined)?.code;
169+
if (err && code !== 'ERR_SERVER_NOT_RUNNING') {
170+
this.warn('Failed to stop local OAuth callback server.');
171+
debugLogger('Failed to stop local OAuth callback server: %s', getErrorMessage(err));
172+
}
173+
174+
resolve();
175+
};
176+
177+
timeout = setTimeout(() => {
178+
debugLogger('Timed out while stopping local OAuth callback server after %dms', timeoutMs);
179+
complete();
180+
}, timeoutMs);
181+
182+
try {
183+
server.close((err) => complete(err));
184+
} catch (err) {
185+
complete(err as Error);
186+
}
187+
}).finally(() => {
188+
this.stopServerPromise = undefined;
189+
});
190+
191+
this.stopServerPromise = stopPromise;
192+
return stopPromise;
144193
}
145194

146195
private async exchangeCodeForToken(code: string, codeVerifier: string): Promise<TokenResponse> {

test/commands/auth/login.test.ts

Lines changed: 39 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,8 +20,18 @@ interface ServerStub {
2020

2121
const serverInstances: ServerStub[] = [];
2222

23+
class ServerNotRunningError extends Error implements NodeJS.ErrnoException {
24+
code = 'ERR_SERVER_NOT_RUNNING';
25+
26+
constructor() {
27+
super('Server is not running.');
28+
this.name = 'ServerNotRunningError';
29+
}
30+
}
31+
2332
const createServerStub = (handler: ServerHandler): ServerStub => {
2433
let errorListener: ((error: Error) => void) | undefined;
34+
let closed = false;
2535
const stub: ServerStub = {
2636
handler,
2737
listen: vi.fn((_port: number, cb?: () => void) => {
@@ -32,7 +42,9 @@ const createServerStub = (handler: ServerHandler): ServerStub => {
3242
return stub;
3343
}),
3444
close: vi.fn((cb?: (err?: Error) => void) => {
35-
cb?.();
45+
const closeError = closed ? new ServerNotRunningError() : undefined;
46+
closed = true;
47+
setImmediate(() => cb?.(closeError));
3648
return stub;
3749
}),
3850
on: vi.fn((event: string, cb: (err: Error) => void) => {
@@ -80,8 +92,8 @@ vi.mock('../../../src/utils/open-in-browser.ts', () => ({
8092
openInBrowser: vi.fn(),
8193
}));
8294

83-
const questionMock = vi.fn<[string, (answer: string) => void], void>();
84-
const closeMock = vi.fn<[], void>();
95+
const questionMock = vi.fn<(question: string, callback: (answer: string) => void) => void>();
96+
const closeMock = vi.fn<() => void>();
8597

8698
vi.mock('node:readline', () => ({
8799
__esModule: true,
@@ -303,6 +315,30 @@ describe('AuthLogin', () => {
303315
warnSpy.mockRestore();
304316
}
305317
});
318+
319+
it('deduplicates shutdown when callback success and server error race', async () => {
320+
const command = createCommand(basePort + 8);
321+
const state = 'expected-state';
322+
const pendingCode = (
323+
command as unknown as { startServerAndAwaitCode: (url: string, state: string) => Promise<string> }
324+
).startServerAndAwaitCode(authUrl, state);
325+
const server = getLatestServer();
326+
const warnSpy = vi
327+
.spyOn(command as unknown as { warn: (...args: unknown[]) => unknown }, 'warn')
328+
.mockImplementation(() => {});
329+
330+
try {
331+
await flushAsync();
332+
sendCallbackThroughStub({ code: 'race-code', state });
333+
server.emitError(new Error('late listener error'));
334+
335+
await expect(pendingCode).resolves.toBe('race-code');
336+
expect(server.close).toHaveBeenCalledTimes(1);
337+
expect(warnSpy).not.toHaveBeenCalledWith(expect.stringContaining('Failed to stop local OAuth callback server'));
338+
} finally {
339+
warnSpy.mockRestore();
340+
}
341+
});
306342
});
307343

308344
describe('exchangeCodeForToken', () => {

0 commit comments

Comments
 (0)