feat(auth): implement Anthropic OAuth PKCE browser flow
Add openBrowser, exchangeCodeForToken, and loginAnthropicOAuth to src/auth/anthropic.ts, completing the full PKCE OAuth flow. Includes 5 new tests covering happy path, state mismatch, 403 subscription error, 500 error, and missing access_token cases. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -145,3 +145,122 @@ describe('startCallbackServer', () => {
|
|||||||
await waitForCode;
|
await waitForCode;
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('loginAnthropicOAuth and exchangeCodeForToken', () => {
|
||||||
|
const originalHome = process.env.HOME;
|
||||||
|
let homeDir: string;
|
||||||
|
let mockFetch: ReturnType<typeof vi.fn>;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
homeDir = mkdtempSync(join(tmpdir(), 'flynn-auth-oauth-'));
|
||||||
|
process.env.HOME = homeDir;
|
||||||
|
mockFetch = vi.fn();
|
||||||
|
vi.stubGlobal('fetch', mockFetch);
|
||||||
|
vi.resetModules();
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
process.env.HOME = originalHome;
|
||||||
|
vi.unstubAllGlobals();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('loginAnthropicOAuth builds correct auth URL and exchanges code for token', async () => {
|
||||||
|
const fakeToken = 'tok-from-oauth';
|
||||||
|
mockFetch.mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({ access_token: fakeToken }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { loginAnthropicOAuth } = await import('./anthropic.js');
|
||||||
|
|
||||||
|
let capturedAuthUrl = '';
|
||||||
|
let resolveCb!: (v: { code: string; state: string }) => void;
|
||||||
|
|
||||||
|
// Custom _startServer that gives us control over waitForCode
|
||||||
|
const mockStartServer = async (_timeoutMs: number) => {
|
||||||
|
return {
|
||||||
|
port: 9999,
|
||||||
|
waitForCode: new Promise<{ code: string; state: string }>((res) => {
|
||||||
|
resolveCb = res;
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
// Start the flow — onOpen fires after _startServer resolves and URL is built
|
||||||
|
const flowPromise = loginAnthropicOAuth((url) => {
|
||||||
|
capturedAuthUrl = url;
|
||||||
|
// Extract state from the URL and resolve waitForCode with matching state
|
||||||
|
const parsedUrl = new URL(url);
|
||||||
|
const state = parsedUrl.searchParams.get('state')!;
|
||||||
|
resolveCb({ code: 'auth-code-123', state });
|
||||||
|
}, mockStartServer);
|
||||||
|
|
||||||
|
const token = await flowPromise;
|
||||||
|
|
||||||
|
// Auth URL assertions
|
||||||
|
const authUrl = new URL(capturedAuthUrl);
|
||||||
|
expect(authUrl.hostname).toBe('claude.ai');
|
||||||
|
expect(authUrl.searchParams.get('client_id')).toBe('9d1c250a-e61b-44d9-88ed-5944d1962f5e');
|
||||||
|
expect(authUrl.searchParams.get('code_challenge_method')).toBe('S256');
|
||||||
|
expect(authUrl.searchParams.get('response_type')).toBe('code');
|
||||||
|
expect(authUrl.searchParams.get('scope')).toContain('user:inference');
|
||||||
|
expect(authUrl.searchParams.get('state')).toBeTruthy();
|
||||||
|
|
||||||
|
// Token stored and returned
|
||||||
|
expect(token).toBe(fakeToken);
|
||||||
|
|
||||||
|
// Verify token exchange fetch
|
||||||
|
expect(mockFetch).toHaveBeenCalledWith(
|
||||||
|
'https://console.anthropic.com/v1/oauth/token',
|
||||||
|
expect.objectContaining({ method: 'POST' }),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('loginAnthropicOAuth throws on state mismatch', async () => {
|
||||||
|
const { loginAnthropicOAuth } = await import('./anthropic.js');
|
||||||
|
|
||||||
|
const mockStartServer = async (_timeoutMs: number) => ({
|
||||||
|
port: 9999,
|
||||||
|
waitForCode: Promise.resolve({ code: 'code', state: 'DEFINITELY-WRONG-STATE' }),
|
||||||
|
});
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
loginAnthropicOAuth(() => undefined, mockStartServer),
|
||||||
|
).rejects.toThrow('state mismatch');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('exchangeCodeForToken throws subscription message on 403', async () => {
|
||||||
|
mockFetch.mockResolvedValue({
|
||||||
|
ok: false,
|
||||||
|
status: 403,
|
||||||
|
text: async () => 'Forbidden',
|
||||||
|
});
|
||||||
|
const { exchangeCodeForToken } = await import('./anthropic.js');
|
||||||
|
await expect(
|
||||||
|
exchangeCodeForToken('code', 'verifier', 'http://127.0.0.1:1234/callback'),
|
||||||
|
).rejects.toThrow(/Pro\/Max subscription/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('exchangeCodeForToken throws with status on 500', async () => {
|
||||||
|
mockFetch.mockResolvedValue({
|
||||||
|
ok: false,
|
||||||
|
status: 500,
|
||||||
|
text: async () => 'Server error',
|
||||||
|
});
|
||||||
|
const { exchangeCodeForToken } = await import('./anthropic.js');
|
||||||
|
await expect(
|
||||||
|
exchangeCodeForToken('code', 'verifier', 'http://127.0.0.1:1234/callback'),
|
||||||
|
).rejects.toThrow(/500/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('exchangeCodeForToken throws when no access_token in response', async () => {
|
||||||
|
mockFetch.mockResolvedValue({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({ something_else: 'x' }),
|
||||||
|
});
|
||||||
|
const { exchangeCodeForToken } = await import('./anthropic.js');
|
||||||
|
await expect(
|
||||||
|
exchangeCodeForToken('code', 'verifier', 'http://127.0.0.1:1234/callback'),
|
||||||
|
).rejects.toThrow(/no access_token/);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -88,6 +88,92 @@ export function startCallbackServer(timeoutMs: number): Promise<CallbackServer>
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Open a URL in the user's default browser (platform-specific). Failures are silent. */
|
||||||
|
export function openBrowser(url: string): void {
|
||||||
|
const cmd = process.platform === 'win32' ? 'start'
|
||||||
|
: process.platform === 'darwin' ? 'open'
|
||||||
|
: 'xdg-open';
|
||||||
|
try {
|
||||||
|
spawn(cmd, [url], { detached: true, stdio: 'ignore' }).unref();
|
||||||
|
} catch {
|
||||||
|
// Caller should already be printing the URL as fallback
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Exchange an authorization code for an Anthropic access token. */
|
||||||
|
export async function exchangeCodeForToken(
|
||||||
|
code: string,
|
||||||
|
codeVerifier: string,
|
||||||
|
redirectUri: string,
|
||||||
|
): Promise<string> {
|
||||||
|
const response = await fetch(ANTHROPIC_TOKEN_URL, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||||
|
body: new URLSearchParams({
|
||||||
|
grant_type: 'authorization_code',
|
||||||
|
client_id: ANTHROPIC_CLIENT_ID,
|
||||||
|
code,
|
||||||
|
code_verifier: codeVerifier,
|
||||||
|
redirect_uri: redirectUri,
|
||||||
|
}).toString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
const body = await response.text();
|
||||||
|
if (response.status === 403) {
|
||||||
|
throw new Error(
|
||||||
|
`Anthropic OAuth requires an active Claude Pro/Max subscription (403): ${body}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
throw new Error(`Anthropic token exchange failed (${response.status}): ${body}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await response.json() as Record<string, unknown>;
|
||||||
|
if (typeof data.access_token !== 'string') {
|
||||||
|
throw new Error('Anthropic token exchange returned no access_token');
|
||||||
|
}
|
||||||
|
return data.access_token;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run the full Anthropic OAuth PKCE browser flow.
|
||||||
|
* Calls onOpen with the authorization URL (caller should open browser or print URL).
|
||||||
|
* Stores the resulting token via storeAnthropicAuthToken.
|
||||||
|
* Returns the access token.
|
||||||
|
*/
|
||||||
|
export async function loginAnthropicOAuth(
|
||||||
|
onOpen: (url: string) => void,
|
||||||
|
_startServer: typeof startCallbackServer = startCallbackServer,
|
||||||
|
): Promise<string> {
|
||||||
|
const codeVerifier = generateCodeVerifier();
|
||||||
|
const codeChallenge = generateCodeChallenge(codeVerifier);
|
||||||
|
const state = randomBytes(16).toString('hex');
|
||||||
|
|
||||||
|
const { port, waitForCode } = await _startServer(OAUTH_TIMEOUT_MS);
|
||||||
|
const redirectUri = `http://127.0.0.1:${port}/callback`;
|
||||||
|
|
||||||
|
const authUrl = new URL(ANTHROPIC_AUTH_URL);
|
||||||
|
authUrl.searchParams.set('response_type', 'code');
|
||||||
|
authUrl.searchParams.set('client_id', ANTHROPIC_CLIENT_ID);
|
||||||
|
authUrl.searchParams.set('redirect_uri', redirectUri);
|
||||||
|
authUrl.searchParams.set('scope', ANTHROPIC_OAUTH_SCOPES);
|
||||||
|
authUrl.searchParams.set('code_challenge', codeChallenge);
|
||||||
|
authUrl.searchParams.set('code_challenge_method', 'S256');
|
||||||
|
authUrl.searchParams.set('state', state);
|
||||||
|
|
||||||
|
onOpen(authUrl.toString());
|
||||||
|
|
||||||
|
const { code, state: returnedState } = await waitForCode;
|
||||||
|
|
||||||
|
if (returnedState !== state) {
|
||||||
|
throw new Error('Anthropic OAuth state mismatch — possible CSRF attack.');
|
||||||
|
}
|
||||||
|
|
||||||
|
const token = await exchangeCodeForToken(code, codeVerifier, redirectUri);
|
||||||
|
storeAnthropicAuthToken(token);
|
||||||
|
return token;
|
||||||
|
}
|
||||||
|
|
||||||
export interface AnthropicAuthInfo {
|
export interface AnthropicAuthInfo {
|
||||||
/** Anthropic API key. */
|
/** Anthropic API key. */
|
||||||
api_key?: string;
|
api_key?: string;
|
||||||
|
|||||||
Reference in New Issue
Block a user