Vectrum
Sign In

claudecode: refresh the access token, write back only on rotation

An expired login now renews itself instead of telling you to go run
claude.

The endpoint never answered — ten attempts, every one a 429, no
exchange ever completed — so whether it rotates refresh tokens is still
unknown. Rather than gamble on it, the code stops needing the answer.
Refresh keeps the result in memory, which is the common path and never
touches the file. It compares the returned refresh token to the stored
one, and writes back only when they differ: if the server rotated, the
token on disk is already dead, and leaving it there is precisely what
breaks the user's Claude Code login. Writing is the safe act in that
one case and the risky one everywhere else, so it is conditional rather
than routine.

Save is a read-modify-write, not a serialize. It rewrites a file Claude
Code owns, so every key it did not come to change goes back as found —
including rateLimitTier, which this package does not model. Numbers
decode as literals rather than float64, or the millisecond timestamps
would come back as 1.78966826946e+12 and we would have corrupted the
file we were trying to keep valid. Verified by round-tripping a copy of
a real credentials file and diffing every key.

Write-back is best effort: a store we cannot write is a worse next
session, not a failed current one.

None of this is verified against the live endpoint, because the live
endpoint has never once responded. It is verified against httptest and
against the real file's shape.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Jjonicommitted 1 parent 17474dccommitd10d851

Showing 7 changed files with 699 additions and 59 deletions.

