Vectrum
Sign In
110 lines · 3.9 KB · Go
// 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)
}