feat: add support for claude pro and max plans (#327)

This commit is contained in:
jnjpng
2025-12-19 16:26:41 -08:00
committed by GitHub
parent 8c5618ec36
commit f9bffaed81
11 changed files with 1750 additions and 2 deletions

235
src/auth/anthropic-oauth.ts Normal file
View File

@@ -0,0 +1,235 @@
/**
* OAuth 2.0 utilities for Anthropic authentication
* Uses Authorization Code Flow with PKCE
*/
export const ANTHROPIC_OAUTH_CONFIG = {
clientId: "9d1c250a-e61b-44d9-88ed-5944d1962f5e",
authorizationUrl: "https://claude.ai/oauth/authorize",
tokenUrl: "https://console.anthropic.com/v1/oauth/token",
redirectUri: "https://console.anthropic.com/oauth/code/callback",
scope: "org:create_api_key user:profile user:inference",
} as const;
export interface AnthropicTokens {
access_token: string;
refresh_token?: string;
token_type: string;
expires_in: number;
scope?: string;
}
export interface OAuthError {
error: string;
error_description?: string;
}
/**
* Generate PKCE code verifier (43-128 characters of unreserved URI characters)
*/
export function generateCodeVerifier(): string {
const array = new Uint8Array(32);
crypto.getRandomValues(array);
return base64UrlEncode(array);
}
/**
* Generate PKCE code challenge from verifier using SHA-256
*/
export async function generateCodeChallenge(verifier: string): Promise<string> {
const encoder = new TextEncoder();
const data = encoder.encode(verifier);
const digest = await crypto.subtle.digest("SHA-256", data);
return base64UrlEncode(new Uint8Array(digest));
}
/**
* Generate cryptographically secure state parameter (32-byte hex)
*/
export function generateState(): string {
const array = new Uint8Array(32);
crypto.getRandomValues(array);
return Array.from(array)
.map((b) => b.toString(16).padStart(2, "0"))
.join("");
}
/**
* Base64 URL encode (RFC 4648)
*/
function base64UrlEncode(buffer: Uint8Array): string {
const base64 = btoa(String.fromCharCode(...buffer));
return base64.replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
}
/**
* Generate PKCE code verifier and challenge
*/
export async function generatePKCE(): Promise<{
codeVerifier: string;
codeChallenge: string;
}> {
const codeVerifier = generateCodeVerifier();
const codeChallenge = await generateCodeChallenge(codeVerifier);
return { codeVerifier, codeChallenge };
}
/**
* Start OAuth flow - returns authorization URL and PKCE values
*/
export async function startAnthropicOAuth(): Promise<{
authorizationUrl: string;
state: string;
codeVerifier: string;
}> {
const state = generateState();
const { codeVerifier, codeChallenge } = await generatePKCE();
const params = new URLSearchParams({
response_type: "code",
client_id: ANTHROPIC_OAUTH_CONFIG.clientId,
redirect_uri: ANTHROPIC_OAUTH_CONFIG.redirectUri,
scope: ANTHROPIC_OAUTH_CONFIG.scope,
state,
code_challenge: codeChallenge,
code_challenge_method: "S256",
});
const authorizationUrl = `${ANTHROPIC_OAUTH_CONFIG.authorizationUrl}?${params.toString()}`;
return {
authorizationUrl,
state,
codeVerifier,
};
}
/**
* Exchange authorization code for tokens
*/
export async function exchangeCodeForTokens(
code: string,
codeVerifier: string,
state: string,
): Promise<AnthropicTokens> {
const response = await fetch(ANTHROPIC_OAUTH_CONFIG.tokenUrl, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({
grant_type: "authorization_code",
client_id: ANTHROPIC_OAUTH_CONFIG.clientId,
code,
state,
redirect_uri: ANTHROPIC_OAUTH_CONFIG.redirectUri,
code_verifier: codeVerifier,
}),
});
if (!response.ok) {
const errorText = await response.text();
// Show full response for debugging
throw new Error(
`Failed to exchange code for tokens (HTTP ${response.status}): ${errorText}`,
);
}
return (await response.json()) as AnthropicTokens;
}
/**
* Refresh an access token using a refresh token
*/
export async function refreshAnthropicToken(
refreshToken: string,
): Promise<AnthropicTokens> {
const response = await fetch(ANTHROPIC_OAUTH_CONFIG.tokenUrl, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
body: new URLSearchParams({
grant_type: "refresh_token",
client_id: ANTHROPIC_OAUTH_CONFIG.clientId,
refresh_token: refreshToken,
}),
});
if (!response.ok) {
const error = (await response.json().catch(() => ({
error: "unknown_error",
error_description: `HTTP ${response.status}`,
}))) as OAuthError;
throw new Error(
`Failed to refresh access token: ${error.error_description || error.error}`,
);
}
return (await response.json()) as AnthropicTokens;
}
/**
* Validate credentials by making a test API call
* OAuth tokens require the anthropic-beta header
*/
export async function validateAnthropicCredentials(
accessToken: string,
): Promise<boolean> {
try {
// Use the models endpoint to validate the token
const response = await fetch("https://api.anthropic.com/v1/models", {
method: "GET",
headers: {
Authorization: `Bearer ${accessToken}`,
"anthropic-version": "2023-06-01",
"anthropic-beta": "oauth-2025-04-20",
},
});
return response.ok;
} catch {
return false;
}
}
/**
* Get a valid Anthropic access token, refreshing if necessary
* Returns null if no OAuth tokens are configured
*/
export async function getAnthropicAccessToken(): Promise<string | null> {
// Lazy import to avoid circular dependencies
const { settingsManager } = await import("../settings-manager");
const tokens = settingsManager.getAnthropicTokens();
if (!tokens) {
return null;
}
// Check if token is expired or about to expire (within 5 minutes)
const fiveMinutesFromNow = Date.now() + 5 * 60 * 1000;
if (tokens.expires_at < fiveMinutesFromNow && tokens.refresh_token) {
try {
const newTokens = await refreshAnthropicToken(tokens.refresh_token);
settingsManager.storeAnthropicTokens(newTokens);
return newTokens.access_token;
} catch (error) {
console.error("Failed to refresh Anthropic access token:", error);
// Return existing token even if refresh failed - it might still work
return tokens.access_token;
}
}
return tokens.access_token;
}
/**
* Check if Anthropic OAuth is configured and valid
*/
export async function hasValidAnthropicAuth(): Promise<boolean> {
const token = await getAnthropicAccessToken();
if (!token) {
return false;
}
return validateAnthropicCredentials(token);
}

