feat(coding-agent): add OAuth authentication for Claude Pro/Max

- Add /login and /logout commands for OAuth flow
- OAuth tokens stored in ~/.pi/agent/oauth.json with 0600 permissions
- Auto-refresh tokens when expired (5min buffer)
- Priority: OAuth > ANTHROPIC_OAUTH_TOKEN env > ANTHROPIC_API_KEY env
- Fix model selector async loading and re-render
- Add bracketed paste support to Input component for long codes
- Update README.md with OAuth documentation
- Add implementation docs and testing checklist
This commit is contained in:
Mario Zechner 2025-11-18 17:33:33 +01:00
parent 387cc97bac
commit 587d7c39a4
17 changed files with 1632 additions and 76 deletions

View file

@ -0,0 +1,128 @@
import { createHash, randomBytes } from "crypto";
import { type OAuthCredentials, saveOAuthCredentials } from "./storage.js";
const CLIENT_ID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e";
const AUTHORIZE_URL = "https://claude.ai/oauth/authorize";
const TOKEN_URL = "https://console.anthropic.com/v1/oauth/token";
const REDIRECT_URI = "https://console.anthropic.com/oauth/code/callback";
const SCOPES = "org:create_api_key user:profile user:inference";
/**
* Generate PKCE code verifier and challenge
*/
function generatePKCE(): { verifier: string; challenge: string } {
const verifier = randomBytes(32).toString("base64url");
const challenge = createHash("sha256").update(verifier).digest("base64url");
return { verifier, challenge };
}
/**
* Login with Anthropic OAuth (device code flow)
*/
export async function loginAnthropic(
onAuthUrl: (url: string) => void,
onPromptCode: () => Promise<string>,
): Promise<void> {
const { verifier, challenge } = generatePKCE();
// Build authorization URL
const authParams = new URLSearchParams({
code: "true",
client_id: CLIENT_ID,
response_type: "code",
redirect_uri: REDIRECT_URI,
scope: SCOPES,
code_challenge: challenge,
code_challenge_method: "S256",
state: verifier,
});
const authUrl = `${AUTHORIZE_URL}?${authParams.toString()}`;
// Notify caller with URL to open
onAuthUrl(authUrl);
// Wait for user to paste authorization code (format: code#state)
const authCode = await onPromptCode();
const splits = authCode.split("#");
const code = splits[0];
const state = splits[1];
// Exchange code for tokens
const tokenResponse = await fetch(TOKEN_URL, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
grant_type: "authorization_code",
client_id: CLIENT_ID,
code: code,
state: state,
redirect_uri: REDIRECT_URI,
code_verifier: verifier,
}),
});
if (!tokenResponse.ok) {
const error = await tokenResponse.text();
throw new Error(`Token exchange failed: ${error}`);
}
const tokenData = (await tokenResponse.json()) as {
access_token: string;
refresh_token: string;
expires_in: number;
};
// Calculate expiry time (current time + expires_in seconds - 5 min buffer)
const expiresAt = Date.now() + tokenData.expires_in * 1000 - 5 * 60 * 1000;
// Save credentials
const credentials: OAuthCredentials = {
type: "oauth",
refresh: tokenData.refresh_token,
access: tokenData.access_token,
expires: expiresAt,
};
saveOAuthCredentials("anthropic", credentials);
}
/**
* Refresh Anthropic OAuth token using refresh token
*/
export async function refreshAnthropicToken(refreshToken: string): Promise<OAuthCredentials> {
const tokenResponse = await fetch(TOKEN_URL, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
grant_type: "refresh_token",
client_id: CLIENT_ID,
refresh_token: refreshToken,
}),
});
if (!tokenResponse.ok) {
const error = await tokenResponse.text();
throw new Error(`Token refresh failed: ${error}`);
}
const tokenData = (await tokenResponse.json()) as {
access_token: string;
refresh_token: string;
expires_in: number;
};
// Calculate expiry time (current time + expires_in seconds - 5 min buffer)
const expiresAt = Date.now() + tokenData.expires_in * 1000 - 5 * 60 * 1000;
return {
type: "oauth",
refresh: tokenData.refresh_token,
access: tokenData.access_token,
expires: expiresAt,
};
}

View file

