This gist provides a small, framework-agnostic Node.js module for implementing a Proof of Concept (PoC) of a "browser login with localhost callback" flow in command-line tools.
It handles the full sequence:
- Spins up a temporary local HTTP server.
- Opens the user’s browser to your backend’s login endpoint.
- Waits for the backend to redirect back with a credential (e.g., API key, token).
- Validates state/CSRF token and resolves with the credential.
- Cleans up the server and displays a success or error page in the browser.
- Works on Node.js 18+ with no external dependencies.
- Cross-platform browser opener (
open,start,xdg-open) with override support. - Configurable:
- Ports (range + attempts)
- Callback transport (
portorurl) - Path and query param names
- Timeout duration
- Logging interface
- Custom success/error HTML
- Supports cancellation with
AbortSignal(e.g., Ctrl+C). - Provides extension points (custom
openfunction, polling fallback, etc.).
This is useful when building CLIs that need interactive login with a web service
without embedding a full OAuth client library. For example, developer tools, API CLIs, or internal company scripts.
The following was generated by codex 0.25.0 using gpt-5 on high reasoning, based on examining the implementation in @sourcegraph/amp v0.0.1756440072-g123a97:
user
Looking at
main.js, when we use the 'login' command and it is 'Waiting for login to complete...', how does it know when we have logged in? Does it set up a server, or does it make a polling loop, or?
codex
Short answer: it spins up a local HTTP server and waits for a browser callback — no polling.
What happens in
main.js:
- Generate auth link:
POA()picks an available loopback port (starts at 35789, tries up to 10), stores it injK1, and builds${ampURL}/auth/cli-login?authToken=<token>&callbackPort=<port>.- Open browser:
kOA()opens that URL.- Wait for callback:
SOA(token)starts anode:httpserver on127.0.0.1:<port>and listens forGET /auth/callback?....- Verify and finish: It checks
authTokenmatches, readsapiKeyfrom the query, returns it, stores it viavOA(), and closes the server. If nothing arrives within 5 minutes, it times out.So “Waiting for login to complete...” is the local server waiting for the browser to hit
/auth/callbackwith the token and API key.
user
Can you write generic/configurable code that would allow us to implement this sort of pattern in our own CLI tool
codex
Got it. Here’s a small, configurable Node.js module you can drop into your CLI to implement “open browser → wait for callback on localhost” login. It’s framework-agnostic and lets you tweak ports, paths, query param names, timeouts, and logging.
Code
// browser-login.js (ESM) // Node 18+ // Usage: import { performBrowserLogin } from './browser-login.js'; import { createServer } from 'node:http'; import { randomBytes } from 'node:crypto'; import { platform } from 'node:os'; import { exec as execCb } from 'node:child_process'; import { promisify } from 'node:util'; const exec = promisify(execCb); /** * @typedef {Object} BrowserLoginOptions * @property {string} baseAuthUrl - e.g. 'https://example.com/auth/cli-login' * @property {'port'|'url'} [callbackParamKind='port'] - Send callback as a port or full URL. * @property {string} [callbackParamName] - Defaults to 'callbackPort' or 'callbackUrl' based on kind. * @property {string} [callbackPath='/auth/callback'] - Local path your CLI listens on. * @property {string} [stateParamName='state'] - Query param for CSRF/state. * @property {string} [responseParamName='apiKey'] - Query param carrying the credential. * @property {Record<string,string>} [extraQueryParams] - Extra params to append to the auth URL. * @property {string} [host='127.0.0.1'] - Local host to bind. * @property {number} [startPort=35789] - First port to try. * @property {number} [maxPortAttempts=10] - Number of consecutive ports to try. * @property {number} [timeoutMs=300000] - Server timeout in ms (default 5 minutes). * @property {boolean} [autoOpenBrowser=true] - Open the browser automatically. * @property {(url:string)=>Promise<void>} [open] - Custom browser opener. * @property {{info:Function, warn:Function, error:Function}} [logger] - Logger interface. * @property {AbortSignal} [signal] - Optional AbortSignal to cancel waiting. * @property {string} [successHtml] - HTML shown on success. * @property {string} [errorHtml] - HTML shown on error. */ /** * Opens the browser cross-platform. * @param {string} url */ async function defaultOpen(url) { const p = platform(); if (p === 'win32') { await exec(`start "" "${url.replace(/"/g, '\\"')}"`); } else if (p === 'darwin') { await exec(`open "${url.replace(/"/g, '\\"')}"`); } else { await exec(`xdg-open "${url.replace(/"/g, '\\"')}"`); } } /** * Find an available port starting at `start` within `attempts` tries. * @param {number} start * @param {number} attempts * @param {string} host * @param {{info:Function}} logger * @returns {Promise<number>} */ async function findAvailablePort(start, attempts, host, logger) { for (let i = 0; i < attempts; i++) { const port = start + i; try { await new Promise((resolve, reject) => { const s = createServer(); s.once('error', reject); s.once('listening', () => s.close(resolve)); s.listen(port, host); }); logger?.info?.(`Found available port: ${port}`); return port; } catch { logger?.info?.(`Port ${port} in use, trying ${port + 1}`); } } throw new Error(`Could not find an available port after ${attempts} attempts`); } /** * Build the external auth URL with required params. */ function buildAuthUrl({ baseAuthUrl, callbackParamKind, callbackParamName, host, port, callbackPath, stateParamName, state, responseParamName, extraQueryParams, }) { const u = new URL(baseAuthUrl); const cbName = callbackParamName || (callbackParamKind === 'url' ? 'callbackUrl' : 'callbackPort'); if (callbackParamKind === 'url') { const cbUrl = `http://${host}:${port}${callbackPath}`; u.searchParams.set(cbName, cbUrl); } else { u.searchParams.set(cbName, String(port)); } u.searchParams.set(stateParamName, state); // Optionally declare what param will be returned (some backends may use it) if (responseParamName) { u.searchParams.set('responseParam', responseParamName); } if (extraQueryParams) { for (const [k, v] of Object.entries(extraQueryParams)) { u.searchParams.set(k, v); } } return u.toString(); } /** * Start a temporary HTTP server and wait for the browser callback. * Resolves with the credential from `responseParamName`. */ function startCallbackServer({ host, port, callbackPath, stateParamName, expectedState, responseParamName, timeoutMs, logger, signal, successHtml, errorHtml, }) { const successPage = successHtml || `<!doctype html><html><head><meta charset="utf-8"><title>Login Successful</title> <style>body{font-family:system-ui,sans-serif;max-width:640px;margin:2rem auto;color:#0b0d0b;background:#fafaf8}</style></head> <body><h1>Login Successful</h1><p>You can close this window and return to your terminal.</p></body></html>`; const errorPage = errorHtml || `<!doctype html><html><head><meta charset="utf-8"><title>Login Error</title> <style>body{font-family:system-ui,sans-serif;max-width:640px;margin:2rem auto;color:#0b0d0b;background:#fafaf8}h1{color:#d44444}</style></head> <body><h1>Login Failed</h1><p>There was an error processing your login. Please try again.</p></body></html>`; return new Promise((resolve, reject) => { const server = createServer((req, res) => { try { if (!req.url) { res.writeHead(400, { 'Content-Type': 'text/html; charset=utf-8' }); res.end(errorPage); return; } const url = new URL(req.url, `http://${host}:${port}`); if (url.pathname !== callbackPath) { res.writeHead(404).end(); return; } // Optional CORS headers if you need cross-origin scripting. Not required for a simple page. res.setHeader('Access-Control-Allow-Origin', '*'); res.setHeader('Access-Control-Allow-Methods', 'GET'); const state = url.searchParams.get(stateParamName); const value = url.searchParams.get(responseParamName); if (state !== expectedState) { logger?.error?.('Invalid state token'); res.writeHead(400, { 'Content-Type': 'text/html; charset=utf-8' }); res.end(errorPage); reject(new Error('Invalid state token')); server.close(); return; } if (!value) { logger?.error?.(`Missing response param: ${responseParamName}`); res.writeHead(400, { 'Content-Type': 'text/html; charset=utf-8' }); res.end(errorPage); reject(new Error(`Missing "${responseParamName}" in callback`)); server.close(); return; } logger?.info?.(`Received credential: ${responseParamName}`); res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }); res.end(successPage); server.close(() => resolve(value)); } catch (err) { logger?.error?.(`Server handler error: ${err?.message || String(err)}`); res.writeHead(500, { 'Content-Type': 'text/html; charset=utf-8' }); res.end(errorPage); server.close(() => reject(err)); } }); const timeout = setTimeout(() => { logger?.warn?.('Login timed out'); server.close(); reject(new Error('Login timed out')); }, timeoutMs); server.on('error', (err) => { clearTimeout(timeout); logger?.error?.(`Server error: ${err.message}`); reject(err); }); if (signal) { const onAbort = () => { clearTimeout(timeout); logger?.warn?.('Login aborted'); server.close(); reject(new Error('Login aborted')); }; if (signal.aborted) return onAbort(); signal.addEventListener('abort', onAbort, { once: true }); server.once('close', () => signal.removeEventListener('abort', onAbort)); } server.listen(port, host, () => { logger?.info?.(`Listening on http://${host}:${port}${callbackPath}`); }); }); } /** * Orchestrates the browser login: * - picks a port, * - builds auth URL, * - opens the browser, * - waits for callback with credential, * - returns the credential. * * @param {BrowserLoginOptions} opts * @returns {Promise<{credential:string, state:string, port:number, authUrl:string}>} */ export async function performBrowserLogin(opts) { const { baseAuthUrl, callbackParamKind = 'port', callbackParamName, callbackPath = '/auth/callback', stateParamName = 'state', responseParamName = 'apiKey', extraQueryParams, host = '127.0.0.1', startPort = 35789, maxPortAttempts = 10, timeoutMs = 300000, autoOpenBrowser = true, open = defaultOpen, logger = console, signal, successHtml, errorHtml, } = opts; if (!baseAuthUrl) throw new Error('baseAuthUrl is required'); const state = randomBytes(32).toString('hex'); const port = await findAvailablePort(startPort, maxPortAttempts, host, logger); const authUrl = buildAuthUrl({ baseAuthUrl, callbackParamKind, callbackParamName, host, port, callbackPath, stateParamName, state, responseParamName, extraQueryParams, }); logger.info('If your browser does not open automatically, visit:'); logger.info(authUrl); if (autoOpenBrowser) { try { await open(authUrl); logger.info('Opened browser'); } catch (err) { logger.warn(`Could not open browser automatically: ${err?.message || String(err)}`); } } const credential = await startCallbackServer({ host, port, callbackPath, stateParamName, expectedState: state, responseParamName, timeoutMs, logger, signal, successHtml, errorHtml, }); return { credential, state, port, authUrl }; }Usage Example
// In your CLI command: import { performBrowserLogin } from './browser-login.js'; async function loginCmd() { console.log('Waiting for login to complete...'); try { const { credential } = await performBrowserLogin({ baseAuthUrl: 'https://example.com/auth/cli-login', // Optional overrides: // callbackParamKind: 'url', // or 'port' (default) // callbackParamName: 'callbackUrl', // or 'callbackPort' // responseParamName: 'token', // extraQueryParams: { scope: 'cli' }, // timeoutMs: 5 * 60_000, }); // Persist the credential securely (keychain, config store, etc.) // await secrets.set('apiKey', credential) console.log('Login successful!'); } catch (err) { console.error(`Login failed: ${err.message}`); process.exitCode = 1; } }Options & Extensibility
- Callback transport:
- port style: set
callbackParamKind: 'port'and server reads/auth/callback.- full URL style: set
callbackParamKind: 'url'if your backend wants a callback URL.- Query names:
- Change
stateParamNameandresponseParamNameto match your backend.- UX:
- Customize
successHtmlanderrorHtmlfor the final browser page.- Abort/cancel:
- Pass an
AbortSignalviasignalto allow Ctrl+C handling to cancel the wait.- Fallback:
- If you need a polling fallback (e.g., localhost blocked), provide a custom
openthat triggers backend polling and bypasses the local server step; or wrapperformBrowserLoginin a try/catch and implement a polling path if port binding fails. Want me to add a polling fallback helper?
Depending on your backend and user experience goals, there are other common approaches for CLI login:
- Device Code Flow (OAuth 2.0 Device Authorization Grant)
- User is shown a short code and a URL to open manually.
- CLI polls the server until login completes.
- Works even if localhost callbacks are blocked (e.g., in locked-down environments).
- Copy-Paste Token Flow
- CLI prints a one-time URL.
- User logs in via browser and copies a displayed token back into the CLI.
- Very simple, but worse UX than automatic callbacks.
- PKCE / OAuth Authorization Code Flow
- Standards-based approach where CLI runs a full OAuth client flow.
- Provides refresh tokens, scopes, and integration with existing IdPs.
- Typically more secure and standardized, but heavier to implement.
- Polling APIs / Long-Polling
- Instead of a local callback, CLI polls the server for login completion.
- Useful fallback when firewalls or containerized environments prevent localhost binding.
- Service Accounts / API Keys
- Bypass interactive login entirely by provisioning static credentials.
- Appropriate for automation but not end-user flows.
This gist focuses on the localhost callback pattern because it provides the smoothest user experience for most CLI tools while keeping implementation light.