Skip to content

feat: support ANTHROPIC_OAUTH env var for credential injection#93

Closed
feisuzhu wants to merge 2 commits intogriffinmartin:mainfrom
feisuzhu:from-env
Closed

feat: support ANTHROPIC_OAUTH env var for credential injection#93
feisuzhu wants to merge 2 commits intogriffinmartin:mainfrom
feisuzhu:from-env

Conversation

@feisuzhu
Copy link
Copy Markdown

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 acp under an home grown agent, which manages pool of claude oauth credentials, so I need to have a convenient method to directly inject OAuth tokens.

Check ANTHROPIC_OAUTH=access,refresh,expires before Keychain and
credentials file lookups. When set, all other credential sources
are skipped entirely.
Copy link
Copy Markdown
Owner

@griffinmartin griffinmartin left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@griffinmartin griffinmartin added the enhancement New feature or request label Mar 26, 2026
@feisuzhu
Copy link
Copy Markdown
Author

Question: I can directly add refresh logic here, but do you have any concerns, like 'dont want to break some sort of rules' ?

@feisuzhu
Copy link
Copy Markdown
Author

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.

@feisuzhu
Copy link
Copy Markdown
Author

I'm skipping tests since now the logic is trivial

@minzique
Copy link
Copy Markdown
Contributor

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 expiry

expiresAt: 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 (sk-ant-ort01-...). Then there are long lived tokens (the /install-github-app flow, or manual PKCE with user:inference scope) that seem to last about a year. No 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 ANTHROPIC_OAUTH, the plugin will think it's valid for a year and keep sending expired tokens. The 30s cache TTL means it re-reads the same dead value every cycle.

Not sure what the best fix is. Infinity maybe? Or just document that this is for long lived inference-only tokens and call it a day. I think the server rejects long expiry when the scope includes user:sessions:claude_code, but I'm not 100% on that.

Stale snapshot in refreshIfNeeded

refreshIfNeeded returns target.credentials for env sourced tokens (line 188), but that's whatever was in the env var at startup. refreshAccount("env") exists and re-reads the var, it just never gets called on this path. So if something updates ANTHROPIC_OAUTH mid session (orchestrator, wrapper script, etc), the plugin won't notice.

Probably straightforward:

if (target.source === "env") return refreshAccount("env") ?? creds

Token validation

Right now it's just trim(). OAuth access tokens I've seen follow the sk-ant-oat01- prefix. A quick check there would catch obvious mistakes like passing a refresh token (sk-ant-ort01-), an API key (sk-ant-api), or a partial paste. Not blocking, but it'd save people some debugging.

Disk persistence

syncAuthJson still runs for env sourced tokens, which writes the access token to ~/.local/share/opencode/auth.json. Might surprise someone who specifically chose an env var to keep tokens off disk. Maybe skip the sync for source === "env", or at least mention it in the docs.

Tests

141 tests in the suite and this adds none. The env path has enough moving parts (priority, parsing, refresh bypass, label generation with empty refreshToken) that a few cases would go a long way.

Minor

readEnvCredentials sets refreshToken: "". If buildAccountLabels uses the refresh token for differentiation anywhere (last N chars or whatever), empty string might do something weird. Haven't traced that fully.

Generating long lived tokens directly

Somewhat related, but this could be a lot more useful than just accepting a raw env var. The token exchange endpoint (POST https://claude.ai/v1/oauth/token) accepts an expires_in parameter during the auth code exchange. If you request user:inference scope only and pass expires_in: 31536000, you get a 1 year access token back. No refresh token needed.

The PKCE flow is standard OAuth2. Generate a verifier/challenge, open the auth URL with scope=user:inference, get the callback code, exchange it with expires_in: 31536000. The plugin could do this itself instead of making users figure out where to get a token. Something like an opencode claude-auth setup-token command that opens a browser, runs the PKCE flow, and drops the result into ANTHROPIC_OAUTH or wherever. This is basically what /install-github-app does internally, just without the GitHub secrets part.

The client ID is the same public one Claude Code uses (9d1c250a-e61b-44d9-88ed-5944d1962f5e), auth method is none (public client). Could be a nice follow up if you want to make this env var path actually ergonomic.


The stale snapshot thing in refreshIfNeeded is probably the one I'd want fixed before merging. The rest could be follow ups.

Copy link
Copy Markdown
Owner

@griffinmartin griffinmartin left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 creds

returns 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") ?? creds

2. 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 persistencesyncAuthJson 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.

Copy link
Copy Markdown

@JiwaniZakir JiwaniZakir left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants