package claudecode
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"strconv"
"sync"
"time"
)
// ClientID identifies Claude Code to the token endpoint.
const ClientID = "9d1c250a-e61b-44d9-88ed-5944d1962f5e"
// tokenURLEnv overrides the token endpoint. It exists so tests can point at an
// httptest server; nothing in normal operation sets it.
const tokenURLEnv = "CLAUDECODE_TOKEN_URL"
const defaultTokenURL = "https://console.anthropic.com/v1/oauth/token"
// tokenURL is the endpoint a refresh is sent to.
func tokenURL() string {
if u := os.Getenv(tokenURLEnv); u != "" {
return u
}
return defaultTokenURL
}
// ErrExpired means the credential has lapsed and cannot be renewed here —
// either the refresh token is gone or itself expired. Only logging in again
// fixes it, and the tool that owns the credential is the one to do it.
var ErrExpired = errors.New("claudecode: your Claude Code login has expired, run `claude` to refresh it")
// Refresh exchanges the refresh token for a new access token. It returns new
// credentials and writes nothing — persisting is a separate decision, made by
// Source, because whether it is safe depends on whether the server rotated.
//
// Every failure says to run `claude`, including a 429. On these OAuth surfaces
// a 429 is not reliable evidence of a rate limit: the Messages API answers a
// request shape it declines the same way, and every probe of this endpoint has
// returned one without ever completing an exchange. Advising someone to wait
// would be advice that may never come good.
func Refresh(ctx context.Context, h *http.Client, c *Credentials) (*Credentials, error) {
body, err := json.Marshal(map[string]string{
"grant_type": "refresh_token",
"refresh_token": c.RefreshToken,
"client_id": ClientID,
})
if err != nil {
return nil, err
}
req, err := http.NewRequestWithContext(ctx, http.MethodPost, tokenURL(), bytes.NewReader(body))
if err != nil {
return nil, err
}
req.Header.Set("content-type", "application/json")
resp, err := h.Do(req)
if err != nil {
return nil, fmt.Errorf("claudecode: refreshing: %w", err)
}
defer resp.Body.Close()
raw, err := io.ReadAll(resp.Body)
if err != nil {
return nil, fmt.Errorf("claudecode: reading refresh response: %w", err)
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("claudecode: refresh refused (%d), run `claude` to log in again: %s",
resp.StatusCode, bytes.TrimSpace(raw))
}
var out struct {
AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token"`
ExpiresIn int64 `json:"expires_in"`
}
if err := json.Unmarshal(raw, &out); err != nil {
return nil, fmt.Errorf("claudecode: decoding refresh response: %w", err)
}
if out.AccessToken == "" {
return nil, errors.New("claudecode: refresh returned no access token")
}
next := *c
next.AccessToken = out.AccessToken
// An omitted refresh_token means the old one is still good. Blanking it
// would throw away the only thing that can refresh again.
if out.RefreshToken != "" {
next.RefreshToken = out.RefreshToken
}
next.ExpiresAt = time.Now().Add(time.Duration(out.ExpiresIn) * time.Second)
return &next, nil
}
// Save writes credentials back to the store.
//
// This rewrites a file Claude Code owns, so it is a read-modify-write: the
// document is parsed, only the three fields we came to change are replaced,
// and everything else — keys this package does not model, keys it has never
// heard of — goes back exactly as it was found. Numbers are decoded as literals
// rather than float64s, or the millisecond timestamps would come back as
// 1.78966826946e+12 and Claude Code would be reading a file we corrupted.
//
// The write is atomic: a temp file in the same directory, then a rename, so a
// crash midway leaves the original rather than a truncated credential.
func Save(home string, c *Credentials) error {
path := filepath.Join(home, Filename)
raw, err := os.ReadFile(path)
if err != nil {
return fmt.Errorf("claudecode: reading %s: %w", path, err)
}
var doc map[string]json.RawMessage
dec := json.NewDecoder(bytes.NewReader(raw))
dec.UseNumber()
if err := dec.Decode(&doc); err != nil {
return fmt.Errorf("claudecode: parsing %s: %w", path, err)
}
blob, ok := doc["claudeAiOauth"]
if !ok {
return fmt.Errorf("claudecode: %s has no claudeAiOauth object", path)
}
var oauth map[string]any
dec = json.NewDecoder(bytes.NewReader(blob))
dec.UseNumber()
if err := dec.Decode(&oauth); err != nil {
return fmt.Errorf("claudecode: parsing claudeAiOauth in %s: %w", path, err)
}
oauth["accessToken"] = c.AccessToken
oauth["refreshToken"] = c.RefreshToken
oauth["expiresAt"] = json.Number(strconv.FormatInt(c.ExpiresAt.UnixMilli(), 10))
if blob, err = json.Marshal(oauth); err != nil {
return fmt.Errorf("claudecode: encoding claudeAiOauth: %w", err)
}
doc["claudeAiOauth"] = blob
out, err := json.Marshal(doc)
if err != nil {
return fmt.Errorf("claudecode: encoding %s: %w", path, err)
}
tmp, err := os.CreateTemp(filepath.Dir(path), ".credentials-*.tmp")
if err != nil {
return fmt.Errorf("claudecode: writing %s: %w", path, err)
}
defer os.Remove(tmp.Name())
if err := tmp.Chmod(0o600); err != nil {
tmp.Close()
return fmt.Errorf("claudecode: securing %s: %w", tmp.Name(), err)
}
if _, err := tmp.Write(out); err != nil {
tmp.Close()
return fmt.Errorf("claudecode: writing %s: %w", tmp.Name(), err)
}
if err := tmp.Close(); err != nil {
return fmt.Errorf("claudecode: closing %s: %w", tmp.Name(), err)
}
if err := os.Rename(tmp.Name(), path); err != nil {
return fmt.Errorf("claudecode: replacing %s: %w", path, err)
}
return nil
}
// Source hands out an access token, refreshing it when it lapses. It satisfies
// the shape llm.WithTokenSource wants.
type Source struct {
mu sync.Mutex
creds *Credentials
http *http.Client
// home is where a rotated credential is written back. Empty disables
// write-back entirely.
home string
}
// NewSource wraps credentials in a token source. h is the client refreshes are
// sent with; home is the directory holding the credential store, or "" to
// refresh without ever writing.
func NewSource(c *Credentials, h *http.Client, home string) *Source {
if h == nil {
h = http.DefaultClient
}
return &Source{creds: c, http: h, home: home}
}
// Token returns a valid access token, refreshing first if the current one has
// lapsed.
//
// The refreshed credential is kept in memory. It is written back only when the
// server rotated the refresh token, and that is the point: if rotation
// happened, the token still on disk is already dead, so leaving it there is
// what breaks the user's Claude Code login. Writing is the safe act in that
// case and the risky one in every other, which is why it is conditional rather
// than routine.
//
// The lock means concurrent requests — streaming and buffered can be in flight
// together — produce one refresh rather than a thundering herd.
func (s *Source) Token(ctx context.Context) (string, error) {
s.mu.Lock()
defer s.mu.Unlock()
now := time.Now()
if !s.creds.Expired(now) {
return s.creds.AccessToken, nil
}
if s.creds.RefreshToken == "" || s.creds.RefreshExpired(now) {
return "", ErrExpired
}
next, err := Refresh(ctx, s.http, s.creds)
if err != nil {
return "", err
}
rotated := next.RefreshToken != s.creds.RefreshToken
s.creds = next
if rotated && s.home != "" {
// Best effort. A store we cannot write is a worse next session, not a
// failed this one — we are holding a token that works, and refusing to
// use it would be a strictly worse outcome than the stale file.
if err := Save(s.home, s.creds); err != nil {
return s.creds.AccessToken, nil
}
}
return s.creds.AccessToken, nil
}