Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ module github.com/kooshapari/cliproxyapi-plusplus/v6
go 1.26.0

require (
github.com/KooshaPari/phenotype-go-kit v0.0.0
github.com/andybalholm/brotli v1.2.0
github.com/atotto/clipboard v0.1.4
github.com/charmbracelet/bubbles v1.0.0
Expand Down Expand Up @@ -110,3 +111,5 @@ require (
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
)

replace github.com/KooshaPari/phenotype-go-kit => ../../template-commons/phenotype-go-kit
Copy link

Copilot AI Mar 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The go.mod uses a replace directive pointing to a relative path ../../template-commons/phenotype-go-kit. This is a local filesystem path that will only work if the phenotype-go-kit repository is checked out at that exact relative location on the developer's machine. The go.sum file also has no entry for this module, which means go mod tidy has not been run. For a publishable or deployable build, the dependency should be tagged with a real version and referenced from a VCS (e.g., a GitHub tag), rather than relying on a local replace directive.

Copilot uses AI. Check for mistakes.
348 changes: 348 additions & 0 deletions internal/auth/claude/anthropic_auth.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,348 @@
// Package claude provides OAuth2 authentication functionality for Anthropic's Claude API.
// This package implements the complete OAuth2 flow with PKCE (Proof Key for Code Exchange)
// for secure authentication with Claude API, including token exchange, refresh, and storage.
package claude

import (
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"

"github.com/kooshapari/cliproxyapi-plusplus/v6/internal/config"

Check failure on line 16 in internal/auth/claude/anthropic_auth.go

View workflow job for this annotation

GitHub Actions / Analyze (Go) (go)

github.com/KooshaPari/phenotype-go-kit@v0.0.0 (replaced by ../../template-commons/phenotype-go-kit): reading ../../template-commons/phenotype-go-kit/go.mod: open /home/runner/work/template-commons/phenotype-go-kit/go.mod: no such file or directory

Check failure on line 16 in internal/auth/claude/anthropic_auth.go

View workflow job for this annotation

GitHub Actions / Analyze (Go) (go)

github.com/KooshaPari/phenotype-go-kit@v0.0.0 (replaced by ../../template-commons/phenotype-go-kit): reading ../../template-commons/phenotype-go-kit/go.mod: open /home/runner/work/template-commons/phenotype-go-kit/go.mod: no such file or directory
Copy link

Copilot AI Mar 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The import internal/config does not exist in the repository. The equivalent package is pkg/llmproxy/config. This will cause compilation failure.

Suggested change
"github.com/kooshapari/cliproxyapi-plusplus/v6/internal/config"
"github.com/kooshapari/cliproxyapi-plusplus/v6/pkg/llmproxy/config"

Copilot uses AI. Check for mistakes.
log "github.com/sirupsen/logrus"
)

// OAuth configuration constants for Claude/Anthropic
const (
AuthURL = "https://claude.ai/oauth/authorize"
TokenURL = "https://api.anthropic.com/v1/oauth/token"
ClientID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e"
RedirectURI = "http://localhost:54545/callback"
)

// tokenResponse represents the response structure from Anthropic's OAuth token endpoint.
// It contains access token, refresh token, and associated user/organization information.
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.
// It provides methods for generating authorization URLs, exchanging codes for tokens,
// and refreshing expired tokens using PKCE for enhanced security.
type ClaudeAuth struct {
httpClient *http.Client
}

// NewClaudeAuth creates a new Anthropic authentication service.
// It initializes the HTTP client with a custom TLS transport that uses Firefox
// fingerprint to bypass Cloudflare's TLS fingerprinting on Anthropic domains.
//
// Parameters:
// - cfg: The application configuration containing proxy settings
//
// Returns:
// - *ClaudeAuth: A new Claude authentication service instance
func NewClaudeAuth(cfg *config.Config) *ClaudeAuth {
// Use custom HTTP client with Firefox TLS fingerprint to bypass
// Cloudflare's bot detection on Anthropic domains
return &ClaudeAuth{
httpClient: NewAnthropicHttpClient(&cfg.SDKConfig),

Check failure on line 65 in internal/auth/claude/anthropic_auth.go

View workflow job for this annotation

GitHub Actions / golangci-lint

undefined: NewAnthropicHttpClient (typecheck)
}
}
Comment on lines +61 to +67
Copy link

Copilot AI Mar 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The types ClaudeTokenData, ClaudeAuthBundle, and PKCECodes are used throughout this file but are not defined anywhere in the internal/auth/claude package. They exist in pkg/llmproxy/auth/claude (see pkg/llmproxy/auth/claude/anthropic.go), but that's a different package and these types are not imported. This will cause compilation failure.

Copilot uses AI. Check for mistakes.

// GenerateAuthURL creates the OAuth authorization URL with PKCE.
// This method generates a secure authorization URL including PKCE challenge codes
// for the OAuth2 flow with Anthropic's API.
//
// Parameters:
// - state: A random state parameter for CSRF protection
// - pkceCodes: The PKCE codes for secure code exchange
//
// Returns:
// - string: The complete authorization URL
// - string: The state parameter for verification
// - error: An error if PKCE codes are missing or URL generation fails
func (o *ClaudeAuth) GenerateAuthURL(state string, pkceCodes *PKCECodes) (string, string, error) {

Check failure on line 81 in internal/auth/claude/anthropic_auth.go

View workflow job for this annotation

GitHub Actions / golangci-lint

undefined: PKCECodes (typecheck)
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
}

// parseCodeAndState extracts the authorization code and state from the callback response.
// It handles the parsing of the code parameter which may contain additional fragments.
//
// Parameters:
// - code: The raw code parameter from the OAuth callback
//
// Returns:
// - parsedCode: The extracted authorization code
// - parsedState: The extracted state parameter if present
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.
// This method implements the OAuth2 token exchange flow using PKCE for security.
// It sends the authorization code along with PKCE verifier to get access and refresh tokens.
//
// Parameters:
// - ctx: The context for the request
// - code: The authorization code received from OAuth callback
// - state: The state parameter for verification
// - pkceCodes: The PKCE codes for secure verification
//
// Returns:
// - *ClaudeAuthBundle: The complete authentication bundle with tokens
// - error: An error if token exchange fails
func (o *ClaudeAuth) ExchangeCodeForTokens(ctx context.Context, code, state string, pkceCodes *PKCECodes) (*ClaudeAuthBundle, error) {

Check failure on line 132 in internal/auth/claude/anthropic_auth.go

View workflow job for this annotation

GitHub Actions / golangci-lint

undefined: PKCECodes (typecheck)
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": ClientID,
"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", 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)
}
// 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))
Comment on lines +181 to +186
Copy link

