Pulse/internal/ai/providers/anthropic_oauth.go
rcourtman 67351bf58c feat: AI integration, Docker metrics, RAID display, and infrastructure improvements
- Add Claude OAuth authentication support with hybrid API key/OAuth flow
- Implement Docker container historical metrics in backend and charts API
- Add CEPH cluster data collection and new Ceph page
- Enhance RAID status display with detailed tooltips and visual indicators
- Fix host deduplication logic with Docker bridge IP filtering
- Fix NVMe temperature collection in host agent
- Add comprehensive test coverage for new features
- Improve frontend sparklines and metrics history handling
- Fix navigation issues and frontend reload loops
2025-12-09 09:29:27 +00:00

611 lines
18 KiB
Go

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
}