From d0da5313b72cb6f40be96ace74be7d9abde7fb2d Mon Sep 17 00:00:00 2001 From: Shane Neubauer Date: Mon, 25 May 2026 14:13:51 +0100 Subject: [PATCH 1/2] Print auth URL to console to support a remote auth flow --- packages/cli/src/lib/auth/login.ts | 103 +++++++++++++++++++++++++++-- 1 file changed, 96 insertions(+), 7 deletions(-) diff --git a/packages/cli/src/lib/auth/login.ts b/packages/cli/src/lib/auth/login.ts index 2dbc8b4..f54b6aa 100644 --- a/packages/cli/src/lib/auth/login.ts +++ b/packages/cli/src/lib/auth/login.ts @@ -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, @@ -29,14 +31,20 @@ export interface LoginOptions { port?: number; openUrl?: (url: string) => Promise | unknown; env?: NodeJS.ProcessEnv; + input?: Readable; + output?: Writable; } export async function login(options: LoginOptions = {}): Promise { 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") @@ -51,9 +59,12 @@ export async function login(options: LoginOptions = {}): Promise { authBaseUrl: options.authBaseUrl, openUrl: options.openUrl, env: options.env, + output, }); - const authResult = new Promise((resolve, reject) => { + let completed = false; + + const httpResult = new Promise((resolve, reject) => { server.on("request", async (req, res) => { const url = new URL(`http://${state.host}${req.url}`); if (url.pathname !== "/auth/callback") { @@ -62,11 +73,22 @@ export async function login(options: LoginOptions = {}): Promise { 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; + } + 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; @@ -79,21 +101,61 @@ export async function login(options: LoginOptions = {}): Promise { }); }); + 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((resolve) => server.close(() => resolve())); } } } +async function waitForPastedCallback(options: { + input: Readable; + output: Writable; + signal: AbortSignal; +}): Promise { + // 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; private readonly tokenStorage: TokenStorage; + private readonly output?: Writable; constructor( private readonly options: { @@ -105,9 +167,11 @@ class LoginState { authBaseUrl?: string; openUrl?: (url: string) => Promise | 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`, @@ -116,6 +180,7 @@ class LoginState { authBaseUrl: options.authBaseUrl, }); this.openUrl = options.openUrl ?? open; + this.output = options.output; } async openLoginPage(): Promise { @@ -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 { @@ -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", + ); } } @@ -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; } From 20dbbcbf66132c8f98c7057ea8e4224ecf8c5a83 Mon Sep 17 00:00:00 2001 From: Shane Neubauer Date: Tue, 2 Jun 2026 10:29:04 +0100 Subject: [PATCH 2/2] fix(cli): address review on remote auth paste flow - Gate the paste fallback on a TTY: only swallow browser-launch failures when stdin is interactive, and only start/race the paste prompt then. Non-TTY environments keep the existing browser-callback behaviour and surface a failed browser launch instead of hanging. - Funnel both completion paths through a single completeOnce() so the token exchange runs at most once when the browser redirect and a pasted URL arrive together; clear the in-flight promise on failure to allow retries. - Make the remote sign-in instructions concrete. - Re-prompt after a premature Enter, an unparseable paste, or a callback the exchange rejects, instead of ending the whole login. The browser callback path stays open throughout. --- packages/cli/src/lib/auth/login.ts | 133 +++++++++---- packages/cli/tests/auth-login.test.ts | 272 ++++++++++++++++++++++---- 2 files changed, 331 insertions(+), 74 deletions(-) diff --git a/packages/cli/src/lib/auth/login.ts b/packages/cli/src/lib/auth/login.ts index f54b6aa..6a1b2e8 100644 --- a/packages/cli/src/lib/auth/login.ts +++ b/packages/cli/src/lib/auth/login.ts @@ -40,6 +40,7 @@ export async function login(options: LoginOptions = {}): Promise { const port = options.port ?? 0; const input = options.input ?? process.stdin; const output = options.output ?? process.stderr; + const interactive = (input as NodeJS.ReadStream).isTTY === true; const server = http.createServer(); server.listen({ host: hostname, port }); @@ -63,6 +64,25 @@ export async function login(options: LoginOptions = {}): Promise { }); let completed = false; + let completion: Promise | undefined; + + // The browser redirect and a pasted callback URL can both deliver the same + // auth code. Funnel both through one in-flight promise so the token + // exchange runs at most once; clear it on failure so a retry can try again. + const completeOnce = (url: URL): Promise => { + if (!completion) { + completion = state.handleCallback(url).then( + () => { + completed = true; + }, + (error) => { + completion = undefined; + throw error; + }, + ); + } + return completion; + }; const httpResult = new Promise((resolve, reject) => { server.on("request", async (req, res) => { @@ -83,8 +103,7 @@ export async function login(options: LoginOptions = {}): Promise { } try { - await state.handleCallback(url); - completed = true; + await completeOnce(url); } catch (error) { res.statusCode = 400; const message = @@ -101,18 +120,21 @@ export async function login(options: LoginOptions = {}): Promise { }); }); - 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(interactive); - await state.openLoginPage(); - await Promise.race([httpResult, pasteResult]); + // Only race the paste flow when stdin is a TTY we can actually prompt on. + // Without one (CI, pipes, tests) the browser callback is the only path. + if (interactive) { + const pasteResult = consumePastedCallback({ + input, + output, + signal: pasteAbort.signal, + complete: completeOnce, + }); + await Promise.race([httpResult, pasteResult]); + } else { + await httpResult; + } } finally { pasteAbort.abort(); if (server.listening) { @@ -121,29 +143,60 @@ export async function login(options: LoginOptions = {}): Promise { } } -async function waitForPastedCallback(options: { +async function consumePastedCallback(options: { input: Readable; output: Writable; signal: AbortSignal; -}): Promise { - // 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. + complete: (url: URL) => Promise; +}): Promise { + // Defensive: callers only start this on a TTY. Without one there is nowhere + // to paste, so let the browser callback be the only path that resolves. const input = options.input as NodeJS.ReadStream; - if (!input.isTTY) return null; + if (!input.isTTY) return; 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; + // Keep prompting until a paste completes sign-in. A premature Enter or a + // wrong paste shows a hint and re-asks instead of ending the whole login; + // the browser callback stays open the whole time and can still win. + for (;;) { + let answer: string; + try { + answer = await rl.question("Paste the callback URL here: ", { + signal: options.signal, + }); + } catch (error) { + // The browser callback won the race and aborted us. Stop prompting. + if ((error as { name?: string } | null)?.name === "AbortError") return; + throw error; + } + + const trimmed = answer.trim().replace(/^["']|["']$/g, ""); + let url: URL; + try { + if (!trimmed) throw new Error("empty input"); + url = new URL(trimmed); + } catch { + options.output.write( + "That didn't look like a URL. Paste the full localhost callback URL and try again.\n", + ); + continue; + } + + try { + await options.complete(url); + return; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + options.output.write( + `Sign-in didn't complete (${message}). Paste the callback URL to try again.\n`, + ); + continue; + } + } } finally { rl.close(); } @@ -183,7 +236,7 @@ class LoginState { this.output = options.output; } - async openLoginPage(): Promise { + async openLoginPage(interactive: boolean): Promise { const { url, state, verifier } = await this.sdk.getLoginUrl({ scope: "workspace:admin offline_access", additionalParams: { @@ -196,26 +249,30 @@ class LoginState { this.latestState = state; this.latestVerifier = verifier; - this.printLoginInstructions(url); + // The instructions describe the paste fallback, which only exists on a TTY. + if (interactive) { + 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. + } catch (error) { + // On a TTY the user can finish via the pasted-URL prompt, so a failed + // browser launch is non-fatal. Without one there is no fallback — surface + // the failure instead of waiting on a callback that will never arrive. + if (!interactive) throw error; } } private printLoginInstructions(url: string): void { - const output = this.output as (Writable & { isTTY?: boolean }) | undefined; - if (!output?.isTTY) return; + const output = this.output; + if (!output) 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`, + `\nOpen this URL to sign in: ${url}\n\n` + + `If the browser opens on another machine, finish sign-in there. When it\n` + + `redirects to localhost, copy the full localhost URL from the address bar\n` + + `and paste it here.\n\n`, ); } diff --git a/packages/cli/tests/auth-login.test.ts b/packages/cli/tests/auth-login.test.ts index ef4877d..657dce3 100644 --- a/packages/cli/tests/auth-login.test.ts +++ b/packages/cli/tests/auth-login.test.ts @@ -1,3 +1,5 @@ +import { PassThrough } from "node:stream"; + import { afterEach, describe, expect, it, vi } from "vitest"; import type { TokenStorage } from "@prisma/management-api-sdk"; @@ -32,7 +34,9 @@ describe("auth login callback", () => { }); it("renders generic success copy when the workspace lookup fails", async () => { - const result = await requestSuccessPage({ workspaceLookupError: new Error("lookup failed") }); + const result = await requestSuccessPage({ + workspaceLookupError: new Error("lookup failed"), + }); expect(result.body).toContain("You're all set."); expect(result.body).toContain( @@ -42,7 +46,9 @@ describe("auth login callback", () => { }); it("escapes the workspace name before rendering it", async () => { - const result = await requestSuccessPage({ workspaceName: 'Acme & "Team"' }); + const result = await requestSuccessPage({ + workspaceName: 'Acme & "Team"', + }); expect(result.body).toContain( "Your terminal is now connected to your Acme <Corp> & "Team" workspace. Head back to your terminal to continue.", @@ -69,7 +75,11 @@ describe("auth login callback", () => { async function requestSuccessPage(options: { workspaceName?: string; workspaceLookupError?: Error; -}): Promise<{ contentType: string | null; body: string; loginScope: string | undefined }> { +}): Promise<{ + contentType: string | null; + body: string; + loginScope: string | undefined; +}> { let redirectUri: string | undefined; let loginScope: string | undefined; let contentType: string | null = null; @@ -86,41 +96,45 @@ async function requestSuccessPage(options: { vi.doMock("@prisma/management-api-sdk", () => ({ AuthError: class SDKAuthError extends Error {}, - createManagementApiSdk: vi.fn().mockImplementation((sdkOptions: { redirectUri: string }) => { - redirectUri = sdkOptions.redirectUri; + createManagementApiSdk: vi + .fn() + .mockImplementation((sdkOptions: { redirectUri: string }) => { + redirectUri = sdkOptions.redirectUri; - return { - getLoginUrl: vi.fn().mockImplementation((options: { scope: string }) => { - loginScope = options.scope; - return { - url: "https://auth.example.test/login", - state: "state_123", - verifier: "verifier_123", - }; - }), - handleCallback: vi.fn().mockResolvedValue(undefined), - client: { - GET: vi.fn().mockImplementation((pathName: string) => { - if (pathName !== "/v1/workspaces/{id}") { - throw new Error(`Unexpected path ${pathName}`); - } - - if (options.workspaceLookupError) { - throw options.workspaceLookupError; - } - - return { - data: { + return { + getLoginUrl: vi + .fn() + .mockImplementation((options: { scope: string }) => { + loginScope = options.scope; + return { + url: "https://auth.example.test/login", + state: "state_123", + verifier: "verifier_123", + }; + }), + handleCallback: vi.fn().mockResolvedValue(undefined), + client: { + GET: vi.fn().mockImplementation((pathName: string) => { + if (pathName !== "/v1/workspaces/{id}") { + throw new Error(`Unexpected path ${pathName}`); + } + + if (options.workspaceLookupError) { + throw options.workspaceLookupError; + } + + return { data: { - id: "ws_123", - name: options.workspaceName, + data: { + id: "ws_123", + name: options.workspaceName, + }, }, - }, - }; - }), - }, - }; - }), + }; + }), + }, + }; + }), })); const { login } = await import("../src/lib/auth/login"); @@ -130,7 +144,9 @@ async function requestSuccessPage(options: { tokenStorage, openUrl: async () => { expect(redirectUri).toBeDefined(); - const response = await fetch(`${redirectUri}?code=code_123&state=state_123`); + const response = await fetch( + `${redirectUri}?code=code_123&state=state_123`, + ); contentType = response.headers.get("content-type"); body = await response.text(); @@ -139,3 +155,187 @@ async function requestSuccessPage(options: { return { contentType, body, loginScope }; } + +describe("auth login remote paste flow", () => { + it("completes the token exchange via a pasted callback URL on a TTY", async () => { + const result = await runLogin({ + ttyInput: true, + openUrl: () => { + // Browser is unavailable on the remote machine; do nothing so the + // paste path is the only one that can complete sign-in. + }, + pasteLines: [PASTE_CALLBACK_URL], + }); + + expect(result.handleCallbackCalls).toBe(1); + expect(result.output).toContain("Paste the callback URL here:"); + }); + + it("prints the concrete remote sign-in instructions on a TTY", async () => { + const result = await runLogin({ + ttyInput: true, + openUrl: () => {}, + pasteLines: [PASTE_CALLBACK_URL], + }); + + expect(result.output).toContain("Open this URL to sign in:"); + expect(result.output).toContain( + "copy the full localhost URL from the address bar", + ); + }); + + it("runs the token exchange once when browser and paste both deliver", async () => { + const result = await runLogin({ + ttyInput: true, + // The browser callback wins first... + openUrl: async (redirectUri) => { + await fetch(`${redirectUri}?code=code_123&state=state_123`); + }, + // ...and the user also pastes the same callback URL. + pasteLines: [PASTE_CALLBACK_URL], + }); + + expect(result.handleCallbackCalls).toBe(1); + }); + + it("re-prompts after an unparseable paste instead of ending login", async () => { + const result = await runLogin({ + ttyInput: true, + openUrl: () => {}, + pasteLines: ["not a url", PASTE_CALLBACK_URL], + }); + + expect(result.output).toContain("That didn't look like a URL"); + expect(result.handleCallbackCalls).toBe(1); + }); + + it("re-prompts after a callback the exchange rejects, then succeeds", async () => { + const result = await runLogin({ + ttyInput: true, + openUrl: () => {}, + // First paste carries the wrong state and is rejected; second is valid. + pasteLines: [ + "http://localhost:9999/auth/callback?code=code_123&state=wrong_state", + PASTE_CALLBACK_URL, + ], + }); + + expect(result.output).toContain("Sign-in didn't complete"); + expect(result.handleCallbackCalls).toBe(2); + }); + + it("surfaces a browser-launch failure when stdin is not a TTY", async () => { + await expect( + runLogin({ + ttyInput: false, + openUrl: () => { + throw new Error("no browser available"); + }, + }), + ).rejects.toThrow("no browser available"); + }); + + it("does not prompt or print instructions when stdin is not a TTY", async () => { + const result = await runLogin({ + ttyInput: false, + openUrl: async (redirectUri) => { + await fetch(`${redirectUri}?code=code_123&state=state_123`); + }, + }); + + expect(result.handleCallbackCalls).toBe(1); + expect(result.output).not.toContain("Paste the callback URL here:"); + expect(result.output).not.toContain("Open this URL to sign in:"); + }); +}); + +const PASTE_CALLBACK_URL = + "http://localhost:9999/auth/callback?code=code_123&state=state_123"; + +async function runLogin(options: { + ttyInput: boolean; + openUrl: (redirectUri: string) => Promise | unknown; + pasteLines?: string[]; +}): Promise<{ handleCallbackCalls: number; output: string }> { + let redirectUri = ""; + const handleCallback = vi.fn( + async (args: { callbackUrl: URL; expectedState: string }) => { + if (args.callbackUrl.searchParams.get("state") !== args.expectedState) { + throw new SDKAuthErrorStub("State mismatch"); + } + }, + ); + + const tokenStorage: TokenStorage = { + getTokens: vi.fn().mockResolvedValue({ + workspaceId: "ws_123", + accessToken: "access-token", + refreshToken: "refresh-token", + }), + setTokens: vi.fn().mockResolvedValue(undefined), + clearTokens: vi.fn().mockResolvedValue(undefined), + }; + + vi.doMock("@prisma/management-api-sdk", () => ({ + AuthError: SDKAuthErrorStub, + createManagementApiSdk: vi + .fn() + .mockImplementation((sdkOptions: { redirectUri: string }) => { + redirectUri = sdkOptions.redirectUri; + + return { + getLoginUrl: vi.fn().mockReturnValue({ + url: "https://auth.example.test/login", + state: "state_123", + verifier: "verifier_123", + }), + handleCallback, + client: { + GET: vi.fn().mockResolvedValue({ + data: { data: { id: "ws_123", name: "Acme Corp" } }, + }), + }, + }; + }), + })); + + const input = new PassThrough(); + if (options.ttyInput) { + (input as unknown as { isTTY: boolean }).isTTY = true; + } + + // Feed one pending line each time the prompt is (re)displayed. readline drops + // 'line' events that arrive with no question awaiting, so pre-buffering every + // line at once loses all but the first across re-prompts. + const pasteLines = [...(options.pasteLines ?? [])]; + const output = new PassThrough(); + const chunks: string[] = []; + output.on("data", (chunk) => { + const text = chunk.toString(); + chunks.push(text); + if ( + text.includes("Paste the callback URL here:") && + pasteLines.length > 0 + ) { + const line = pasteLines.shift() as string; + queueMicrotask(() => input.write(`${line}\n`)); + } + }); + + const { login } = await import("../src/lib/auth/login"); + + await login({ + hostname: "127.0.0.1", + tokenStorage, + input, + output, + openUrl: () => options.openUrl(redirectUri), + }); + + return { + handleCallbackCalls: handleCallback.mock.calls.length, + output: chunks.join(""), + }; +} + +class SDKAuthErrorStub extends Error {}