Skip to content

Instantly share code, notes, and snippets.

@0xdevalias
Last active August 29, 2025 05:46
Show Gist options
  • Select an option

  • Save 0xdevalias/ddf54720815720a2cd42b83955bb346e to your computer and use it in GitHub Desktop.

Select an option

Save 0xdevalias/ddf54720815720a2cd42b83955bb346e to your computer and use it in GitHub Desktop.
PoC Browser-Based CLI Login Helper (Node.js)

[PoC] Browser-Based CLI Login Helper (Node.js)

Table of Contents

Overview

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:

  1. Spins up a temporary local HTTP server.
  2. Opens the user’s browser to your backend’s login endpoint.
  3. Waits for the backend to redirect back with a credential (e.g., API key, token).
  4. Validates state/CSRF token and resolves with the credential.
  5. Cleans up the server and displays a success or error page in the browser.

Features

  • 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 (port or url)
    • 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 open function, 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 in jK1, and builds ${ampURL}/auth/cli-login?authToken=<token>&callbackPort=<port>.
  • Open browser: kOA() opens that URL.
  • Wait for callback: SOA(token) starts a node:http server on 127.0.0.1:<port> and listens for GET /auth/callback?....
  • Verify and finish: It checks authToken matches, reads apiKey from the query, returns it, stores it via vOA(), 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/callback with 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 stateParamName and responseParamName to match your backend.
  • UX:
    • Customize successHtml and errorHtml for the final browser page.
  • Abort/cancel:
    • Pass an AbortSignal via signal to allow Ctrl+C handling to cancel the wait.
  • Fallback:
    • If you need a polling fallback (e.g., localhost blocked), provide a custom open that triggers backend polling and bypasses the local server step; or wrap performBrowserLogin in a try/catch and implement a polling path if port binding fails. Want me to add a polling fallback helper?

Related Patterns & Alternatives

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment