mirror of
https://github.com/router-for-me/CLIProxyAPI.git
synced 2026-02-18 20:30:51 +08:00
265 lines
7.7 KiB
Go
265 lines
7.7 KiB
Go
package claude
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/luispater/CLIProxyAPI/internal/config"
|
|
"github.com/luispater/CLIProxyAPI/internal/util"
|
|
log "github.com/sirupsen/logrus"
|
|
)
|
|
|
|
const (
|
|
anthropicAuthURL = "https://claude.ai/oauth/authorize"
|
|
anthropicTokenURL = "https://console.anthropic.com/v1/oauth/token"
|
|
anthropicClientID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e"
|
|
redirectURI = "http://localhost:54545/callback"
|
|
)
|
|
|
|
// Parse token response
|
|
type tokenResponse struct {
|
|
AccessToken string `json:"access_token"`
|
|
RefreshToken string `json:"refresh_token"`
|
|
TokenType string `json:"token_type"`
|
|
ExpiresIn int `json:"expires_in"`
|
|
Organization struct {
|
|
UUID string `json:"uuid"`
|
|
Name string `json:"name"`
|
|
} `json:"organization"`
|
|
Account struct {
|
|
UUID string `json:"uuid"`
|
|
EmailAddress string `json:"email_address"`
|
|
} `json:"account"`
|
|
}
|
|
|
|
// ClaudeAuth handles Anthropic OAuth2 authentication flow
|
|
type ClaudeAuth struct {
|
|
httpClient *http.Client
|
|
}
|
|
|
|
// NewClaudeAuth creates a new Anthropic authentication service
|
|
func NewClaudeAuth(cfg *config.Config) *ClaudeAuth {
|
|
return &ClaudeAuth{
|
|
httpClient: util.SetProxy(cfg, &http.Client{}),
|
|
}
|
|
}
|
|
|
|
// GenerateAuthURL creates the OAuth authorization URL with PKCE
|
|
func (o *ClaudeAuth) GenerateAuthURL(state string, pkceCodes *PKCECodes) (string, string, error) {
|
|
if pkceCodes == nil {
|
|
return "", "", fmt.Errorf("PKCE codes are required")
|
|
}
|
|
|
|
params := url.Values{
|
|
"code": {"true"},
|
|
"client_id": {anthropicClientID},
|
|
"response_type": {"code"},
|
|
"redirect_uri": {redirectURI},
|
|
"scope": {"org:create_api_key user:profile user:inference"},
|
|
"code_challenge": {pkceCodes.CodeChallenge},
|
|
"code_challenge_method": {"S256"},
|
|
"state": {state},
|
|
}
|
|
|
|
authURL := fmt.Sprintf("%s?%s", anthropicAuthURL, params.Encode())
|
|
return authURL, state, nil
|
|
}
|
|
|
|
func (c *ClaudeAuth) parseCodeAndState(code string) (parsedCode, parsedState string) {
|
|
splits := strings.Split(code, "#")
|
|
parsedCode = splits[0]
|
|
if len(splits) > 1 {
|
|
parsedState = splits[1]
|
|
}
|
|
return
|
|
}
|
|
|
|
// ExchangeCodeForTokens exchanges authorization code for access tokens
|
|
func (o *ClaudeAuth) ExchangeCodeForTokens(ctx context.Context, code, state string, pkceCodes *PKCECodes) (*ClaudeAuthBundle, error) {
|
|
if pkceCodes == nil {
|
|
return nil, fmt.Errorf("PKCE codes are required for token exchange")
|
|
}
|
|
newCode, newState := o.parseCodeAndState(code)
|
|
|
|
// Prepare token exchange request
|
|
reqBody := map[string]interface{}{
|
|
"code": newCode,
|
|
"state": state,
|
|
"grant_type": "authorization_code",
|
|
"client_id": anthropicClientID,
|
|
"redirect_uri": redirectURI,
|
|
"code_verifier": pkceCodes.CodeVerifier,
|
|
}
|
|
|
|
// Include state if present
|
|
if newState != "" {
|
|
reqBody["state"] = newState
|
|
}
|
|
|
|
jsonBody, err := json.Marshal(reqBody)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to marshal request body: %w", err)
|
|
}
|
|
|
|
// log.Debugf("Token exchange request: %s", string(jsonBody))
|
|
|
|
req, err := http.NewRequestWithContext(ctx, "POST", anthropicTokenURL, strings.NewReader(string(jsonBody)))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to create token request: %w", err)
|
|
}
|
|
req.Header.Set("Content-Type", "application/json")
|
|
req.Header.Set("Accept", "application/json")
|
|
|
|
resp, err := o.httpClient.Do(req)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("token exchange request failed: %w", err)
|
|
}
|
|
defer func() {
|
|
_ = resp.Body.Close()
|
|
}()
|
|
|
|
body, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to read token response: %w", err)
|
|
}
|
|
// log.Debugf("Token response: %s", string(body))
|
|
|
|
if resp.StatusCode != http.StatusOK {
|
|
return nil, fmt.Errorf("token exchange failed with status %d: %s", resp.StatusCode, string(body))
|
|
}
|
|
// log.Debugf("Token response: %s", string(body))
|
|
|
|
var tokenResp tokenResponse
|
|
if err = json.Unmarshal(body, &tokenResp); err != nil {
|
|
return nil, fmt.Errorf("failed to parse token response: %w", err)
|
|
}
|
|
|
|
// Create token data
|
|
tokenData := ClaudeTokenData{
|
|
AccessToken: tokenResp.AccessToken,
|
|
RefreshToken: tokenResp.RefreshToken,
|
|
Email: tokenResp.Account.EmailAddress,
|
|
Expire: time.Now().Add(time.Duration(tokenResp.ExpiresIn) * time.Second).Format(time.RFC3339),
|
|
}
|
|
|
|
// Create auth bundle
|
|
bundle := &ClaudeAuthBundle{
|
|
TokenData: tokenData,
|
|
LastRefresh: time.Now().Format(time.RFC3339),
|
|
}
|
|
|
|
return bundle, nil
|
|
}
|
|
|
|
// RefreshTokens refreshes the access token using the refresh token
|
|
func (o *ClaudeAuth) RefreshTokens(ctx context.Context, refreshToken string) (*ClaudeTokenData, error) {
|
|
if refreshToken == "" {
|
|
return nil, fmt.Errorf("refresh token is required")
|
|
}
|
|
|
|
reqBody := map[string]interface{}{
|
|
"client_id": anthropicClientID,
|
|
"grant_type": "refresh_token",
|
|
"refresh_token": refreshToken,
|
|
}
|
|
|
|
jsonBody, err := json.Marshal(reqBody)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to marshal request body: %w", err)
|
|
}
|
|
|
|
req, err := http.NewRequestWithContext(ctx, "POST", anthropicTokenURL, strings.NewReader(string(jsonBody)))
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to create refresh request: %w", err)
|
|
}
|
|
|
|
req.Header.Set("Content-Type", "application/json")
|
|
req.Header.Set("Accept", "application/json")
|
|
|
|
resp, err := o.httpClient.Do(req)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("token refresh request failed: %w", err)
|
|
}
|
|
defer func() {
|
|
_ = 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 {
|
|
return nil, fmt.Errorf("token refresh failed with status %d: %s", resp.StatusCode, string(body))
|
|
}
|
|
|
|
// log.Debugf("Token response: %s", string(body))
|
|
|
|
var tokenResp tokenResponse
|
|
if err = json.Unmarshal(body, &tokenResp); err != nil {
|
|
return nil, fmt.Errorf("failed to parse token response: %w", err)
|
|
}
|
|
|
|
// Create token data
|
|
return &ClaudeTokenData{
|
|
AccessToken: tokenResp.AccessToken,
|
|
RefreshToken: tokenResp.RefreshToken,
|
|
Email: tokenResp.Account.EmailAddress,
|
|
Expire: time.Now().Add(time.Duration(tokenResp.ExpiresIn) * time.Second).Format(time.RFC3339),
|
|
}, nil
|
|
}
|
|
|
|
// CreateTokenStorage creates a new ClaudeTokenStorage from auth bundle and user info
|
|
func (o *ClaudeAuth) CreateTokenStorage(bundle *ClaudeAuthBundle) *ClaudeTokenStorage {
|
|
storage := &ClaudeTokenStorage{
|
|
AccessToken: bundle.TokenData.AccessToken,
|
|
RefreshToken: bundle.TokenData.RefreshToken,
|
|
LastRefresh: bundle.LastRefresh,
|
|
Email: bundle.TokenData.Email,
|
|
Expire: bundle.TokenData.Expire,
|
|
}
|
|
|
|
return storage
|
|
}
|
|
|
|
// RefreshTokensWithRetry refreshes tokens with automatic retry logic
|
|
func (o *ClaudeAuth) RefreshTokensWithRetry(ctx context.Context, refreshToken string, maxRetries int) (*ClaudeTokenData, error) {
|
|
var lastErr error
|
|
|
|
for attempt := 0; attempt < maxRetries; attempt++ {
|
|
if attempt > 0 {
|
|
// Wait before retry
|
|
select {
|
|
case <-ctx.Done():
|
|
return nil, ctx.Err()
|
|
case <-time.After(time.Duration(attempt) * time.Second):
|
|
}
|
|
}
|
|
|
|
tokenData, err := o.RefreshTokens(ctx, refreshToken)
|
|
if err == nil {
|
|
return tokenData, nil
|
|
}
|
|
|
|
lastErr = err
|
|
log.Warnf("Token refresh attempt %d failed: %v", attempt+1, err)
|
|
}
|
|
|
|
return nil, fmt.Errorf("token refresh failed after %d attempts: %w", maxRetries, lastErr)
|
|
}
|
|
|
|
// UpdateTokenStorage updates an existing token storage with new token data
|
|
func (o *ClaudeAuth) UpdateTokenStorage(storage *ClaudeTokenStorage, tokenData *ClaudeTokenData) {
|
|
storage.AccessToken = tokenData.AccessToken
|
|
storage.RefreshToken = tokenData.RefreshToken
|
|
storage.LastRefresh = time.Now().Format(time.RFC3339)
|
|
storage.Email = tokenData.Email
|
|
storage.Expire = tokenData.Expire
|
|
}
|