Vectrum
Sign In

claudecode: harden refresh against the ways write-back can go wrong

Review of the previous commit found two ways it could hurt the user.

A failed write-back after rotation was swallowed in silence — the exact
harm the conditional write exists to prevent. The session runs on the
new token, the dead one stays on disk, and nothing anywhere says so.
Source now carries an OnWriteBackFailure hook, wired to the event log
always and to stderr outside the TUI.

A 200 without expires_in minted a token that was already expired, so
every subsequent request refreshed again — a hammering loop of our own
making against an endpoint that only ever answers 429. Now rejected as
malformed.

Four more, all reachable: Save is a compare-and-swap, so running
`claude` mid-session no longer gets its fresh login overwritten by our
older chain; the temp file is fsynced before the rename and the
directory after, so power loss cannot publish an empty credential file;
a rotated token's unknown refresh lifetime reads as unknown rather than
expired, instead of refusing a request the server would have honoured;
and error bodies are bounded and truncated, because a token endpoint is
a plausible place for a submitted credential to be echoed back.

Save still never deletes a key. A rotated token's expiry is unknowable,
and deleting the stale one would be more honest — but Claude Code reads
this file and we cannot see what it does with a missing key, while a
too-early expiry only makes it renew sooner.

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

Showing 6 changed files with 449 additions and 46 deletions.