Copilot AI Mar 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

There are multiple commented-out debug log statements left in the file. These should be removed before merging, or replaced with proper log.Debugf(...) calls (without the comment marker) since the log package is already imported.

Suggested change
// 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))
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("token exchange failed with status %d: %s", resp.StatusCode, string(body))
}

Copilot uses AI. Check for mistakes.

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{

Check failure on line 202 in internal/auth/claude/anthropic_auth.go

View workflow job for this annotation

GitHub Actions / golangci-lint

undefined: ClaudeAuthBundle (typecheck)
TokenData: tokenData,
LastRefresh: time.Now().Format(time.RFC3339),
}

return bundle, nil
}

// RefreshTokens refreshes the access token using the refresh token.
// This method exchanges a valid refresh token for a new access token,
// extending the user's authenticated session.
//
// Parameters:
// - ctx: The context for the request
// - refreshToken: The refresh token to use for getting new access token
//
// Returns:
// - *ClaudeTokenData: The new token data with updated access token
// - error: An error if token refresh fails
func (o *ClaudeAuth) RefreshTokens(ctx context.Context, refreshToken string) (*ClaudeTokenData, error) {

Check failure on line 221 in internal/auth/claude/anthropic_auth.go

View workflow job for this annotation

GitHub Actions / golangci-lint

undefined: ClaudeTokenData (typecheck)
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))
}

// 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.
// This method converts the authentication bundle into a token storage structure
// suitable for persistence and later use.
//
// Parameters:
// - bundle: The authentication bundle containing token data
//
// Returns:
// - *ClaudeTokenStorage: A new token storage instance
func (o *ClaudeAuth) CreateTokenStorage(bundle *ClaudeAuthBundle) *ClaudeTokenStorage {

Check failure on line 287 in internal/auth/claude/anthropic_auth.go

View workflow job for this annotation

GitHub Actions / golangci-lint

undefined: ClaudeAuthBundle (typecheck)
storage := NewClaudeTokenStorage("")
storage.AccessToken = bundle.TokenData.AccessToken

Check failure on line 289 in internal/auth/claude/anthropic_auth.go

View workflow job for this annotation

GitHub Actions / golangci-lint

storage.AccessToken undefined (type *ClaudeTokenStorage has no field or method AccessToken) (typecheck)
storage.RefreshToken = bundle.TokenData.RefreshToken

Check failure on line 290 in internal/auth/claude/anthropic_auth.go

View workflow job for this annotation

GitHub Actions / golangci-lint

storage.RefreshToken undefined (type *ClaudeTokenStorage has no field or method RefreshToken) (typecheck)
storage.LastRefresh = bundle.LastRefresh

Check failure on line 291 in internal/auth/claude/anthropic_auth.go

View workflow job for this annotation

GitHub Actions / golangci-lint

storage.LastRefresh undefined (type *ClaudeTokenStorage has no field or method LastRefresh) (typecheck)
storage.Email = bundle.TokenData.Email

Check failure on line 292 in internal/auth/claude/anthropic_auth.go

View workflow job for this annotation

GitHub Actions / golangci-lint

storage.Email undefined (type *ClaudeTokenStorage has no field or method Email) (typecheck)
storage.Expire = bundle.TokenData.Expire

return storage
}

// RefreshTokensWithRetry refreshes tokens with automatic retry logic.
// This method implements exponential backoff retry logic for token refresh operations,
// providing resilience against temporary network or service issues.
//
// Parameters:
// - ctx: The context for the request
// - refreshToken: The refresh token to use
// - maxRetries: The maximum number of retry attempts
//
// Returns:
// - *ClaudeTokenData: The refreshed token data
// - error: An error if all retry attempts fail
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.
// This method refreshes the token storage with newly obtained access and refresh tokens,
// updating timestamps and expiration information.
//
// Parameters:
// - storage: The existing token storage to update
// - tokenData: The new token data to apply
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
}
Loading
Loading