From 090c8981ddfedac7c83e0ff54ac57ca9b489fd4c Mon Sep 17 00:00:00 2001 From: shaw Date: Mon, 19 Jan 2026 16:40:06 +0800 Subject: [PATCH] =?UTF-8?q?fix:=20=E6=9B=B4=E6=96=B0Claude=20OAuth?= =?UTF-8?q?=E6=8E=88=E6=9D=83=E9=85=8D=E7=BD=AE=E4=BB=A5=E5=8C=B9=E9=85=8D?= =?UTF-8?q?=E6=9C=80=E6=96=B0=E8=A7=84=E8=8C=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 更新TokenURL和RedirectURI为platform.claude.com - 更新scope定义,区分浏览器URL和内部API调用 - 修正state/code_verifier生成算法使用base64url编码 - 修正授权URL参数顺序并添加code=true - 更新token交换请求头匹配官方实现 - 清理未使用的类型和函数 --- backend/internal/pkg/oauth/oauth.go | 120 ++++++++---------- .../repository/claude_oauth_service.go | 6 +- .../repository/claude_oauth_service_test.go | 2 +- backend/internal/service/oauth_service.go | 6 +- 4 files changed, 59 insertions(+), 75 deletions(-) diff --git a/backend/internal/pkg/oauth/oauth.go b/backend/internal/pkg/oauth/oauth.go index d29c2422..0a607dfb 100644 --- a/backend/internal/pkg/oauth/oauth.go +++ b/backend/internal/pkg/oauth/oauth.go @@ -13,20 +13,26 @@ import ( "time" ) -// Claude OAuth Constants (from CRS project) +// Claude OAuth Constants const ( // OAuth Client ID for Claude ClientID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e" // OAuth endpoints AuthorizeURL = "https://claude.ai/oauth/authorize" - TokenURL = "https://console.anthropic.com/v1/oauth/token" - RedirectURI = "https://console.anthropic.com/oauth/code/callback" + TokenURL = "https://platform.claude.com/v1/oauth/token" + RedirectURI = "https://platform.claude.com/oauth/code/callback" - // Scopes - ScopeProfile = "user:profile" + // Scopes - Browser URL (includes org:create_api_key for user authorization) + ScopeOAuth = "org:create_api_key user:profile user:inference user:sessions:claude_code" + // Scopes - Internal API call (org:create_api_key not supported in API) + ScopeAPI = "user:profile user:inference user:sessions:claude_code" + // Scopes - Setup token (inference only) ScopeInference = "user:inference" + // Code Verifier character set (RFC 7636 compliant) + codeVerifierCharset = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~" + // Session TTL SessionTTL = 30 * time.Minute ) @@ -53,7 +59,6 @@ func NewSessionStore() *SessionStore { sessions: make(map[string]*OAuthSession), stopCh: make(chan struct{}), } - // Start cleanup goroutine go store.cleanup() return store } @@ -78,7 +83,6 @@ func (s *SessionStore) Get(sessionID string) (*OAuthSession, bool) { if !ok { return nil, false } - // Check if expired if time.Since(session.CreatedAt) > SessionTTL { return nil, false } @@ -122,13 +126,13 @@ func GenerateRandomBytes(n int) ([]byte, error) { return b, nil } -// GenerateState generates a random state string for OAuth +// GenerateState generates a random state string for OAuth (base64url encoded) func GenerateState() (string, error) { bytes, err := GenerateRandomBytes(32) if err != nil { return "", err } - return hex.EncodeToString(bytes), nil + return base64URLEncode(bytes), nil } // GenerateSessionID generates a unique session ID @@ -140,13 +144,30 @@ func GenerateSessionID() (string, error) { return hex.EncodeToString(bytes), nil } -// GenerateCodeVerifier generates a PKCE code verifier (32 bytes -> base64url) +// GenerateCodeVerifier generates a PKCE code verifier using character set method func GenerateCodeVerifier() (string, error) { - bytes, err := GenerateRandomBytes(32) - if err != nil { - return "", err + const targetLen = 32 + charsetLen := len(codeVerifierCharset) + limit := 256 - (256 % charsetLen) + + result := make([]byte, 0, targetLen) + randBuf := make([]byte, targetLen*2) + + for len(result) < targetLen { + if _, err := rand.Read(randBuf); err != nil { + return "", err + } + for _, b := range randBuf { + if int(b) < limit { + result = append(result, codeVerifierCharset[int(b)%charsetLen]) + if len(result) >= targetLen { + break + } + } + } } - return base64URLEncode(bytes), nil + + return base64URLEncode(result), nil } // GenerateCodeChallenge generates a PKCE code challenge using S256 method @@ -158,42 +179,31 @@ func GenerateCodeChallenge(verifier string) string { // base64URLEncode encodes bytes to base64url without padding func base64URLEncode(data []byte) string { encoded := base64.URLEncoding.EncodeToString(data) - // Remove padding return strings.TrimRight(encoded, "=") } -// BuildAuthorizationURL builds the OAuth authorization URL +// BuildAuthorizationURL builds the OAuth authorization URL with correct parameter order func BuildAuthorizationURL(state, codeChallenge, scope string) string { - params := url.Values{} - params.Set("response_type", "code") - params.Set("client_id", ClientID) - params.Set("redirect_uri", RedirectURI) - params.Set("scope", scope) - params.Set("state", state) - params.Set("code_challenge", codeChallenge) - params.Set("code_challenge_method", "S256") + encodedRedirectURI := url.QueryEscape(RedirectURI) + encodedScope := strings.ReplaceAll(url.QueryEscape(scope), "%20", "+") - return fmt.Sprintf("%s?%s", AuthorizeURL, params.Encode()) -} - -// TokenRequest represents the token exchange request body -type TokenRequest struct { - GrantType string `json:"grant_type"` - ClientID string `json:"client_id"` - Code string `json:"code"` - RedirectURI string `json:"redirect_uri"` - CodeVerifier string `json:"code_verifier"` - State string `json:"state"` + return fmt.Sprintf("%s?code=true&client_id=%s&response_type=code&redirect_uri=%s&scope=%s&code_challenge=%s&code_challenge_method=S256&state=%s", + AuthorizeURL, + ClientID, + encodedRedirectURI, + encodedScope, + codeChallenge, + state, + ) } // TokenResponse represents the token response from OAuth provider type TokenResponse struct { - AccessToken string `json:"access_token"` - TokenType string `json:"token_type"` - ExpiresIn int64 `json:"expires_in"` - RefreshToken string `json:"refresh_token,omitempty"` - Scope string `json:"scope,omitempty"` - // Organization and Account info from OAuth response + AccessToken string `json:"access_token"` + TokenType string `json:"token_type"` + ExpiresIn int64 `json:"expires_in"` + RefreshToken string `json:"refresh_token,omitempty"` + Scope string `json:"scope,omitempty"` Organization *OrgInfo `json:"organization,omitempty"` Account *AccountInfo `json:"account,omitempty"` } @@ -207,31 +217,3 @@ type OrgInfo struct { type AccountInfo struct { UUID string `json:"uuid"` } - -// RefreshTokenRequest represents the refresh token request -type RefreshTokenRequest struct { - GrantType string `json:"grant_type"` - RefreshToken string `json:"refresh_token"` - ClientID string `json:"client_id"` -} - -// BuildTokenRequest creates a token exchange request -func BuildTokenRequest(code, codeVerifier, state string) *TokenRequest { - return &TokenRequest{ - GrantType: "authorization_code", - ClientID: ClientID, - Code: code, - RedirectURI: RedirectURI, - CodeVerifier: codeVerifier, - State: state, - } -} - -// BuildRefreshTokenRequest creates a refresh token request -func BuildRefreshTokenRequest(refreshToken string) *RefreshTokenRequest { - return &RefreshTokenRequest{ - GrantType: "refresh_token", - RefreshToken: refreshToken, - ClientID: ClientID, - } -} diff --git a/backend/internal/repository/claude_oauth_service.go b/backend/internal/repository/claude_oauth_service.go index 677fce52..1f1db553 100644 --- a/backend/internal/repository/claude_oauth_service.go +++ b/backend/internal/repository/claude_oauth_service.go @@ -182,7 +182,9 @@ func (s *claudeOAuthService) ExchangeCodeForToken(ctx context.Context, code, cod resp, err := client.R(). SetContext(ctx). + SetHeader("Accept", "application/json, text/plain, */*"). SetHeader("Content-Type", "application/json"). + SetHeader("User-Agent", "axios/1.8.4"). SetBody(reqBody). SetSuccessResult(&tokenResp). Post(s.tokenURL) @@ -205,8 +207,6 @@ func (s *claudeOAuthService) ExchangeCodeForToken(ctx context.Context, code, cod func (s *claudeOAuthService) RefreshToken(ctx context.Context, refreshToken, proxyURL string) (*oauth.TokenResponse, error) { client := s.clientFactory(proxyURL) - // 使用 JSON 格式(与 ExchangeCodeForToken 保持一致) - // Anthropic OAuth API 期望 JSON 格式的请求体 reqBody := map[string]any{ "grant_type": "refresh_token", "refresh_token": refreshToken, @@ -217,7 +217,9 @@ func (s *claudeOAuthService) RefreshToken(ctx context.Context, refreshToken, pro resp, err := client.R(). SetContext(ctx). + SetHeader("Accept", "application/json, text/plain, */*"). SetHeader("Content-Type", "application/json"). + SetHeader("User-Agent", "axios/1.8.4"). SetBody(reqBody). SetSuccessResult(&tokenResp). Post(s.tokenURL) diff --git a/backend/internal/repository/claude_oauth_service_test.go b/backend/internal/repository/claude_oauth_service_test.go index a7f76056..7395c6d8 100644 --- a/backend/internal/repository/claude_oauth_service_test.go +++ b/backend/internal/repository/claude_oauth_service_test.go @@ -171,7 +171,7 @@ func (s *ClaudeOAuthServiceSuite) TestGetAuthorizationCode() { s.client.baseURL = "http://in-process" s.client.clientFactory = func(string) *req.Client { return newTestReqClient(rt) } - code, err := s.client.GetAuthorizationCode(context.Background(), "sess", "org-1", oauth.ScopeProfile, "cc", "st", "") + code, err := s.client.GetAuthorizationCode(context.Background(), "sess", "org-1", oauth.ScopeInference, "cc", "st", "") if tt.wantErr { require.Error(s.T(), err) diff --git a/backend/internal/service/oauth_service.go b/backend/internal/service/oauth_service.go index 0039cb44..03c3438a 100644 --- a/backend/internal/service/oauth_service.go +++ b/backend/internal/service/oauth_service.go @@ -48,8 +48,7 @@ type GenerateAuthURLResult struct { // GenerateAuthURL generates an OAuth authorization URL with full scope func (s *OAuthService) GenerateAuthURL(ctx context.Context, proxyID *int64) (*GenerateAuthURLResult, error) { - scope := fmt.Sprintf("%s %s", oauth.ScopeProfile, oauth.ScopeInference) - return s.generateAuthURLWithScope(ctx, scope, proxyID) + return s.generateAuthURLWithScope(ctx, oauth.ScopeOAuth, proxyID) } // GenerateSetupTokenURL generates an OAuth authorization URL for setup token (inference only) @@ -176,7 +175,8 @@ func (s *OAuthService) CookieAuth(ctx context.Context, input *CookieAuthInput) ( } // Determine scope and if this is a setup token - scope := fmt.Sprintf("%s %s", oauth.ScopeProfile, oauth.ScopeInference) + // Internal API call uses ScopeAPI (org:create_api_key not supported) + scope := oauth.ScopeAPI isSetupToken := false if input.Scope == "inference" { scope = oauth.ScopeInference