+1−1cmd/harness/auth_test.goView File
@@ -37,7 +37,7 @@ func TestClaudeCodeFillsInAMissingCredential(t *testing.T) {
3737 if src == nil {
3838 t.Fatal("expected a token source")
3939 }
40- tok, err := src(context.Background())
40+ tok, err := src.Token(context.Background())
4141 if err != nil {
4242 t.Fatalf("token: %v", err)
4343 }
+28−3cmd/harness/main.goView File
@@ -83,13 +83,17 @@ func run() error {
8383 // request that omits it is rejected (as a 429, not a 401 — it reads like a
8484 // quota problem and is not one).
8585 var systemPrefix string
86+ // ccSource is kept so its write-back failure hook can be wired once the
87+ // places a warning can go — the notice channel, the event log — exist.
88+ var ccSource *claudecode.Source
8689 if home != "" {
8790 src, note, err := resolveClaudeCode(client, home, *noClaudeCodeAuth)
8891 if err != nil {
8992 return err
9093 }
9194 if src != nil {
92- client = llm.New(append(opts, llm.WithTokenSource(src))...)
95+ ccSource = src
96+ client = llm.New(append(opts, llm.WithTokenSource(src.Token))...)
9397 // Not expected to ever trigger: resolveClaudeCode already checked
9498 // the endpoint before we agreed to borrow this credential, but
9599 // that check ran against the client we had before this rebuild.
@@ -138,6 +142,23 @@ func run() error {
138142 if authNote != "" {
139143 notice("%s", authNote)
140144 }
145+ if ccSource != nil {
146+ // A rotation we could not store means the credential on disk is dead
147+ // and its replacement dies with this process — the user's next `claude`
148+ // will ask them to log in again, for a reason nothing else would
149+ // explain. It goes to the event log unconditionally, because that
150+ // survives the session; stderr only outside the TUI, which owns the
151+ // screen once it starts.
152+ ccSource.OnWriteBackFailure = func(err error) {
153+ log.Log(metrics.Event{Type: "credential_writeback_failed", Extra: map[string]any{
154+ "error": err.Error(), "file": "~/" + claudecode.Filename,
155+ }})
156+ if !useTUI {
157+ fmt.Fprintf(os.Stderr, "harness: your Claude Code login was renewed but ~/%s could not be updated: %v\n", claudecode.Filename, err)
158+ fmt.Fprintln(os.Stderr, "harness: run `claude` when convenient — its stored login is now out of date.")
159+ }
160+ }
161+ }
141162
142163 var found []skills.Skill
143164 if !*noSkills {
@@ -344,7 +365,7 @@ To use a non-Anthropic endpoint, set ANTHROPIC_BASE_URL or -base-url as well.`)
344365 // because a credential we found on disk is not one the user chose to send —
345366 // forwarding it to a -base-url someone else operates would leak it to a third
346367 // party because a file happened to exist.
347- func resolveClaudeCode(client *llm.Client, home string, disabled bool) (func(context.Context) (string, error), string, error) {
368+ func resolveClaudeCode(client *llm.Client, home string, disabled bool) (*claudecode.Source, string, error) {
348369 if disabled || client.HasCredentials() || !client.IsDefaultEndpoint() {
349370 return nil, "", nil
350371 }
@@ -359,7 +380,11 @@ func resolveClaudeCode(client *llm.Client, home string, disabled bool) (func(con
359380 // home is handed on so a rotated refresh token can be written back. That
360381 // is the only circumstance in which this process writes to Claude Code's
361382 // file, and not writing then is what would break its login.
362- return claudecode.NewSource(creds, client.HTTP, home).Token, note, nil
383+ //
384+ // The refresh client is deliberately not the harness's: that one allows
385+ // ten minutes for a model to answer, which is the wrong budget for a token
386+ // exchange running under a lock. NewSource supplies a short-timeout client.
387+ return claudecode.NewSource(creds, nil, home), note, nil
363388 }
364389
365390 // interactive reports whether both ends of the terminal are a real one. A
+28−3docs/superpowers/specs/2026-09-17-claude-code-oauth-design.mdView File
@@ -190,9 +190,34 @@ in the same directory, `0600`, then rename, so a crash midway leaves the
190190 original rather than half a credential. Verified by round-tripping a copy of a
191191 real credentials file and diffing every key.
192192
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.
193+ Write-back is best effort but never silent. A store we cannot write is a worse
194+ next session, not a failed current one — we are holding a token that works, and
195+ refusing to use it would be strictly worse than a stale file. But rotated-and-
196+ unstorable is exactly the state this design exists to prevent: the token on disk
197+ is dead and its replacement dies with the process. So `Source` carries an
198+ `OnWriteBackFailure` hook, wired to the event log unconditionally and to stderr
199+ outside the TUI, and swallowing that error is a bug rather than a policy.
200+
201+ Four more things the write has to survive, none of them hypothetical:
202+
203+ - **The owner logging in underneath us.** If the user runs `claude` mid-session,
204+ their fresh credential is strictly better than ours. `Save` takes the refresh
205+ token it expects to find and declines with `ErrStoreChanged` if the file no
206+ longer holds it — a compare-and-swap, not a blind overwrite.
207+ - **Power loss.** The temp file is fsynced before the rename and the directory
208+ after, or the rename can be durable while the contents are not, and the
209+ machine comes back to an empty credential file.
210+ - **A response with no `expires_in`.** That would mint a token born expired, so
211+ the next request refreshes again, and the one after that — a hammering loop of
212+ our own making against an endpoint that already only answers 429. It is
213+ rejected as malformed instead.
214+ - **A rotated token's unknown lifetime.** The exchange never says how long the
215+ new refresh token lives. In memory that is the zero time, and `RefreshExpired`
216+ reads zero as *unknown* rather than *expired* — otherwise we would refuse to
217+ send a request the server would have honoured. On disk the stale key is left
218+ alone rather than deleted: Claude Code reads this file, we cannot see what it
219+ does with a missing key, and a too-early expiry merely makes it renew sooner.
220+ Between annoying a tool we do not own and possibly breaking it, annoying wins.
196221
197222 Because the endpoint has never once answered, none of this is verified against
198223 the live service. It is verified against `httptest` and against the real file's
+17−3internal/claudecode/claudecode.goView File
@@ -73,14 +73,19 @@ func Load(home string) (*Credentials, error) {
7373 if o.ExpiresAt == 0 {
7474 return nil, fmt.Errorf("claudecode: %s has no expiresAt", path)
7575 }
76- return &Credentials{
76+ c := &Credentials{
7777 AccessToken: o.AccessToken,
7878 RefreshToken: o.RefreshToken,
7979 ExpiresAt: time.UnixMilli(o.ExpiresAt),
80- RefreshExpiresAt: time.UnixMilli(o.RefreshTokenExpiresAt),
8180 Scopes: o.Scopes,
8281 SubscriptionType: o.SubscriptionType,
83- }, nil
82+ }
83+ // Absent means unknown, and unknown must stay the zero Time: UnixMilli(0)
84+ // is 1970, which reads as long expired and would refuse to refresh at all.
85+ if o.RefreshTokenExpiresAt != 0 {
86+ c.RefreshExpiresAt = time.UnixMilli(o.RefreshTokenExpiresAt)
87+ }
88+ return c, nil
8489 }
8590
8691 // Expired reports whether the access token is past its expiry, or close enough
@@ -91,6 +96,15 @@ func (c *Credentials) Expired(now time.Time) bool {
9196
9297 // RefreshExpired reports whether the refresh token is past its expiry. That is
9398 // terminal: no amount of refreshing fixes it, only logging in again.
99+ //
100+ // A zero expiry means unknown, not expired — the store may not carry the key,
101+ // and a rotated token's lifetime is never returned by the exchange. Treating
102+ // unknown as dead would refuse to send a request the server would have
103+ // honoured, and turn any schema drift into a false "your login has expired".
104+ // When we do not know, let the server be the authority: it refuses with a 400.
94105 func (c *Credentials) RefreshExpired(now time.Time) bool {
106+ if c.RefreshExpiresAt.IsZero() {
107+ return false
108+ }
95109 return !now.Before(c.RefreshExpiresAt)
96110 }
+133−34internal/claudecode/refresh.goView File
@@ -24,6 +24,17 @@ const tokenURLEnv = "CLAUDECODE_TOKEN_URL"
2424
2525 const defaultTokenURL = "https://console.anthropic.com/v1/oauth/token"
2626
27+ // refreshTimeout bounds a token exchange. The harness's own HTTP client allows
28+ // ten minutes, which is right for a model response and badly wrong here: the
29+ // refresh runs under a lock every other request is queued behind.
30+ const refreshTimeout = 30 * time.Second
31+
32+ // maxErrorBody is how much of a failed response is quoted back. Unbounded, a
33+ // wrong endpoint returning a large page becomes the error message; and the
34+ // body of a token-endpoint error is a place a submitted credential could
35+ // plausibly be echoed, so it is truncated as well as capped.
36+ const maxErrorBody = 512
37+
2738 // tokenURL is the endpoint a refresh is sent to.
2839 func tokenURL() string {
2940 if u := os.Getenv(tokenURLEnv); u != "" {
@@ -33,20 +44,24 @@ func tokenURL() string {
3344 }
3445
3546 // 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.
47+ // the refresh token is missing, itself expired, or was refused. Only logging
48+ // in again fixes it, and the tool that owns the credential is the one to do it.
3849 var ErrExpired = errors.New("claudecode: your Claude Code login has expired, run `claude` to refresh it")
3950
4051 // Refresh exchanges the refresh token for a new access token. It returns new
4152 // credentials and writes nothing — persisting is a separate decision, made by
4253 // Source, because whether it is safe depends on whether the server rotated.
4354 //
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
55+ // A refusal wraps ErrExpired, so callers can classify it with errors.Is rather
56+ // than by reading status codes. That includes a 429: on these OAuth surfaces a
57+ // 429 is not reliable evidence of a rate limit — the Messages API answers a
4658 // 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.
59+ // returned one without ever completing an exchange — so advising someone to
60+ // wait would be advice that may never come good.
4961 func Refresh(ctx context.Context, h *http.Client, c *Credentials) (*Credentials, error) {
62+ ctx, cancel := context.WithTimeout(ctx, refreshTimeout)
63+ defer cancel()
64+
5065 body, err := json.Marshal(map[string]string{
5166 "grant_type": "refresh_token",
5267 "refresh_token": c.RefreshToken,
@@ -66,13 +81,12 @@ func Refresh(ctx context.Context, h *http.Client, c *Credentials) (*Credentials,
6681 return nil, fmt.Errorf("claudecode: refreshing: %w", err)
6782 }
6883 defer resp.Body.Close()
69- raw, err := io.ReadAll(resp.Body)
84+ raw, err := io.ReadAll(io.LimitReader(resp.Body, maxErrorBody+1))
7085 if err != nil {
7186 return nil, fmt.Errorf("claudecode: reading refresh response: %w", err)
7287 }
7388 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))
89+ return nil, fmt.Errorf("%w (refresh refused: %d %s)", ErrExpired, resp.StatusCode, clip(raw))
7690 }
7791
7892 var out struct {
@@ -81,35 +95,65 @@ func Refresh(ctx context.Context, h *http.Client, c *Credentials) (*Credentials,
8195 ExpiresIn int64 `json:"expires_in"`
8296 }
8397 if err := json.Unmarshal(raw, &out); err != nil {
84- return nil, fmt.Errorf("claudecode: decoding refresh response: %w", err)
98+ // A 200 carrying HTML — a captive portal, a proxy interstitial — lands
99+ // here, so say what arrived rather than only that it would not parse.
100+ return nil, fmt.Errorf("claudecode: refresh returned something that is not a token response (%s): %w", clip(raw), err)
85101 }
86102 if out.AccessToken == "" {
87103 return nil, errors.New("claudecode: refresh returned no access token")
88104 }
105+ // Without a lifetime the new token would be born expired — Expired() would
106+ // report true immediately and every single request would refresh again,
107+ // hammering the endpoint on a loop of our own making.
108+ if out.ExpiresIn <= 0 {
109+ return nil, fmt.Errorf("claudecode: refresh returned no usable expires_in (%d)", out.ExpiresIn)
110+ }
89111
90112 next := *c
91113 next.AccessToken = out.AccessToken
114+ next.ExpiresAt = time.Now().Add(time.Duration(out.ExpiresIn) * time.Second)
92115 // An omitted refresh_token means the old one is still good. Blanking it
93116 // would throw away the only thing that can refresh again.
94- if out.RefreshToken != "" {
117+ if out.RefreshToken != "" && out.RefreshToken != c.RefreshToken {
95118 next.RefreshToken = out.RefreshToken
119+ // The response says nothing about how long the NEW refresh token
120+ // lives, and carrying the old one's expiry forward would eventually
121+ // make a perfectly good token look dead. Unknown is the truth.
122+ next.RefreshExpiresAt = time.Time{}
96123 }
97- next.ExpiresAt = time.Now().Add(time.Duration(out.ExpiresIn) * time.Second)
98124 return &next, nil
99125 }
100126
101- // Save writes credentials back to the store.
127+ // clip bounds untrusted response text before it reaches an error message.
128+ func clip(b []byte) string {
129+ b = bytes.TrimSpace(b)
130+ if len(b) > maxErrorBody {
131+ return string(b[:maxErrorBody]) + "…"
132+ }
133+ return string(b)
134+ }
135+
136+ // ErrStoreChanged means the credential store no longer holds the refresh token
137+ // we refreshed from: something else — Claude Code itself, most likely — logged
138+ // in while we were running. Its credential is newer than ours, so we leave it
139+ // alone rather than overwrite a fresh login with our older chain.
140+ var ErrStoreChanged = errors.New("claudecode: credential store changed underneath us")
141+
142+ // Save writes credentials back to the store, provided the refresh token on
143+ // disk is still expectRefresh. Passing "" skips that check.
102144 //
103145 // 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
146+ // document is parsed, only the fields we came to change are replaced, and
147+ // everything else — keys this package does not model, keys it has never heard
148+ // of — goes back exactly as it was found. Numbers are decoded as literals
107149 // rather than float64s, or the millisecond timestamps would come back as
108150 // 1.78966826946e+12 and Claude Code would be reading a file we corrupted.
109151 //
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 {
152+ // The write is a temp file in the same directory, fsynced, then renamed, and
153+ // the directory is fsynced after. That survives both a process crash and a
154+ // machine losing power: the rename is atomic, and the sync before it is what
155+ // stops the rename from publishing a file whose contents have not landed yet.
156+ func Save(home string, c *Credentials, expectRefresh string) error {
113157 path := filepath.Join(home, Filename)
114158 raw, err := os.ReadFile(path)
115159 if err != nil {
@@ -133,9 +177,29 @@ func Save(home string, c *Credentials) error {
133177 return fmt.Errorf("claudecode: parsing claudeAiOauth in %s: %w", path, err)
134178 }
135179
180+ // Compare-and-swap against the owner of this file. Re-reading is not
181+ // enough on its own: between our Load at startup and this write, the user
182+ // may have run `claude` and logged in afresh, and that credential is
183+ // strictly better than ours.
184+ if expectRefresh != "" {
185+ if cur, _ := oauth["refreshToken"].(string); cur != expectRefresh {
186+ return ErrStoreChanged
187+ }
188+ }
189+
136190 oauth["accessToken"] = c.AccessToken
137191 oauth["refreshToken"] = c.RefreshToken
138192 oauth["expiresAt"] = json.Number(strconv.FormatInt(c.ExpiresAt.UnixMilli(), 10))
193+ // A rotated refresh token has an unknown lifetime: the exchange does not
194+ // say. In memory that is the zero time, but the stale key is left in place
195+ // here rather than deleted. Deleting would be more honest and is the wrong
196+ // trade — Claude Code reads this file and we cannot see what it does with
197+ // a missing key, whereas a too-early expiry only makes it renew or ask for
198+ // a login sooner than it needed to. Between annoying a tool we do not own
199+ // and possibly breaking it, annoying wins.
200+ if !c.RefreshExpiresAt.IsZero() {
201+ oauth["refreshTokenExpiresAt"] = json.Number(strconv.FormatInt(c.RefreshExpiresAt.UnixMilli(), 10))
202+ }
139203
140204 if blob, err = json.Marshal(oauth); err != nil {
141205 return fmt.Errorf("claudecode: encoding claudeAiOauth: %w", err)
@@ -145,26 +209,49 @@ func Save(home string, c *Credentials) error {
145209 if err != nil {
146210 return fmt.Errorf("claudecode: encoding %s: %w", path, err)
147211 }
212+ return writeAtomic(path, out)
213+ }
148214
149- tmp, err := os.CreateTemp(filepath.Dir(path), ".credentials-*.tmp")
215+ // writeAtomic replaces path with data, or leaves it untouched.
216+ func writeAtomic(path string, data []byte) error {
217+ dir := filepath.Dir(path)
218+ tmp, err := os.CreateTemp(dir, ".credentials-*.tmp")
150219 if err != nil {
151220 return fmt.Errorf("claudecode: writing %s: %w", path, err)
152221 }
153- defer os.Remove(tmp.Name())
222+ name := tmp.Name()
223+ // Removing by the captured name, and only while the temp file still
224+ // exists under it: after a successful rename this path is gone, and
225+ // os.Remove on a missing file is a no-op rather than a hazard.
226+ defer os.Remove(name)
227+
154228 if err := tmp.Chmod(0o600); err != nil {
155229 tmp.Close()
156- return fmt.Errorf("claudecode: securing %s: %w", tmp.Name(), err)
230+ return fmt.Errorf("claudecode: securing %s: %w", name, err)
231+ }
232+ if _, err := tmp.Write(data); err != nil {
233+ tmp.Close()
234+ return fmt.Errorf("claudecode: writing %s: %w", name, err)
157235 }
158- if _, err := tmp.Write(out); err != nil {
236+ // Without this the rename can be durable while the contents are not, and
237+ // a machine that loses power here comes back to an empty credential file.
238+ if err := tmp.Sync(); err != nil {
159239 tmp.Close()
160- return fmt.Errorf("claudecode: writing %s: %w", tmp.Name(), err)
240+ return fmt.Errorf("claudecode: syncing %s: %w", name, err)
161241 }
162242 if err := tmp.Close(); err != nil {
163- return fmt.Errorf("claudecode: closing %s: %w", tmp.Name(), err)
243+ return fmt.Errorf("claudecode: closing %s: %w", name, err)
164244 }
165- if err := os.Rename(tmp.Name(), path); err != nil {
245+ if err := os.Rename(name, path); err != nil {
166246 return fmt.Errorf("claudecode: replacing %s: %w", path, err)
167247 }
248+ // Fsync the directory so the rename itself survives a power loss. Failing
249+ // here means the new file is written and probably linked; reporting it as
250+ // a save failure would be more alarming than useful.
251+ if d, err := os.Open(dir); err == nil {
252+ d.Sync()
253+ d.Close()
254+ }
168255 return nil
169256 }
170257
@@ -177,6 +264,13 @@ type Source struct {
177264 // home is where a rotated credential is written back. Empty disables
178265 // write-back entirely.
179266 home string
267+
268+ // OnWriteBackFailure is called when the server rotated the refresh token
269+ // but the new one could not be stored. That combination is the one this
270+ // whole design exists to avoid — the token on disk is dead and its
271+ // replacement is about to be lost when this process exits — so it must not
272+ // pass silently. Leave it nil only if something else is watching.
273+ OnWriteBackFailure func(error)
180274 }
181275
182276 // NewSource wraps credentials in a token source. h is the client refreshes are
@@ -184,7 +278,7 @@ type Source struct {
184278 // refresh without ever writing.
185279 func NewSource(c *Credentials, h *http.Client, home string) *Source {
186280 if h == nil {
187- h = http.DefaultClient
281+ h = &http.Client{Timeout: refreshTimeout}
188282 }
189283 return &Source{creds: c, http: h, home: home}
190284 }
@@ -200,7 +294,9 @@ func NewSource(c *Credentials, h *http.Client, home string) *Source {
200294 // than routine.
201295 //
202296 // The lock means concurrent requests — streaming and buffered can be in flight
203- // together — produce one refresh rather than a thundering herd.
297+ // together — produce one refresh rather than a thundering herd. Refresh bounds
298+ // its own time, so the wait behind that lock is seconds, not the harness
299+ // client's ten-minute model-response budget.
204300 func (s *Source) Token(ctx context.Context) (string, error) {
205301 s.mu.Lock()
206302 defer s.mu.Unlock()
@@ -213,19 +309,22 @@ func (s *Source) Token(ctx context.Context) (string, error) {
213309 return "", ErrExpired
214310 }
215311
312+ from := s.creds.RefreshToken
216313 next, err := Refresh(ctx, s.http, s.creds)
217314 if err != nil {
218315 return "", err
219316 }
220- rotated := next.RefreshToken != s.creds.RefreshToken
317+ rotated := next.RefreshToken != from
221318 s.creds = next
222319
223320 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
321+ // Best effort on the request, but never silent: we hold a token that
322+ // works, and failing the request would be strictly worse than a stale
323+ // file. What would be worse still is saying nothing, because the
324+ // user's Claude Code login is now broken in a way only this message
325+ // explains.
326+ if err := Save(s.home, s.creds, from); err != nil && s.OnWriteBackFailure != nil {
327+ s.OnWriteBackFailure(err)
229328 }
230329 }
231330 return s.creds.AccessToken, nil
+242−2internal/claudecode/refresh_test.goView File
@@ -242,7 +242,7 @@ func TestSavePreservesUnknownKeys(t *testing.T) {
242242 }
243243 c.AccessToken, c.RefreshToken = "new-access", "new-refresh"
244244 c.ExpiresAt = time.UnixMilli(1999999999000)
245- if err := Save(home, c); err != nil {
245+ if err := Save(home, c, ""); err != nil {
246246 t.Fatalf("Save: %v", err)
247247 }
248248
@@ -285,7 +285,12 @@ func TestSaveKeepsTheFileOwnerOnly(t *testing.T) {
285285 if err != nil {
286286 t.Fatal(err)
287287 }
288- if err := Save(home, c); err != nil {
288+ // Loosen it first, or this test cannot tell "Save sets 0600" from
289+ // "Save did nothing at all".
290+ if err := os.Chmod(filepath.Join(home, Filename), 0o644); err != nil {
291+ t.Fatal(err)
292+ }
293+ if err := Save(home, c, ""); err != nil {
289294 t.Fatalf("Save: %v", err)
290295 }
291296 info, err := os.Stat(filepath.Join(home, Filename))
@@ -390,3 +395,238 @@ func TestSourceWithoutAHomeStillRefreshes(t *testing.T) {
390395 t.Errorf("Token = %q, want fresh", tok)
391396 }
392397 }
398+
399+ // A 200 with no usable lifetime would mint a token that is already expired,
400+ // so the very next request refreshes again — an endless loop of our own making
401+ // against an endpoint we already know answers 429.
402+ func TestRefreshWithoutExpiresInIsAnError(t *testing.T) {
403+ var hits int
404+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
405+ hits++
406+ io.WriteString(w, `{"access_token":"new-access","refresh_token":"r1"}`)
407+ }))
408+ defer srv.Close()
409+ t.Setenv(tokenURLEnv, srv.URL)
410+
411+ if _, err := Refresh(context.Background(), srv.Client(), &Credentials{RefreshToken: "r1"}); err == nil {
412+ t.Fatal("a response with no expires_in must be an error, not a token born expired")
413+ }
414+ // And the Source must not spin on it.
415+ s := NewSource(liveCreds(), srv.Client(), "")
416+ for i := 0; i < 3; i++ {
417+ if _, err := s.Token(context.Background()); err == nil {
418+ t.Fatal("Token should surface the malformed refresh")
419+ }
420+ }
421+ if hits > 4 {
422+ t.Errorf("sent %d requests — a malformed response should not become a retry loop", hits)
423+ }
424+ }
425+
426+ // A captive portal or proxy interstitial arrives as a 200 full of HTML.
427+ func TestRefreshNonJSONResponseSaysWhatArrived(t *testing.T) {
428+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
429+ io.WriteString(w, `<html><body>Attention Required! Cloudflare</body></html>`)
430+ }))
431+ defer srv.Close()
432+ t.Setenv(tokenURLEnv, srv.URL)
433+
434+ _, err := Refresh(context.Background(), srv.Client(), &Credentials{RefreshToken: "r"})
435+ if err == nil {
436+ t.Fatal("HTML is not a token response")
437+ }
438+ if !strings.Contains(err.Error(), "Cloudflare") {
439+ t.Errorf("the error should quote what actually arrived, got %q", err)
440+ }
441+ }
442+
443+ // A refusal is a terminal expiry, so callers can classify it without reading
444+ // status codes back out of a string.
445+ func TestRefreshRefusalIsErrExpired(t *testing.T) {
446+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
447+ w.WriteHeader(http.StatusBadRequest)
448+ io.WriteString(w, `{"error":"invalid_grant"}`)
449+ }))
450+ defer srv.Close()
451+ t.Setenv(tokenURLEnv, srv.URL)
452+
453+ _, err := Refresh(context.Background(), srv.Client(), &Credentials{RefreshToken: "dead"})
454+ if !errors.Is(err, ErrExpired) {
455+ t.Fatalf("err = %v, want it to wrap ErrExpired", err)
456+ }
457+ }
458+
459+ // An enormous error body must not become the error message, and must not carry
460+ // a credential back to the screen wholesale.
461+ func TestRefreshErrorBodyIsBounded(t *testing.T) {
462+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
463+ w.WriteHeader(http.StatusBadRequest)
464+ io.WriteString(w, strings.Repeat("A", 100_000))
465+ }))
466+ defer srv.Close()
467+ t.Setenv(tokenURLEnv, srv.URL)
468+
469+ _, err := Refresh(context.Background(), srv.Client(), &Credentials{RefreshToken: "r"})
470+ if err == nil {
471+ t.Fatal("expected an error")
472+ }
473+ if len(err.Error()) > 2*maxErrorBody {
474+ t.Errorf("error is %d bytes; the response body was not bounded", len(err.Error()))
475+ }
476+ }
477+
478+ // Rotation gives us a token whose lifetime the response never states. Carrying
479+ // the old one's expiry forward would eventually refuse to refresh a perfectly
480+ // good token.
481+ func TestRotationClearsTheRefreshExpiry(t *testing.T) {
482+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
483+ io.WriteString(w, `{"access_token":"a","refresh_token":"r2","expires_in":28800}`)
484+ }))
485+ defer srv.Close()
486+ t.Setenv(tokenURLEnv, srv.URL)
487+
488+ got, err := Refresh(context.Background(), srv.Client(), liveCreds())
489+ if err != nil {
490+ t.Fatal(err)
491+ }
492+ if !got.RefreshExpiresAt.IsZero() {
493+ t.Errorf("RefreshExpiresAt = %s, want zero (unknown) after rotation", got.RefreshExpiresAt)
494+ }
495+ // Unknown must not read as expired, or the next lapse refuses to refresh.
496+ if got.RefreshExpired(time.Now()) {
497+ t.Error("an unknown refresh expiry must not count as expired")
498+ }
499+ }
500+
501+ // If the user runs `claude` and logs in afresh while we are running, their new
502+ // credential is strictly better than ours. Overwriting it would undo a login
503+ // they just performed.
504+ func TestSaveDeclinesWhenTheStoreMovedOn(t *testing.T) {
505+ home := storeWith(t, "somebody-elses-newer-token")
506+ c, err := Load(home)
507+ if err != nil {
508+ t.Fatal(err)
509+ }
510+ before := mustRaw(t, home)
511+ c.AccessToken = "ours"
512+ if err := Save(home, c, "the-one-we-refreshed-from"); !errors.Is(err, ErrStoreChanged) {
513+ t.Fatalf("err = %v, want ErrStoreChanged", err)
514+ }
515+ if got := mustRaw(t, home); string(got) != string(before) {
516+ t.Error("Save wrote despite the store having changed underneath it")
517+ }
518+ }
519+
520+ // A write that cannot happen must leave the original readable, not truncated.
521+ func TestSaveFailureLeavesTheOriginalIntact(t *testing.T) {
522+ home := storeWith(t, "r1")
523+ c, err := Load(home)
524+ if err != nil {
525+ t.Fatal(err)
526+ }
527+ before := mustRaw(t, home)
528+
529+ // A read-only directory: the temp file cannot be created.
530+ dir := filepath.Join(home, ".claude")
531+ if err := os.Chmod(dir, 0o500); err != nil {
532+ t.Skip("cannot make the directory read-only here")
533+ }
534+ t.Cleanup(func() { os.Chmod(dir, 0o700) })
535+
536+ c.AccessToken = "new"
537+ if err := Save(home, c, ""); err == nil {
538+ t.Skip("the directory was writable after all (running as root?)")
539+ }
540+ if got := mustRaw(t, home); string(got) != string(before) {
541+ t.Errorf("a failed Save damaged the original:\n%s", got)
542+ }
543+ }
544+
545+ // The scenario the whole design exists to prevent: the server rotated, we
546+ // cannot store the replacement, and the token on disk is now dead. The request
547+ // still succeeds — but it must not do so silently.
548+ func TestRotatedButUnstorableIsReported(t *testing.T) {
549+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
550+ io.WriteString(w, `{"access_token":"fresh","refresh_token":"r2-rotated","expires_in":28800}`)
551+ }))
552+ defer srv.Close()
553+ t.Setenv(tokenURLEnv, srv.URL)
554+
555+ c := liveCreds()
556+ s := NewSource(c, srv.Client(), t.TempDir()) // a home with no store in it
557+ var reported error
558+ s.OnWriteBackFailure = func(err error) { reported = err }
559+
560+ tok, err := s.Token(context.Background())
561+ if err != nil {
562+ t.Fatalf("the request must still succeed: %v", err)
563+ }
564+ if tok != "fresh" {
565+ t.Errorf("Token = %q, want fresh", tok)
566+ }
567+ if reported == nil {
568+ t.Fatal("a rotation that could not be stored must be reported, never swallowed")
569+ }
570+ }
571+
572+ // No rotation, no write, and therefore nothing to report.
573+ func TestNoRotationReportsNothing(t *testing.T) {
574+ srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
575+ io.WriteString(w, `{"access_token":"fresh","refresh_token":"r1","expires_in":28800}`)
576+ }))
577+ defer srv.Close()
578+ t.Setenv(tokenURLEnv, srv.URL)
579+
580+ s := NewSource(liveCreds(), srv.Client(), t.TempDir())
581+ var called bool
582+ s.OnWriteBackFailure = func(error) { called = true }
583+ if _, err := s.Token(context.Background()); err != nil {
584+ t.Fatal(err)
585+ }
586+ if called {
587+ t.Error("nothing was rotated, so nothing should have been written or reported")
588+ }
589+ }
590+
591+ // A store with no refreshTokenExpiresAt must not read as permanently expired:
592+ // the package doc admits the schema can drift, and drift should not silently
593+ // turn refresh into a false "your login has expired".
594+ func TestMissingRefreshExpiryIsUnknownNotExpired(t *testing.T) {
595+ home := t.TempDir()
596+ if err := os.MkdirAll(filepath.Join(home, ".claude"), 0o700); err != nil {
597+ t.Fatal(err)
598+ }
599+ body := `{"claudeAiOauth":{"accessToken":"a","refreshToken":"r","expiresAt":1789668269460}}`
600+ if err := os.WriteFile(filepath.Join(home, Filename), []byte(body), 0o600); err != nil {
601+ t.Fatal(err)
602+ }
603+ c, err := Load(home)
604+ if err != nil {
605+ t.Fatal(err)
606+ }
607+ if c.RefreshExpired(time.Now()) {
608+ t.Error("an absent refreshTokenExpiresAt must mean unknown, not expired")
609+ }
610+ }
611+
612+ // Write-back must not remove a key from a file Claude Code reads, even one we
613+ // can no longer describe accurately after a rotation.
614+ func TestSaveNeverDeletesTheRefreshExpiryKey(t *testing.T) {
615+ home := storeWith(t, "r1")
616+ c, err := Load(home)
617+ if err != nil {
618+ t.Fatal(err)
619+ }
620+ c.RefreshToken = "r2-rotated"
621+ c.RefreshExpiresAt = time.Time{} // unknown, as after a rotation
622+ if err := Save(home, c, "r1"); err != nil {
623+ t.Fatalf("Save: %v", err)
624+ }
625+ oauth := readStore(t, home)["claudeAiOauth"].(map[string]any)
626+ if _, ok := oauth["refreshTokenExpiresAt"]; !ok {
627+ t.Error("refreshTokenExpiresAt was deleted; a key we did not add is not ours to remove")
628+ }
629+ if oauth["refreshToken"] != "r2-rotated" {
630+ t.Errorf("refreshToken = %v, want the rotated one", oauth["refreshToken"])
631+ }
632+ }