import { exec } from 'node:child_process' import { randomBytes } from 'node:crypto' import http from 'node:http' import { createInterface } from 'node:readline/promises' import { refreshSharedDesktopAuthSession, sharedDesktopAuthAccounts, writeSharedDesktopAuthSession, type SharedDesktopAuthSession, } from '../../src/auth/shared-session' import { rnxPublicBrand } from '../../src/public-brand' import { rnxExit } from '../run-rnx' const REMOTE_DEFAULT_ORIGIN = process.env.RNX_AUTH_ORIGIN || 'https://contrast.dev' type LoginResult = | { ok: true; token: string; userId?: string; email?: string } | { ok: false; error: string } function resolveDefaultAuthOrigin(explicitOrigin?: string) { if (explicitOrigin) return explicitOrigin if (process.env.RNX_AUTH_ORIGIN) return process.env.RNX_AUTH_ORIGIN if (process.env.RNX_UPLOAD_ORIGIN) return process.env.RNX_UPLOAD_ORIGIN return REMOTE_DEFAULT_ORIGIN } function printHelp() { console.log(` rnx login — sign in so uploads can attach to your account usage: rnx login [--origin ] [--account ] options: --origin auth host (default: ${REMOTE_DEFAULT_ORIGIN}) override with RNX_AUTH_ORIGIN env var --account the account remote simulators and boxes bill to. one account is chosen for you; several ask. -h, --help `) } // a session token names the person, not an account. the account that pays for // cloud work is chosen here, once, and stored on the session; a person with one // account never sees the question. async function chooseAccount( session: SharedDesktopAuthSession, requested: string | undefined, ): Promise<{ id: string; name: string } | null> { const accounts = sharedDesktopAuthAccounts(session.user) if (requested !== undefined) { const wanted = requested.trim().toLowerCase() const match = accounts.find( (account) => account.id === requested.trim() || account.name.toLowerCase() === wanted, ) if (!match) { console.error( ` no account named ${JSON.stringify(requested)}. your accounts:\n` + accounts.map((account) => ` ${account.name} (${account.id})`).join('\n'), ) rnxExit(1) } return match } if (accounts.length <= 1) return accounts[0] ?? null if (!process.stdin.isTTY) { console.error( ' this sign-in belongs to several accounts; rerun with --account to choose the one to bill:\n' + accounts.map((account) => ` ${account.name} (${account.id})`).join('\n'), ) rnxExit(1) } console.log(' which account should remote simulators and boxes bill to?') accounts.forEach((account, index) => console.log(` ${index + 1}. ${account.name}`)) const prompt = createInterface({ input: process.stdin, output: process.stdout }) try { for (;;) { const answer = (await prompt.question(` account [1-${accounts.length}]: `)).trim() const chosen = accounts[Number(answer) - 1] if (chosen && /^\d+$/.test(answer)) return chosen console.log(` enter a number from 1 to ${accounts.length}`) } } finally { prompt.close() } } async function finishLogin( origin: string, requestedAccount: string | undefined, fallbackLabel: string | undefined, suffix: string, ) { const refreshed = await refreshSharedDesktopAuthSession(origin) const account = refreshed ? await chooseAccount(refreshed, requestedAccount) : null if (refreshed && account) { writeSharedDesktopAuthSession({ ...refreshed, accountId: account.id }) } const label = refreshed?.user?.email || fallbackLabel || refreshed?.user?.id console.log( ` signed in${label ? ` as ${label}` : ''}${account ? ` (${account.name})` : ''}${suffix}`, ) } function extractStringFlag(args: string[], name: string): string | undefined { const idx = args.findIndex((a) => a === name) if (idx < 0) return undefined const value = args[idx + 1] args.splice(idx, 2) return value } function openBrowser(url: string) { const command = process.platform === 'darwin' ? `open "${url}"` : process.platform === 'win32' ? `start "" "${url}"` : `xdg-open "${url}"` exec(command, (err) => { if (!err) return console.log(' could not open browser automatically.') console.log(` open this URL manually:\n ${url}\n`) }) } function renderCallbackPage(body: string) { return `

${rnxPublicBrand.name}

${body}

