Vectrum
Sign In
184 lines · 8.3 KB

Claude Code OAuth credentials

The harness already speaks OAuth on the wire: -auth-token sends Authorization: Bearer and the oauth-2025-04-20 beta header, and has since Phase 1. What it cannot do is find a token. You either paste one on the command line or export it, and either way it expires in a few hours and you paste again.

Read Claude Code's credentials instead. If you are logged into Claude Code, the harness starts with no arguments.

What we are reading, and why that is a liability

Claude Code stores a subscription OAuth credential at ~/.claude/.credentials.json:

{ "claudeAiOauth": {
    "accessToken": "sk-ant-oat01…", "refreshToken": "sk-ant-ort01…",
    "expiresAt": 1789668269460, "refreshTokenExpiresAt": 1791404074460,
    "scopes": ["user:inference", "user:profile", …],
    "subscriptionType": "team", "rateLimitTier": "…" } }

Both timestamps are epoch milliseconds. user:inference is the scope that matters; the others are for surfaces we do not call.

Two caveats that shape everything below.

This is Claude Code's private storage, not a documented interface. The first-party OAuth path is ant auth login, which writes profiles under ~/.config/anthropic/ that every Anthropic SDK reads automatically. That path is stable and supported. It is also not available here — no ant binary, no ~/.config/anthropic — so we read the file that exists. The schema and the refresh endpoint can change under us without warning, which is the argument for putting all of it behind one small package whose tests fail loudly rather than one that degrades quietly.

And the credential is issued to Claude Code. Using it from another client is outside Anthropic's terms for that credential, which is a reason to keep this strictly opt-out-able and clearly announced, and never the silent default when another credential was offered. API keys and gateway bearer tokens carry no such condition and keep working exactly as they do today.

Precedence

First match wins:

SourceNotes
-api-key / -auth-tokenexplicit, so it beats everything
ANTHROPIC_API_KEY / ANTHROPIC_AUTH_TOKENtoday's behaviour, unchanged
~/.claude/.credentials.jsonnew, and only under the conditions below
—nothing: errNoCredentials, as now

The file is consulted only when every earlier source came up empty and client.IsDefaultEndpoint() is true. That second condition is the one worth being stubborn about: a discovered credential must never be shipped to an endpoint the user pointed us at. Otherwise -base-url https://someone-elses-proxy silently forwards your subscription token to a third party because a file happened to exist on disk. A credential you typed is a credential you chose to send; a credential we found is not.

When the file is the source, say so through notice() — the same channel that already announces a non-default endpoint, and for the same reason. -no-claude-code-auth skips discovery entirely.

internal/claudecode

One package, three jobs, no ambient state:

type Credentials struct {
	AccessToken, RefreshToken string
	ExpiresAt                 time.Time
	Scopes                    []string
	SubscriptionType          string
}

func Load(home string) (*Credentials, error)                    // parse. no network
func (c *Credentials) Expired(now time.Time) bool               // with skew
func Refresh(context.Context, *http.Client, *Credentials) (*Credentials, error)

Load takes home rather than calling os.UserHomeDir so tests point it at a fixture directory, matching how skills.Discover already takes its roots. Refresh returns new credentials and writes nothing — see below.

Missing file is not an error worth decorating: it means "not logged in", and the caller falls through to errNoCredentials. A file that exists but does not parse is an error, reported with the path, because that is a broken install rather than an absent one.

Expired subtracts a 60-second skew margin, so a token expiring during the request we are about to send counts as expired now.

On macOS Claude Code keeps this in the Keychain and the file does not exist. That degrades to "no credential found", which is correct, and is documented rather than worked around.

Dynamic credentials in llm.Client

authToken is a fixed string read once at construction, used by both attempt and the streaming path. A token that expires mid-session needs to change, so:

func WithTokenSource(f func(context.Context) (string, error)) Option

WithAuthToken becomes the constant case of it. Both request paths call the source and set the bearer from the result; the existing header logic is untouched, because the header was never the problem.

Resolving a credential can now fail at request time rather than at startup. That error returns as-is — it is not an *APIError, so Retryable is false and the loop surfaces it instead of hammering a dead endpoint.

The token source refreshes at most once per expiry, caches in memory for the process lifetime, and is mutex-guarded: streaming and non-streaming requests can be in flight together, and two concurrent refreshes of the same token is exactly the scenario the next section is about.

Refresh, and an open question that gates it

Refreshing is POST /v1/oauth/token with the refresh token. If the endpoint returns a new refresh token, then refreshing without writing it back consumes the one Claude Code still has on disk — its copy goes dead and claude asks you to log in again. We would have broken a tool outside this repo to save a paste.

We do not yet know whether that endpoint rotates. So the first implementation task is a probe, not code: refresh once, compare the returned refresh token to the stored one, report.

  • No rotation — in-memory-only refresh is safe. Build it as described.
  • Rotation — stop. Bring the result back before writing refresh code; the choice then is write-back (with atomic rename and 0600) or no refresh at all, and it is not a choice to make silently inside an implementation task.

Until that resolves, the fallback is the honest one: use the stored token, and when it is expired fail with your Claude Code login has expired, run claude to refresh it. That is a working feature on its own. Refresh is an improvement on top of it, not a prerequisite.

The system prompt

OAuth requests are expected to require the Claude Code identity string as the first system block. If that holds, defaultSystem at loop.go:26 is currently the first block and these requests will be rejected.

This is also a question rather than a fact, and a cheap one: one live request with the token and no identity block. If it fails, the agent prepends a second SystemBlock carrying the identity string when — and only when — the credential came from OAuth. Both blocks stay static, so the cached prefix survives intact, which is the property defaultSystem's comment exists to protect. If the request succeeds, nothing changes and this section is deleted.

Ordering matters: this probe uses the access token and touches no refresh token, so it is free of the rotation risk above and can run first.

Testing

Load: missing file, unparseable JSON, the millisecond epoch converted correctly, an expired access token, an expired refresh token (which is terminal — no amount of refreshing fixes it).

Refresh against httptest: success, 400 invalid_grant, transport error. No test makes a real network call. These land only if the probe clears refresh to be built; if it does not, the expired-token error path is what gets tested instead.

llm.Client: the token source is called per request and its value reaches the Authorization header; a source error propagates without retries; WithAuthToken still behaves as the constant source.

Precedence, which is where the security property lives and therefore gets the most tests: flag beats env beats file; a non-default base URL means the file is never read; -no-claude-code-auth means the file is never read. That third one is a test rather than a comment because "we did not send the credential" is not observable from the outside.

Not doing

No login flow — no PKCE, no browser, no callback server. You log in with Claude Code; the harness reads what that produced. If ant shows up on this machine later, its profile is a better source than this file and worth revisiting then.