/** * Browser-based authentication for Tinybird CLI * * Implements OAuth flow via local HTTP server callback */ import * as http from "node:http"; import { spawn } from "node:child_process"; import { platform } from "node:os"; import { URL } from "node:url"; import { tinybirdFetch } from "../api/fetcher.js"; /** * Port for the local OAuth callback server */ export const AUTH_SERVER_PORT = 49160; /** * Default auth host (Tinybird cloud) */ export const DEFAULT_AUTH_HOST = "https://cloud.tinybird.co"; /** * Default API host (EU region) */ export const DEFAULT_API_HOST = "https://api.tinybird.co"; /** * Maximum time to wait for authentication (in seconds) */ export const SERVER_MAX_WAIT_TIME = 180; /** * Get the auth host from environment or use default */ export function getAuthHost(): string { return process.env.TINYBIRD_AUTH_HOST ?? DEFAULT_AUTH_HOST; } /** * Result of a login attempt */ export interface AuthResult { success: boolean; token?: string; baseUrl?: string; workspaceName?: string; userEmail?: string; error?: string; } /** * Options for the browser login flow */ export interface LoginOptions { /** Override the default auth host */ authHost?: string; /** Override the API host (region) */ apiHost?: string; } /** * Token response from Tinybird auth API */ interface TokenResponse { workspace_token: string; user_token: string; api_host: string; workspace_name?: string; user_email?: string; } /** * Generate the HTML callback page served by the local server * * This page extracts the code from the query string and POSTs it back to the server * * NOTE: State parameter validation is disabled until Tinybird backend supports it. * TODO: Re-enable state validation once /api/cli-login echoes back the state parameter. */ function getCallbackHtml(authHost: string): string { return `

Completing authentication...

`; } /** * Start a local HTTP server to receive the OAuth callback * * @param onCode - Callback invoked when auth code is received * @param authHost - Auth host for redirect URL in HTML * @returns Promise that resolves to the server instance * * NOTE: State parameter validation is disabled until Tinybird backend supports it. */ function startAuthServer( onCode: (code: string) => void, authHost: string ): Promise { return new Promise((resolve, reject) => { const server = http.createServer((req, res) => { const url = new URL( req.url ?? "/", `http://localhost:${AUTH_SERVER_PORT}` ); if (req.method === "GET") { // Serve the callback HTML page res.writeHead(200, { "Content-Type": "text/html" }); res.end(getCallbackHtml(authHost)); } else if (req.method === "POST") { // Receive the auth code const code = url.searchParams.get("code"); if (!code) { res.writeHead(400); res.end("Missing code parameter"); return; } onCode(code); res.writeHead(200); res.end(); } else { res.writeHead(405); res.end("Method not allowed"); } }); server.on("error", (err) => { reject(new Error(`Failed to start auth server: ${err.message}`)); }); // Bind to localhost only for security (prevents network access) server.listen(AUTH_SERVER_PORT, "127.0.0.1", () => { resolve(server); }); }); } /** * Open a URL in the user's default browser * * Cross-platform support for macOS, Linux, and Windows * * @param url - URL to open * @returns Promise that resolves to true if browser was opened successfully */ export async function openBrowser(url: string): Promise { const os = platform(); let command: string; let args: string[]; switch (os) { case "darwin": command = "open"; args = [url]; break; case "win32": command = "cmd"; args = ["/c", "start", "", url]; break; default: // Linux and others command = "xdg-open"; args = [url]; break; } return new Promise((resolve) => { try { const child = spawn(command, args, { detached: true, stdio: "ignore", }); child.unref(); child.on("error", () => { resolve(false); }); // Give it a moment to potentially fail setTimeout(() => resolve(true), 500); } catch { resolve(false); } }); } /** * Exchange an authorization code for tokens * * @param code - Authorization code from OAuth callback * @param authHost - Auth host URL * @returns Promise that resolves to token response */ export async function exchangeCodeForTokens( code: string, authHost: string ): Promise { const url = new URL("/api/cli-login", authHost); url.searchParams.set("code", code); const response = await tinybirdFetch(url.toString()); if (!response.ok) { const body = await response.text(); throw new Error( `Token exchange failed: ${response.status} ${response.statusText}\n${body}` ); } return (await response.json()) as TokenResponse; } /** * Perform browser-based login flow * * 1. Starts a local HTTP server for OAuth callback * 2. Opens the user's browser to the auth URL * 3. Waits for the callback with auth code * 4. Exchanges the code for tokens * * @param options - Login options * @returns Promise that resolves to auth result */ export async function browserLogin( options: LoginOptions = {} ): Promise { const authHost = options.authHost ?? getAuthHost(); const apiHost = options.apiHost ?? DEFAULT_API_HOST; let server: http.Server | null = null; let timeoutId: ReturnType | null = null; try { // Start the server first const serverPromise = new Promise<{ server: http.Server; code: string }>( (resolve, reject) => { // Set up timeout timeoutId = setTimeout(() => { reject(new Error("Authentication timed out after 180 seconds")); }, SERVER_MAX_WAIT_TIME * 1000); startAuthServer((code) => { if (timeoutId) clearTimeout(timeoutId); if (server) { resolve({ server, code }); } }, authHost) .then((srv) => { server = srv; }) .catch(reject); } ); // Wait for server to start await new Promise((resolve) => setTimeout(resolve, 100)); // Build auth URL const authUrl = new URL("/api/cli-login", authHost); authUrl.searchParams.set("apiHost", apiHost); authUrl.searchParams.set("origin", "ts-sdk"); console.log("Opening browser for authentication..."); // Open browser await openBrowser(authUrl.toString()); console.log("\nIf the browser doesn't open, please visit:"); console.log(authUrl.toString()); // Wait for auth code const { code } = await serverPromise; // Exchange code for tokens console.log("\nExchanging code for tokens..."); const tokens = await exchangeCodeForTokens(code, authHost); return { success: true, token: tokens.workspace_token, baseUrl: tokens.api_host, workspaceName: tokens.workspace_name, userEmail: tokens.user_email, }; } catch (error) { return { success: false, error: (error as Error).message, }; } finally { // Clean up if (timeoutId) clearTimeout(timeoutId); if (server) { (server as http.Server).close(); } } }