Vectrum
Sign In
96 lines · 3.2 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)
	}
	return &Credentials{
		AccessToken:      o.AccessToken,
		RefreshToken:     o.RefreshToken,
		ExpiresAt:        time.UnixMilli(o.ExpiresAt),
		RefreshExpiresAt: time.UnixMilli(o.RefreshTokenExpiresAt),
		Scopes:           o.Scopes,
		SubscriptionType: o.SubscriptionType,
	}, 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.
func (c *Credentials) RefreshExpired(now time.Time) bool {
	return !now.Before(c.RefreshExpiresAt)
}