Vectrum
Sign In

Design: Claude Code OAuth credentials

Read ~/.claude/.credentials.json so a logged-in Claude Code means the
harness starts with no arguments. The wire format was already there;
what was missing was finding a token.

Two questions the design deliberately leaves open, each gated behind a
cheap probe rather than a guess: whether the token endpoint rotates
refresh tokens (if it does, in-memory refresh would break Claude Code's
own login), and whether OAuth requests need the Claude Code identity
block first in the system prompt.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Jjonicommitted 1 parent f04f191commit614f37e

Showing 1 changed file with 184 additions and 0 deletions.

+184−0docs/superpowers/specs/2026-09-17-claude-code-oauth-design.mdaddedView File
@@ -0,0 +1,184 @@
1+ # Claude Code OAuth credentials
2+
3+ The harness already speaks OAuth on the wire: `-auth-token` sends
4+ `Authorization: Bearer` and the `oauth-2025-04-20` beta header, and has since
5+ Phase 1. What it cannot do is find a token. You either paste one on the command
6+ line or export it, and either way it expires in a few hours and you paste again.
7+
8+ Read Claude Code's credentials instead. If you are logged into Claude Code, the
9+ harness starts with no arguments.
10+
11+ ## What we are reading, and why that is a liability
12+
13+ Claude Code stores a subscription OAuth credential at
14+ `~/.claude/.credentials.json`:
15+
16+ ```json
17+ { "claudeAiOauth": {
18+ "accessToken": "sk-ant-oat01…", "refreshToken": "sk-ant-ort01…",
19+ "expiresAt": 1789668269460, "refreshTokenExpiresAt": 1791404074460,
20+ "scopes": ["user:inference", "user:profile", …],
21+ "subscriptionType": "team", "rateLimitTier": "…" } }
22+ ```
23+
24+ Both timestamps are epoch **milliseconds**. `user:inference` is the scope that
25+ matters; the others are for surfaces we do not call.
26+
27+ Two caveats that shape everything below.
28+
29+ This is Claude Code's private storage, not a documented interface. The
30+ first-party OAuth path is `ant auth login`, which writes profiles under
31+ `~/.config/anthropic/` that every Anthropic SDK reads automatically. That path
32+ is stable and supported. It is also not available here — no `ant` binary, no
33+ `~/.config/anthropic` — so we read the file that exists. The schema and the
34+ refresh endpoint can change under us without warning, which is the argument for
35+ putting all of it behind one small package whose tests fail loudly rather than
36+ one that degrades quietly.
37+
38+ And the credential is issued to Claude Code. Using it from another client is
39+ outside Anthropic's terms for that credential, which is a reason to keep this
40+ strictly opt-out-able and clearly announced, and never the silent default when
41+ another credential was offered. API keys and gateway bearer tokens carry no such
42+ condition and keep working exactly as they do today.
43+
44+ ## Precedence
45+
46+ First match wins:
47+
48+ | Source | Notes |
49+ | --- | --- |
50+ | `-api-key` / `-auth-token` | explicit, so it beats everything |
51+ | `ANTHROPIC_API_KEY` / `ANTHROPIC_AUTH_TOKEN` | today's behaviour, unchanged |
52+ | `~/.claude/.credentials.json` | new, and only under the conditions below |
53+ | — | nothing: `errNoCredentials`, as now |
54+
55+ The file is consulted only when every earlier source came up empty **and**
56+ `client.IsDefaultEndpoint()` is true. That second condition is the one worth
57+ being stubborn about: a discovered credential must never be shipped to an
58+ endpoint the user pointed us at. Otherwise `-base-url https://someone-elses-proxy`
59+ silently forwards your subscription token to a third party because a file
60+ happened to exist on disk. A credential you typed is a credential you chose to
61+ send; a credential we found is not.
62+
63+ When the file is the source, say so through `notice()` — the same channel that
64+ already announces a non-default endpoint, and for the same reason. `-no-claude-code-auth`
65+ skips discovery entirely.
66+
67+ ## `internal/claudecode`
68+
69+ One package, three jobs, no ambient state:
70+
71+ ```go
72+ type Credentials struct {
73+ AccessToken, RefreshToken string
74+ ExpiresAt time.Time
75+ Scopes []string
76+ SubscriptionType string
77+ }
78+
79+ func Load(home string) (*Credentials, error) // parse. no network
80+ func (c *Credentials) Expired(now time.Time) bool // with skew
81+ func Refresh(context.Context, *http.Client, *Credentials) (*Credentials, error)
82+ ```
83+
84+ `Load` takes `home` rather than calling `os.UserHomeDir` so tests point it at a
85+ fixture directory, matching how `skills.Discover` already takes its roots.
86+ `Refresh` returns new credentials and writes nothing — see below.
87+
88+ Missing file is not an error worth decorating: it means "not logged in", and the
89+ caller falls through to `errNoCredentials`. A file that exists but does not parse
90+ *is* an error, reported with the path, because that is a broken install rather
91+ than an absent one.
92+
93+ `Expired` subtracts a 60-second skew margin, so a token expiring during the
94+ request we are about to send counts as expired now.
95+
96+ On macOS Claude Code keeps this in the Keychain and the file does not exist.
97+ That degrades to "no credential found", which is correct, and is documented
98+ rather than worked around.
99+
100+ ## Dynamic credentials in `llm.Client`
101+
102+ `authToken` is a fixed string read once at construction, used by both `attempt`
103+ and the streaming path. A token that expires mid-session needs to change, so:
104+
105+ ```go
106+ func WithTokenSource(f func(context.Context) (string, error)) Option
107+ ```
108+
109+ `WithAuthToken` becomes the constant case of it. Both request paths call the
110+ source and set the bearer from the result; the existing header logic is
111+ untouched, because the header was never the problem.
112+
113+ Resolving a credential can now fail at request time rather than at startup.
114+ That error returns as-is — it is not an `*APIError`, so `Retryable` is false and
115+ the loop surfaces it instead of hammering a dead endpoint.
116+
117+ The token source refreshes at most once per expiry, caches in memory for the
118+ process lifetime, and is mutex-guarded: streaming and non-streaming requests can
119+ be in flight together, and two concurrent refreshes of the same token is exactly
120+ the scenario the next section is about.
121+
122+ ## Refresh, and an open question that gates it
123+
124+ Refreshing is `POST /v1/oauth/token` with the refresh token. If the endpoint
125+ returns a *new* refresh token, then refreshing without writing it back consumes
126+ the one Claude Code still has on disk — its copy goes dead and `claude` asks you
127+ to log in again. We would have broken a tool outside this repo to save a paste.
128+
129+ We do not yet know whether that endpoint rotates. So the first implementation
130+ task is a probe, not code: refresh once, compare the returned refresh token to
131+ the stored one, report.
132+
133+ - **No rotation** — in-memory-only refresh is safe. Build it as described.
134+ - **Rotation** — stop. Bring the result back before writing refresh code; the
135+ choice then is write-back (with atomic rename and 0600) or no refresh at all,
136+ and it is not a choice to make silently inside an implementation task.
137+
138+ Until that resolves, the fallback is the honest one: use the stored token, and
139+ when it is expired fail with `your Claude Code login has expired, run claude to
140+ refresh it`. That is a working feature on its own. Refresh is an improvement on
141+ top of it, not a prerequisite.
142+
143+ ## The system prompt
144+
145+ OAuth requests are expected to require the Claude Code identity string as the
146+ first system block. If that holds, `defaultSystem` at `loop.go:26` is currently
147+ the first block and these requests will be rejected.
148+
149+ This is also a question rather than a fact, and a cheap one: one live request
150+ with the token and no identity block. If it fails, the agent prepends a second
151+ `SystemBlock` carrying the identity string when — and only when — the credential
152+ came from OAuth. Both blocks stay static, so the cached prefix survives intact,
153+ which is the property `defaultSystem`'s comment exists to protect. If the
154+ request succeeds, nothing changes and this section is deleted.
155+
156+ Ordering matters: this probe uses the *access* token and touches no refresh
157+ token, so it is free of the rotation risk above and can run first.
158+
159+ ## Testing
160+
161+ `Load`: missing file, unparseable JSON, the millisecond epoch converted
162+ correctly, an expired access token, an expired *refresh* token (which is
163+ terminal — no amount of refreshing fixes it).
164+
165+ `Refresh` against `httptest`: success, `400 invalid_grant`, transport error.
166+ No test makes a real network call. These land only if the probe clears refresh
167+ to be built; if it does not, the expired-token error path is what gets tested
168+ instead.
169+
170+ `llm.Client`: the token source is called per request and its value reaches the
171+ `Authorization` header; a source error propagates without retries; `WithAuthToken`
172+ still behaves as the constant source.
173+
174+ Precedence, which is where the security property lives and therefore gets the
175+ most tests: flag beats env beats file; a non-default base URL means the file is
176+ never read; `-no-claude-code-auth` means the file is never read. That third one
177+ is a test rather than a comment because "we did not send the credential" is not
178+ observable from the outside.
179+
180+ ## Not doing
181+
182+ No login flow — no PKCE, no browser, no callback server. You log in with Claude
183+ Code; the harness reads what that produced. If `ant` shows up on this machine
184+ later, its profile is a better source than this file and worth revisiting then.