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
This commit is contained in:
世界
2026-03-22 06:02:55 +08:00
parent 0950783479
commit 084a6f1302
5 changed files with 298 additions and 45 deletions
+52 -28
View File
@@ -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)
}
+48
View File
@@ -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{}
+84
View File
@@ -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
// <realpath(configDir)>.lock (proper-lockfile default: <path>.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)
}
}
+77 -17
View File
@@ -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
}
+37
View File
@@ -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))