From 084a6f1302147791beeff0533bb3a063b16d4687 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Sun, 22 Mar 2026 06:02:55 +0800 Subject: [PATCH] fix(ccm): align OAuth token refresh with Claude Code v2.1.81 After re-login with newer Claude Code (v2.1.75+), CCM refresh requests returned persistent 429s. Root cause: CCM omitted the `scope` parameter that the server now requires for tokens with `user:file_upload` scope. Changes to fully match Claude Code's OAuth behavior: - Add `scope` parameter to token refresh request body - Parse `scope` from refresh response and store back - Add `subscriptionType`/`rateLimitTier` to credential struct to preserve Claude Code's profile state on write-back - Change credential file write to read-modify-write, preserving other top-level JSON keys (matches Claude Code's BP6 pattern) - Same for macOS keychain write path - Increase token expiry buffer from 1 min to 5 min (matching CC's isOAuthTokenExpired with 300s buffer) - Add cross-process mkdir-based file lock compatible with Claude Code's proper-lockfile protocol (~/.claude.lock) - Add post-failure recovery: re-read credentials from disk after refresh failure in case another process succeeded - Add 401/403 "OAuth token has been revoked" recovery in proxy handler: reload credentials and retry once --- service/ccm/credential_darwin.go | 80 +++++++++++++++++--------- service/ccm/credential_default.go | 48 ++++++++++++++++ service/ccm/credential_lock.go | 84 +++++++++++++++++++++++++++ service/ccm/credential_oauth.go | 94 +++++++++++++++++++++++++------ service/ccm/service_handler.go | 37 ++++++++++++ 5 files changed, 298 insertions(+), 45 deletions(-) create mode 100644 service/ccm/credential_lock.go diff --git a/service/ccm/credential_darwin.go b/service/ccm/credential_darwin.go index aef10c874..d025fd47c 100644 --- a/service/ccm/credential_darwin.go +++ b/service/ccm/credential_darwin.go @@ -76,42 +76,66 @@ func platformCanWriteCredentials(customPath string) error { return checkCredentialFileWritable(customPath) } -func platformWriteCredentials(oauthCredentials *oauthCredentials, customPath string) error { +// platformWriteCredentials performs a read-modify-write on the keychain entry, +// preserving any fields or top-level keys not managed by CCM. +// +// ref (@anthropic-ai/claude-code @2.1.81): cli.js BP6 (line 179444-179454) — read-modify-write +func platformWriteCredentials(credentials *oauthCredentials, customPath string) error { if customPath != "" { - return writeCredentialsToFile(oauthCredentials, customPath) + return writeCredentialsToFile(credentials, customPath) } userInfo, err := getRealUser() if err == nil { - data, err := json.Marshal(map[string]any{"claudeAiOauth": oauthCredentials}) + serviceName := getKeychainServiceName() + + existing := make(map[string]json.RawMessage) + query := keychain.NewItem() + query.SetSecClass(keychain.SecClassGenericPassword) + query.SetService(serviceName) + query.SetAccount(userInfo.Username) + query.SetMatchLimit(keychain.MatchLimitOne) + query.SetReturnData(true) + results, queryErr := keychain.QueryItem(query) + if queryErr == nil && len(results) == 1 { + _ = json.Unmarshal(results[0].Data, &existing) + } + + credentialData, err := json.Marshal(credentials) + if err != nil { + return E.Cause(err, "marshal credentials") + } + existing["claudeAiOauth"] = credentialData + data, err := json.Marshal(existing) + if err != nil { + return E.Cause(err, "marshal credential container") + } + + item := keychain.NewItem() + item.SetSecClass(keychain.SecClassGenericPassword) + item.SetService(serviceName) + item.SetAccount(userInfo.Username) + item.SetData(data) + item.SetAccessible(keychain.AccessibleWhenUnlocked) + + err = keychain.AddItem(item) if err == nil { - serviceName := getKeychainServiceName() - item := keychain.NewItem() - item.SetSecClass(keychain.SecClassGenericPassword) - item.SetService(serviceName) - item.SetAccount(userInfo.Username) - item.SetData(data) - item.SetAccessible(keychain.AccessibleWhenUnlocked) + return nil + } - err = keychain.AddItem(item) - if err == nil { + if err == keychain.ErrorDuplicateItem { + updateQuery := keychain.NewItem() + updateQuery.SetSecClass(keychain.SecClassGenericPassword) + updateQuery.SetService(serviceName) + updateQuery.SetAccount(userInfo.Username) + + updateItem := keychain.NewItem() + updateItem.SetData(data) + + updateErr := keychain.UpdateItem(updateQuery, updateItem) + if updateErr == nil { return nil } - - if err == keychain.ErrorDuplicateItem { - query := keychain.NewItem() - query.SetSecClass(keychain.SecClassGenericPassword) - query.SetService(serviceName) - query.SetAccount(userInfo.Username) - - updateItem := keychain.NewItem() - updateItem.SetData(data) - - updateErr := keychain.UpdateItem(query, updateItem) - if updateErr == nil { - return nil - } - } } } @@ -119,5 +143,5 @@ func platformWriteCredentials(oauthCredentials *oauthCredentials, customPath str if err != nil { return err } - return writeCredentialsToFile(oauthCredentials, defaultPath) + return writeCredentialsToFile(credentials, defaultPath) } diff --git a/service/ccm/credential_default.go b/service/ccm/credential_default.go index 57e0ca912..4c571a38e 100644 --- a/service/ccm/credential_default.go +++ b/service/ccm/credential_default.go @@ -9,6 +9,7 @@ import ( "math" "net" "net/http" + "slices" "strconv" "sync" "time" @@ -29,6 +30,7 @@ type defaultCredential struct { serviceContext context.Context credentialPath string credentialFilePath string + configDir string statePath string credentials *oauthCredentials access sync.RWMutex @@ -132,6 +134,7 @@ func (c *defaultCredential) start() error { return E.Cause(err, "resolve credential path for ", c.tag) } c.credentialFilePath = credentialFilePath + c.configDir = resolveConfigDir(c.credentialPath, credentialFilePath) c.loadPersistedState() err = c.ensureCredentialWatcher() if err != nil { @@ -176,6 +179,7 @@ func (c *defaultCredential) statusSnapshotLocked() statusSnapshot { func (c *defaultCredential) getAccessToken() (string, error) { c.retryCredentialReloadIfNeeded() + // Fast path: cached token is still valid c.access.RLock() if c.credentials != nil && !c.credentials.needsRefresh() { token := c.credentials.AccessToken @@ -184,6 +188,7 @@ func (c *defaultCredential) getAccessToken() (string, error) { } c.access.RUnlock() + // Reload from disk — Claude Code or another process may have refreshed err := c.reloadCredentials(true) if err == nil { c.access.RLock() @@ -195,6 +200,41 @@ func (c *defaultCredential) getAccessToken() (string, error) { c.access.RUnlock() } + // ref (@anthropic-ai/claude-code @2.1.81): cli.js _P1 line 179526 + // Claude Code skips refresh for tokens without user:inference scope. + // Return existing token (may be expired); 401 recovery is the safety net. + c.access.RLock() + if c.credentials != nil && !slices.Contains(c.credentials.Scopes, "user:inference") { + token := c.credentials.AccessToken + c.access.RUnlock() + return token, nil + } + c.access.RUnlock() + + // Acquire cross-process lock before refresh (outside Go mutex to avoid holding mutex during sleep) + // ref: cli.js _P1 (line 179534-179536) — proper-lockfile lock on config dir + release, lockErr := acquireCredentialLock(c.configDir) + if lockErr != nil { + c.logger.Debug("acquire credential lock for ", c.tag, ": ", lockErr) + release = func() {} + } + defer release() + + // ref: cli.js _P1 (line 179559-179562) — re-read after lock, skip if race resolved + _ = c.reloadCredentials(true) + c.access.RLock() + noRefreshToken := c.credentials == nil || c.credentials.RefreshToken == "" + raceResolved := !noRefreshToken && !c.credentials.needsRefresh() + var racedToken string + if (noRefreshToken || raceResolved) && c.credentials != nil { + racedToken = c.credentials.AccessToken + } + c.access.RUnlock() + if noRefreshToken || raceResolved { + return racedToken, nil + } + + // Slow path: acquire Go mutex and refresh c.access.Lock() defer c.access.Unlock() @@ -227,6 +267,14 @@ func (c *defaultCredential) getAccessToken() (string, error) { c.refreshRetryAt = time.Now().Add(retryDelay) c.refreshRetryError = err } + // ref: cli.js _P1 (line 179568-179573) — post-failure recovery: + // re-read from disk; if another process refreshed successfully, use that. + // Cannot call reloadCredentials here (deadlock: already holding c.access). + latestCredentials, readErr := platformReadCredentials(c.credentialPath) + if readErr == nil && latestCredentials != nil && !latestCredentials.needsRefresh() { + c.credentials = latestCredentials + return latestCredentials.AccessToken, nil + } return "", err } c.refreshRetryAt = time.Time{} diff --git a/service/ccm/credential_lock.go b/service/ccm/credential_lock.go new file mode 100644 index 000000000..2374437c4 --- /dev/null +++ b/service/ccm/credential_lock.go @@ -0,0 +1,84 @@ +package ccm + +import ( + "math/rand/v2" + "os" + "path/filepath" + "time" + + E "github.com/sagernet/sing/common/exceptions" +) + +// acquireCredentialLock acquires a cross-process lock compatible with Claude Code's +// proper-lockfile protocol. The lock is a directory created via mkdir (atomic on +// POSIX filesystems). +// +// ref (@anthropic-ai/claude-code @2.1.81): cli.js _P1 (line 179530-179577) +// ref: proper-lockfile mkdir protocol (cli.js:43570) +// ref: proper-lockfile default options — stale=10s, update=stale/2=5s, realpath=true (cli.js:43661-43664) +// +// Claude Code locks d1() (= ~/.claude config dir). The lock directory is +// .lock (proper-lockfile default: .lock). +// Manual retry: initial + 5 retries = 6 total, delay 1+rand(1s) per retry. +func acquireCredentialLock(configDir string) (func(), error) { + // ref: cli.js _P1 line 179531 — mkdir -p configDir before locking + os.MkdirAll(configDir, 0o700) + // ref: proper-lockfile realpath:true (cli.js:43664) — resolve symlinks before appending .lock + resolved, err := filepath.EvalSymlinks(configDir) + if err != nil { + resolved = filepath.Clean(configDir) + } + lockPath := resolved + ".lock" + // ref: cli.js _P1 line 179539-179543 — initial + 5 retries = 6 total attempts + for attempt := 0; attempt < 6; attempt++ { + if attempt > 0 { + // ref: cli.js _P1 line 179542 — 1000 + Math.random() * 1000 + delay := time.Second + time.Duration(rand.IntN(1000))*time.Millisecond + time.Sleep(delay) + } + err = os.Mkdir(lockPath, 0o755) + if err == nil { + return startLockHeartbeat(lockPath), nil + } + if !os.IsExist(err) { + return nil, E.Cause(err, "create lock directory") + } + // ref: proper-lockfile stale check (cli.js:43603-43604) + // stale threshold = 10s (cli.js:43662) + info, statErr := os.Stat(lockPath) + if statErr != nil { + continue + } + if time.Since(info.ModTime()) > 10*time.Second { + os.Remove(lockPath) + } + } + return nil, E.New("credential lock timeout") +} + +// startLockHeartbeat spawns a goroutine that touches the lock directory's mtime +// every 5 seconds to prevent stale detection by other processes. +// +// ref: proper-lockfile update interval = stale/2 = 5s (cli.js:43662-43663) +// +// Returns a release function that stops the heartbeat and removes the lock directory. +func startLockHeartbeat(lockPath string) func() { + done := make(chan struct{}) + go func() { + ticker := time.NewTicker(5 * time.Second) + defer ticker.Stop() + for { + select { + case <-ticker.C: + now := time.Now() + os.Chtimes(lockPath, now, now) + case <-done: + return + } + } + }() + return func() { + close(done) + os.Remove(lockPath) + } +} diff --git a/service/ccm/credential_oauth.go b/service/ccm/credential_oauth.go index 9d0b5146e..71caf6267 100644 --- a/service/ccm/credential_oauth.go +++ b/service/ccm/credential_oauth.go @@ -12,6 +12,7 @@ import ( "runtime" "slices" "strconv" + "strings" "sync" "time" @@ -23,10 +24,32 @@ const ( oauth2ClientID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e" oauth2TokenURL = "https://platform.claude.com/v1/oauth/token" claudeAPIBaseURL = "https://api.anthropic.com" - tokenRefreshBufferMs = 60000 anthropicBetaOAuthValue = "oauth-2025-04-20" + + // ref (@anthropic-ai/claude-code @2.1.81): cli.js vB (line 172879) + tokenRefreshBufferMs = 300000 ) +// ref (@anthropic-ai/claude-code @2.1.81): cli.js q78 (line 33167) +// These scopes may change across Claude Code versions. +var defaultOAuthScopes = []string{ + "user:profile", "user:inference", "user:sessions:claude_code", + "user:mcp_servers", "user:file_upload", +} + +// resolveRefreshScopes determines which scopes to send in the token refresh request. +// +// ref (@anthropic-ai/claude-code @2.1.81): cli.js NR() (line 172693) + mB6 scope logic (line 172761) +// +// Claude Code behavior: if stored scopes include "user:inference", send default +// scopes; otherwise send the stored scopes verbatim. +func resolveRefreshScopes(stored []string) string { + if len(stored) == 0 || slices.Contains(stored, "user:inference") { + return strings.Join(defaultOAuthScopes, " ") + } + return strings.Join(stored, " ") +} + const ccmUserAgentFallback = "claude-code/2.1.72" var ( @@ -71,6 +94,22 @@ func detectClaudeCodeVersion() (string, error) { return filepath.Base(target), nil } +// resolveConfigDir returns the Claude config directory for lock coordination. +// +// ref (@anthropic-ai/claude-code @2.1.81): cli.js d1() (line 2983) — config dir used for locking +func resolveConfigDir(credentialPath string, credentialFilePath string) string { + if credentialPath == "" { + if configDir := os.Getenv("CLAUDE_CONFIG_DIR"); configDir != "" { + return configDir + } + userInfo, err := getRealUser() + if err == nil { + return filepath.Join(userInfo.HomeDir, ".claude") + } + } + return filepath.Dir(credentialFilePath) +} + func getRealUser() (*user.User, error) { if sudoUser := os.Getenv("SUDO_USER"); sudoUser != "" { sudoUserInfo, err := user.Lookup(sudoUser) @@ -118,10 +157,24 @@ func checkCredentialFileWritable(path string) error { return file.Close() } -func writeCredentialsToFile(oauthCredentials *oauthCredentials, path string) error { - data, err := json.MarshalIndent(map[string]any{ - "claudeAiOauth": oauthCredentials, - }, "", " ") +// writeCredentialsToFile performs a read-modify-write: reads the existing JSON, +// replaces only the claudeAiOauth key, and writes back. This preserves any +// other top-level keys in the credential file. +// +// ref (@anthropic-ai/claude-code @2.1.81): cli.js BP6 (line 179444-179454) — read-modify-write +// ref: cli.js qD1.update (line 176156) — writeFileSync + chmod 0o600 +func writeCredentialsToFile(credentials *oauthCredentials, path string) error { + existing := make(map[string]json.RawMessage) + data, readErr := os.ReadFile(path) + if readErr == nil { + _ = json.Unmarshal(data, &existing) + } + credentialData, err := json.Marshal(credentials) + if err != nil { + return err + } + existing["claudeAiOauth"] = credentialData + data, err = json.MarshalIndent(existing, "", " ") if err != nil { return err } @@ -131,16 +184,14 @@ func writeCredentialsToFile(oauthCredentials *oauthCredentials, path string) err // oauthCredentials mirrors the claudeAiOauth object in Claude Code's // credential file ($CLAUDE_CONFIG_DIR/.credentials.json). // -// ref (@anthropic-ai/claude-code @2.1.81): cli.js mB6() / refreshOAuthToken -// -// Note: subscriptionType, rateLimitTier, and isMax were removed from this -// struct — they are profile state, not auth credentials. Claude Code also -// stores them here, but we persist them separately via state_path instead. +// ref (@anthropic-ai/claude-code @2.1.81): cli.js BP6 (line 179446-179452) type oauthCredentials struct { - AccessToken string `json:"accessToken"` - RefreshToken string `json:"refreshToken"` - ExpiresAt int64 `json:"expiresAt"` - Scopes []string `json:"scopes,omitempty"` + AccessToken string `json:"accessToken"` // ref: cli.js line 179447 + RefreshToken string `json:"refreshToken"` // ref: cli.js line 179448 + ExpiresAt int64 `json:"expiresAt"` // ref: cli.js line 179449 (epoch ms) + Scopes []string `json:"scopes"` // ref: cli.js line 179450 + SubscriptionType *string `json:"subscriptionType"` // ref: cli.js line 179451 (?? null) + RateLimitTier *string `json:"rateLimitTier"` // ref: cli.js line 179452 (?? null) } func (c *oauthCredentials) needsRefresh() bool { @@ -155,10 +206,12 @@ func refreshToken(ctx context.Context, httpClient *http.Client, credentials *oau return nil, 0, E.New("refresh token is empty") } + // ref (@anthropic-ai/claude-code @2.1.81): cli.js mB6 (line 172757-172761) requestBody, err := json.Marshal(map[string]string{ "grant_type": "refresh_token", "refresh_token": credentials.RefreshToken, "client_id": oauth2ClientID, + "scope": resolveRefreshScopes(credentials.Scopes), }) if err != nil { return nil, 0, E.Cause(err, "marshal request") @@ -194,10 +247,12 @@ func refreshToken(ctx context.Context, httpClient *http.Client, credentials *oau return nil, 0, E.New("refresh failed: ", response.Status, " ", string(body)) } + // ref (@anthropic-ai/claude-code @2.1.81): cli.js mB6 response (line 172769-172772) var tokenResponse struct { - AccessToken string `json:"access_token"` - RefreshToken string `json:"refresh_token"` - ExpiresIn int `json:"expires_in"` + AccessToken string `json:"access_token"` // ref: cli.js line 172770 z + RefreshToken string `json:"refresh_token"` // ref: cli.js line 172770 w (defaults to input) + ExpiresIn int `json:"expires_in"` // ref: cli.js line 172770 O + Scope string `json:"scope"` // ref: cli.js line 172772 uB6(Y.scope) } err = json.NewDecoder(response.Body).Decode(&tokenResponse) if err != nil { @@ -210,6 +265,11 @@ func refreshToken(ctx context.Context, httpClient *http.Client, credentials *oau newCredentials.RefreshToken = tokenResponse.RefreshToken } newCredentials.ExpiresAt = time.Now().UnixMilli() + int64(tokenResponse.ExpiresIn)*1000 + // ref: cli.js uB6 (line 172696-172697): A?.split(" ").filter(Boolean) + // strings.Fields matches .filter(Boolean): splits on whitespace runs, removes empty strings + if tokenResponse.Scope != "" { + newCredentials.Scopes = strings.Fields(tokenResponse.Scope) + } return &newCredentials, 0, nil } diff --git a/service/ccm/service_handler.go b/service/ccm/service_handler.go index 94af4e90e..870a39514 100644 --- a/service/ccm/service_handler.go +++ b/service/ccm/service_handler.go @@ -372,6 +372,43 @@ func (s *Service) ServeHTTP(w http.ResponseWriter, r *http.Request) { return } + // ref (@anthropic-ai/claude-code @2.1.81): cli.js NA9 (line 179488-179494) — 401 recovery + // ref: cli.js CR1 (line 314268-314273) — 403 "OAuth token has been revoked" recovery + if !selectedCredential.isExternal() && bodyBytes != nil && + (response.StatusCode == http.StatusUnauthorized || response.StatusCode == http.StatusForbidden) { + shouldRetry := response.StatusCode == http.StatusUnauthorized + if response.StatusCode == http.StatusForbidden { + peekBody, _ := io.ReadAll(response.Body) + shouldRetry = strings.Contains(string(peekBody), "OAuth token has been revoked") + if !shouldRetry { + response.Body.Close() + s.logger.ErrorContext(ctx, "upstream error from ", selectedCredential.tagName(), ": status ", response.StatusCode, " ", string(peekBody)) + writeJSONError(w, r, http.StatusInternalServerError, "api_error", + "proxy request (status "+strconv.Itoa(response.StatusCode)+"): "+string(peekBody)) + return + } + } + if shouldRetry { + response.Body.Close() + s.logger.WarnContext(ctx, "upstream auth failure from ", selectedCredential.tagName(), ", reloading credentials and retrying") + if defaultCred, ok := selectedCredential.(*defaultCredential); ok { + _ = defaultCred.reloadCredentials(true) + } + retryRequest, buildErr := selectedCredential.buildProxyRequest(requestContext, r, bodyBytes, s.httpHeaders) + if buildErr != nil { + writeJSONError(w, r, http.StatusBadGateway, "api_error", E.Cause(buildErr, "rebuild request after auth recovery").Error()) + return + } + retryResponse, retryErr := selectedCredential.httpClient().Do(retryRequest) + if retryErr != nil { + writeJSONError(w, r, http.StatusBadGateway, "api_error", E.Cause(retryErr, "retry request after auth recovery").Error()) + return + } + response = retryResponse + defer retryResponse.Body.Close() + } + } + if response.StatusCode != http.StatusOK && response.StatusCode != http.StatusTooManyRequests { body, _ := io.ReadAll(response.Body) s.logger.ErrorContext(ctx, "upstream error from ", selectedCredential.tagName(), ": status ", response.StatusCode, " ", string(body))