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:
@@ -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 {
|
||||
/** Anthropic API key. */
|
||||
api_key?: string;
|
||||
|
||||
Reference in New Issue
Block a user