Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 61 additions & 2 deletions src/commands/login.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -15,17 +15,31 @@ export function registerLoginCommand(program: Command): void {
.option('--email', 'Login with email and password instead of browser')
.option('--client-id <id>', 'OAuth client ID (defaults to insforge-cli)')
.option('--user-api-key <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 <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);
}
Expand Down Expand Up @@ -99,6 +113,51 @@ async function loginWithEmail(json: boolean, apiUrl?: string): Promise<void> {
}
}

/**
* `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 "<url from the browser address bar>"',
}));
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 "<pasted 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<void> {
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<void> {
if (!json) {
clack.intro('InsForge CLI');
Expand Down
168 changes: 168 additions & 0 deletions src/lib/auth.headless.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof OsModule>();
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');
});
});
Loading
Loading