diff --git a/src/commands/login.ts b/src/commands/login.ts index 127ffcb..9852fd9 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 0000000..45a92f6 --- /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 c141897..2c77536 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 f791a21..208e9ec 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 1a0427c..3655a89 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;