Vectrum
Sign In

claudecode: parse Claude Code's credential store

Timestamps are epoch milliseconds; reading them as seconds puts expiry
55,000 years out and the token never looks stale. A missing file means
not logged in and falls through; a file that will not parse names its
path, because that is a broken install rather than an absent one.
Jjonicommitted 1 parent 806ec13commitfdda42b

Showing 2 changed files with 217 additions and 0 deletions.

+93−0internal/claudecode/claudecode.goaddedView File
@@ -0,0 +1,93 @@
1+ // Package claudecode reads the OAuth credential Claude Code stores on disk, so
2+ // a harness run can borrow a login that already exists.
3+ //
4+ // This is Claude Code's private storage, not a documented interface: the
5+ // schema can change without notice. Everything that knows its shape lives
6+ // here, and parses strictly, so a change surfaces as a clear error rather than
7+ // as a puzzling 401 somewhere else.
8+ package claudecode
9+
10+ import (
11+ "encoding/json"
12+ "errors"
13+ "fmt"
14+ "os"
15+ "path/filepath"
16+ "time"
17+ )
18+
19+ // Filename is the credential store, relative to the home directory.
20+ const Filename = ".claude/.credentials.json"
21+
22+ // skew is how long before its stated expiry a token starts counting as
23+ // expired. A token with seconds left would expire during the request we are
24+ // about to send.
25+ const skew = 60 * time.Second
26+
27+ // ErrNotFound means there is no credentials file: not logged in, rather than
28+ // broken. Callers fall through to whatever source is next.
29+ var ErrNotFound = errors.New("claudecode: no credentials file")
30+
31+ // Credentials is the claudeAiOauth object, with its millisecond timestamps
32+ // converted to something Go can compare.
33+ type Credentials struct {
34+ AccessToken string
35+ RefreshToken string
36+ ExpiresAt time.Time
37+ RefreshExpiresAt time.Time
38+ Scopes []string
39+ SubscriptionType string
40+ }
41+
42+ // stored mirrors the on-disk JSON. Timestamps are epoch milliseconds.
43+ type stored struct {
44+ ClaudeAIOAuth struct {
45+ AccessToken string `json:"accessToken"`
46+ RefreshToken string `json:"refreshToken"`
47+ ExpiresAt int64 `json:"expiresAt"`
48+ RefreshTokenExpiresAt int64 `json:"refreshTokenExpiresAt"`
49+ Scopes []string `json:"scopes"`
50+ SubscriptionType string `json:"subscriptionType"`
51+ } `json:"claudeAiOauth"`
52+ }
53+
54+ // Load reads and parses the credential store. home is passed in rather than
55+ // looked up so tests can point it at a fixture, matching skills.Discover.
56+ func Load(home string) (*Credentials, error) {
57+ path := filepath.Join(home, Filename)
58+ raw, err := os.ReadFile(path)
59+ if errors.Is(err, os.ErrNotExist) {
60+ return nil, ErrNotFound
61+ }
62+ if err != nil {
63+ return nil, fmt.Errorf("claudecode: reading %s: %w", path, err)
64+ }
65+ var s stored
66+ if err := json.Unmarshal(raw, &s); err != nil {
67+ return nil, fmt.Errorf("claudecode: parsing %s: %w", path, err)
68+ }
69+ o := s.ClaudeAIOAuth
70+ if o.AccessToken == "" {
71+ return nil, fmt.Errorf("claudecode: %s has no access token", path)
72+ }
73+ return &Credentials{
74+ AccessToken: o.AccessToken,
75+ RefreshToken: o.RefreshToken,
76+ ExpiresAt: time.UnixMilli(o.ExpiresAt),
77+ RefreshExpiresAt: time.UnixMilli(o.RefreshTokenExpiresAt),
78+ Scopes: o.Scopes,
79+ SubscriptionType: o.SubscriptionType,
80+ }, nil
81+ }
82+
83+ // Expired reports whether the access token is past its expiry, or close enough
84+ // that it would lapse mid-request.
85+ func (c *Credentials) Expired(now time.Time) bool {
86+ return !now.Before(c.ExpiresAt.Add(-skew))
87+ }
88+
89+ // RefreshExpired reports whether the refresh token is past its expiry. That is
90+ // terminal: no amount of refreshing fixes it, only logging in again.
91+ func (c *Credentials) RefreshExpired(now time.Time) bool {
92+ return !now.Before(c.RefreshExpiresAt)
93+ }
+124−0internal/claudecode/claudecode_test.goaddedView File
@@ -0,0 +1,124 @@
1+ package claudecode
2+
3+ import (
4+ "errors"
5+ "os"
6+ "path/filepath"
7+ "strings"
8+ "testing"
9+ "time"
10+ )
11+
12+ // write builds a fake home directory holding one credentials file.
13+ func write(t *testing.T, body string) string {
14+ t.Helper()
15+ home := t.TempDir()
16+ dir := filepath.Join(home, ".claude")
17+ if err := os.MkdirAll(dir, 0o700); err != nil {
18+ t.Fatal(err)
19+ }
20+ if err := os.WriteFile(filepath.Join(dir, ".credentials.json"), []byte(body), 0o600); err != nil {
21+ t.Fatal(err)
22+ }
23+ return home
24+ }
25+
26+ const sample = `{"claudeAiOauth":{
27+ "accessToken":"sk-ant-oat01-access",
28+ "refreshToken":"sk-ant-ort01-refresh",
29+ "expiresAt":1789668269460,
30+ "refreshTokenExpiresAt":1791404074460,
31+ "scopes":["user:inference","user:profile"],
32+ "subscriptionType":"team"}}`
33+
34+ func TestLoadReadsEveryField(t *testing.T) {
35+ c, err := Load(write(t, sample))
36+ if err != nil {
37+ t.Fatalf("Load: %v", err)
38+ }
39+ if c.AccessToken != "sk-ant-oat01-access" {
40+ t.Errorf("AccessToken = %q", c.AccessToken)
41+ }
42+ if c.RefreshToken != "sk-ant-ort01-refresh" {
43+ t.Errorf("RefreshToken = %q", c.RefreshToken)
44+ }
45+ if c.SubscriptionType != "team" {
46+ t.Errorf("SubscriptionType = %q", c.SubscriptionType)
47+ }
48+ if len(c.Scopes) != 2 || c.Scopes[0] != "user:inference" {
49+ t.Errorf("Scopes = %v", c.Scopes)
50+ }
51+ }
52+
53+ // The file stores epoch milliseconds. Reading them as seconds puts expiry
54+ // 55,000 years out and the token never looks stale.
55+ func TestLoadTreatsTimestampsAsMilliseconds(t *testing.T) {
56+ c, err := Load(write(t, sample))
57+ if err != nil {
58+ t.Fatalf("Load: %v", err)
59+ }
60+ want := time.UnixMilli(1789668269460)
61+ if !c.ExpiresAt.Equal(want) {
62+ t.Errorf("ExpiresAt = %s, want %s", c.ExpiresAt, want)
63+ }
64+ if want := time.UnixMilli(1791404074460); !c.RefreshExpiresAt.Equal(want) {
65+ t.Errorf("RefreshExpiresAt = %s, want %s", c.RefreshExpiresAt, want)
66+ }
67+ }
68+
69+ // Not logged in is not a failure. It is a fall-through to the next source.
70+ func TestLoadMissingFileIsErrNotFound(t *testing.T) {
71+ _, err := Load(t.TempDir())
72+ if !errors.Is(err, ErrNotFound) {
73+ t.Fatalf("err = %v, want ErrNotFound", err)
74+ }
75+ }
76+
77+ // A file that exists but does not parse is a broken install, and saying so
78+ // with the path beats falling through to "no credentials".
79+ func TestLoadMalformedJSONNamesThePath(t *testing.T) {
80+ home := write(t, "{not json")
81+ _, err := Load(home)
82+ if err == nil || errors.Is(err, ErrNotFound) {
83+ t.Fatalf("err = %v, want a parse error", err)
84+ }
85+ if !strings.Contains(err.Error(), ".credentials.json") {
86+ t.Errorf("error should name the file, got %q", err)
87+ }
88+ }
89+
90+ // An empty access token is a file we cannot use, and it fails at parse time
91+ // rather than as a puzzling 401 later.
92+ func TestLoadEmptyAccessTokenIsAnError(t *testing.T) {
93+ _, err := Load(write(t, `{"claudeAiOauth":{"accessToken":""}}`))
94+ if err == nil || errors.Is(err, ErrNotFound) {
95+ t.Fatalf("err = %v, want an error about the missing token", err)
96+ }
97+ }
98+
99+ func TestExpiredUsesASkewMargin(t *testing.T) {
100+ base := time.Unix(1_700_000_000, 0)
101+ c := &Credentials{ExpiresAt: base}
102+ if c.Expired(base.Add(-5 * time.Minute)) {
103+ t.Error("a token with five minutes left is not expired")
104+ }
105+ // Inside the skew margin: it would expire during the request we are about
106+ // to send, so it counts as expired now.
107+ if !c.Expired(base.Add(-30 * time.Second)) {
108+ t.Error("a token expiring in 30s should count as expired")
109+ }
110+ if !c.Expired(base.Add(time.Minute)) {
111+ t.Error("a token past its expiry is expired")
112+ }
113+ }
114+
115+ func TestRefreshExpiredIsTerminal(t *testing.T) {
116+ base := time.Unix(1_700_000_000, 0)
117+ c := &Credentials{RefreshExpiresAt: base}
118+ if c.RefreshExpired(base.Add(-24 * time.Hour)) {
119+ t.Error("a refresh token with a day left is not expired")
120+ }
121+ if !c.RefreshExpired(base.Add(time.Hour)) {
122+ t.Error("a refresh token past its expiry is expired")
123+ }
124+ }