// Package claudecode reads the OAuth credential Claude Code stores on disk, so
// a harness run can borrow a login that already exists.
//
// This is Claude Code's private storage, not a documented interface: the
// schema can change without notice. Everything that knows its shape lives
// here, and parses strictly, so a change surfaces as a clear error rather than
// as a puzzling 401 somewhere else.
package claudecode
import (
"encoding/json"
"errors"
"fmt"
"os"
"path/filepath"
"time"
)
// Filename is the credential store, relative to the home directory.
const Filename = ".claude/.credentials.json"
// skew is how long before its stated expiry a token starts counting as
// expired. A token with seconds left would expire during the request we are
// about to send.
const skew = 60 * time.Second
// ErrNotFound means there is no credentials file: not logged in, rather than
// broken. Callers fall through to whatever source is next.
var ErrNotFound = errors.New("claudecode: no credentials file")
// Credentials is the claudeAiOauth object, with its millisecond timestamps
// converted to something Go can compare.
type Credentials struct {
AccessToken string
RefreshToken string
ExpiresAt time.Time
RefreshExpiresAt time.Time
Scopes []string
SubscriptionType string
}
// stored mirrors the on-disk JSON. Timestamps are epoch milliseconds.
type stored struct {
ClaudeAIOAuth struct {
AccessToken string `json:"accessToken"`
RefreshToken string `json:"refreshToken"`
ExpiresAt int64 `json:"expiresAt"`
RefreshTokenExpiresAt int64 `json:"refreshTokenExpiresAt"`
Scopes []string `json:"scopes"`
SubscriptionType string `json:"subscriptionType"`
} `json:"claudeAiOauth"`
}
// Load reads and parses the credential store. home is passed in rather than
// looked up so tests can point it at a fixture, matching skills.Discover.
func Load(home string) (*Credentials, error) {
path := filepath.Join(home, Filename)
raw, err := os.ReadFile(path)
if errors.Is(err, os.ErrNotExist) {
return nil, ErrNotFound
}
if err != nil {
return nil, fmt.Errorf("claudecode: reading %s: %w", path, err)
}
var s stored
if err := json.Unmarshal(raw, &s); err != nil {
return nil, fmt.Errorf("claudecode: parsing %s: %w", path, err)
}
o := s.ClaudeAIOAuth
if o.AccessToken == "" {
return nil, fmt.Errorf("claudecode: %s has no access token", path)
}
if o.ExpiresAt == 0 {
return nil, fmt.Errorf("claudecode: %s has no expiresAt", path)
}
c := &Credentials{
AccessToken: o.AccessToken,
RefreshToken: o.RefreshToken,
ExpiresAt: time.UnixMilli(o.ExpiresAt),
Scopes: o.Scopes,
SubscriptionType: o.SubscriptionType,
}
// Absent means unknown, and unknown must stay the zero Time: UnixMilli(0)
// is 1970, which reads as long expired and would refuse to refresh at all.
if o.RefreshTokenExpiresAt != 0 {
c.RefreshExpiresAt = time.UnixMilli(o.RefreshTokenExpiresAt)
}
return c, nil
}
// Expired reports whether the access token is past its expiry, or close enough
// that it would lapse mid-request.
func (c *Credentials) Expired(now time.Time) bool {
return !now.Before(c.ExpiresAt.Add(-skew))
}
// RefreshExpired reports whether the refresh token is past its expiry. That is
// terminal: no amount of refreshing fixes it, only logging in again.
//
// A zero expiry means unknown, not expired — the store may not carry the key,
// and a rotated token's lifetime is never returned by the exchange. Treating
// unknown as dead would refuse to send a request the server would have
// honoured, and turn any schema drift into a false "your login has expired".
// When we do not know, let the server be the authority: it refuses with a 400.
func (c *Credentials) RefreshExpired(now time.Time) bool {
if c.RefreshExpiresAt.IsZero() {
return false
}
return !now.Before(c.RefreshExpiresAt)
}