feat(tui): replace Anthropic token paste with browser OAuth flow

This commit is contained in:
William Valentin
2026-02-26 11:31:19 -08:00
parent 7b9e1e6cba
commit 0c500855c5
3 changed files with 137 additions and 11 deletions
+7
View File
@@ -7,7 +7,14 @@ export {
storeAnthropicAuthToken, storeAnthropicAuthToken,
clearAnthropicAuthToken, clearAnthropicAuthToken,
getAnthropicAuthToken, getAnthropicAuthToken,
generateCodeVerifier,
generateCodeChallenge,
startCallbackServer,
openBrowser,
exchangeCodeForToken,
loginAnthropicOAuth,
type AnthropicAuthInfo, type AnthropicAuthInfo,
type CallbackServer,
} from './anthropic.js'; } from './anthropic.js';
export { export {
+118
View File
@@ -7,10 +7,14 @@ const {
mockLoadStoredAnthropicAuth, mockLoadStoredAnthropicAuth,
mockLoadStoredAnthropicAuthToken, mockLoadStoredAnthropicAuthToken,
mockStoreAnthropicAuth, mockStoreAnthropicAuth,
mockLoginAnthropicOAuth,
mockOpenBrowser,
} = vi.hoisted(() => ({ } = vi.hoisted(() => ({
mockLoadStoredAnthropicAuth: vi.fn(), mockLoadStoredAnthropicAuth: vi.fn(),
mockLoadStoredAnthropicAuthToken: vi.fn(), mockLoadStoredAnthropicAuthToken: vi.fn(),
mockStoreAnthropicAuth: vi.fn(), mockStoreAnthropicAuth: vi.fn(),
mockLoginAnthropicOAuth: vi.fn(),
mockOpenBrowser: vi.fn(),
})); }));
const { mockCreateInterface } = vi.hoisted(() => ({ const { mockCreateInterface } = vi.hoisted(() => ({
@@ -23,8 +27,10 @@ vi.mock('../../auth/index.js', () => ({
loadStoredOpenAIApiKey: vi.fn(), loadStoredOpenAIApiKey: vi.fn(),
loadStoredOpenAIAuth: vi.fn(), loadStoredOpenAIAuth: vi.fn(),
loadStoredZaiAuth: vi.fn(), loadStoredZaiAuth: vi.fn(),
loginAnthropicOAuth: mockLoginAnthropicOAuth,
loginGitHub: vi.fn(), loginGitHub: vi.fn(),
loginOpenAI: vi.fn(), loginOpenAI: vi.fn(),
openBrowser: mockOpenBrowser,
storeAnthropicAuth: mockStoreAnthropicAuth, storeAnthropicAuth: mockStoreAnthropicAuth,
storeAnthropicAuthToken: vi.fn(), storeAnthropicAuthToken: vi.fn(),
storeOpenAIApiKey: vi.fn(), storeOpenAIApiKey: vi.fn(),
@@ -152,3 +158,115 @@ describe('MinimalTui login re-auth confirmation', () => {
consoleError.mockRestore(); 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();
});
});
+12 -11
View File
@@ -16,8 +16,10 @@ import {
loadStoredOpenAIApiKey, loadStoredOpenAIApiKey,
loadStoredOpenAIAuth, loadStoredOpenAIAuth,
loadStoredZaiAuth, loadStoredZaiAuth,
loginAnthropicOAuth,
loginGitHub, loginGitHub,
loginOpenAI, loginOpenAI,
openBrowser,
storeAnthropicAuth, storeAnthropicAuth,
storeAnthropicAuthToken, storeAnthropicAuthToken,
storeOpenAIApiKey, storeOpenAIApiKey,
@@ -1254,14 +1256,14 @@ export class MinimalTui {
if (target === 'anthropic') { if (target === 'anthropic') {
console.log(`${colors.gray}Anthropic login:${colors.reset}`); 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 choice = (await this.prompt(`${colors.orange}Choose [1-2] (default 1):${colors.reset} `)).trim();
const existing = loadStoredAnthropicAuth(); const existing = loadStoredAnthropicAuth();
const hasApiKey = Boolean(existing?.api_key); const hasApiKey = Boolean(existing?.api_key);
const hasToken = Boolean(loadStoredAnthropicAuthToken()); const hasToken = Boolean(loadStoredAnthropicAuthToken());
// 2) Auth token // 2) Browser OAuth
if (choice === '2') { if (choice === '2') {
if (hasToken) { if (hasToken) {
console.log(`${colors.gray}Anthropic auth token already exists.${colors.reset}`); 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(`${colors.gray}Starting Anthropic browser OAuth...${colors.reset}`);
console.log('');
let credentialStored = false; let credentialStored = false;
try { try {
this.rl.pause(); await loginAnthropicOAuth((url) => {
const token = await promptHidden('Enter Anthropic auth token: '); openBrowser(url);
storeAnthropicAuthToken(token); console.log(`${colors.gray}Opening browser. If it didn't open, visit:${colors.reset}`);
console.log(''); 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`); console.log(`${colors.gray}Anthropic auth token stored in ~/.config/flynn/auth.json${colors.reset}\n`);
credentialStored = true; credentialStored = true;
} catch (error) { } catch (error) {
const message = error instanceof Error ? error.message : String(error); const message = error instanceof Error ? error.message : String(error);
console.log(`${colors.gray}Anthropic auth failed:${colors.reset} ${message}\n`); console.log(`${colors.gray}Anthropic OAuth failed:${colors.reset} ${message}\n`);
} finally {
this.rl.resume();
} }
// Offer to set auth_mode if config is available // Offer to set auth_mode if config is available