diff --git a/src/common/auth/__tests__/server.test.ts b/src/common/auth/__tests__/server.test.ts
new file mode 100644
index 00000000..b89aaaa6
--- /dev/null
+++ b/src/common/auth/__tests__/server.test.ts
@@ -0,0 +1,50 @@
+/** Copyright © 2024-present P0 Security
+
+This file is part of @p0security/cli
+
+@p0security/cli is free software: you can redistribute it and/or modify it under the terms of the GNU General Public License as published by the Free Software Foundation, version 3 of the License.
+
+@p0security/cli is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for more details.
+
+You should have received a copy of the GNU General Public License along with @p0security/cli. If not, see .
+**/
+import { withRedirectServer } from "../server";
+import http from "node:http";
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
+
+// Under vitest the landing-page assets resolve to src/public, which does not exist
+vi.mock("node:fs/promises", () => ({
+ readFile: vi.fn().mockResolvedValue(Buffer.from("")),
+}));
+
+vi.mock("../../../util", async (importOriginal) => ({
+ ...(await importOriginal()),
+ getAppName: () => "p0",
+}));
+
+describe("withRedirectServer", () => {
+ let blocker: http.Server;
+ let port: number;
+
+ beforeEach(async () => {
+ blocker = http.createServer();
+ await new Promise((resolve) => blocker.listen(0, resolve));
+ const address = blocker.address();
+ if (!address || typeof address === "string")
+ throw new Error("blocker is not listening on a TCP port");
+ port = address.port;
+ });
+
+ afterEach(async () => {
+ await new Promise((resolve) => blocker.close(() => resolve()));
+ });
+
+ it("explains that another login holds the port when the port is in use", async () => {
+ const beginAuth = vi.fn();
+
+ await expect(withRedirectServer(beginAuth, vi.fn(), { port })).rejects.toBe(
+ `Login failed: port ${port} is already in use, probably by another \`p0 login\` waiting for browser approval.\nFinish or cancel that login (Ctrl+C in its terminal), then run this command again.`
+ );
+ expect(beginAuth).not.toHaveBeenCalled();
+ });
+});
diff --git a/src/common/auth/server.ts b/src/common/auth/server.ts
index 42de5be6..e3a614f7 100644
--- a/src/common/auth/server.ts
+++ b/src/common/auth/server.ts
@@ -10,7 +10,7 @@ You should have received a copy of the GNU General Public License along with @p0
**/
/** Implements a local auth server, which can receive auth tokens from an OIDC app */
-import { sleep } from "../../util";
+import { getAppName, sleep } from "../../util";
import express from "express";
import { noop } from "lodash";
import { readFile } from "node:fs/promises";
@@ -89,7 +89,8 @@ export const withRedirectServer = async (
app.use(redirectRouter);
- const server = app.listen(options?.port ?? 0);
+ const port = options?.port ?? 0;
+ const server = app.listen(port);
// Set up cleanup handler for process interruption
const cleanup = async () => {
@@ -109,11 +110,18 @@ export const withRedirectServer = async (
process.once("SIGTERM", signalHandler);
// Wait for server to start listening or fail
+ // Rejects only this promise: redirectPromise is never awaited when listen fails,
+ // so rejecting it would surface as an unhandled rejection
await new Promise((resolve, reject) => {
server.once("listening", () => resolve());
- server.once("error", (error) => {
- redirectReject(error);
- reject(error);
+ server.once("error", (error: NodeJS.ErrnoException) => {
+ if (error.code === "EADDRINUSE") {
+ reject(
+ `Login failed: port ${port} is already in use, probably by another \`${getAppName()} login\` waiting for browser approval.\nFinish or cancel that login (Ctrl+C in its terminal), then run this command again.`
+ );
+ } else {
+ reject(error);
+ }
});
});