+4−2README.mdView File
@@ -45,8 +45,10 @@ from another client is outside Anthropic's terms for it, which API keys and
4545 gateway tokens do not carry.
4646
4747 On macOS Claude Code keeps this in the Keychain rather than a file, so there is
48- nothing to borrow there. An expired access token is not refreshed — run `claude`
49- and the harness picks up the new one.
48+ nothing to borrow there. An expired access token is refreshed automatically; the
49+ credentials file is left alone unless the server rotates the refresh token, in
50+ which case the replacement is written back — leaving the dead one there is what
51+ would break your Claude Code login.
5052
5153 Useful flags: `-model`, `-effort low|medium|high|xhigh|max`, `-max-turns`,
5254 `-resume latest`, `-no-stream`, `-no-cache`, `-v`, `-yolo`, `-no-tui`, `-no-skills`,
+4−1cmd/harness/main.goView File
@@ -356,7 +356,10 @@ func resolveClaudeCode(client *llm.Client, home string, disabled bool) (func(con
356356 return nil, "", err
357357 }
358358 note := fmt.Sprintf("using your Claude Code login (%s plan) from ~/%s", creds.SubscriptionType, claudecode.Filename)
359- return claudecode.NewSource(creds).Token, note, nil
359+ // home is handed on so a rotated refresh token can be written back. That
360+ // is the only circumstance in which this process writes to Claude Code's
361+ // file, and not writing then is what would break its login.
362+ return claudecode.NewSource(creds, client.HTTP, home).Token, note, nil
360363 }
361364
362365 // interactive reports whether both ends of the terminal are a real one. A
+60−24docs/superpowers/specs/2026-09-17-claude-code-oauth-design.mdView File
@@ -66,24 +66,33 @@ skips discovery entirely.
6666
6767 ## `internal/claudecode`
6868
69- One package, three jobs, no ambient state:
69+ One package, one concern, no ambient state:
7070
7171 ```go
7272 type Credentials struct {
73- AccessToken, RefreshToken string
74- ExpiresAt time.Time
75- Scopes []string
76- SubscriptionType string
73+ AccessToken, RefreshToken string
74+ ExpiresAt, RefreshExpiresAt time.Time
75+ Scopes []string
76+ SubscriptionType string
7777 }
7878
79- func Load(home string) (*Credentials, error) // parse. no network
80- func (c *Credentials) Expired(now time.Time) bool // with skew
79+ func Load(home string) (*Credentials, error) // parse. no network
80+ func Save(home string, c *Credentials) error // read-modify-write, atomic
81+ func (c *Credentials) Expired(now time.Time) bool // with skew
82+ func (c *Credentials) RefreshExpired(now time.Time) bool // terminal
8183 func Refresh(context.Context, *http.Client, *Credentials) (*Credentials, error)
84+
85+ // Source is the policy: refresh on expiry, write back only on rotation.
86+ func NewSource(c *Credentials, h *http.Client, home string) *Source
87+ func (s *Source) Token(context.Context) (string, error)
8288 ```
8389
8490 `Load` takes `home` rather than calling `os.UserHomeDir` so tests point it at a
8591 fixture directory, matching how `skills.Discover` already takes its roots.
86- `Refresh` returns new credentials and writes nothing — see below.
92+ `Refresh` returns new credentials and writes nothing; `Source` decides whether
93+ anything is persisted, because that decision is policy and `Refresh` is
94+ mechanism. `NewSource` takes `home` rather than a path so the "no write-back"
95+ case is the obvious one to express — pass `""`.
8796
8897 Missing file is not an error worth decorating: it means "not logged in", and the
8998 caller falls through to `errNoCredentials`. A file that exists but does not parse
@@ -126,41 +135,68 @@ process lifetime, and is mutex-guarded: streaming and non-streaming requests can
126135 be in flight together, and two concurrent refreshes of the same token is exactly
127136 the scenario the next section is about.
128137
129- ## Refresh: not shipped, and why
138+ ## Refresh, and the question that could not be answered
130139
131140 The question was whether `POST /v1/oauth/token` rotates refresh tokens. If it
132141 does, refreshing without writing the result back consumes the token Claude Code
133142 still has on disk — its copy goes dead and `claude` asks you to log in again.
134143 We would have broken a tool outside this repo to save a paste.
135144
136- **The probe never got an answer, and that is the answer.** Nine attempts to
145+ **The probe never got an answer, and that is the answer.** Ten attempts to
137146 `https://console.anthropic.com/v1/oauth/token` with the stored refresh token and
138- Claude Code's client id, spread over roughly six minutes, every one of them:
147+ Claude Code's client id — nine in a burst over roughly six minutes, then one
148+ more much later in case the first nine were self-inflicted — every one of them:
139149
140150 ```
141151 HTTP 429 {"error":{"type":"rate_limit_error","message":"Rate limited. Please try again later."}}
142152 ```
143153
144154 No exchange ever happened, so the refresh token was not consumed and nothing
145- was damaged. A rejection that persists uniformly across six minutes is not
146- throttling. And we have already seen this exact pattern once: the Messages API
147- answers a request shape it declines with `429 rate_limit_error` too, rather than
148- a 401. On these OAuth surfaces a 429 is not reliable evidence of a rate limit,
149- which is worth knowing before anyone reads one as "wait and retry".
155+ was damaged. A rejection that persists uniformly across six minutes, and again
156+ long after the traffic that might have caused it, is not throttling. And we have
157+ already seen this exact pattern once: the Messages API answers a request shape
158+ it declines with `429 rate_limit_error` too, rather than a 401. On these OAuth
159+ surfaces a 429 is not reliable evidence of a rate limit, which is worth knowing
160+ before anyone reads one as "wait and retry".
150161
151162 The honest reading is that this request — endpoint, client id, or grant shape —
152163 is not the one this credential is refreshed with. Finding the one that is would
153164 mean guessing at authentication endpoints while holding someone's live refresh
154165 token, which is not a thing to do on a hunch.
155166
156- **So refresh is not implemented.** The shipped behaviour is the fallback, and it
157- is a complete feature: use the stored access token, and when it has expired fail
158- with `your Claude Code login has expired, run claude to refresh it`. Tokens last
159- around eight hours, so the cost is an occasional re-login — paid by running the
160- tool that owns the credential, which is the right place to pay it.
161-
162- If refresh is wanted later, the open question is unchanged and the probe is
163- unchanged; what is needed first is a refresh route that actually responds.
167+ **Refresh is implemented anyway, designed for both answers.** Since we cannot
168+ learn whether the endpoint rotates, the code stops needing to know:
169+
170+ - Refresh, and keep the result in memory. This is the common path and it never
171+ touches the file, which is what "keep in memory" was asking for.
172+ - Compare the returned refresh token to the stored one. If it is absent or
173+ identical, nothing is written — ever.
174+ - If it **differs**, the server rotated, and the token still on disk is already
175+ dead. Leaving it there is what breaks the Claude Code login. So that one case
176+ writes back, and writing is the safe act rather than the risky one.
177+
178+ The conditional is the whole design. Unconditional write-back makes us a routine
179+ writer of another tool's credential store; never writing gambles the user's
180+ login on an answer we could not get. Writing exactly when not writing has
181+ already caused harm needs no answer at all.
182+
183+ `Save` is a read-modify-write, not a serialize: the document is parsed, the
184+ three fields we came to change are replaced, and every other key goes back as
185+ found — including `rateLimitTier`, which this package does not model, and any
186+ key it has never heard of. Numbers decode as literals rather than float64, or
187+ the millisecond timestamps would return as `1.78966826946e+12` and we would have
188+ corrupted the file we were trying to keep valid. The write is atomic: temp file
189+ in the same directory, `0600`, then rename, so a crash midway leaves the
190+ original rather than half a credential. Verified by round-tripping a copy of a
191+ real credentials file and diffing every key.
192+
193+ Write-back is best effort. A store we cannot write is a worse next session, not
194+ a failed current one — we are holding a token that works, and refusing to use it
195+ would be strictly worse than a stale file.
196+
197+ Because the endpoint has never once answered, none of this is verified against
198+ the live service. It is verified against `httptest` and against the real file's
199+ shape. The first real refresh is still the first real refresh.
164200
165201 ## The system prompt
166202
+0−28internal/claudecode/claudecode.goView File
@@ -8,7 +8,6 @@
88 package claudecode
99
1010 import (
11- "context"
1211 "encoding/json"
1312 "errors"
1413 "fmt"
@@ -95,30 +94,3 @@ func (c *Credentials) Expired(now time.Time) bool {
9594 func (c *Credentials) RefreshExpired(now time.Time) bool {
9695 return !now.Before(c.RefreshExpiresAt)
9796 }
98-
99- // ErrExpired means the stored access token has lapsed. Refresh is not
100- // implemented: probing the token endpoint got a persistent 429 on every
101- // attempt, with no exchange ever happening, so there is no evidence of a
102- // route that actually refreshes this credential — and guessing further at
103- // auth endpoints while holding someone's live refresh token is not something
104- // to do on a hunch. Falling back to "expired, run claude to refresh it" is a
105- // complete feature on its own: tokens last hours, so the cost is an
106- // occasional re-login, paid by the tool that owns the credential.
107- var ErrExpired = errors.New("claudecode: your Claude Code login has expired, run `claude` to refresh it")
108-
109- // Source hands out the access token for as long as it is valid. It satisfies
110- // the shape llm.WithTokenSource wants.
111- type Source struct {
112- creds *Credentials
113- }
114-
115- // NewSource wraps credentials in a token source.
116- func NewSource(c *Credentials) *Source { return &Source{creds: c} }
117-
118- // Token returns the access token, or ErrExpired once it has lapsed.
119- func (s *Source) Token(context.Context) (string, error) {
120- if s.creds.Expired(time.Now()) {
121- return "", ErrExpired
122- }
123- return s.creds.AccessToken, nil
124- }
+7−4internal/claudecode/claudecode_test.goView File
@@ -139,11 +139,13 @@ func TestRefreshExpiredIsTerminal(t *testing.T) {
139139 }
140140 }
141141
142+ // A token that has not lapsed is handed straight back, with no refresh and no
143+ // network of any kind.
142144 func TestSourceReturnsTheAccessToken(t *testing.T) {
143145 s := NewSource(&Credentials{
144146 AccessToken: "tok",
145147 ExpiresAt: time.Now().Add(time.Hour),
146- })
148+ }, nil, "")
147149 got, err := s.Token(context.Background())
148150 if err != nil {
149151 t.Fatalf("Token: %v", err)
@@ -153,13 +155,14 @@ func TestSourceReturnsTheAccessToken(t *testing.T) {
153155 }
154156 }
155157
156- // Until refresh lands, an expired token is a clear instruction rather than a
157- // 401 from the API.
158+ // An expired token with nothing to refresh it by is a clear instruction rather
159+ // than a puzzling rejection from the API. Refreshing when there IS a usable
160+ // refresh token is covered in refresh_test.go.
158161 func TestSourceExpiredTokenSaysWhatToDo(t *testing.T) {
159162 s := NewSource(&Credentials{
160163 AccessToken: "tok",
161164 ExpiresAt: time.Now().Add(-time.Hour),
162- })
165+ }, nil, "")
163166 _, err := s.Token(context.Background())
164167 if !errors.Is(err, ErrExpired) {
165168 t.Fatalf("err = %v, want ErrExpired", err)
+232−0internal/claudecode/refresh.goaddedView File
@@ -0,0 +1,232 @@
1+ package claudecode
2+
3+ import (
4+ "bytes"
5+ "context"
6+ "encoding/json"
7+ "errors"
8+ "fmt"
9+ "io"
10+ "net/http"
11+ "os"
12+ "path/filepath"
13+ "strconv"
14+ "sync"
15+ "time"
16+ )
17+
18+ // ClientID identifies Claude Code to the token endpoint.
19+ const ClientID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e"
20+
21+ // tokenURLEnv overrides the token endpoint. It exists so tests can point at an
22+ // httptest server; nothing in normal operation sets it.
23+ const tokenURLEnv = "CLAUDECODE_TOKEN_URL"
24+
25+ const defaultTokenURL = "https://console.anthropic.com/v1/oauth/token"
26+
27+ // tokenURL is the endpoint a refresh is sent to.
28+ func tokenURL() string {
29+ if u := os.Getenv(tokenURLEnv); u != "" {
30+ return u
31+ }
32+ return defaultTokenURL
33+ }
34+
35+ // ErrExpired means the credential has lapsed and cannot be renewed here —
36+ // either the refresh token is gone or itself expired. Only logging in again
37+ // fixes it, and the tool that owns the credential is the one to do it.
38+ var ErrExpired = errors.New("claudecode: your Claude Code login has expired, run `claude` to refresh it")
39+
40+ // Refresh exchanges the refresh token for a new access token. It returns new
41+ // credentials and writes nothing — persisting is a separate decision, made by
42+ // Source, because whether it is safe depends on whether the server rotated.
43+ //
44+ // Every failure says to run `claude`, including a 429. On these OAuth surfaces
45+ // a 429 is not reliable evidence of a rate limit: the Messages API answers a
46+ // request shape it declines the same way, and every probe of this endpoint has
47+ // returned one without ever completing an exchange. Advising someone to wait
48+ // would be advice that may never come good.
49+ func Refresh(ctx context.Context, h *http.Client, c *Credentials) (*Credentials, error) {
50+ body, err := json.Marshal(map[string]string{
51+ "grant_type": "refresh_token",
52+ "refresh_token": c.RefreshToken,
53+ "client_id": ClientID,
54+ })
55+ if err != nil {
56+ return nil, err
57+ }
58+ req, err := http.NewRequestWithContext(ctx, http.MethodPost, tokenURL(), bytes.NewReader(body))
59+ if err != nil {
60+ return nil, err
61+ }
62+ req.Header.Set("content-type", "application/json")
63+
64+ resp, err := h.Do(req)
65+ if err != nil {
66+ return nil, fmt.Errorf("claudecode: refreshing: %w", err)
67+ }
68+ defer resp.Body.Close()
69+ raw, err := io.ReadAll(resp.Body)
70+ if err != nil {
71+ return nil, fmt.Errorf("claudecode: reading refresh response: %w", err)
72+ }
73+ if resp.StatusCode != http.StatusOK {
74+ return nil, fmt.Errorf("claudecode: refresh refused (%d), run `claude` to log in again: %s",
75+ resp.StatusCode, bytes.TrimSpace(raw))
76+ }
77+
78+ var out struct {
79+ AccessToken string `json:"access_token"`
80+ RefreshToken string `json:"refresh_token"`
81+ ExpiresIn int64 `json:"expires_in"`
82+ }
83+ if err := json.Unmarshal(raw, &out); err != nil {
84+ return nil, fmt.Errorf("claudecode: decoding refresh response: %w", err)
85+ }
86+ if out.AccessToken == "" {
87+ return nil, errors.New("claudecode: refresh returned no access token")
88+ }
89+
90+ next := *c
91+ next.AccessToken = out.AccessToken
92+ // An omitted refresh_token means the old one is still good. Blanking it
93+ // would throw away the only thing that can refresh again.
94+ if out.RefreshToken != "" {
95+ next.RefreshToken = out.RefreshToken
96+ }
97+ next.ExpiresAt = time.Now().Add(time.Duration(out.ExpiresIn) * time.Second)
98+ return &next, nil
99+ }
100+
101+ // Save writes credentials back to the store.
102+ //
103+ // This rewrites a file Claude Code owns, so it is a read-modify-write: the
104+ // document is parsed, only the three fields we came to change are replaced,
105+ // and everything else — keys this package does not model, keys it has never
106+ // heard of — goes back exactly as it was found. Numbers are decoded as literals
107+ // rather than float64s, or the millisecond timestamps would come back as
108+ // 1.78966826946e+12 and Claude Code would be reading a file we corrupted.
109+ //
110+ // The write is atomic: a temp file in the same directory, then a rename, so a
111+ // crash midway leaves the original rather than a truncated credential.
112+ func Save(home string, c *Credentials) error {
113+ path := filepath.Join(home, Filename)
114+ raw, err := os.ReadFile(path)
115+ if err != nil {
116+ return fmt.Errorf("claudecode: reading %s: %w", path, err)
117+ }
118+
119+ var doc map[string]json.RawMessage
120+ dec := json.NewDecoder(bytes.NewReader(raw))
121+ dec.UseNumber()
122+ if err := dec.Decode(&doc); err != nil {
123+ return fmt.Errorf("claudecode: parsing %s: %w", path, err)
124+ }
125+ blob, ok := doc["claudeAiOauth"]
126+ if !ok {
127+ return fmt.Errorf("claudecode: %s has no claudeAiOauth object", path)
128+ }
129+ var oauth map[string]any
130+ dec = json.NewDecoder(bytes.NewReader(blob))
131+ dec.UseNumber()
132+ if err := dec.Decode(&oauth); err != nil {
133+ return fmt.Errorf("claudecode: parsing claudeAiOauth in %s: %w", path, err)
134+ }
135+
136+ oauth["accessToken"] = c.AccessToken
137+ oauth["refreshToken"] = c.RefreshToken
138+ oauth["expiresAt"] = json.Number(strconv.FormatInt(c.ExpiresAt.UnixMilli(), 10))
139+
140+ if blob, err = json.Marshal(oauth); err != nil {
141+ return fmt.Errorf("claudecode: encoding claudeAiOauth: %w", err)
142+ }
143+ doc["claudeAiOauth"] = blob
144+ out, err := json.Marshal(doc)
145+ if err != nil {
146+ return fmt.Errorf("claudecode: encoding %s: %w", path, err)
147+ }
148+
149+ tmp, err := os.CreateTemp(filepath.Dir(path), ".credentials-*.tmp")
150+ if err != nil {
151+ return fmt.Errorf("claudecode: writing %s: %w", path, err)
152+ }
153+ defer os.Remove(tmp.Name())
154+ if err := tmp.Chmod(0o600); err != nil {
155+ tmp.Close()
156+ return fmt.Errorf("claudecode: securing %s: %w", tmp.Name(), err)
157+ }
158+ if _, err := tmp.Write(out); err != nil {
159+ tmp.Close()
160+ return fmt.Errorf("claudecode: writing %s: %w", tmp.Name(), err)
161+ }
162+ if err := tmp.Close(); err != nil {
163+ return fmt.Errorf("claudecode: closing %s: %w", tmp.Name(), err)
164+ }
165+ if err := os.Rename(tmp.Name(), path); err != nil {
166+ return fmt.Errorf("claudecode: replacing %s: %w", path, err)
167+ }
168+ return nil
169+ }
170+
171+ // Source hands out an access token, refreshing it when it lapses. It satisfies
172+ // the shape llm.WithTokenSource wants.
173+ type Source struct {
174+ mu sync.Mutex
175+ creds *Credentials
176+ http *http.Client
177+ // home is where a rotated credential is written back. Empty disables
178+ // write-back entirely.
179+ home string
180+ }
181+
182+ // NewSource wraps credentials in a token source. h is the client refreshes are
183+ // sent with; home is the directory holding the credential store, or "" to
184+ // refresh without ever writing.
185+ func NewSource(c *Credentials, h *http.Client, home string) *Source {
186+ if h == nil {
187+ h = http.DefaultClient
188+ }
189+ return &Source{creds: c, http: h, home: home}
190+ }
191+
192+ // Token returns a valid access token, refreshing first if the current one has
193+ // lapsed.
194+ //
195+ // The refreshed credential is kept in memory. It is written back only when the
196+ // server rotated the refresh token, and that is the point: if rotation
197+ // happened, the token still on disk is already dead, so leaving it there is
198+ // what breaks the user's Claude Code login. Writing is the safe act in that
199+ // case and the risky one in every other, which is why it is conditional rather
200+ // than routine.
201+ //
202+ // The lock means concurrent requests — streaming and buffered can be in flight
203+ // together — produce one refresh rather than a thundering herd.
204+ func (s *Source) Token(ctx context.Context) (string, error) {
205+ s.mu.Lock()
206+ defer s.mu.Unlock()
207+
208+ now := time.Now()
209+ if !s.creds.Expired(now) {
210+ return s.creds.AccessToken, nil
211+ }
212+ if s.creds.RefreshToken == "" || s.creds.RefreshExpired(now) {
213+ return "", ErrExpired
214+ }
215+
216+ next, err := Refresh(ctx, s.http, s.creds)
217+ if err != nil {
218+ return "", err
219+ }
220+ rotated := next.RefreshToken != s.creds.RefreshToken
221+ s.creds = next
222+
223+ if rotated && s.home != "" {
224+ // Best effort. A store we cannot write is a worse next session, not a
225+ // failed this one — we are holding a token that works, and refusing to
226+ // use it would be a strictly worse outcome than the stale file.
227+ if err := Save(s.home, s.creds); err != nil {
228+ return s.creds.AccessToken, nil
229+ }
230+ }
231+ return s.creds.AccessToken, nil
232+ }
+392−0internal/claudecode/refresh_test.goaddedView File
@@ -0,0 +1,392 @@
1+ package claudecode
2+
3+ import (
4+ "context"
5+ "encoding/json"
6+ "errors"
7+ "io"
8+ "net/http"
9+ "net/http/httptest"
10+ "os"
11+ "path/filepath"
12+ "strings"
13+ "sync"
14+ "testing"
15+ "time"
16+ )
17+
18+ // liveCreds is a credential that has lapsed but whose refresh token has not,
19+ // which is the only state in which a refresh should be attempted.
20+ func liveCreds() *Credentials {
21+ return &Credentials{
22+ AccessToken: "stale",
23+ RefreshToken: "r1",
24+ ExpiresAt: time.Now().Add(-time.Hour),
25+ RefreshExpiresAt: time.Now().Add(24 * time.Hour),
26+ }
27+ }
28+
29+ func TestRefreshExchangesTheToken(t *testing.T) {
30+ var body map[string]any
31+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
32+ json.NewDecoder(r.Body).Decode(&body)
33+ io.WriteString(w, `{"access_token":"new-access","refresh_token":"new-refresh","expires_in":28800}`)
34+ }))
35+ defer srv.Close()
36+ t.Setenv(tokenURLEnv, srv.URL)
37+
38+ got, err := Refresh(context.Background(), srv.Client(), &Credentials{AccessToken: "old", RefreshToken: "old-refresh"})
39+ if err != nil {
40+ t.Fatalf("Refresh: %v", err)
41+ }
42+ if got.AccessToken != "new-access" || got.RefreshToken != "new-refresh" {
43+ t.Errorf("got %q/%q, want new-access/new-refresh", got.AccessToken, got.RefreshToken)
44+ }
45+ if body["grant_type"] != "refresh_token" || body["refresh_token"] != "old-refresh" {
46+ t.Errorf("request body = %v", body)
47+ }
48+ if body["client_id"] != ClientID {
49+ t.Errorf("client_id = %v, want %s", body["client_id"], ClientID)
50+ }
51+ // expires_in is a duration; the stored form is an absolute instant.
52+ if !got.ExpiresAt.After(time.Now().Add(7 * time.Hour)) {
53+ t.Errorf("ExpiresAt = %s, want ~8h out", got.ExpiresAt)
54+ }
55+ }
56+
57+ // A response that omits refresh_token means the old one stays valid. Blanking
58+ // it would throw away the only thing that can refresh again.
59+ func TestRefreshKeepsTheOldRefreshTokenWhenNoneIsReturned(t *testing.T) {
60+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
61+ io.WriteString(w, `{"access_token":"new-access","expires_in":28800}`)
62+ }))
63+ defer srv.Close()
64+ t.Setenv(tokenURLEnv, srv.URL)
65+
66+ got, err := Refresh(context.Background(), srv.Client(), &Credentials{RefreshToken: "keep-me"})
67+ if err != nil {
68+ t.Fatalf("Refresh: %v", err)
69+ }
70+ if got.RefreshToken != "keep-me" {
71+ t.Errorf("RefreshToken = %q, want the original kept", got.RefreshToken)
72+ }
73+ }
74+
75+ func TestRefreshRejectedSaysToLogInAgain(t *testing.T) {
76+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
77+ w.WriteHeader(http.StatusBadRequest)
78+ io.WriteString(w, `{"error":"invalid_grant"}`)
79+ }))
80+ defer srv.Close()
81+ t.Setenv(tokenURLEnv, srv.URL)
82+
83+ _, err := Refresh(context.Background(), srv.Client(), &Credentials{RefreshToken: "dead"})
84+ if err == nil || !strings.Contains(err.Error(), "claude") {
85+ t.Fatalf("err = %v, want an error telling the user to run claude", err)
86+ }
87+ }
88+
89+ // A 429 from these OAuth surfaces is not reliable evidence of a rate limit:
90+ // the Messages API answers a request shape it declines the same way. Telling
91+ // the user to wait would be advice that never comes good.
92+ func TestRefreshRateLimitedDoesNotAdviseWaiting(t *testing.T) {
93+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
94+ w.WriteHeader(http.StatusTooManyRequests)
95+ io.WriteString(w, `{"error":{"type":"rate_limit_error","message":"Rate limited. Please try again later."}}`)
96+ }))
97+ defer srv.Close()
98+ t.Setenv(tokenURLEnv, srv.URL)
99+
100+ _, err := Refresh(context.Background(), srv.Client(), &Credentials{RefreshToken: "r"})
101+ if err == nil {
102+ t.Fatal("a 429 must be an error")
103+ }
104+ if !strings.Contains(err.Error(), "claude") {
105+ t.Errorf("a 429 should still point at `claude`, got %q", err)
106+ }
107+ }
108+
109+ func TestRefreshTransportErrorIsReported(t *testing.T) {
110+ t.Setenv(tokenURLEnv, "http://127.0.0.1:1")
111+ _, err := Refresh(context.Background(), &http.Client{Timeout: time.Second}, &Credentials{RefreshToken: "r"})
112+ if err == nil {
113+ t.Fatal("a transport error must be reported")
114+ }
115+ }
116+
117+ // The point of the feature: an expired token becomes a working one without the
118+ // caller knowing anything happened, and only one exchange is spent doing it.
119+ func TestSourceRefreshesOnExpiryAndCaches(t *testing.T) {
120+ var hits int
121+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
122+ hits++
123+ io.WriteString(w, `{"access_token":"fresh","refresh_token":"r1","expires_in":28800}`)
124+ }))
125+ defer srv.Close()
126+ t.Setenv(tokenURLEnv, srv.URL)
127+
128+ s := NewSource(liveCreds(), srv.Client(), "")
129+ for i := 0; i < 3; i++ {
130+ tok, err := s.Token(context.Background())
131+ if err != nil {
132+ t.Fatalf("Token: %v", err)
133+ }
134+ if tok != "fresh" {
135+ t.Fatalf("Token = %q, want fresh", tok)
136+ }
137+ }
138+ if hits != 1 {
139+ t.Errorf("refreshed %d times, want 1 — the result should be cached", hits)
140+ }
141+ }
142+
143+ func TestSourceConcurrentTokensRefreshOnce(t *testing.T) {
144+ var mu sync.Mutex
145+ var hits int
146+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
147+ mu.Lock()
148+ hits++
149+ mu.Unlock()
150+ io.WriteString(w, `{"access_token":"fresh","refresh_token":"r1","expires_in":28800}`)
151+ }))
152+ defer srv.Close()
153+ t.Setenv(tokenURLEnv, srv.URL)
154+
155+ s := NewSource(liveCreds(), srv.Client(), "")
156+ var wg sync.WaitGroup
157+ for i := 0; i < 8; i++ {
158+ wg.Add(1)
159+ go func() {
160+ defer wg.Done()
161+ if _, err := s.Token(context.Background()); err != nil {
162+ t.Error(err)
163+ }
164+ }()
165+ }
166+ wg.Wait()
167+ if hits != 1 {
168+ t.Errorf("refreshed %d times under concurrency, want 1", hits)
169+ }
170+ }
171+
172+ // An expired refresh token cannot be refreshed. Sending the request anyway
173+ // spends a round trip to be told what we already knew.
174+ func TestSourceExpiredRefreshTokenDoesNotCallOut(t *testing.T) {
175+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
176+ t.Error("no request should be sent for a dead refresh token")
177+ }))
178+ defer srv.Close()
179+ t.Setenv(tokenURLEnv, srv.URL)
180+
181+ c := liveCreds()
182+ c.RefreshExpiresAt = time.Now().Add(-time.Hour)
183+ s := NewSource(c, srv.Client(), "")
184+ if _, err := s.Token(context.Background()); !errors.Is(err, ErrExpired) {
185+ t.Fatalf("err = %v, want ErrExpired", err)
186+ }
187+ }
188+
189+ func TestSourceMissingRefreshTokenDoesNotCallOut(t *testing.T) {
190+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
191+ t.Error("no request should be sent without a refresh token")
192+ }))
193+ defer srv.Close()
194+ t.Setenv(tokenURLEnv, srv.URL)
195+
196+ c := liveCreds()
197+ c.RefreshToken = ""
198+ s := NewSource(c, srv.Client(), "")
199+ if _, err := s.Token(context.Background()); !errors.Is(err, ErrExpired) {
200+ t.Fatalf("err = %v, want ErrExpired", err)
201+ }
202+ }
203+
204+ // storeWith writes a credentials file carrying a key this package does not
205+ // model, so a round trip through Save can be checked for dropping it.
206+ func storeWith(t *testing.T, refresh string) string {
207+ t.Helper()
208+ home := t.TempDir()
209+ if err := os.MkdirAll(filepath.Join(home, ".claude"), 0o700); err != nil {
210+ t.Fatal(err)
211+ }
212+ body := `{"claudeAiOauth":{"accessToken":"stale","refreshToken":"` + refresh + `",
213+ "expiresAt":1789668269460,"refreshTokenExpiresAt":1791404074460,
214+ "scopes":["user:inference"],"subscriptionType":"team",
215+ "rateLimitTier":"default_claude_ai"},"someOtherTool":{"keep":"me"}}`
216+ if err := os.WriteFile(filepath.Join(home, Filename), []byte(body), 0o600); err != nil {
217+ t.Fatal(err)
218+ }
219+ return home
220+ }
221+
222+ func readStore(t *testing.T, home string) map[string]any {
223+ t.Helper()
224+ raw, err := os.ReadFile(filepath.Join(home, Filename))
225+ if err != nil {
226+ t.Fatal(err)
227+ }
228+ var doc map[string]any
229+ if err := json.Unmarshal(raw, &doc); err != nil {
230+ t.Fatalf("file is no longer valid JSON after Save: %v", err)
231+ }
232+ return doc
233+ }
234+
235+ // Save rewrites a file another program owns, so it must put back everything it
236+ // did not come to change — including keys this package has never heard of.
237+ func TestSavePreservesUnknownKeys(t *testing.T) {
238+ home := storeWith(t, "r1")
239+ c, err := Load(home)
240+ if err != nil {
241+ t.Fatal(err)
242+ }
243+ c.AccessToken, c.RefreshToken = "new-access", "new-refresh"
244+ c.ExpiresAt = time.UnixMilli(1999999999000)
245+ if err := Save(home, c); err != nil {
246+ t.Fatalf("Save: %v", err)
247+ }
248+
249+ doc := readStore(t, home)
250+ oauth, ok := doc["claudeAiOauth"].(map[string]any)
251+ if !ok {
252+ t.Fatal("claudeAiOauth is missing after Save")
253+ }
254+ if oauth["rateLimitTier"] != "default_claude_ai" {
255+ t.Errorf("rateLimitTier = %v, want it preserved — Save dropped a key it does not model", oauth["rateLimitTier"])
256+ }
257+ if oauth["subscriptionType"] != "team" {
258+ t.Errorf("subscriptionType = %v, want it preserved", oauth["subscriptionType"])
259+ }
260+ if _, ok := doc["someOtherTool"]; !ok {
261+ t.Error("Save dropped a whole top-level key it does not model")
262+ }
263+ if oauth["accessToken"] != "new-access" || oauth["refreshToken"] != "new-refresh" {
264+ t.Errorf("Save did not write the new tokens: %v", oauth)
265+ }
266+ // The stored form is epoch milliseconds, and it must stay an integer
267+ // literal rather than turning into 1.999999999e+12.
268+ if got := string(mustRaw(t, home)); !strings.Contains(got, "1999999999000") {
269+ t.Errorf("expiresAt was not written as an integer millisecond literal:\n%s", got)
270+ }
271+ }
272+
273+ func mustRaw(t *testing.T, home string) []byte {
274+ t.Helper()
275+ raw, err := os.ReadFile(filepath.Join(home, Filename))
276+ if err != nil {
277+ t.Fatal(err)
278+ }
279+ return raw
280+ }
281+
282+ func TestSaveKeepsTheFileOwnerOnly(t *testing.T) {
283+ home := storeWith(t, "r1")
284+ c, err := Load(home)
285+ if err != nil {
286+ t.Fatal(err)
287+ }
288+ if err := Save(home, c); err != nil {
289+ t.Fatalf("Save: %v", err)
290+ }
291+ info, err := os.Stat(filepath.Join(home, Filename))
292+ if err != nil {
293+ t.Fatal(err)
294+ }
295+ if perm := info.Mode().Perm(); perm != 0o600 {
296+ t.Errorf("mode = %o, want 600 — a credential must not become readable", perm)
297+ }
298+ }
299+
300+ // The whole rotation policy, in two tests. When the server hands back the same
301+ // refresh token, the file is Claude Code's business and we do not touch it.
302+ func TestSourceDoesNotWriteBackWhenNotRotated(t *testing.T) {
303+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
304+ io.WriteString(w, `{"access_token":"fresh","refresh_token":"r1","expires_in":28800}`)
305+ }))
306+ defer srv.Close()
307+ t.Setenv(tokenURLEnv, srv.URL)
308+
309+ home := storeWith(t, "r1")
310+ before := mustRaw(t, home)
311+
312+ c := liveCreds()
313+ c.RefreshToken = "r1"
314+ s := NewSource(c, srv.Client(), home)
315+ if _, err := s.Token(context.Background()); err != nil {
316+ t.Fatalf("Token: %v", err)
317+ }
318+ if got := mustRaw(t, home); string(got) != string(before) {
319+ t.Errorf("the file was rewritten despite no rotation:\nbefore %s\nafter %s", before, got)
320+ }
321+ }
322+
323+ // When the server rotates, the stored refresh token is already dead. Leaving
324+ // it there is what breaks the user's Claude Code login, so this is the one
325+ // case where writing is the safe act rather than the risky one.
326+ func TestSourceWritesBackWhenRotated(t *testing.T) {
327+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
328+ io.WriteString(w, `{"access_token":"fresh","refresh_token":"r2-rotated","expires_in":28800}`)
329+ }))
330+ defer srv.Close()
331+ t.Setenv(tokenURLEnv, srv.URL)
332+
333+ home := storeWith(t, "r1")
334+ c := liveCreds()
335+ c.RefreshToken = "r1"
336+ s := NewSource(c, srv.Client(), home)
337+ if _, err := s.Token(context.Background()); err != nil {
338+ t.Fatalf("Token: %v", err)
339+ }
340+
341+ oauth := readStore(t, home)["claudeAiOauth"].(map[string]any)
342+ if oauth["refreshToken"] != "r2-rotated" {
343+ t.Errorf("refreshToken = %v, want the rotated one written back", oauth["refreshToken"])
344+ }
345+ if oauth["accessToken"] != "fresh" {
346+ t.Errorf("accessToken = %v, want it written back alongside", oauth["accessToken"])
347+ }
348+ if oauth["rateLimitTier"] != "default_claude_ai" {
349+ t.Error("write-back dropped an unmodelled key")
350+ }
351+ }
352+
353+ // Write-back is best-effort: a read-only store is a worse session, not a
354+ // failed one, and failing the request would be a strictly worse outcome than
355+ // running on the token we just successfully obtained.
356+ func TestSourceSurvivesAFailedWriteBack(t *testing.T) {
357+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
358+ io.WriteString(w, `{"access_token":"fresh","refresh_token":"r2-rotated","expires_in":28800}`)
359+ }))
360+ defer srv.Close()
361+ t.Setenv(tokenURLEnv, srv.URL)
362+
363+ c := liveCreds()
364+ c.RefreshToken = "r1"
365+ // A home with no credentials file at all: Save cannot succeed.
366+ s := NewSource(c, srv.Client(), t.TempDir())
367+ tok, err := s.Token(context.Background())
368+ if err != nil {
369+ t.Fatalf("a failed write-back must not fail the request: %v", err)
370+ }
371+ if tok != "fresh" {
372+ t.Errorf("Token = %q, want the refreshed token", tok)
373+ }
374+ }
375+
376+ // With no home, write-back is disabled outright and refresh still works.
377+ func TestSourceWithoutAHomeStillRefreshes(t *testing.T) {
378+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
379+ io.WriteString(w, `{"access_token":"fresh","refresh_token":"r2","expires_in":28800}`)
380+ }))
381+ defer srv.Close()
382+ t.Setenv(tokenURLEnv, srv.URL)
383+
384+ s := NewSource(liveCreds(), srv.Client(), "")
385+ tok, err := s.Token(context.Background())
386+ if err != nil {
387+ t.Fatalf("Token: %v", err)
388+ }
389+ if tok != "fresh" {
390+ t.Errorf("Token = %q, want fresh", tok)
391+ }
392+ }