` } async function runDevLogin(origin: string): Promise { try { const res = await fetch(`${origin.replace(/\/$/, '')}/api/dev-login`, { method: 'POST', }) if (res.status === 403) { return { ok: false, error: 'local dev-login unavailable' } } if (!res.ok) { const text = await res.text().catch(() => '') return { ok: false, error: `dev-login ${res.status}: ${text}` } } const body = (await res.json()) as { ok?: boolean token?: string email?: string user?: { id?: string } } if (!body.token) { return { ok: false, error: 'dev-login response missing token' } } return { ok: true, token: body.token, userId: body.user?.id, email: body.email, } } catch (err) { return { ok: false, error: err instanceof Error ? err.message : 'dev-login fetch failed', } } } function isLocalOrigin(origin: string): boolean { try { const hostname = new URL(origin).hostname.replace(/^\[|\]$/g, '').toLowerCase() if (hostname === 'localhost') return true if (/^127(?:\.\d{1,3}){3}$/.test(hostname)) return true if (hostname === '::1' || hostname === '0.0.0.0') return true if (hostname.endsWith('.local')) return true if (hostname.endsWith('.localhost')) return true // VM↔host bridge aliases — orb, docker, lima, multipass all forward the // host on a well-known name (IPv4 or IPv6) so a VM-internal `rnx login` // can still take the dev-login fast path against the host's Contrast dev // server. without this the CLI falls through to a browser callback that // is unreachable from a headless VM. if (hostname === 'host.containers.internal') return true if (hostname === 'host.docker.internal') return true if (hostname === 'host.orb.internal') return true if (hostname === 'host.lima.internal') return true // orb's IPv6 form of host.orb.internal: fd07:b51a:cc66:f0::fe (always // this fixed address; it's a stable orb ULA assignment, see orb docs). if (hostname === 'fd07:b51a:cc66:f0::fe') return true // lima's qemu (slirp) backend does NOT resolve host.lima.internal — the // host is the fixed slirp gateway 192.168.5.2. a lima/qemu user therefore // targets the raw gateway IP, which would otherwise miss the dev-login fast // path and fall through to the headless-broken browser callback. recognize // it (and multipass's default bridge .1 gateways) so dev-login still fires. // safe: no-device dev-login requests are accepted only in local dev. if (hostname === '192.168.5.2') return true if (/^192\.168\.(?:64|139)\.1$/.test(hostname)) return true return false } catch { return false } } export async function runLogin(args: string[]) { if (args.includes('--help') || args.includes('-h')) { printHelp() rnxExit(0) } const mutableArgs = [...args] const origin = resolveDefaultAuthOrigin(extractStringFlag(mutableArgs, '--origin')) const requestedAccount = extractStringFlag(mutableArgs, '--account') // in dev (localhost), skip the browser and sign in as the shared dev // user via POST /api/dev-login. the no-device contract is local-only, so this // path is safe to take unconditionally against local hosts. if (isLocalOrigin(origin)) { console.log(` detected local origin (${origin}) — signing in as dev user`) const devResult = await runDevLogin(origin) if (devResult.ok) { writeSharedDesktopAuthSession({ token: devResult.token, user: devResult.userId ? { id: devResult.userId, email: devResult.email } : null, origin, source: 'cli', }) await finishLogin( origin, requestedAccount, devResult.email || devResult.userId, ' (dev)', ) return } console.log(` dev-login not available (${devResult.error}); falling back to browser`) } const state = randomBytes(16).toString('hex') // the browser round-trip needs human action (github's authorize screen on // first sign-in), so the wait is generous, but it must not be infinite: a // closed tab, denied consent, or blocked popup used to hang the CLI forever. const LOGIN_TIMEOUT_MS = 5 * 60 * 1000 const result = await new Promise((resolve) => { let timer: ReturnType let settled = false const finish = (r: LoginResult) => { if (settled) return settled = true clearTimeout(timer) resolve(r) server.close() } const server = http.createServer((req, res) => { try { const url = new URL(req.url || '/', 'http://127.0.0.1') const returnedState = url.searchParams.get('state') const token = url.searchParams.get('token') const email = url.searchParams.get('email') || undefined const userId = url.searchParams.get('userId') || undefined const error = url.searchParams.get('error') // stray localhost traffic (port scanners, favicon fetches, browser // prefetch) must not abort the login. only a request carrying our // state is the real callback; rfc 8252 loopback servers ignore the // rest. aborting here used to kill the login mid-wait. if (returnedState !== state) { res.statusCode = 404 res.end('not found') return } res.setHeader('content-type', 'text/html; charset=utf-8') if (error) { res.end(renderCallbackPage(`sign-in failed: ${error}`)) finish({ ok: false, error }) return } if (!token) { res.end( renderCallbackPage('missing token. close this tab and retry `rnx login`.'), ) finish({ ok: false, error: 'missing token' }) return } res.end(renderCallbackPage('sign-in complete. you can return to the terminal.')) finish({ ok: true, token, email, userId }) } catch (err) { res.statusCode = 500 res.end(renderCallbackPage('sign-in callback failed. retry `rnx login`.')) finish({ ok: false, error: err instanceof Error ? err.message : 'callback failed', }) } }) timer = setTimeout(() => { finish({ ok: false, error: 'timed out waiting for the browser sign-in (5 minutes). rerun `rnx login` and complete the github authorize screen if it appears.', }) }, LOGIN_TIMEOUT_MS) server.listen(0, '127.0.0.1', () => { const address = server.address() if (!address || typeof address === 'string') { finish({ ok: false, error: 'failed to bind callback server' }) return } const url = new URL(`${origin.replace(/\/$/, '')}/auth/rnx-cli`) url.searchParams.set('state', state) url.searchParams.set('port', String(address.port)) url.searchParams.set('contrast', origin) console.log(' opening browser for rnx login...') console.log( ' first time here? github will show an authorize screen. approve it to continue.', ) console.log(` waiting up to 5 minutes for the browser (ctrl+c to cancel)`) console.log(` if nothing opens, visit:\n ${url.toString()}\n`) openBrowser(url.toString()) }) }) if (!result.ok) { console.error(` login failed: ${result.error}`) rnxExit(1) } writeSharedDesktopAuthSession({ token: result.token, user: result.userId ? { id: result.userId, email: result.email, } : null, origin, source: 'cli', }) await finishLogin(origin, requestedAccount, result.email || result.userId, '') }