feat(tui): replace Anthropic token paste with browser OAuth flow
This commit is contained in:
@@ -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 {
|
||||||
|
|||||||
@@ -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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user