From 03f9a071a054a2f25f91abe0cf632f76a88efbb1 Mon Sep 17 00:00:00 2001 From: yaowenc2 Date: Thu, 16 Jul 2026 16:40:30 +0800 Subject: [PATCH] feat(login): add --no-browser / --callback-url headless OAuth flow In sandboxed environments (ChatGPT app, SSH, containers) the browser can never reach the CLI's loopback callback server, so browser login hangs forever. Split the flow into two invocations: - login --no-browser: prints the authorize URL and persists PKCE verifier/state to ~/.insforge/pending-login.json (0600, 10-min TTL matching the server's authorization-code expiry), then exits. - login --callback-url : accepts the full redirect URL copied from the browser address bar (the 127.0.0.1 page shows a connection error but the code/state survive in the URL), verifies state, exchanges the code with the persisted verifier, and stores credentials. The default browser flow is unchanged; its non-TTY output now hints at the fallback. Co-Authored-By: Claude Fable 5 Claude-Session: https://claude.ai/code/session_01GcszudKMZBSm5RMP14h7fu --- src/commands/login.ts | 63 ++++++++++++- src/lib/auth.headless.test.ts | 168 ++++++++++++++++++++++++++++++++++ src/lib/auth.ts | 150 +++++++++++++++++++++++++++++- src/lib/config.ts | 24 ++++- src/types.ts | 15 +++ 5 files changed, 414 insertions(+), 6 deletions(-) create mode 100644 src/lib/auth.headless.test.ts diff --git a/src/commands/login.ts b/src/commands/login.ts index 127ffcb9..9852fd99 100644 --- a/src/commands/login.ts +++ b/src/commands/login.ts @@ -3,7 +3,7 @@ import * as clack from '@clack/prompts'; import * as prompts from '../lib/prompts.js'; import { saveCredentials, getPlatformApiUrl } from '../lib/config.js'; import { login as platformLogin } from '../lib/api/platform.js'; -import { performOAuthLogin } from '../lib/auth.js'; +import { performOAuthLogin, startHeadlessOAuthLogin, completeHeadlessOAuthLogin } from '../lib/auth.js'; import { handleError, getRootOpts, CLIError, formatFetchError } from '../lib/errors.js'; import { trackTopLevelUsage } from '../lib/command-telemetry.js'; import type { StoredCredentials, User } from '../types.js'; @@ -15,17 +15,31 @@ export function registerLoginCommand(program: Command): void { .option('--email', 'Login with email and password instead of browser') .option('--client-id ', 'OAuth client ID (defaults to insforge-cli)') .option('--user-api-key ', 'Authenticate with a uak_ user API key') + .option('--no-browser', 'Print the sign-in URL and exit; finish later with --callback-url (for sandboxes/SSH where the browser cannot reach this process)') + .option('--callback-url ', 'Complete a --no-browser login with the URL the browser was redirected to') .action(async (opts, cmd) => { const { json, apiUrl } = getRootOpts(cmd); // Which auth path was taken — user_api_key logins are the signal the // dashboard's connect-agent onboarding funnel is measured by. - const method = opts.userApiKey ? 'user_api_key' : opts.email ? 'email' : 'oauth'; + const method = opts.userApiKey + ? 'user_api_key' + : opts.email + ? 'email' + : opts.callbackUrl + ? 'oauth_callback_url' + : opts.browser === false + ? 'oauth_no_browser' + : 'oauth'; try { if (opts.userApiKey) { await loginWithUserApiKey(opts.userApiKey, json, apiUrl); } else if (opts.email) { await loginWithEmail(json, apiUrl); + } else if (opts.callbackUrl) { + await completeHeadlessLogin(opts.callbackUrl, json, apiUrl); + } else if (opts.browser === false) { + startHeadlessLogin(json, apiUrl); } else { await loginWithOAuth(json, apiUrl); } @@ -99,6 +113,51 @@ async function loginWithEmail(json: boolean, apiUrl?: string): Promise { } } +/** + * `login --no-browser`, step 1: print the authorize URL and exit. The PKCE + * state is persisted so a separate `login --callback-url` invocation can + * finish — required in agent sandboxes (e.g. the ChatGPT app) where the + * browser can never reach a loopback listener inside this process. + */ +function startHeadlessLogin(json: boolean, apiUrl?: string): void { + const { authUrl } = startHeadlessOAuthLogin(apiUrl); + + if (json) { + console.log(JSON.stringify({ + success: true, + pending: true, + auth_url: authUrl, + next_step: 'Open auth_url in a browser, sign in, then run: insforge login --callback-url ""', + })); + return; + } + + clack.intro('InsForge CLI'); + clack.log.info(`Open this URL in your browser to sign in:\n\n${authUrl}`); + clack.log.info( + 'After signing in, the browser will land on a http://127.0.0.1/... page that cannot connect — that is expected.\n' + + 'Copy the FULL URL from the address bar and run:\n\n' + + ' insforge login --callback-url ""', + ); + clack.outro('Waiting for you to finish in the browser (link valid ~10 minutes)'); +} + +/** `login --callback-url`, step 2: redeem the pasted callback URL. */ +async function completeHeadlessLogin(callbackUrl: string, json: boolean, apiUrl?: string): Promise { + if (!json) { + clack.intro('InsForge CLI'); + } + + const creds = await completeHeadlessOAuthLogin(callbackUrl, apiUrl); + + if (!json) { + clack.log.success(`Authenticated as ${creds.user.email || creds.user.id}`); + clack.outro('Done'); + } else { + console.log(JSON.stringify({ success: true, user: creds.user })); + } +} + async function loginWithOAuth(json: boolean, apiUrl?: string): Promise { if (!json) { clack.intro('InsForge CLI'); diff --git a/src/lib/auth.headless.test.ts b/src/lib/auth.headless.test.ts new file mode 100644 index 00000000..45a92f62 --- /dev/null +++ b/src/lib/auth.headless.test.ts @@ -0,0 +1,168 @@ +import { mkdtempSync, rmSync, existsSync, readFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { describe, it, expect, vi, beforeAll, afterAll, beforeEach } from 'vitest'; + +// GLOBAL_DIR in config.ts is derived from homedir() at import time, so the +// mock must be in place before auth.js/config.js are (dynamically) imported. +// Type-only imports are erased at compile time, so they don't evaluate the +// modules before the homedir mock is in place — the real imports are dynamic. +import type * as AuthModule from './auth.js'; +import type * as ConfigModule from './config.js'; +import type * as OsModule from 'node:os'; + +const mocks = vi.hoisted(() => ({ home: '' })); +vi.mock('node:os', async (importOriginal) => { + const actual = await importOriginal(); + return { ...actual, homedir: () => mocks.home || actual.homedir() }; +}); + +let auth: typeof AuthModule; +let config: typeof ConfigModule; + +beforeAll(async () => { + mocks.home = mkdtempSync(join(tmpdir(), 'insforge-auth-test-')); + auth = await import('./auth.js'); + config = await import('./config.js'); +}); + +afterAll(() => { + rmSync(mocks.home, { recursive: true, force: true }); +}); + +beforeEach(() => { + config.clearPendingLogin(); + vi.unstubAllGlobals(); +}); + +describe('parseCallbackInput', () => { + it('parses a full callback URL', () => { + const result = auth.parseCallbackInput( + 'http://127.0.0.1:38961/callback?code=ac_abc123&state=st_xyz', + ); + expect(result).toEqual({ code: 'ac_abc123', state: 'st_xyz' }); + }); + + it('tolerates surrounding whitespace and quotes from copy-paste', () => { + const result = auth.parseCallbackInput( + ' "http://127.0.0.1:1234/callback?code=ac_a&state=s1" ', + ); + expect(result).toEqual({ code: 'ac_a', state: 's1' }); + }); + + it('accepts a bare query string', () => { + expect(auth.parseCallbackInput('?code=ac_a&state=s1')).toEqual({ code: 'ac_a', state: 's1' }); + expect(auth.parseCallbackInput('code=ac_a&state=s1')).toEqual({ code: 'ac_a', state: 's1' }); + }); + + it('surfaces the provider error over a missing code', () => { + expect(() => + auth.parseCallbackInput('http://127.0.0.1:1/callback?error=access_denied&error_description=User+denied'), + ).toThrow(/denied/i); + }); + + it('rejects a bare code with guidance to paste the full URL', () => { + expect(() => auth.parseCallbackInput('ac_abc123')).toThrow(/full callback URL/i); + }); + + it('rejects a URL missing state', () => { + expect(() => auth.parseCallbackInput('http://127.0.0.1:1/callback?code=ac_a')).toThrow(/missing code or state/i); + }); +}); + +describe('startHeadlessOAuthLogin', () => { + it('persists pending state and returns a matching authorize URL', () => { + const { authUrl, redirectUri } = auth.startHeadlessOAuthLogin(); + const url = new URL(authUrl); + + expect(url.pathname).toBe('/api/oauth/v1/authorize'); + expect(url.searchParams.get('redirect_uri')).toBe(redirectUri); + expect(url.searchParams.get('code_challenge_method')).toBe('S256'); + + const pending = config.getPendingLogin(); + expect(pending).not.toBeNull(); + expect(pending!.state).toBe(url.searchParams.get('state')); + expect(pending!.redirect_uri).toBe(redirectUri); + expect(pending!.code_verifier).toBeTruthy(); + // Loopback redirect, random port — the platform allows any 127.0.0.1 port. + expect(redirectUri).toMatch(/^http:\/\/127\.0\.0\.1:\d+\/callback$/); + }); +}); + +describe('completeHeadlessOAuthLogin', () => { + function stubTokenAndProfileFetch() { + const fetchMock = vi.fn(async (input: string | URL | Request) => { + const url = String(input); + if (url.includes('/api/oauth/v1/token')) { + return new Response( + JSON.stringify({ access_token: 'at_test', refresh_token: 'rt_test', expires_in: 3600 }), + { status: 200, headers: { 'Content-Type': 'application/json' } }, + ); + } + if (url.includes('/auth/v1/profile')) { + return new Response( + JSON.stringify({ user: { id: 'u1', name: 'T', email: 't@x.dev', avatar_url: null, email_verified: true } }), + { status: 200, headers: { 'Content-Type': 'application/json' } }, + ); + } + throw new Error(`Unexpected fetch: ${url}`); + }); + vi.stubGlobal('fetch', fetchMock); + return fetchMock; + } + + it('fails with guidance when no login is pending', async () => { + await expect( + auth.completeHeadlessOAuthLogin('http://127.0.0.1:1/callback?code=ac_a&state=s1'), + ).rejects.toThrow(/--no-browser/); + }); + + it('rejects a state mismatch', async () => { + auth.startHeadlessOAuthLogin(); + await expect( + auth.completeHeadlessOAuthLogin('http://127.0.0.1:1/callback?code=ac_a&state=WRONG'), + ).rejects.toThrow(/state mismatch/i); + }); + + it('rejects an expired pending login and clears it', async () => { + auth.startHeadlessOAuthLogin(); + const pending = config.getPendingLogin()!; + config.savePendingLogin({ + ...pending, + created_at: new Date(Date.now() - 11 * 60 * 1000).toISOString(), + }); + await expect( + auth.completeHeadlessOAuthLogin(`http://127.0.0.1:1/callback?code=ac_a&state=${pending.state}`), + ).rejects.toThrow(/expired/i); + expect(config.getPendingLogin()).toBeNull(); + }); + + it('exchanges the code with the pending PKCE verifier and stores credentials', async () => { + const fetchMock = stubTokenAndProfileFetch(); + const { redirectUri } = auth.startHeadlessOAuthLogin(); + const pending = config.getPendingLogin()!; + + const creds = await auth.completeHeadlessOAuthLogin( + `${redirectUri}?code=ac_good&state=${pending.state}`, + ); + + // Token exchange used the persisted verifier + the exact redirect_uri. + const tokenCall = fetchMock.mock.calls.find(([u]) => String(u).includes('/token'))!; + const body = JSON.parse((tokenCall[1] as RequestInit).body as string); + expect(body).toMatchObject({ + grant_type: 'authorization_code', + code: 'ac_good', + redirect_uri: redirectUri, + code_verifier: pending.code_verifier, + }); + + expect(creds.access_token).toBe('at_test'); + expect(creds.user.email).toBe('t@x.dev'); + + // Pending file is single-use; credentials are persisted 0600. + expect(config.getPendingLogin()).toBeNull(); + const credFile = join(mocks.home, '.insforge', 'credentials.json'); + expect(existsSync(credFile)).toBe(true); + expect(JSON.parse(readFileSync(credFile, 'utf-8')).access_token).toBe('at_test'); + }); +}); diff --git a/src/lib/auth.ts b/src/lib/auth.ts index c141897b..2c77536a 100644 --- a/src/lib/auth.ts +++ b/src/lib/auth.ts @@ -1,13 +1,20 @@ import { createServer } from 'node:http'; -import { randomBytes, createHash } from 'node:crypto'; +import { randomBytes, randomInt, createHash } from 'node:crypto'; import { URL } from 'node:url'; import * as clack from '@clack/prompts'; import pc from 'picocolors'; import { isInteractive } from './prompts.js'; -import { getGlobalConfig, getPlatformApiUrl, saveCredentials } from './config.js'; +import { + getGlobalConfig, + getPlatformApiUrl, + saveCredentials, + getPendingLogin, + savePendingLogin, + clearPendingLogin, +} from './config.js'; import { getProfile } from './api/platform.js'; import { formatFetchError } from './errors.js'; -import type { StoredCredentials } from '../types.js'; +import type { PendingOAuthLogin, StoredCredentials } from '../types.js'; // Default OAuth client for InsForge CLI (pre-registered on the platform) export const DEFAULT_CLIENT_ID = 'clf_NK8cMUs41gm8ZcfdtSguVw'; @@ -230,6 +237,7 @@ export async function performOAuthLogin(apiUrl?: string): Promise]+$/g, ''); + + let params: URLSearchParams; + if (cleaned.includes('://')) { + let url: URL; + try { + url = new URL(cleaned); + } catch { + throw new Error('Could not parse the callback URL. Paste the full URL from the browser address bar.'); + } + params = url.searchParams; + } else if (cleaned.includes('code=')) { + params = new URLSearchParams(cleaned.replace(/^\?/, '')); + } else { + throw new Error( + 'Expected the full callback URL (http://127.0.0.1:.../callback?code=...&state=...) from the browser address bar, not a bare code.', + ); + } + + const error = params.get('error'); + if (error) { + throw new Error(params.get('error_description') ?? `Authorization failed: ${error}`); + } + + const code = params.get('code'); + const state = params.get('state'); + if (!code || !state) { + throw new Error('Callback URL is missing code or state. Paste the full URL from the browser address bar.'); + } + return { code, state }; +} + +/** + * Headless OAuth login, step 2 of 2 (`login --callback-url `). + * Verifies the pasted callback against the pending login saved by + * startHeadlessOAuthLogin, exchanges the code, and stores credentials. + */ +export async function completeHeadlessOAuthLogin( + callbackInput: string, + apiUrl?: string, +): Promise { + const pending = getPendingLogin(); + if (!pending) { + throw new Error('No login in progress. Run `insforge login --no-browser` first, then retry with the callback URL.'); + } + if (Date.now() - new Date(pending.created_at).getTime() > PENDING_LOGIN_TTL_MS) { + clearPendingLogin(); + throw new Error('The pending login expired (10 minutes). Run `insforge login --no-browser` again.'); + } + + const { code, state } = parseCallbackInput(callbackInput); + if (state !== pending.state) { + throw new Error('State mismatch — the callback URL does not belong to this login attempt. Run `insforge login --no-browser` again.'); + } + + const tokens = await exchangeCodeForTokens({ + platformUrl: apiUrl ? getPlatformApiUrl(apiUrl) : pending.platform_url, + code, + redirectUri: pending.redirect_uri, + clientId: pending.client_id, + codeVerifier: pending.code_verifier, + }); + clearPendingLogin(); + + const creds: StoredCredentials = { + access_token: tokens.access_token, + refresh_token: tokens.refresh_token, + user: { id: '', name: '', email: '', avatar_url: null, email_verified: true }, + }; + saveCredentials(creds); + + try { + const profile = await getProfile(apiUrl); + creds.user = profile; + saveCredentials(creds); + } catch { + // Token is saved and valid; profile fetch is best-effort (same as performOAuthLogin). + } + + return creds; +} diff --git a/src/lib/config.ts b/src/lib/config.ts index f791a21d..208e9ec8 100644 --- a/src/lib/config.ts +++ b/src/lib/config.ts @@ -1,11 +1,12 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync, unlinkSync } from 'node:fs'; import { homedir } from 'node:os'; import { join } from 'node:path'; -import type { GlobalConfig, ProjectConfig, StoredCredentials } from '../types.js'; +import type { GlobalConfig, PendingOAuthLogin, ProjectConfig, StoredCredentials } from '../types.js'; const GLOBAL_DIR = join(homedir(), '.insforge'); const CREDENTIALS_FILE = join(GLOBAL_DIR, 'credentials.json'); const CONFIG_FILE = join(GLOBAL_DIR, 'config.json'); +const PENDING_LOGIN_FILE = join(GLOBAL_DIR, 'pending-login.json'); const DEFAULT_PLATFORM_URL = 'https://api.insforge.dev'; const DEFAULT_FRONTEND_URL = 'https://insforge.dev'; @@ -50,6 +51,27 @@ export function saveCredentials(creds: StoredCredentials): void { writeFileSync(CREDENTIALS_FILE, JSON.stringify(creds, null, 2), { mode: 0o600 }); } +// --- Pending headless OAuth login (login --no-browser) --- + +export function getPendingLogin(): PendingOAuthLogin | null { + if (!existsSync(PENDING_LOGIN_FILE)) { + return null; + } + const raw = readFileSync(PENDING_LOGIN_FILE, 'utf-8'); + return JSON.parse(raw); +} + +export function savePendingLogin(pending: PendingOAuthLogin): void { + ensureGlobalDir(); + writeFileSync(PENDING_LOGIN_FILE, JSON.stringify(pending, null, 2), { mode: 0o600 }); +} + +export function clearPendingLogin(): void { + if (existsSync(PENDING_LOGIN_FILE)) { + unlinkSync(PENDING_LOGIN_FILE); + } +} + export function clearCredentials(): void { if (existsSync(CREDENTIALS_FILE)) { unlinkSync(CREDENTIALS_FILE); diff --git a/src/types.ts b/src/types.ts index 1a0427ce..3655a895 100644 --- a/src/types.ts +++ b/src/types.ts @@ -91,6 +91,21 @@ export interface StoredCredentials { user: User; } +/** + * In-flight `login --no-browser` state persisted between the invocation that + * prints the authorize URL and the later `login --callback-url` invocation + * that redeems the pasted code. Holds the PKCE verifier, so it is stored + * 0600 and deleted as soon as the login completes (or expires). + */ +export interface PendingOAuthLogin { + code_verifier: string; + state: string; + redirect_uri: string; + platform_url: string; + client_id: string; + created_at: string; +} + // Global config export interface GlobalConfig { default_org_id?: string;