From 0c500855c5334c51395447cc46038c2747be0489 Mon Sep 17 00:00:00 2001 From: William Valentin Date: Thu, 26 Feb 2026 11:31:19 -0800 Subject: [PATCH] feat(tui): replace Anthropic token paste with browser OAuth flow --- src/auth/index.ts | 7 ++ src/frontends/tui/minimal.login.test.ts | 118 ++++++++++++++++++++++++ src/frontends/tui/minimal.ts | 23 ++--- 3 files changed, 137 insertions(+), 11 deletions(-) diff --git a/src/auth/index.ts b/src/auth/index.ts index c2f87b3..80525cc 100644 --- a/src/auth/index.ts +++ b/src/auth/index.ts @@ -7,7 +7,14 @@ export { storeAnthropicAuthToken, clearAnthropicAuthToken, getAnthropicAuthToken, + generateCodeVerifier, + generateCodeChallenge, + startCallbackServer, + openBrowser, + exchangeCodeForToken, + loginAnthropicOAuth, type AnthropicAuthInfo, + type CallbackServer, } from './anthropic.js'; export { diff --git a/src/frontends/tui/minimal.login.test.ts b/src/frontends/tui/minimal.login.test.ts index 36340ea..4294538 100644 --- a/src/frontends/tui/minimal.login.test.ts +++ b/src/frontends/tui/minimal.login.test.ts @@ -7,10 +7,14 @@ const { mockLoadStoredAnthropicAuth, mockLoadStoredAnthropicAuthToken, mockStoreAnthropicAuth, + mockLoginAnthropicOAuth, + mockOpenBrowser, } = vi.hoisted(() => ({ mockLoadStoredAnthropicAuth: vi.fn(), mockLoadStoredAnthropicAuthToken: vi.fn(), mockStoreAnthropicAuth: vi.fn(), + mockLoginAnthropicOAuth: vi.fn(), + mockOpenBrowser: vi.fn(), })); const { mockCreateInterface } = vi.hoisted(() => ({ @@ -23,8 +27,10 @@ vi.mock('../../auth/index.js', () => ({ loadStoredOpenAIApiKey: vi.fn(), loadStoredOpenAIAuth: vi.fn(), loadStoredZaiAuth: vi.fn(), + loginAnthropicOAuth: mockLoginAnthropicOAuth, loginGitHub: vi.fn(), loginOpenAI: vi.fn(), + openBrowser: mockOpenBrowser, storeAnthropicAuth: mockStoreAnthropicAuth, storeAnthropicAuthToken: vi.fn(), storeOpenAIApiKey: vi.fn(), @@ -152,3 +158,115 @@ describe('MinimalTui login re-auth confirmation', () => { consoleError.mockRestore(); }); }); + +describe('MinimalTui anthropic browser OAuth', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockLoadStoredAnthropicAuth.mockReturnValue(null); + mockLoadStoredAnthropicAuthToken.mockReturnValue(null); + mockCreateInterface.mockReset(); + }); + + it('calls loginAnthropicOAuth when user selects option 2', async () => { + mockLoginAnthropicOAuth.mockResolvedValue('tok-from-browser'); + + const { MinimalTui } = await import('./minimal.js'); + + const mockSession = { + id: 'test', + getHistory: () => [], + addMessage: vi.fn(), + clear: vi.fn(), + replaceHistory: vi.fn(), + }; + + const tui = new MinimalTui({ + session: asSession(mockSession), + modelClient: asModelClient({}), + modelRouter: asModelRouter({}), + systemPrompt: 'test', + }); + + minimalTuiPrivates(tui).rl = { pause: vi.fn(), resume: vi.fn() }; + vi.spyOn(minimalTuiPrivates(tui), 'prompt').mockResolvedValue('2'); + + const consoleLog = vi.spyOn(console, 'log').mockImplementation(() => undefined); + + await minimalTuiPrivates(tui).handleLoginCommand('anthropic'); + + expect(mockLoginAnthropicOAuth).toHaveBeenCalled(); + expect(consoleLog).toHaveBeenCalledWith(expect.stringContaining('auth token stored')); + + consoleLog.mockRestore(); + }); + + it('shows error message when OAuth fails', async () => { + mockLoginAnthropicOAuth.mockRejectedValue(new Error('OAuth timed out')); + + const { MinimalTui } = await import('./minimal.js'); + + const mockSession = { + id: 'test', + getHistory: () => [], + addMessage: vi.fn(), + clear: vi.fn(), + replaceHistory: vi.fn(), + }; + + const tui = new MinimalTui({ + session: asSession(mockSession), + modelClient: asModelClient({}), + modelRouter: asModelRouter({}), + systemPrompt: 'test', + }); + + minimalTuiPrivates(tui).rl = { pause: vi.fn(), resume: vi.fn() }; + vi.spyOn(minimalTuiPrivates(tui), 'prompt').mockResolvedValue('2'); + + const consoleLog = vi.spyOn(console, 'log').mockImplementation(() => undefined); + + await minimalTuiPrivates(tui).handleLoginCommand('anthropic'); + + expect(mockLoginAnthropicOAuth).toHaveBeenCalled(); + expect(consoleLog).toHaveBeenCalledWith( + expect.stringContaining('OAuth failed'), + ); + + consoleLog.mockRestore(); + }); + + it('cancels when token exists and user answers no', async () => { + mockLoadStoredAnthropicAuthToken.mockReturnValue('existing-tok'); + + const { MinimalTui } = await import('./minimal.js'); + + const mockSession = { + id: 'test', + getHistory: () => [], + addMessage: vi.fn(), + clear: vi.fn(), + replaceHistory: vi.fn(), + }; + + const tui = new MinimalTui({ + session: asSession(mockSession), + modelClient: asModelClient({}), + modelRouter: asModelRouter({}), + systemPrompt: 'test', + }); + + minimalTuiPrivates(tui).rl = { pause: vi.fn(), resume: vi.fn() }; + vi.spyOn(minimalTuiPrivates(tui), 'prompt') + .mockResolvedValueOnce('2') // choose option 2 + .mockResolvedValueOnce('n'); // decline re-auth + + const consoleLog = vi.spyOn(console, 'log').mockImplementation(() => undefined); + + await minimalTuiPrivates(tui).handleLoginCommand('anthropic'); + + expect(mockLoginAnthropicOAuth).not.toHaveBeenCalled(); + expect(consoleLog).toHaveBeenCalledWith(expect.stringContaining('Cancelled.')); + + consoleLog.mockRestore(); + }); +}); diff --git a/src/frontends/tui/minimal.ts b/src/frontends/tui/minimal.ts index a7f2416..c6f7942 100644 --- a/src/frontends/tui/minimal.ts +++ b/src/frontends/tui/minimal.ts @@ -16,8 +16,10 @@ import { loadStoredOpenAIApiKey, loadStoredOpenAIAuth, loadStoredZaiAuth, + loginAnthropicOAuth, loginGitHub, loginOpenAI, + openBrowser, storeAnthropicAuth, storeAnthropicAuthToken, storeOpenAIApiKey, @@ -1254,14 +1256,14 @@ export class MinimalTui { if (target === 'anthropic') { console.log(`${colors.gray}Anthropic login:${colors.reset}`); - console.log(`${colors.gray} 1) Paste API key 2) Paste auth token${colors.reset}`); + console.log(`${colors.gray} 1) Paste API key 2) Browser OAuth (Claude Pro/Max)${colors.reset}`); const choice = (await this.prompt(`${colors.orange}Choose [1-2] (default 1):${colors.reset} `)).trim(); const existing = loadStoredAnthropicAuth(); const hasApiKey = Boolean(existing?.api_key); const hasToken = Boolean(loadStoredAnthropicAuthToken()); - // 2) Auth token + // 2) Browser OAuth if (choice === '2') { if (hasToken) { console.log(`${colors.gray}Anthropic auth token already exists.${colors.reset}`); @@ -1271,22 +1273,21 @@ export class MinimalTui { } } - console.log(`${colors.gray}Anthropic supports token-style auth (provider-specific).${colors.reset}`); - console.log(''); + console.log(`${colors.gray}Starting Anthropic browser OAuth...${colors.reset}`); let credentialStored = false; try { - this.rl.pause(); - const token = await promptHidden('Enter Anthropic auth token: '); - storeAnthropicAuthToken(token); - console.log(''); + await loginAnthropicOAuth((url) => { + openBrowser(url); + console.log(`${colors.gray}Opening browser. If it didn't open, visit:${colors.reset}`); + console.log(url); + console.log(`${colors.gray}Waiting for authentication (up to 5 minutes)...${colors.reset}`); + }); console.log(`${colors.gray}Anthropic auth token stored in ~/.config/flynn/auth.json${colors.reset}\n`); credentialStored = true; } catch (error) { const message = error instanceof Error ? error.message : String(error); - console.log(`${colors.gray}Anthropic auth failed:${colors.reset} ${message}\n`); - } finally { - this.rl.resume(); + console.log(`${colors.gray}Anthropic OAuth failed:${colors.reset} ${message}\n`); } // Offer to set auth_mode if config is available