package providers // anthropic_oauth.go - OAuth client for Claude Pro/Max subscription authentication // // STATUS: DISABLED IN UI - Anthropic restricts OAuth to Claude Code only // // This implementation follows Claude Code's OAuth flow exactly: // 1. Get authorization code from claude.ai/oauth/authorize // 2. Exchange code for tokens at console.anthropic.com/v1/oauth/token // 3. Try to create API key (works for Team/Enterprise users) // 4. Use OAuth tokens directly (for Pro/Max users) // // However, Anthropic has locked down their OAuth system: // - OAuth tokens are server-side validated to only work within Claude Code // - The error "This credential is only authorized for use with Claude Code" is returned // - Pro/Max users don't have org:create_api_key permission to create API keys // // This code is kept intact in case Anthropic opens up OAuth for third-party apps in the future. // To re-enable, update the frontend AISettings.tsx to un-disable the OAuth button. import ( "bytes" "context" "crypto/rand" "crypto/sha256" "encoding/base64" "encoding/json" "fmt" "io" "net/http" "net/url" "time" "github.com/rs/zerolog/log" ) const ( // OAuth endpoints - these are the same endpoints Claude Code uses // Authorization happens at claude.ai, token exchange at console.anthropic.com anthropicAuthorizeURL = "https://claude.ai/oauth/authorize" anthropicTokenURL = "https://console.anthropic.com/v1/oauth/token" // API key creation endpoint - OAuth tokens create an API key for actual use anthropicAPIKeyURL = "https://api.anthropic.com/api/oauth/claude_cli/create_api_key" // Claude Code's client ID - this is what enables subscription-based auth claudeCodeClientID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e" // Scopes needed for inference with subscription (matching Claude Code's Oo0 array) // org:create_api_key, user:profile, user:inference, user:sessions:claude_code oauthScopes = "org:create_api_key user:profile user:inference user:sessions:claude_code" ) // OAuthTokens represents the tokens obtained from OAuth flow type OAuthTokens struct { AccessToken string `json:"access_token"` RefreshToken string `json:"refresh_token"` TokenType string `json:"token_type"` ExpiresIn int `json:"expires_in"` Scope string `json:"scope"` ExpiresAt time.Time `json:"-"` // APIKey is created from the access token and used for actual API calls APIKey string `json:"api_key,omitempty"` } // OAuthSession stores the state for an in-progress OAuth flow type OAuthSession struct { State string CodeVerifier string RedirectURI string CreatedAt time.Time } // GenerateOAuthSession creates a new OAuth session with PKCE parameters func GenerateOAuthSession(redirectURI string) (*OAuthSession, error) { // Generate state (for CSRF protection) stateBytes := make([]byte, 32) if _, err := rand.Read(stateBytes); err != nil { return nil, fmt.Errorf("failed to generate state: %w", err) } state := base64.RawURLEncoding.EncodeToString(stateBytes) // Generate code verifier (PKCE) verifierBytes := make([]byte, 32) if _, err := rand.Read(verifierBytes); err != nil { return nil, fmt.Errorf("failed to generate code verifier: %w", err) } codeVerifier := base64.RawURLEncoding.EncodeToString(verifierBytes) return &OAuthSession{ State: state, CodeVerifier: codeVerifier, RedirectURI: redirectURI, CreatedAt: time.Now(), }, nil } // GetAuthorizationURL returns the URL to redirect the user to for OAuth authorization // The user will visit this URL, log in, and get an authorization code to paste back func GetAuthorizationURL(session *OAuthSession) string { // Generate code challenge from code verifier (S256) h := sha256.Sum256([]byte(session.CodeVerifier)) codeChallenge := base64.RawURLEncoding.EncodeToString(h[:]) params := url.Values{} // Match Claude Code's exact parameter order and values params.Set("code", "true") // Claude Code adds this params.Set("client_id", claudeCodeClientID) params.Set("response_type", "code") // Use Anthropic's official callback which displays the code for the user to copy params.Set("redirect_uri", "https://console.anthropic.com/oauth/code/callback") params.Set("scope", oauthScopes) params.Set("code_challenge", codeChallenge) params.Set("code_challenge_method", "S256") params.Set("state", session.State) return anthropicAuthorizeURL + "?" + params.Encode() } // ExchangeCodeForTokens exchanges an authorization code for access tokens func ExchangeCodeForTokens(ctx context.Context, code string, session *OAuthSession) (*OAuthTokens, error) { // Build JSON body (matching Claude Code's exact implementation) // Claude Code uses Content-Type: application/json, NOT form-urlencoded payload := map[string]interface{}{ "grant_type": "authorization_code", "code": code, "redirect_uri": "https://console.anthropic.com/oauth/code/callback", "client_id": claudeCodeClientID, "code_verifier": session.CodeVerifier, "state": session.State, } jsonBody, err := json.Marshal(payload) if err != nil { return nil, fmt.Errorf("failed to marshal token request: %w", err) } log.Debug(). Str("grant_type", "authorization_code"). Str("client_id", claudeCodeClientID). Str("code_len", fmt.Sprintf("%d", len(code))). Str("verifier_len", fmt.Sprintf("%d", len(session.CodeVerifier))). Str("state_prefix", session.State[:min(8, len(session.State))]). Str("redirect_uri", "https://console.anthropic.com/oauth/code/callback"). Msg("Sending OAuth token exchange request (JSON)") req, err := http.NewRequestWithContext(ctx, "POST", anthropicTokenURL, bytes.NewReader(jsonBody)) if err != nil { return nil, fmt.Errorf("failed to create token request: %w", err) } req.Header.Set("Content-Type", "application/json") client := &http.Client{Timeout: 30 * time.Second} resp, err := client.Do(req) if err != nil { return nil, fmt.Errorf("token request failed: %w", err) } defer resp.Body.Close() body, err := io.ReadAll(resp.Body) if err != nil { return nil, fmt.Errorf("failed to read token response: %w", err) } if resp.StatusCode != http.StatusOK { log.Error(). Int("status", resp.StatusCode). Str("body", string(body)). Msg("OAuth token exchange failed") return nil, fmt.Errorf("token exchange failed with status %d: %s", resp.StatusCode, string(body)) } var tokens OAuthTokens if err := json.Unmarshal(body, &tokens); err != nil { return nil, fmt.Errorf("failed to parse token response: %w", err) } // Calculate expiration time if tokens.ExpiresIn > 0 { tokens.ExpiresAt = time.Now().Add(time.Duration(tokens.ExpiresIn) * time.Second) } log.Info(). Str("token_type", tokens.TokenType). Int("expires_in", tokens.ExpiresIn). Str("scope", tokens.Scope). Msg("Successfully exchanged OAuth code for tokens") return &tokens, nil } // CreateAPIKeyFromOAuth uses an OAuth access token to create a real API key // This is how Claude Code uses the OAuth flow - the OAuth token creates an API key func CreateAPIKeyFromOAuth(ctx context.Context, accessToken string) (string, error) { req, err := http.NewRequestWithContext(ctx, "POST", anthropicAPIKeyURL, nil) if err != nil { return "", fmt.Errorf("failed to create API key request: %w", err) } req.Header.Set("Authorization", "Bearer "+accessToken) client := &http.Client{Timeout: 30 * time.Second} resp, err := client.Do(req) if err != nil { return "", fmt.Errorf("API key request failed: %w", err) } defer resp.Body.Close() body, err := io.ReadAll(resp.Body) if err != nil { return "", fmt.Errorf("failed to read API key response: %w", err) } if resp.StatusCode != http.StatusOK { log.Error(). Int("status", resp.StatusCode). Str("body", string(body)). Msg("Failed to create API key from OAuth token") return "", fmt.Errorf("API key creation failed with status %d: %s", resp.StatusCode, string(body)) } var result struct { RawKey string `json:"raw_key"` } if err := json.Unmarshal(body, &result); err != nil { return "", fmt.Errorf("failed to parse API key response: %w", err) } if result.RawKey == "" { return "", fmt.Errorf("no API key returned from OAuth") } log.Info().Msg("Successfully created API key from OAuth token") return result.RawKey, nil } // RefreshAccessToken uses a refresh token to get a new access token func RefreshAccessToken(ctx context.Context, refreshToken string) (*OAuthTokens, error) { // Build JSON body (matching Claude Code's implementation) payload := map[string]interface{}{ "grant_type": "refresh_token", "refresh_token": refreshToken, "client_id": claudeCodeClientID, "scope": oauthScopes, } jsonBody, err := json.Marshal(payload) if err != nil { return nil, fmt.Errorf("failed to marshal refresh request: %w", err) } log.Debug(). Str("grant_type", "refresh_token"). Str("client_id", claudeCodeClientID). Msg("Sending OAuth token refresh request") req, err := http.NewRequestWithContext(ctx, "POST", anthropicTokenURL, bytes.NewReader(jsonBody)) if err != nil { return nil, fmt.Errorf("failed to create refresh request: %w", err) } req.Header.Set("Content-Type", "application/json") client := &http.Client{Timeout: 30 * time.Second} resp, err := client.Do(req) if err != nil { return nil, fmt.Errorf("refresh request failed: %w", err) } defer resp.Body.Close() body, err := io.ReadAll(resp.Body) if err != nil { return nil, fmt.Errorf("failed to read refresh response: %w", err) } if resp.StatusCode != http.StatusOK { log.Warn(). Int("status", resp.StatusCode). Str("body", string(body)). Msg("OAuth token refresh failed") return nil, fmt.Errorf("token refresh failed with status %d: %s", resp.StatusCode, string(body)) } var tokens OAuthTokens if err := json.Unmarshal(body, &tokens); err != nil { return nil, fmt.Errorf("failed to parse refresh response: %w", err) } // Keep the original refresh token if a new one wasn't returned if tokens.RefreshToken == "" { tokens.RefreshToken = refreshToken } // Calculate expiration time if tokens.ExpiresIn > 0 { tokens.ExpiresAt = time.Now().Add(time.Duration(tokens.ExpiresIn) * time.Second) } log.Info(). Int("expires_in", tokens.ExpiresIn). Msg("Successfully refreshed OAuth access token") return &tokens, nil } // AnthropicOAuthClient is a client that uses OAuth tokens instead of API keys type AnthropicOAuthClient struct { accessToken string refreshToken string expiresAt time.Time model string client *http.Client onTokenRefresh func(tokens *OAuthTokens) // Callback when tokens are refreshed } // NewAnthropicOAuthClient creates a new Anthropic client using OAuth tokens func NewAnthropicOAuthClient(accessToken, refreshToken string, expiresAt time.Time, model string) *AnthropicOAuthClient { return &AnthropicOAuthClient{ accessToken: accessToken, refreshToken: refreshToken, expiresAt: expiresAt, model: model, client: &http.Client{ Timeout: 300 * time.Second, }, } } // SetTokenRefreshCallback sets a callback that will be called when tokens are refreshed func (c *AnthropicOAuthClient) SetTokenRefreshCallback(callback func(tokens *OAuthTokens)) { c.onTokenRefresh = callback } // Name returns the provider name func (c *AnthropicOAuthClient) Name() string { return "anthropic-oauth" } // ensureValidToken checks if the token is expired and refreshes if needed func (c *AnthropicOAuthClient) ensureValidToken(ctx context.Context) error { // Add some buffer (5 minutes) before expiration if time.Now().Add(5 * time.Minute).Before(c.expiresAt) { return nil // Token is still valid } return c.forceRefreshToken(ctx) } // forceRefreshToken forces a token refresh (used on 401 errors) func (c *AnthropicOAuthClient) forceRefreshToken(ctx context.Context) error { if c.refreshToken == "" { return fmt.Errorf("access token expired and no refresh token available") } log.Info().Msg("OAuth access token expired, refreshing...") tokens, err := RefreshAccessToken(ctx, c.refreshToken) if err != nil { return fmt.Errorf("failed to refresh token: %w", err) } c.accessToken = tokens.AccessToken if tokens.RefreshToken != "" { c.refreshToken = tokens.RefreshToken } c.expiresAt = tokens.ExpiresAt // Notify callback of new tokens if c.onTokenRefresh != nil { c.onTokenRefresh(tokens) } return nil } // Chat sends a chat request to the Anthropic API using OAuth bearer token func (c *AnthropicOAuthClient) Chat(ctx context.Context, req ChatRequest) (*ChatResponse, error) { // Ensure we have a valid token if err := c.ensureValidToken(ctx); err != nil { return nil, err } // Convert messages to Anthropic format (same as regular client) messages := make([]anthropicMessage, 0, len(req.Messages)) for _, m := range req.Messages { if m.Role == "system" { continue } if m.ToolResult != nil { contentJSON, _ := json.Marshal(m.ToolResult.Content) messages = append(messages, anthropicMessage{ Role: "user", Content: []anthropicContent{ { Type: "tool_result", ToolUseID: m.ToolResult.ToolUseID, Content: contentJSON, IsError: m.ToolResult.IsError, }, }, }) continue } if m.Role == "assistant" && len(m.ToolCalls) > 0 { contentBlocks := make([]anthropicContent, 0) if m.Content != "" { contentBlocks = append(contentBlocks, anthropicContent{ Type: "text", Text: m.Content, }) } for _, tc := range m.ToolCalls { contentBlocks = append(contentBlocks, anthropicContent{ Type: "tool_use", ID: tc.ID, Name: tc.Name, Input: tc.Input, }) } messages = append(messages, anthropicMessage{ Role: "assistant", Content: contentBlocks, }) continue } messages = append(messages, anthropicMessage{ Role: m.Role, Content: m.Content, }) } model := req.Model if model == "" { model = c.model } maxTokens := req.MaxTokens if maxTokens == 0 { maxTokens = 4096 } anthropicReq := anthropicRequest{ Model: model, Messages: messages, MaxTokens: maxTokens, System: req.System, } if req.Temperature > 0 { anthropicReq.Temperature = req.Temperature } if len(req.Tools) > 0 { anthropicReq.Tools = make([]anthropicTool, len(req.Tools)) for i, t := range req.Tools { if t.Type == "web_search_20250305" { anthropicReq.Tools[i] = anthropicTool{ Type: t.Type, Name: t.Name, MaxUses: t.MaxUses, } } else { anthropicReq.Tools[i] = anthropicTool{ Name: t.Name, Description: t.Description, InputSchema: t.InputSchema, } } } } body, err := json.Marshal(anthropicReq) if err != nil { return nil, fmt.Errorf("failed to marshal request: %w", err) } // Use the beta endpoint for subscription-based access apiURL := "https://api.anthropic.com/v1/messages?beta=true" var respBody []byte var lastErr error for attempt := 0; attempt <= maxRetries; attempt++ { if attempt > 0 { backoff := initialBackoff * time.Duration(1<<(attempt-1)) log.Warn(). Int("attempt", attempt). Dur("backoff", backoff). Str("last_error", lastErr.Error()). Msg("Retrying Anthropic OAuth API request after transient error") select { case <-ctx.Done(): return nil, ctx.Err() case <-time.After(backoff): } } httpReq, err := http.NewRequestWithContext(ctx, "POST", apiURL, bytes.NewReader(body)) if err != nil { return nil, fmt.Errorf("failed to create request: %w", err) } httpReq.Header.Set("Content-Type", "application/json") // OAuth uses Authorization: Bearer instead of x-api-key httpReq.Header.Set("Authorization", "Bearer "+c.accessToken) httpReq.Header.Set("anthropic-version", anthropicAPIVersion) // Required for OAuth authentication - enables subscription-based access httpReq.Header.Set("anthropic-beta", "oauth-2025-04-20") // Claude Code identification headers httpReq.Header.Set("x-app", "cli") httpReq.Header.Set("User-Agent", "claude-code/2.0.60") resp, err := c.client.Do(httpReq) if err != nil { lastErr = fmt.Errorf("request failed: %w", err) continue } respBody, err = io.ReadAll(resp.Body) resp.Body.Close() if err != nil { lastErr = fmt.Errorf("failed to read response: %w", err) continue } // Check for token expiry error (401) - force refresh if resp.StatusCode == 401 && c.refreshToken != "" { log.Info().Msg("Got 401, forcing token refresh...") if err := c.forceRefreshToken(ctx); err == nil { // Token refreshed, retry immediately lastErr = fmt.Errorf("token expired, retried with refreshed token") continue } else { log.Error().Err(err).Msg("Failed to refresh token after 401") } } if resp.StatusCode == 429 || resp.StatusCode == 529 || resp.StatusCode >= 500 { var errResp anthropicError errMsg := string(respBody) if err := json.Unmarshal(respBody, &errResp); err == nil && errResp.Error.Message != "" { errMsg = errResp.Error.Message } lastErr = fmt.Errorf("API error (%d): %s", resp.StatusCode, errMsg) continue } if resp.StatusCode != http.StatusOK { var errResp anthropicError if err := json.Unmarshal(respBody, &errResp); err == nil && errResp.Error.Message != "" { return nil, fmt.Errorf("API error (%d): %s", resp.StatusCode, errResp.Error.Message) } return nil, fmt.Errorf("API error (%d): %s", resp.StatusCode, string(respBody)) } lastErr = nil break } if lastErr != nil { return nil, fmt.Errorf("failed after %d retries: %w", maxRetries, lastErr) } var anthropicResp anthropicResponse if err := json.Unmarshal(respBody, &anthropicResp); err != nil { return nil, fmt.Errorf("failed to parse response: %w", err) } var textContent string var toolCalls []ToolCall for _, c := range anthropicResp.Content { switch c.Type { case "text": textContent += c.Text case "tool_use": toolCalls = append(toolCalls, ToolCall{ ID: c.ID, Name: c.Name, Input: c.Input, }) case "server_tool_use": log.Debug(). Str("tool_name", c.Name). Msg("Server tool use detected (handled by Anthropic)") case "web_search_tool_result": log.Debug().Msg("Web search results received") } } return &ChatResponse{ Content: textContent, Model: anthropicResp.Model, StopReason: anthropicResp.StopReason, ToolCalls: toolCalls, InputTokens: anthropicResp.Usage.InputTokens, OutputTokens: anthropicResp.Usage.OutputTokens, }, nil } // TestConnection validates the OAuth token by making a minimal request func (c *AnthropicOAuthClient) TestConnection(ctx context.Context) error { _, err := c.Chat(ctx, ChatRequest{ Messages: []Message{ {Role: "user", Content: "Hi"}, }, MaxTokens: 10, }) return err } // ListModels fetches available models from the Anthropic API using OAuth func (c *AnthropicOAuthClient) ListModels(ctx context.Context) ([]ModelInfo, error) { // Ensure we have a valid token if err := c.ensureValidToken(ctx); err != nil { return nil, err } req, err := http.NewRequestWithContext(ctx, "GET", "https://api.anthropic.com/v1/models", nil) if err != nil { return nil, fmt.Errorf("failed to create request: %w", err) } req.Header.Set("Authorization", "Bearer "+c.accessToken) req.Header.Set("anthropic-version", anthropicAPIVersion) resp, err := c.client.Do(req) if err != nil { return nil, fmt.Errorf("request failed: %w", err) } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { body, _ := io.ReadAll(resp.Body) return nil, fmt.Errorf("API error (%d): %s", resp.StatusCode, string(body)) } var result struct { Data []struct { ID string `json:"id"` DisplayName string `json:"display_name"` CreatedAt string `json:"created_at"` } `json:"data"` } if err := json.NewDecoder(resp.Body).Decode(&result); err != nil { return nil, fmt.Errorf("failed to parse response: %w", err) } models := make([]ModelInfo, 0, len(result.Data)) for _, m := range result.Data { models = append(models, ModelInfo{ ID: m.ID, Name: m.DisplayName, }) } return models, nil }