Skip to content
Closed
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
451 changes: 233 additions & 218 deletions internal/api/handlers/management/auth_files.go

Large diffs are not rendered by default.

121 changes: 69 additions & 52 deletions internal/auth/claude/anthropic_auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,25 +4,26 @@
package claude

import (
"bytes"
"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/oauthhttp"
"github.com/router-for-me/CLIProxyAPI/v6/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"
anthropicAuthURL = "https://claude.ai/oauth/authorize"
anthropicTokenURL = "https://console.anthropic.com/v1/oauth/token"
anthropicClientID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e"
defaultRedirectURI = "http://localhost:54545/callback"
)

// tokenResponse represents the response structure from Anthropic's OAuth token endpoint.
Expand Down Expand Up @@ -58,8 +59,9 @@ type ClaudeAuth struct {
// Returns:
// - *ClaudeAuth: A new Claude authentication service instance
func NewClaudeAuth(cfg *config.Config) *ClaudeAuth {
client := &http.Client{Timeout: 30 * time.Second}
return &ClaudeAuth{
httpClient: util.SetProxy(&cfg.SDKConfig, &http.Client{}),
httpClient: util.SetOAuthProxy(&cfg.SDKConfig, client),
}
}

Expand All @@ -76,9 +78,17 @@ func NewClaudeAuth(cfg *config.Config) *ClaudeAuth {
// - 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) {
return o.GenerateAuthURLWithRedirectURI(state, pkceCodes, defaultRedirectURI)
}

func (o *ClaudeAuth) GenerateAuthURLWithRedirectURI(state string, pkceCodes *PKCECodes, redirectURI string) (string, string, error) {
if pkceCodes == nil {
return "", "", fmt.Errorf("PKCE codes are required")
}
redirectURI = strings.TrimSpace(redirectURI)
if redirectURI == "" {
redirectURI = defaultRedirectURI
}

params := url.Values{
"code": {"true"},
Expand Down Expand Up @@ -127,10 +137,18 @@ func (c *ClaudeAuth) parseCodeAndState(code string) (parsedCode, parsedState str
// - *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) {
return o.ExchangeCodeForTokensWithRedirectURI(ctx, code, state, pkceCodes, defaultRedirectURI)
}

func (o *ClaudeAuth) ExchangeCodeForTokensWithRedirectURI(ctx context.Context, code, state string, pkceCodes *PKCECodes, redirectURI string) (*ClaudeAuthBundle, error) {
if pkceCodes == nil {
return nil, fmt.Errorf("PKCE codes are required for token exchange")
}
newCode, newState := o.parseCodeAndState(code)
redirectURI = strings.TrimSpace(redirectURI)
if redirectURI == "" {
redirectURI = defaultRedirectURI
}

// Prepare token exchange request
reqBody := map[string]interface{}{
Expand All @@ -152,35 +170,33 @@ func (o *ClaudeAuth) ExchangeCodeForTokens(ctx context.Context, code, state stri
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 {
status, _, body, err := oauthhttp.Do(
ctx,
o.httpClient,
func() (*http.Request, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodPost, 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")
req.Header.Set("Accept", "application/json")
return req, nil
},
oauthhttp.DefaultRetryConfig(),
)
if err != nil && status == 0 {
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)
if status != http.StatusOK {
msg := strings.TrimSpace(string(body))
if err != nil {
return nil, fmt.Errorf("token exchange failed with status %d: %s: %w", status, msg, err)
}
}()

body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("failed to read token response: %w", err)
return nil, fmt.Errorf("token exchange failed with status %d: %s", status, msg)
}
// 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))
if err != nil {
return nil, fmt.Errorf("token exchange request failed: %w", err)
}
// log.Debugf("Token response: %s", string(body))

var tokenResp tokenResponse
if err = json.Unmarshal(body, &tokenResp); err != nil {
Expand Down Expand Up @@ -231,33 +247,34 @@ func (o *ClaudeAuth) RefreshTokens(ctx context.Context, refreshToken string) (*C
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 {
status, _, body, err := oauthhttp.Do(
ctx,
o.httpClient,
func() (*http.Request, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodPost, 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")
req.Header.Set("Accept", "application/json")
return req, nil
},
oauthhttp.DefaultRetryConfig(),
)
if err != nil && status == 0 {
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 status != http.StatusOK {
msg := strings.TrimSpace(string(body))
if err != nil {
return nil, fmt.Errorf("token refresh failed with status %d: %s: %w", status, msg, err)
}
return nil, fmt.Errorf("token refresh failed with status %d: %s", status, msg)
}

if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("token refresh failed with status %d: %s", resp.StatusCode, string(body))
if err != nil {
return nil, fmt.Errorf("token refresh request failed: %w", err)
}

// 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)
Expand Down
103 changes: 103 additions & 0 deletions internal/auth/claude/oauth_provider.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
package claude

import (
"context"
"fmt"
"strings"

"github.com/router-for-me/CLIProxyAPI/v6/internal/oauthflow"
)

// OAuthProvider adapts ClaudeAuth to the shared oauthflow.ProviderOAuth interface.
type OAuthProvider struct {
auth *ClaudeAuth
}

func NewOAuthProvider(auth *ClaudeAuth) *OAuthProvider {
return &OAuthProvider{auth: auth}
}

func (p *OAuthProvider) Provider() string {
return "claude"
}

func (p *OAuthProvider) AuthorizeURL(session oauthflow.OAuthSession) (string, oauthflow.OAuthSession, error) {
if p == nil || p.auth == nil {
return "", session, fmt.Errorf("claude oauth provider: auth is nil")
}
pkce := &PKCECodes{
CodeVerifier: session.CodeVerifier,
CodeChallenge: session.CodeChallenge,
}
authURL, returnedState, err := p.auth.GenerateAuthURLWithRedirectURI(session.State, pkce, session.RedirectURI)
if err != nil {
return "", session, err
}
session.State = returnedState
return authURL, session, nil
}

func (p *OAuthProvider) ExchangeCode(ctx context.Context, session oauthflow.OAuthSession, code string) (*oauthflow.TokenResult, error) {
if p == nil || p.auth == nil {
return nil, fmt.Errorf("claude oauth provider: auth is nil")
}
pkce := &PKCECodes{
CodeVerifier: session.CodeVerifier,
CodeChallenge: session.CodeChallenge,
}
bundle, err := p.auth.ExchangeCodeForTokensWithRedirectURI(ctx, code, session.State, pkce, session.RedirectURI)
if err != nil {
return nil, err
}
if bundle == nil {
return nil, fmt.Errorf("claude oauth provider: token bundle is nil")
}

meta := map[string]any{}
if email := strings.TrimSpace(bundle.TokenData.Email); email != "" {
meta["email"] = email
}

return &oauthflow.TokenResult{
AccessToken: strings.TrimSpace(bundle.TokenData.AccessToken),
RefreshToken: strings.TrimSpace(bundle.TokenData.RefreshToken),
ExpiresAt: strings.TrimSpace(bundle.TokenData.Expire),
TokenType: "Bearer",
Metadata: meta,
}, nil
}

func (p *OAuthProvider) Refresh(ctx context.Context, refreshToken string) (*oauthflow.TokenResult, error) {
if p == nil || p.auth == nil {
return nil, fmt.Errorf("claude oauth provider: auth is nil")
}
data, err := p.auth.RefreshTokens(ctx, refreshToken)
if err != nil {
return nil, err
}
if data == nil {
return nil, fmt.Errorf("claude oauth provider: refresh result is nil")
}

meta := map[string]any{}
if email := strings.TrimSpace(data.Email); email != "" {
meta["email"] = email
}

return &oauthflow.TokenResult{
AccessToken: strings.TrimSpace(data.AccessToken),
RefreshToken: strings.TrimSpace(data.RefreshToken),
ExpiresAt: strings.TrimSpace(data.Expire),
TokenType: "Bearer",
Metadata: meta,
}, nil
}

// Revoke invalidates the given token at Anthropic.
// Note: Anthropic does not currently provide a public token revocation endpoint,
// so this method returns ErrRevokeNotSupported.
func (p *OAuthProvider) Revoke(ctx context.Context, token string) error {
// Anthropic does not currently support OAuth token revocation via public API.
// Users should revoke tokens through the Anthropic console.
return oauthflow.ErrRevokeNotSupported
}
Loading
Loading