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.
Showing 2 changed files with 217 additions and 0 deletions.
| @@ -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 | + } | |
| @@ -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 | + } | |