fix: 更新Claude OAuth授权配置以匹配最新规范
- 更新TokenURL和RedirectURI为platform.claude.com - 更新scope定义,区分浏览器URL和内部API调用 - 修正state/code_verifier生成算法使用base64url编码 - 修正授权URL参数顺序并添加code=true - 更新token交换请求头匹配官方实现 - 清理未使用的类型和函数
This commit is contained in:
@@ -13,20 +13,26 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Claude OAuth Constants (from CRS project)
|
// Claude OAuth Constants
|
||||||
const (
|
const (
|
||||||
// OAuth Client ID for Claude
|
// OAuth Client ID for Claude
|
||||||
ClientID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e"
|
ClientID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e"
|
||||||
|
|
||||||
// OAuth endpoints
|
// OAuth endpoints
|
||||||
AuthorizeURL = "https://claude.ai/oauth/authorize"
|
AuthorizeURL = "https://claude.ai/oauth/authorize"
|
||||||
TokenURL = "https://console.anthropic.com/v1/oauth/token"
|
TokenURL = "https://platform.claude.com/v1/oauth/token"
|
||||||
RedirectURI = "https://console.anthropic.com/oauth/code/callback"
|
RedirectURI = "https://platform.claude.com/oauth/code/callback"
|
||||||
|
|
||||||
// Scopes
|
// Scopes - Browser URL (includes org:create_api_key for user authorization)
|
||||||
ScopeProfile = "user:profile"
|
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"
|
ScopeInference = "user:inference"
|
||||||
|
|
||||||
|
// Code Verifier character set (RFC 7636 compliant)
|
||||||
|
codeVerifierCharset = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~"
|
||||||
|
|
||||||
// Session TTL
|
// Session TTL
|
||||||
SessionTTL = 30 * time.Minute
|
SessionTTL = 30 * time.Minute
|
||||||
)
|
)
|
||||||
@@ -53,7 +59,6 @@ func NewSessionStore() *SessionStore {
|
|||||||
sessions: make(map[string]*OAuthSession),
|
sessions: make(map[string]*OAuthSession),
|
||||||
stopCh: make(chan struct{}),
|
stopCh: make(chan struct{}),
|
||||||
}
|
}
|
||||||
// Start cleanup goroutine
|
|
||||||
go store.cleanup()
|
go store.cleanup()
|
||||||
return store
|
return store
|
||||||
}
|
}
|
||||||
@@ -78,7 +83,6 @@ func (s *SessionStore) Get(sessionID string) (*OAuthSession, bool) {
|
|||||||
if !ok {
|
if !ok {
|
||||||
return nil, false
|
return nil, false
|
||||||
}
|
}
|
||||||
// Check if expired
|
|
||||||
if time.Since(session.CreatedAt) > SessionTTL {
|
if time.Since(session.CreatedAt) > SessionTTL {
|
||||||
return nil, false
|
return nil, false
|
||||||
}
|
}
|
||||||
@@ -122,13 +126,13 @@ func GenerateRandomBytes(n int) ([]byte, error) {
|
|||||||
return b, nil
|
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) {
|
func GenerateState() (string, error) {
|
||||||
bytes, err := GenerateRandomBytes(32)
|
bytes, err := GenerateRandomBytes(32)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
return hex.EncodeToString(bytes), nil
|
return base64URLEncode(bytes), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GenerateSessionID generates a unique session ID
|
// GenerateSessionID generates a unique session ID
|
||||||
@@ -140,13 +144,30 @@ func GenerateSessionID() (string, error) {
|
|||||||
return hex.EncodeToString(bytes), nil
|
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) {
|
func GenerateCodeVerifier() (string, error) {
|
||||||
bytes, err := GenerateRandomBytes(32)
|
const targetLen = 32
|
||||||
if err != nil {
|
charsetLen := len(codeVerifierCharset)
|
||||||
return "", err
|
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
|
// 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
|
// base64URLEncode encodes bytes to base64url without padding
|
||||||
func base64URLEncode(data []byte) string {
|
func base64URLEncode(data []byte) string {
|
||||||
encoded := base64.URLEncoding.EncodeToString(data)
|
encoded := base64.URLEncoding.EncodeToString(data)
|
||||||
// Remove padding
|
|
||||||
return strings.TrimRight(encoded, "=")
|
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 {
|
func BuildAuthorizationURL(state, codeChallenge, scope string) string {
|
||||||
params := url.Values{}
|
encodedRedirectURI := url.QueryEscape(RedirectURI)
|
||||||
params.Set("response_type", "code")
|
encodedScope := strings.ReplaceAll(url.QueryEscape(scope), "%20", "+")
|
||||||
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")
|
|
||||||
|
|
||||||
return fmt.Sprintf("%s?%s", AuthorizeURL, params.Encode())
|
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,
|
||||||
// TokenRequest represents the token exchange request body
|
encodedRedirectURI,
|
||||||
type TokenRequest struct {
|
encodedScope,
|
||||||
GrantType string `json:"grant_type"`
|
codeChallenge,
|
||||||
ClientID string `json:"client_id"`
|
state,
|
||||||
Code string `json:"code"`
|
)
|
||||||
RedirectURI string `json:"redirect_uri"`
|
|
||||||
CodeVerifier string `json:"code_verifier"`
|
|
||||||
State string `json:"state"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// TokenResponse represents the token response from OAuth provider
|
// TokenResponse represents the token response from OAuth provider
|
||||||
type TokenResponse struct {
|
type TokenResponse struct {
|
||||||
AccessToken string `json:"access_token"`
|
AccessToken string `json:"access_token"`
|
||||||
TokenType string `json:"token_type"`
|
TokenType string `json:"token_type"`
|
||||||
ExpiresIn int64 `json:"expires_in"`
|
ExpiresIn int64 `json:"expires_in"`
|
||||||
RefreshToken string `json:"refresh_token,omitempty"`
|
RefreshToken string `json:"refresh_token,omitempty"`
|
||||||
Scope string `json:"scope,omitempty"`
|
Scope string `json:"scope,omitempty"`
|
||||||
// Organization and Account info from OAuth response
|
|
||||||
Organization *OrgInfo `json:"organization,omitempty"`
|
Organization *OrgInfo `json:"organization,omitempty"`
|
||||||
Account *AccountInfo `json:"account,omitempty"`
|
Account *AccountInfo `json:"account,omitempty"`
|
||||||
}
|
}
|
||||||
@@ -207,31 +217,3 @@ type OrgInfo struct {
|
|||||||
type AccountInfo struct {
|
type AccountInfo struct {
|
||||||
UUID string `json:"uuid"`
|
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,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -182,7 +182,9 @@ func (s *claudeOAuthService) ExchangeCodeForToken(ctx context.Context, code, cod
|
|||||||
|
|
||||||
resp, err := client.R().
|
resp, err := client.R().
|
||||||
SetContext(ctx).
|
SetContext(ctx).
|
||||||
|
SetHeader("Accept", "application/json, text/plain, */*").
|
||||||
SetHeader("Content-Type", "application/json").
|
SetHeader("Content-Type", "application/json").
|
||||||
|
SetHeader("User-Agent", "axios/1.8.4").
|
||||||
SetBody(reqBody).
|
SetBody(reqBody).
|
||||||
SetSuccessResult(&tokenResp).
|
SetSuccessResult(&tokenResp).
|
||||||
Post(s.tokenURL)
|
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) {
|
func (s *claudeOAuthService) RefreshToken(ctx context.Context, refreshToken, proxyURL string) (*oauth.TokenResponse, error) {
|
||||||
client := s.clientFactory(proxyURL)
|
client := s.clientFactory(proxyURL)
|
||||||
|
|
||||||
// 使用 JSON 格式(与 ExchangeCodeForToken 保持一致)
|
|
||||||
// Anthropic OAuth API 期望 JSON 格式的请求体
|
|
||||||
reqBody := map[string]any{
|
reqBody := map[string]any{
|
||||||
"grant_type": "refresh_token",
|
"grant_type": "refresh_token",
|
||||||
"refresh_token": refreshToken,
|
"refresh_token": refreshToken,
|
||||||
@@ -217,7 +217,9 @@ func (s *claudeOAuthService) RefreshToken(ctx context.Context, refreshToken, pro
|
|||||||
|
|
||||||
resp, err := client.R().
|
resp, err := client.R().
|
||||||
SetContext(ctx).
|
SetContext(ctx).
|
||||||
|
SetHeader("Accept", "application/json, text/plain, */*").
|
||||||
SetHeader("Content-Type", "application/json").
|
SetHeader("Content-Type", "application/json").
|
||||||
|
SetHeader("User-Agent", "axios/1.8.4").
|
||||||
SetBody(reqBody).
|
SetBody(reqBody).
|
||||||
SetSuccessResult(&tokenResp).
|
SetSuccessResult(&tokenResp).
|
||||||
Post(s.tokenURL)
|
Post(s.tokenURL)
|
||||||
|
|||||||
@@ -171,7 +171,7 @@ func (s *ClaudeOAuthServiceSuite) TestGetAuthorizationCode() {
|
|||||||
s.client.baseURL = "http://in-process"
|
s.client.baseURL = "http://in-process"
|
||||||
s.client.clientFactory = func(string) *req.Client { return newTestReqClient(rt) }
|
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 {
|
if tt.wantErr {
|
||||||
require.Error(s.T(), err)
|
require.Error(s.T(), err)
|
||||||
|
|||||||
@@ -48,8 +48,7 @@ type GenerateAuthURLResult struct {
|
|||||||
|
|
||||||
// GenerateAuthURL generates an OAuth authorization URL with full scope
|
// GenerateAuthURL generates an OAuth authorization URL with full scope
|
||||||
func (s *OAuthService) GenerateAuthURL(ctx context.Context, proxyID *int64) (*GenerateAuthURLResult, error) {
|
func (s *OAuthService) GenerateAuthURL(ctx context.Context, proxyID *int64) (*GenerateAuthURLResult, error) {
|
||||||
scope := fmt.Sprintf("%s %s", oauth.ScopeProfile, oauth.ScopeInference)
|
return s.generateAuthURLWithScope(ctx, oauth.ScopeOAuth, proxyID)
|
||||||
return s.generateAuthURLWithScope(ctx, scope, proxyID)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// GenerateSetupTokenURL generates an OAuth authorization URL for setup token (inference only)
|
// 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
|
// 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
|
isSetupToken := false
|
||||||
if input.Scope == "inference" {
|
if input.Scope == "inference" {
|
||||||
scope = oauth.ScopeInference
|
scope = oauth.ScopeInference
|
||||||
|
|||||||
Reference in New Issue
Block a user