@ -0,0 +1,115 @@
import { loginAnthropic, refreshAnthropicToken } from "./anthropic.js";
import {
listOAuthProviders as listOAuthProvidersFromStorage,
loadOAuthCredentials,
type OAuthCredentials,
removeOAuthCredentials,
saveOAuthCredentials,
} from "./storage.js";
// Re-export for convenience
export { listOAuthProvidersFromStorage as listOAuthProviders };
export type SupportedOAuthProvider = "anthropic" | "github-copilot";
export interface OAuthProviderInfo {
id: SupportedOAuthProvider;
name: string;
available: boolean;
}
/**
* Get list of OAuth providers
*/
export function getOAuthProviders(): OAuthProviderInfo[] {
return [
{
id: "anthropic",
name: "Anthropic (Claude Pro/Max)",
available: true,
},
{
id: "github-copilot",
name: "GitHub Copilot (coming soon)",
available: false,
},
];
}
/**
* Login with OAuth provider
*/
export async function login(
provider: SupportedOAuthProvider,
onAuthUrl: (url: string) => void,
onPromptCode: () => Promise<string>,
): Promise<void> {
switch (provider) {
case "anthropic":
await loginAnthropic(onAuthUrl, onPromptCode);
break;
case "github-copilot":
throw new Error("GitHub Copilot OAuth is not yet implemented");
default:
throw new Error(`Unknown OAuth provider: ${provider}`);
}
}
/**
* Logout from OAuth provider
*/
export async function logout(provider: SupportedOAuthProvider): Promise<void> {
removeOAuthCredentials(provider);
}
/**
* Refresh OAuth token for provider
*/
export async function refreshToken(provider: SupportedOAuthProvider): Promise<string> {
const credentials = loadOAuthCredentials(provider);
if (!credentials) {
throw new Error(`No OAuth credentials found for ${provider}`);
}
let newCredentials: OAuthCredentials;
switch (provider) {
case "anthropic":
newCredentials = await refreshAnthropicToken(credentials.refresh);
break;
case "github-copilot":
throw new Error("GitHub Copilot OAuth is not yet implemented");
default:
throw new Error(`Unknown OAuth provider: ${provider}`);
}
// Save new credentials
saveOAuthCredentials(provider, newCredentials);
return newCredentials.access;
}
/**
* Get OAuth token for provider (auto-refreshes if expired)
*/
export async function getOAuthToken(provider: SupportedOAuthProvider): Promise<string | null> {
const credentials = loadOAuthCredentials(provider);
if (!credentials) {
return null;
}
// Check if token is expired (with 5 min buffer already applied)
if (Date.now() >= credentials.expires) {
// Token expired - refresh it
try {
return await refreshToken(provider);
} catch (error) {
console.error(`Failed to refresh OAuth token for ${provider}:`, error);
// Remove invalid credentials
removeOAuthCredentials(provider);
return null;
}
}
return credentials.access;
}

View file

@ -0,0 +1,95 @@
import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
import { homedir } from "os";
import { join } from "path";
export interface OAuthCredentials {
type: "oauth";
refresh: string;
access: string;
expires: number;
}
interface OAuthStorageFormat {
[provider: string]: OAuthCredentials;
}
/**
* Get path to oauth.json
*/
function getOAuthFilePath(): string {
const configDir = join(homedir(), ".pi", "agent");
return join(configDir, "oauth.json");
}
/**
* Ensure the config directory exists
*/
function ensureConfigDir(): void {
const configDir = join(homedir(), ".pi", "agent");
if (!existsSync(configDir)) {
mkdirSync(configDir, { recursive: true, mode: 0o700 });
}
}
/**
* Load all OAuth credentials from oauth.json
*/
function loadStorage(): OAuthStorageFormat {
const filePath = getOAuthFilePath();
if (!existsSync(filePath)) {
return {};
}
try {
const content = readFileSync(filePath, "utf-8");
return JSON.parse(content);
} catch (error) {
console.error(`Warning: Failed to load OAuth credentials: ${error}`);
return {};
}
}
/**
* Save all OAuth credentials to oauth.json
*/
function saveStorage(storage: OAuthStorageFormat): void {
ensureConfigDir();
const filePath = getOAuthFilePath();
writeFileSync(filePath, JSON.stringify(storage, null, 2), "utf-8");
// Set permissions to owner read/write only
chmodSync(filePath, 0o600);
}
/**
* Load OAuth credentials for a specific provider
*/
export function loadOAuthCredentials(provider: string): OAuthCredentials | null {
const storage = loadStorage();
return storage[provider] || null;
}
/**
* Save OAuth credentials for a specific provider
*/
export function saveOAuthCredentials(provider: string, creds: OAuthCredentials): void {
const storage = loadStorage();
storage[provider] = creds;
saveStorage(storage);
}
/**
* Remove OAuth credentials for a specific provider
*/
export function removeOAuthCredentials(provider: string): void {
const storage = loadStorage();
delete storage[provider];
saveStorage(storage);
}
/**
* List all providers with OAuth credentials
*/
export function listOAuthProviders(): string[] {
const storage = loadStorage();
return Object.keys(storage);
}