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>
Showing 7 changed files with 699 additions and 59 deletions.
| @@ -45,8 +45,10 @@ from another client is outside Anthropic's terms for it, which API keys and | ||
| 45 | 45 | gateway tokens do not carry. |
| 46 | 46 | |
| 47 | 47 | 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. | |
| 50 | 52 | |
| 51 | 53 | Useful flags: `-model`, `-effort low|medium|high|xhigh|max`, `-max-turns`, |
| 52 | 54 | `-resume latest`, `-no-stream`, `-no-cache`, `-v`, `-yolo`, `-no-tui`, `-no-skills`, |
| @@ -356,7 +356,10 @@ func resolveClaudeCode(client *llm.Client, home string, disabled bool) (func(con | ||
| 356 | 356 | return nil, "", err |
| 357 | 357 | } |
| 358 | 358 | 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 | |
| 360 | 363 | } |
| 361 | 364 | |
| 362 | 365 | // interactive reports whether both ends of the terminal are a real one. A |
| @@ -66,24 +66,33 @@ skips discovery entirely. | ||
| 66 | 66 | |
| 67 | 67 | ## `internal/claudecode` |
| 68 | 68 | |
| 69 | - One package, three jobs, no ambient state: | |
| 69 | + One package, one concern, no ambient state: | |
| 70 | 70 | |
| 71 | 71 | ```go |
| 72 | 72 | 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 | |
| 77 | 77 | } |
| 78 | 78 | |
| 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 | |
| 81 | 83 | 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) | |
| 82 | 88 | ``` |
| 83 | 89 | |
| 84 | 90 | `Load` takes `home` rather than calling `os.UserHomeDir` so tests point it at a |
| 85 | 91 | 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 `""`. | |
| 87 | 96 | |
| 88 | 97 | Missing file is not an error worth decorating: it means "not logged in", and the |
| 89 | 98 | 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 | ||
| 126 | 135 | be in flight together, and two concurrent refreshes of the same token is exactly |
| 127 | 136 | the scenario the next section is about. |
| 128 | 137 | |
| 129 | - ## Refresh: not shipped, and why | |
| 138 | + ## Refresh, and the question that could not be answered | |
| 130 | 139 | |
| 131 | 140 | The question was whether `POST /v1/oauth/token` rotates refresh tokens. If it |
| 132 | 141 | does, refreshing without writing the result back consumes the token Claude Code |
| 133 | 142 | still has on disk — its copy goes dead and `claude` asks you to log in again. |
| 134 | 143 | We would have broken a tool outside this repo to save a paste. |
| 135 | 144 | |
| 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 | |
| 137 | 146 | `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: | |
| 139 | 149 | |
| 140 | 150 | ``` |
| 141 | 151 | HTTP 429 {"error":{"type":"rate_limit_error","message":"Rate limited. Please try again later."}} |
| 142 | 152 | ``` |
| 143 | 153 | |
| 144 | 154 | 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". | |
| 150 | 161 | |
| 151 | 162 | The honest reading is that this request — endpoint, client id, or grant shape — |
| 152 | 163 | is not the one this credential is refreshed with. Finding the one that is would |
| 153 | 164 | mean guessing at authentication endpoints while holding someone's live refresh |
| 154 | 165 | token, which is not a thing to do on a hunch. |
| 155 | 166 | |
| 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. | |
| 164 | 200 | |
| 165 | 201 | ## The system prompt |
| 166 | 202 | |
| @@ -8,7 +8,6 @@ | ||
| 8 | 8 | package claudecode |
| 9 | 9 | |
| 10 | 10 | import ( |
| 11 | - "context" | |
| 12 | 11 | "encoding/json" |
| 13 | 12 | "errors" |
| 14 | 13 | "fmt" |
| @@ -95,30 +94,3 @@ func (c *Credentials) Expired(now time.Time) bool { | ||
| 95 | 94 | func (c *Credentials) RefreshExpired(now time.Time) bool { |
| 96 | 95 | return !now.Before(c.RefreshExpiresAt) |
| 97 | 96 | } |
| 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 | - } | |
| @@ -139,11 +139,13 @@ func TestRefreshExpiredIsTerminal(t *testing.T) { | ||
| 139 | 139 | } |
| 140 | 140 | } |
| 141 | 141 | |
| 142 | + // A token that has not lapsed is handed straight back, with no refresh and no | |
| 143 | + // network of any kind. | |
| 142 | 144 | func TestSourceReturnsTheAccessToken(t *testing.T) { |
| 143 | 145 | s := NewSource(&Credentials{ |
| 144 | 146 | AccessToken: "tok", |
| 145 | 147 | ExpiresAt: time.Now().Add(time.Hour), |
| 146 | - }) | |
| 148 | + }, nil, "") | |
| 147 | 149 | got, err := s.Token(context.Background()) |
| 148 | 150 | if err != nil { |
| 149 | 151 | t.Fatalf("Token: %v", err) |
| @@ -153,13 +155,14 @@ func TestSourceReturnsTheAccessToken(t *testing.T) { | ||
| 153 | 155 | } |
| 154 | 156 | } |
| 155 | 157 | |
| 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. | |
| 158 | 161 | func TestSourceExpiredTokenSaysWhatToDo(t *testing.T) { |
| 159 | 162 | s := NewSource(&Credentials{ |
| 160 | 163 | AccessToken: "tok", |
| 161 | 164 | ExpiresAt: time.Now().Add(-time.Hour), |
| 162 | - }) | |
| 165 | + }, nil, "") | |
| 163 | 166 | _, err := s.Token(context.Background()) |
| 164 | 167 | if !errors.Is(err, ErrExpired) { |
| 165 | 168 | t.Fatalf("err = %v, want ErrExpired", err) |
| @@ -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 | + } | |
| @@ -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 | + } | |