| |
| |
| |
| package claude |
|
|
| import ( |
| "context" |
| "encoding/json" |
| "fmt" |
| "io" |
| "net/http" |
| "net/url" |
| "strings" |
| "time" |
|
|
| "github.com/router-for-me/CLIProxyAPI/v6/internal/config" |
| "github.com/router-for-me/CLIProxyAPI/v6/internal/util" |
| log "github.com/sirupsen/logrus" |
| ) |
|
|
| |
| const ( |
| AuthURL = "https://claude.ai/oauth/authorize" |
| TokenURL = "https://console.anthropic.com/v1/oauth/token" |
| ClientID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e" |
| RedirectURI = "http://localhost:54545/callback" |
| ) |
|
|
| |
| |
| 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"` |
| } |
|
|
| |
| |
| |
| type ClaudeAuth struct { |
| httpClient *http.Client |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| func NewClaudeAuth(cfg *config.Config) *ClaudeAuth { |
| return &ClaudeAuth{ |
| httpClient: util.SetProxy(&cfg.SDKConfig, &http.Client{}), |
| } |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| 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": {ClientID}, |
| "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", AuthURL, 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 |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| 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) |
|
|
| |
| reqBody := map[string]interface{}{ |
| "code": newCode, |
| "state": state, |
| "grant_type": "authorization_code", |
| "client_id": ClientID, |
| "redirect_uri": RedirectURI, |
| "code_verifier": pkceCodes.CodeVerifier, |
| } |
|
|
| |
| if newState != "" { |
| reqBody["state"] = newState |
| } |
|
|
| 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", TokenURL, 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() { |
| if errClose := resp.Body.Close(); errClose != nil { |
| log.Errorf("failed to close response body: %v", errClose) |
| } |
| }() |
|
|
| 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 { |
| return nil, fmt.Errorf("token exchange failed with status %d: %s", resp.StatusCode, string(body)) |
| } |
| |
|
|
| var tokenResp tokenResponse |
| if err = json.Unmarshal(body, &tokenResp); err != nil { |
| return nil, fmt.Errorf("failed to parse token response: %w", err) |
| } |
|
|
| |
| 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), |
| } |
|
|
| |
| bundle := &ClaudeAuthBundle{ |
| TokenData: tokenData, |
| LastRefresh: time.Now().Format(time.RFC3339), |
| } |
|
|
| return bundle, nil |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| 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": ClientID, |
| "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", TokenURL, 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)) |
| } |
|
|
| |
|
|
| var tokenResp tokenResponse |
| if err = json.Unmarshal(body, &tokenResp); err != nil { |
| return nil, fmt.Errorf("failed to parse token response: %w", err) |
| } |
|
|
| |
| 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 |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| 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 |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| 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 { |
| |
| 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) |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| 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 |
| } |
|
|