271
src/auth/callback-server.ts Normal file
View File

@@ -0,0 +1,271 @@
/**
* Local HTTP callback server for OAuth flows
* Listens on port 19876 for OAuth redirects
*/
import type { Server } from "bun";
export interface CallbackData {
code: string;
state: string;
error?: string;
error_description?: string;
}
interface PendingCallback {
resolve: (data: CallbackData) => void;
reject: (error: Error) => void;
timeout: Timer;
}
/**
* HTML response for successful OAuth callback
*/
function successHtml(): string {
return `<!DOCTYPE html>
<html>
<head>
<title>Authorization Successful</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
display: flex;
justify-content: center;
align-items: center;
height: 100vh;
margin: 0;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
}
.container {
text-align: center;
padding: 40px;
background: rgba(255, 255, 255, 0.1);
border-radius: 16px;
backdrop-filter: blur(10px);
}
h1 { margin: 0 0 16px 0; font-size: 24px; }
p { margin: 0; opacity: 0.9; }
.checkmark {
font-size: 48px;
margin-bottom: 16px;
}
</style>
</head>
<body>
<div class="container">
<div class="checkmark">✓</div>
<h1>Authorization Successful!</h1>
<p>You can close this window and return to Letta Code.</p>
</div>
<script>setTimeout(() => window.close(), 2000);</script>
</body>
</html>`;
}
/**
* HTML response for OAuth error
*/
function errorHtml(error: string, description?: string): string {
return `<!DOCTYPE html>
<html>
<head>
<title>Authorization Failed</title>
<style>
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
display: flex;
justify-content: center;
align-items: center;
height: 100vh;
margin: 0;
background: linear-gradient(135deg, #ff6b6b 0%, #ee5a5a 100%);
color: white;
}
.container {
text-align: center;
padding: 40px;
background: rgba(255, 255, 255, 0.1);
border-radius: 16px;
backdrop-filter: blur(10px);
}
h1 { margin: 0 0 16px 0; font-size: 24px; }
p { margin: 0; opacity: 0.9; }
.error-icon {
font-size: 48px;
margin-bottom: 16px;
}
.error-details {
margin-top: 16px;
font-size: 14px;
opacity: 0.8;
}
</style>
</head>
<body>
<div class="container">
<div class="error-icon">✗</div>
<h1>Authorization Failed</h1>
<p>${description || error}</p>
<p class="error-details">Error: ${error}</p>
</div>
</body>
</html>`;
}
export class OAuthCallbackServer {
private server: Server<unknown> | null = null;
private pendingCallbacks: Map<string, PendingCallback> = new Map();
private static instance: OAuthCallbackServer | null = null;
static getInstance(): OAuthCallbackServer {
if (!OAuthCallbackServer.instance) {
OAuthCallbackServer.instance = new OAuthCallbackServer();
}
return OAuthCallbackServer.instance;
}
async ensureRunning(): Promise<void> {
if (this.server) return;
const self = this;
this.server = Bun.serve({
port: 19876,
fetch(req) {
const url = new URL(req.url);
if (url.pathname === "/callback") {
const code = url.searchParams.get("code");
const state = url.searchParams.get("state");
const error = url.searchParams.get("error");
const errorDescription = url.searchParams.get("error_description");
// Handle error from OAuth provider
if (error) {
const callbackData: CallbackData = {
code: "",
state: state || "",
error,
error_description: errorDescription || undefined,
};
// Resolve pending callback with error
if (state) {
const pending = self.pendingCallbacks.get(state);
if (pending) {
clearTimeout(pending.timeout);
pending.resolve(callbackData);
self.pendingCallbacks.delete(state);
}
}
return new Response(
errorHtml(error, errorDescription || undefined),
{
headers: { "Content-Type": "text/html" },
},
);
}
// Validate required params
if (!code || !state) {
return new Response(
errorHtml("invalid_request", "Missing code or state parameter"),
{
status: 400,
headers: { "Content-Type": "text/html" },
},
);
}
const callbackData: CallbackData = {
code,
state,
};
// Resolve pending callback
const pending = self.pendingCallbacks.get(state);
if (pending) {
clearTimeout(pending.timeout);
pending.resolve(callbackData);
self.pendingCallbacks.delete(state);
}
return new Response(successHtml(), {
headers: { "Content-Type": "text/html" },
});
}
// Health check endpoint
if (url.pathname === "/health") {
return new Response("OK", { status: 200 });
}
return new Response("Not Found", { status: 404 });
},
});
}
/**
* Wait for OAuth callback with matching state
* @param state The state parameter to match
* @param timeout Timeout in milliseconds (default: 5 minutes)
*/
async waitForCallback(
state: string,
timeout: number = 300000,
): Promise<CallbackData> {
return new Promise((resolve, reject) => {
const timeoutId = setTimeout(() => {
this.pendingCallbacks.delete(state);
reject(
new Error(`OAuth callback timeout after ${timeout / 1000} seconds`),
);
}, timeout);
this.pendingCallbacks.set(state, {
resolve,
reject,
timeout: timeoutId,
});
});
}
/**
* Cancel a pending callback
*/
cancelPending(state: string): void {
const pending = this.pendingCallbacks.get(state);
if (pending) {
clearTimeout(pending.timeout);
pending.reject(new Error("Callback cancelled"));
this.pendingCallbacks.delete(state);
}
}
/**
* Stop the callback server
*/
stop(): void {
// Reject all pending callbacks
for (const [state, pending] of this.pendingCallbacks) {
clearTimeout(pending.timeout);
pending.reject(new Error("Server stopped"));
this.pendingCallbacks.delete(state);
}
this.server?.stop();
this.server = null;
}
/**
* Check if server is running
*/
isRunning(): boolean {
return this.server !== null;
}
}
// Export singleton instance
export const oauthCallbackServer = OAuthCallbackServer.getInstance();