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,
clearAnthropicAuthToken,
getAnthropicAuthToken,
generateCodeVerifier,
generateCodeChallenge,
startCallbackServer,
openBrowser,
exchangeCodeForToken,
loginAnthropicOAuth,
type AnthropicAuthInfo,
type CallbackServer,
} from './anthropic.js';
export {
+118
View File
@@ -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();
});
});
+12 -11
View File
@@ -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