feat: support ANTHROPIC_OAUTH env var for credential injection#93
feat: support ANTHROPIC_OAUTH env var for credential injection#93feisuzhu wants to merge 2 commits intogriffinmartin:mainfrom
Conversation
Check ANTHROPIC_OAUTH=access,refresh,expires before Keychain and credentials file lookups. When set, all other credential sources are skipped entirely.
griffinmartin
left a comment
There was a problem hiding this comment.
Nice feature -- clean integration into the existing credential chain. Two things worth addressing:
Tests -- This adds a new credential source with parsing and priority logic but no test coverage. The existing suite has 141 tests, so this stands out. Key cases to cover: valid parsing, malformed input (wrong part count, NaN expiry, empty values), env priority over Keychain/file, and refreshAccount("env") behavior.
Refresh semantics -- refreshAccount("env") re-reads the same env var, so if the access token expires and the env var hasn't been updated externally, the plugin gets back stale credentials. Worth documenting whether the plugin's OAuth refresh logic (using the refresh token) still kicks in here, or if the caller is expected to keep the env var current.
|
Question: I can directly add refresh logic here, but do you have any concerns, like 'dont want to break some sort of rules' ? |
|
For now, I'm removing the refresh token thus we definitely can't refresh it. If you prefer to keep it, please tell me and I'll add it back. |
|
I'm skipping tests since now the logic is trivial |
|
I've been poking at the Claude Code OAuth internals lately (RE work, not official docs, so grain of salt on some of this) and a few things caught my eye. The 365-day fake expiryexpiresAt: Date.now() + 365 * 24 * 60 * 60 * 1000,From what I can tell, Claude Code has two kinds of OAuth tokens: Normal login tokens expire in roughly 10 hours. These come with a rotating refresh token ( The 365d value happens to be right for the second case but wrong for the first. If someone grabs a normal access token from their keychain and sticks it in Not sure what the best fix is. Stale snapshot in refreshIfNeeded
Probably straightforward: if (target.source === "env") return refreshAccount("env") ?? credsToken validationRight now it's just Disk persistence
Tests141 tests in the suite and this adds none. The env path has enough moving parts (priority, parsing, refresh bypass, label generation with empty Minor
Generating long lived tokens directlySomewhat related, but this could be a lot more useful than just accepting a raw env var. The token exchange endpoint ( The PKCE flow is standard OAuth2. Generate a verifier/challenge, open the auth URL with The client ID is the same public one Claude Code uses ( The stale snapshot thing in |
griffinmartin
left a comment
There was a problem hiding this comment.
Thanks for the contribution — the env var injection is a useful feature for orchestrator setups. However, there are two issues that need to be addressed before this can be merged, plus a few follow-ups worth considering.
Requested Changes
1. Stale snapshot bug in refreshIfNeeded (blocking)
As @minzique identified, the early return in credentials.ts:
if (target.source === "env") return credsreturns whatever was in the env var at startup, not its current value. refreshAccount("env") exists and correctly re-reads the variable, but it never gets called on this path. This directly contradicts the stated use case of an orchestrator rotating credentials mid-session.
Fix:
if (target.source === "env") return refreshAccount("env") ?? creds2. Test coverage (blocking)
The existing suite has 141 tests and this adds a new credential source with priority logic but zero test coverage. Key cases to add:
- Valid env var parsing produces correct credentials
- Empty / unset env var is skipped (falls through to Keychain/file)
- Env var takes priority over Keychain and file sources
refreshAccount("env")re-reads the current env var value- Label generation works with empty
refreshToken
Follow-ups (non-blocking, but worth considering)
Echoing several points from @minzique's thorough review:
365-day fake expiry — The hardcoded Date.now() + 365 * 24 * 60 * 60 * 1000 is correct for long-lived inference tokens but wrong for normal 10-hour tokens. If someone grabs a regular token from their keychain and sets it in ANTHROPIC_OAUTH, the plugin will think it's valid for a year and keep sending expired tokens silently. Worth at minimum documenting this limitation, or accepting an optional expiry parameter.
Disk persistence — syncAuthJson still writes env-sourced tokens to ~/.local/share/opencode/auth.json. Users who specifically chose env vars to keep tokens off disk would be surprised. Consider skipping the sync for source === "env", or calling it out in the docs.
Token validation — A basic prefix check (e.g. sk-ant-oat01-) would catch common mistakes like passing a refresh token or API key, saving debugging time.
Please address items 1 and 2 and we can get this merged. The follow-ups can be handled in subsequent PRs if you prefer.
JiwaniZakir
left a comment
There was a problem hiding this comment.
The fake expiresAt of one year in readEnvCredentials() is redundant and slightly misleading. Since credentials.ts already short-circuits with if (target.source === "env") return creds before any expiry comparison, the timestamp is never used for refresh gating — it only affects code that might surface expiry information to the user, where a year-from-now is factually wrong for a real OAuth token that may expire in hours. Setting it to Infinity or Number.MAX_SAFE_INTEGER would be more honest about its meaning (i.e., "this field is intentionally inert").
Similarly, refreshToken: "" is an empty string rather than null or undefined. If any downstream consumer does if (creds.refreshToken) to check whether a refresh is possible, an empty string is already falsy in JS, but it's worth confirming the ClaudeCredentials interface explicitly allows an empty string here rather than treating refreshToken as string | null.
One missing test scenario: what happens when ANTHROPIC_OAUTH is set to a non-empty whitespace string (e.g., " ")? The .trim() call in readEnvCredentials() correctly normalizes it, but if (!raw) after the trim means a whitespace-only value is silently dropped — that's probably the right behavior, but it's worth a test case to document the intent and prevent future regressions.
What have done
Check ANTHROPIC_OAUTH=access,refresh,expires before Keychain and credentials file lookups. When set, all other credential sources are skipped entirely.
Motivation
We are running
opencode acpunder an home grown agent, which manages pool of claude oauth credentials, so I need to have a convenient method to directly inject OAuth tokens.