Skip to content
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
103 changes: 96 additions & 7 deletions packages/cli/src/lib/auth/login.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
import events from "node:events";
import http from "node:http";
import type { AddressInfo } from "node:net";
import readline from "node:readline/promises";
import type { Readable, Writable } from "node:stream";

import {
createManagementApiSdk,
Expand Down Expand Up @@ -29,14 +31,20 @@ export interface LoginOptions {
port?: number;
openUrl?: (url: string) => Promise<unknown> | unknown;
env?: NodeJS.ProcessEnv;
input?: Readable;
output?: Writable;
}

export async function login(options: LoginOptions = {}): Promise<void> {
const hostname = options.hostname ?? "localhost";
const port = options.port ?? 0;
const input = options.input ?? process.stdin;
const output = options.output ?? process.stderr;
const server = http.createServer();
server.listen({ host: hostname, port });

const pasteAbort = new AbortController();

try {
const addressInfo = await events
.once(server, "listening")
Expand All @@ -51,9 +59,12 @@ export async function login(options: LoginOptions = {}): Promise<void> {
authBaseUrl: options.authBaseUrl,
openUrl: options.openUrl,
env: options.env,
output,
});

const authResult = new Promise<void>((resolve, reject) => {
let completed = false;

const httpResult = new Promise<void>((resolve, reject) => {
server.on("request", async (req, res) => {
const url = new URL(`http://${state.host}${req.url}`);
if (url.pathname !== "/auth/callback") {
Expand All @@ -62,11 +73,22 @@ export async function login(options: LoginOptions = {}): Promise<void> {
return;
}

if (completed) {
// The paste path already completed the token exchange. Render the
// success page anyway so a late browser callback isn't left dangling.
const workspaceName = await state.resolveWorkspaceName();
res.setHeader("Content-Type", "text/html; charset=utf-8");
res.end(renderSuccessPage(workspaceName));
return;
Comment on lines +76 to +82
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Don't tear down the listener before a late browser redirect can hit the success branch.

Once the pasted flow wins Promise.race(...), Line 117 starts closing the server immediately. A browser redirect that arrives after that point never reaches the completed branch on Lines 76-82, so the browser still gets a connection failure instead of the success page this branch is trying to preserve.

Also applies to: 115-119

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@packages/cli/src/lib/auth/login.ts` around lines 76 - 82, The server is being
closed immediately when the pasted-flow loses the Promise.race, which prevents
late browser redirects from reaching the request handler's completed branch (the
branch that calls state.resolveWorkspaceName() and renderSuccessPage). Modify
the shutdown logic so the HTTP server isn't closed until after any in-flight
request can be handled: either move the server.close() call into the request
handler after sending the success response (ensure you call server.close() only
once), or, if you must close from the race winner path, wait for a short grace
period or check a shared completed flag and defer closing until completed is
true or the current response finishes; update the code paths that call
server.close() around the Promise.race handling (the shutdown block currently
invoked after the race) to use this deferred-close approach so renderSuccessPage
and state.resolveWorkspaceName() can complete.

}

try {
await state.handleCallback(url);
completed = true;
} catch (error) {
res.statusCode = 400;
const message = error instanceof Error ? error.message : String(error);
const message =
error instanceof Error ? error.message : String(error);
res.end(message);
reject(error);
return;
Expand All @@ -79,21 +101,61 @@ export async function login(options: LoginOptions = {}): Promise<void> {
});
});

const pasteResult = waitForPastedCallback({
input,
output,
signal: pasteAbort.signal,
}).then(async (pastedUrl) => {
if (pastedUrl === null || completed) return;
await state.handleCallback(pastedUrl);
completed = true;
});

await state.openLoginPage();
await authResult;
await Promise.race([httpResult, pasteResult]);
} finally {
pasteAbort.abort();
if (server.listening) {
await new Promise<void>((resolve) => server.close(() => resolve()));
}
}
}

async function waitForPastedCallback(options: {
input: Readable;
output: Writable;
signal: AbortSignal;
}): Promise<URL | null> {
// Only offer the paste path when we have a TTY to read from. In CI or test
// contexts the browser-callback path is the only one that resolves.
const input = options.input as NodeJS.ReadStream;
if (!input.isTTY) return null;

const rl = readline.createInterface({
input: options.input,
output: options.output,
});
try {
const answer = await rl.question("Paste URL here: ", {
signal: options.signal,
});
const trimmed = answer.trim().replace(/^["']|["']$/g, "");
return new URL(trimmed);
} catch (error) {
if ((error as { name?: string } | null)?.name === "AbortError") return null;
throw error;
} finally {
rl.close();
}
}

class LoginState {
private latestVerifier?: string;
private latestState?: string;
private readonly sdk: ManagementApiSdk;
private readonly openUrl: (url: string) => Promise<unknown> | unknown;
private readonly tokenStorage: TokenStorage;
private readonly output?: Writable;

constructor(
private readonly options: {
Expand All @@ -105,9 +167,11 @@ class LoginState {
authBaseUrl?: string;
openUrl?: (url: string) => Promise<unknown> | unknown;
env?: NodeJS.ProcessEnv;
output?: Writable;
},
) {
this.tokenStorage = options.tokenStorage ?? new FileTokenStorage(options.env);
this.tokenStorage =
options.tokenStorage ?? new FileTokenStorage(options.env);
this.sdk = createManagementApiSdk({
clientId: options.clientId ?? CLIENT_ID,
redirectUri: `http://${options.hostname}:${options.port}/auth/callback`,
Expand All @@ -116,6 +180,7 @@ class LoginState {
authBaseUrl: options.authBaseUrl,
});
this.openUrl = options.openUrl ?? open;
this.output = options.output;
}

async openLoginPage(): Promise<void> {
Expand All @@ -131,7 +196,27 @@ class LoginState {
this.latestState = state;
this.latestVerifier = verifier;

await this.openUrl(url);
this.printLoginInstructions(url);

try {
await this.openUrl(url);
} catch {
// Browser may be unavailable (e.g. on a remote machine). The user can
// still complete sign-in by visiting the printed URL and pasting the
// resulting callback URL into the prompt.
}
}

private printLoginInstructions(url: string): void {
const output = this.output as (Writable & { isTTY?: boolean }) | undefined;
if (!output?.isTTY) return;

output.write(
`\nOpening your browser to sign in to Prisma...\n\n` +
` ${url}\n\n` +
`If your browser didn't open, or you're on a remote machine, sign in using\n` +
`the URL above and copy+paste the resulting callback URL into the prompt.\n\n`,
);
}

async handleCallback(url: URL): Promise<void> {
Expand Down Expand Up @@ -159,7 +244,9 @@ class LoginState {
if (error instanceof SDKAuthError) {
throw new AuthError(error.message);
}
throw new AuthError(error instanceof Error ? error.message : "Unknown error during login");
throw new AuthError(
error instanceof Error ? error.message : "Unknown error during login",
);
}
}

Expand All @@ -174,7 +261,9 @@ class LoginState {
params: { path: { id: tokens.workspaceId } },
});
const name = data?.data?.name;
return typeof name === "string" && name.trim().length > 0 ? name.trim() : null;
return typeof name === "string" && name.trim().length > 0
? name.trim()
: null;
} catch {
return null;
}
Expand Down