Skip to content

feat(auth): add Anthropic OAuth token refresh#8602

Closed
maxtongwang wants to merge 12 commits intoopenclaw:mainfrom
maxtongwang:feat/anthropic-oauth
Closed

feat(auth): add Anthropic OAuth token refresh#8602
maxtongwang wants to merge 12 commits intoopenclaw:mainfrom
maxtongwang:feat/anthropic-oauth

Conversation

@maxtongwang
Copy link

@maxtongwang maxtongwang commented Feb 4, 2026

Summary

Adds automatic OAuth token refresh for Anthropic credentials, enabling OpenClaw to use Claude Code's OAuth tokens with auto-renewal. Currently Anthropic is the only major provider in the OAuth dispatch chain without a refresh handler — tokens expire and fall back to static API keys or require manual re-auth.

  • New src/agents/anthropic-oauth.ts — refresh handler using platform.claude.com/v1/oauth/token (same endpoint Claude Code CLI uses), follows the existing Chutes/Qwen pattern
  • Added Anthropic case to the provider dispatch in src/agents/auth-profiles/oauth.ts
  • Utility script scripts/sync-claude-oauth.{sh,ts} to seed initial OAuth credentials from Claude Code

Related Issues

Test plan

  • Build passes (pnpm build)
  • Lint passes (pnpm check — no any, oxfmt clean)
  • Manual test: seeded expired OAuth token, verified refreshAnthropicTokens() returns fresh access token
  • Manual test: gateway uses refreshed token for API requests after token expiry
  • Verified existing Chutes/Qwen dispatch paths unchanged
  • Unit tests for anthropic-oauth.ts (refresh success, missing refresh token, error responses)

🤖 Generated with Claude Code

@openclaw-barnacle openclaw-barnacle bot added scripts Repository scripts agents Agent runtime and tooling labels Feb 4, 2026
Copy link
Contributor

@greptile-apps greptile-apps bot left a comment

Choose a reason for hiding this comment

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

3 files reviewed, 4 comments

Edit Code Review Agent Settings | Greptile

Comment on lines 1 to 3
import * as fs from "node:fs";
import * as path from "node:path";

Copy link
Contributor

Choose a reason for hiding this comment

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

[P1] process.env.HOME! can be undefined (e.g., Windows, some CI, sandboxed envs), which will throw inside path.join before you can print the friendly error. Consider resolving home via os.homedir() (or guard HOME explicitly) so the script fails with a clear message instead of a crash.

Prompt To Fix With AI
This is a comment left during a code review.
Path: scripts/sync-claude-oauth.ts
Line: 1:3

Comment:
[P1] `process.env.HOME!` can be undefined (e.g., Windows, some CI, sandboxed envs), which will throw inside `path.join` before you can print the friendly error. Consider resolving home via `os.homedir()` (or guard HOME explicitly) so the script fails with a clear message instead of a crash.

How can I resolve this? If you propose a fix, please make it concise.

Comment on lines 67 to 69
console.log("Updated agent store (agents/main/agent/auth-profiles.json)");
}

Copy link
Contributor

Choose a reason for hiding this comment

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

[P2] new Date(profile.expires).toISOString() will throw RangeError: Invalid time value when expires is 0 (or otherwise invalid), which can happen because the script sets expires: oauth.expiresAt || 0. Either avoid calling toISOString() when expires is falsy, or default to Date.now() + … so logging never crashes.

Prompt To Fix With AI
This is a comment left during a code review.
Path: scripts/sync-claude-oauth.ts
Line: 67:69

Comment:
[P2] `new Date(profile.expires).toISOString()` will throw `RangeError: Invalid time value` when `expires` is `0` (or otherwise invalid), which can happen because the script sets `expires: oauth.expiresAt || 0`. Either avoid calling `toISOString()` when `expires` is falsy, or default to `Date.now() + …` so logging never crashes.

How can I resolve this? If you propose a fix, please make it concise.

Comment on lines 1 to 3
#!/bin/bash
# Sync Claude Code OAuth credentials into OpenClaw auth-profiles.json
# Usage: ./scripts/sync-claude-oauth.sh
Copy link
Contributor

Choose a reason for hiding this comment

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

[P3] Minor portability: the shebang is #!/bin/bash, which can be missing on some systems (Nix, some containers). If this script is intended to be broadly runnable, #!/usr/bin/env bash is usually more portable.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Prompt To Fix With AI
This is a comment left during a code review.
Path: scripts/sync-claude-oauth.sh
Line: 1:3

Comment:
[P3] Minor portability: the shebang is `#!/bin/bash`, which can be missing on some systems (Nix, some containers). If this script is intended to be broadly runnable, `#!/usr/bin/env bash` is usually more portable.

<sub>Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!</sub>

How can I resolve this? If you propose a fix, please make it concise.

Comment on lines +60 to +62

const access = data.access_token?.trim();
const newRefresh = data.refresh_token?.trim();
Copy link
Contributor

Choose a reason for hiding this comment

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

[P2] response.text() is included verbatim in the thrown error. If the token endpoint returns structured JSON with sensitive fields, this could leak into logs/telemetry. Consider parsing a small subset (e.g., error, error_description) or truncating the body before embedding it in the error message.

Prompt To Fix With AI
This is a comment left during a code review.
Path: src/agents/anthropic-oauth.ts
Line: 60:62

Comment:
[P2] `response.text()` is included verbatim in the thrown error. If the token endpoint returns structured JSON with sensitive fields, this could leak into logs/telemetry. Consider parsing a small subset (e.g., `error`, `error_description`) or truncating the body before embedding it in the error message.

How can I resolve this? If you propose a fix, please make it concise.

@maxtongwang
Copy link
Author

Update: Auto-sync Claude Code OAuth + Keychain fallback (e77978d92)

This commit fixes the core issue where Anthropic OAuth tokens can't auto-refresh because Claude Code stores credentials in macOS Keychain while OpenClaw was reading from the empty ~/.claude/.credentials.json file.

Changes

src/agents/auth-profiles/external-cli-sync.ts

  • Added Claude Code to the external CLI sync chain (same pattern as Qwen/MiniMax)
  • On auth store load, syncs fresh credentials from Claude Code Keychain → anthropic:claude-cli profile
  • Also updates anthropic:default if it exists with stale credentials

src/agents/auth-profiles/oauth.ts

  • Write-back: After successful Anthropic token refresh, writes new tokens back to Claude Code Keychain via writeClaudeCliCredentials() so both stay in sync
  • Keychain fallback: When refreshAnthropicTokens() fails (e.g. rotated refresh token), reads fresh credentials from Claude Code's Keychain before giving up

scripts/sync-claude-oauth.ts

  • Replaced raw fs.readFileSync of ~/.claude/.credentials.json with readClaudeCliCredentials() which tries Keychain first on macOS — fixes the empty file issue

src/agents/anthropic-oauth.test.ts (new)

  • 10 unit tests covering: successful refresh, missing refresh token, error responses, expiry buffer math (5-min buffer + 30s floor), custom clientId, email preservation

Verification

  • pnpm build
  • pnpm check
  • pnpm test — all 208 tests pass including 10 new ones ✅

@purplemonday-dev
Copy link

@steipete, please look for this solution

@maxtongwang
Copy link
Author

2 failed CIs doesn't seem to be related to my changes. @steipete Please check and consider merging this.

@nikolasdehor
Copy link

@steipete — Could you please review this PR? It's been open for weeks and is blocking all users who rely on Anthropic OAuth.

I've confirmed the underlying issue thoroughly on v2026.2.9:

  1. loginAnthropic() from @mariozechner/pi-ai works — creates a proper OAuth profile with access + refresh tokens ✅
  2. refreshAnthropicToken(refreshToken) works — manually refreshing returns valid new tokens ✅
  3. But OpenClaw's refreshOAuthTokenWithLock() never calls it — when the token expires, it throws "No API key found for provider anthropic" instead of refreshing ❌

The pi-ai library already has full Anthropic OAuth support (loginAnthropic, refreshAnthropicToken, anthropicOAuthProvider). The provider IS registered in getOAuthProviders(). But the runtime dispatch in the auth module fails to resolve it, so tokens expire and are never renewed.

This PR wires up exactly what's missing. Multiple users have confirmed it works locally. The CI failures on macOS/Windows appear unrelated to the actual code changes (as @maxtongwang noted).

This is the #1 most impactful issue on the tracker right now — 15+ comments on #9095, affecting everyone using Anthropic without a paid API key. Would really appreciate a review. 🙏

@anieve01
Copy link

@steipete this PR is urgent. please review/approve. thanks!

@openclaw-barnacle openclaw-barnacle bot added the channel: signal Channel integration: signal label Feb 15, 2026
@openclaw-barnacle openclaw-barnacle bot removed the channel: signal Channel integration: signal label Feb 15, 2026
@nikolasdehor
Copy link

Urgent: This PR is the only path to Anthropic OAuth support in OpenClaw

I just tested both auth methods with a fresh OAT token (sk-ant-oat01-*) on v2026.2.15:

# x-api-key header → "invalid x-api-key"
curl -H 'x-api-key: sk-ant-oat01-...' https://api.anthropic.com/v1/messages
→ 401: {"error":{"type":"authentication_error","message":"invalid x-api-key"}}

# Authorization: Bearer → "OAuth not supported"  
curl -H 'Authorization: Bearer sk-ant-oat01-...' https://api.anthropic.com/v1/messages
→ 401: {"error":{"type":"authentication_error","message":"OAuth authentication is currently not supported."}}

OAT tokens cannot be used with the raw Anthropic API. This PR implements the OAuth flow that makes them work. Without it, the only way to use Anthropic in OpenClaw is with a paid API key.

This blocks every user who wants to use their Claude Pro/Max subscription with OpenClaw instead of paying separate API credits.

With @steipete's transition to OpenAI, could one of the active maintainers review this? The auth subsystem has no dedicated maintainer in CONTRIBUTING.md.

cc @sebslight @tyler6204 @gumadeiras — this has been open for weeks with CI passing and confirmed working by 5+ users. No human reviewer has been assigned.

@PresidentStyx
Copy link

i was one of the first trying to figure this out and at this point i've moved to the OpenAI pro subscription, despite my reservations.

Anthropic, especially after today's news, will not make it easy to make this happen. everything they can do to patch they will.

to be clear, i wanted xAI to do what OpenAI did today so i'm riding the wave until the next better alternative comes available.

@gumadeiras gumadeiras self-assigned this Feb 16, 2026
@gumadeiras gumadeiras force-pushed the feat/anthropic-oauth branch 2 times, most recently from 0d5dfe2 to 23322ca Compare February 16, 2026 19:44
@openclaw-barnacle openclaw-barnacle bot removed the scripts Repository scripts label Feb 16, 2026
@gumadeiras
Copy link
Member

can yall try the current implementation and let me know if you still have problems?

@nikolasdehor
Copy link

@gumadeiras — tested the current implementation (v2026.2.15). Here's what I found:

What WORKS

The @mariozechner/pi-ai library (bundled in v2026.2.15) already has Anthropic OAuth support:

  • anthropicOAuthProvider is registered in the OAuth provider registry
  • refreshAnthropicToken() exists and is exported
  • getOAuthProviders() returns 5 providers including anthropic
  • The refreshOAuthTokenWithLock() dispatch chain in model-selection-*.js correctly routes to resolveOAuthProvider() which finds anthropic in the registered set

What DOES NOT WORK

1. openclaw models auth login --provider anthropic fails:

Error: Unknown provider "anthropic". Loaded providers: google-antigravity, google-gemini-cli.

The login command resolves providers from loaded gateway plugins, not from the pi-ai OAuth registry. Since there's no Anthropic plugin loaded (it's a built-in provider), the login command can't find it.

2. openclaw models auth setup-token --provider anthropic requires TTY — can't test programmatically, but it defaults to --provider anthropic so it might work interactively.

3. Manually created type: "token" profiles don't refresh:
Our auth profile is type: "token" with an OAT token. The refreshOAuthTokenWithLock() function checks cred.type !== "oauth" and returns null (line 729). Static tokens can't be refreshed.

4. OAT tokens don't work with the raw API:

curl -H 'x-api-key: sk-ant-oat01-...' → 401 "invalid x-api-key"
curl -H 'Authorization: Bearer sk-ant-oat01-...' → 401 "OAuth authentication is currently not supported"

Root cause

The OAuth refresh infrastructure exists in v2026.2.15, but there's no way to create an type: "oauth" Anthropic credential with a refresh token through the CLI. The login command doesn't recognize Anthropic, and paste-token creates type: "token" entries without refresh tokens.

What PR #8602 adds

  • Routes the login command through the pi-ai OAuth flow for Anthropic
  • Creates proper type: "oauth" credentials with refresh token
  • Implements refreshAnthropicTokens() that calls platform.claude.com/v1/oauth/token
  • Syncs back to Claude Code Keychain on macOS

Suggestion

The simplest fix might be to wire openclaw models auth login to use getOAuthProvider("anthropic") from pi-ai directly, instead of requiring a loaded gateway plugin. This would unblock the entire OAuth flow without needing most of PR #8602's changes.

@gumadeiras
Copy link
Member

@nikolasdehor I meant the current implementation in this PR, not on main

@nikolasdehor
Copy link

@gumadeiras I checked out and built the PR branch locally. Here are my test results:

Build

  • Branch: feat/anthropic-oauth (commit 9e8feb3)
  • Build: ✅ Clean build, 274 output files, no errors
  • Unit tests: ✅ All 10 tests in anthropic-oauth.test.ts pass

CLI Commands

Command Result
models auth login --provider anthropic Recognized! (On main v2026.2.15 this returns "Unknown provider")
models auth paste-token --provider anthropic ✅ Works, prompts for token input
models auth setup-token --provider anthropic ✅ Recognized, correctly requires TTY
models status ✅ Shows anthropic with oauth=0, token=2 (correct — no OAuth profile created yet)

What I couldn't test without TTY

The actual models auth login --provider anthropic flow requires an interactive terminal to open the browser for Anthropic OAuth consent. Since I'm testing from a non-interactive context, I get:

Error: models auth login requires an interactive TTY.

I'll run this in an interactive terminal session and report back on the full OAuth flow (browser → consent → token → refresh cycle).

Code Review Observations

The implementation looks solid:

  1. src/agents/anthropic-oauth.ts — Clean refresh logic hitting platform.claude.com/v1/oauth/token with JSON body. 5-minute expiry buffer is sensible.

  2. src/agents/auth-profiles/oauth.ts:64 — Routes provider === "anthropic" to refreshAnthropicTokens(). The fallback to reading fresh creds from Claude CLI's Keychain (readClaudeCliCredentialsCached({ ttlMs: 0 })) is a smart recovery path.

  3. Changelog note about preserving auth mode — Important: prevents static api_key/token profiles from being auto-converted to OAuth. This was a concern I had from my v2026.2.15 investigation.

Key improvement over main

On main (v2026.2.15):

  • models auth login --provider anthropic → "Unknown provider" ❌
  • refreshOAuthTokenWithLock() skips type: "token" credentials → no refresh ❌
  • Token expires after ~8h → permanent cooldown ❌

On this PR branch:

  • models auth login --provider anthropic → ✅ Recognized, ready for OAuth flow
  • Proper refreshAnthropicTokens() wired in → ✅ refresh infrastructure connected
  • Fallback to Claude CLI Keychain → ✅ recovery path exists

This PR would resolve #9095 (my issue), #17873, and #18624. Looking forward to testing the full interactive flow.

@gumadeiras
Copy link
Member

gumadeiras commented Feb 17, 2026

sounds good; thanks for testing
let me know when you had a chance to test the interactive flow

@nikolasdehor
Copy link

@gumadeiras Important finding from TTY testing:

My earlier report was incomplete — the "requires interactive TTY" error was masking the real issue. When I run with an actual TTY (via script -q /dev/null), the command gets past the TTY check but fails with:

Error: Unknown provider "anthropic". Loaded providers: google-antigravity, google-gemini-cli.
Verify plugins via `openclaw plugins list --json`.

Root cause

models auth login resolves providers from loaded gateway plugins, not from the pi-ai OAuth registry. Only google-antigravity and google-gemini-cli are loaded as plugins. anthropicOAuthProvider is registered in @mariozechner/pi-ai/dist/utils/oauth/index.js but the login command never consults that registry.

What works vs what doesn't

Command Without TTY With TTY
models auth login --provider anthropic ❌ "requires TTY" (masks real error) ❌ "Unknown provider"
models auth paste-token --provider anthropic ✅ Recognized ✅ Prompts for token
models auth setup-token --provider anthropic ❌ "requires TTY" ✅ Launches interactive prompt

So paste-token and setup-token use a different provider resolution path than login. The login command goes through the plugin system, while paste-token/setup-token accept arbitrary provider names.

Suggested fix

The login command's provider resolution should fall back to the pi-ai OAuth registry when the provider isn't found in loaded plugins:

// Current (broken):
const provider = loadedPlugins.find(p => p.id === providerId);
if (!provider) throw new Error(`Unknown provider "${providerId}"`);

// Fixed:
const provider = loadedPlugins.find(p => p.id === providerId) 
  ?? getOAuthProvider(providerId);  // fall back to pi-ai registry
if (!provider) throw new Error(`Unknown provider "${providerId}"`);

This would connect the existing anthropicOAuthProvider (which already has loginAnthropic() and refreshAnthropicToken()) to the CLI command.

Test environment

  • PR branch: feat/anthropic-oauth (commit 9e8feb3)
  • Node: v24.11.0, macOS 15.4
  • Gateway v2026.2.15 installed (tested PR build separately)

@gumadeiras
Copy link
Member

gumadeiras commented Feb 17, 2026

you don't need TTY on the gateway machine but you need TTY in some machine:

Setup-tokens are created by the Claude Code CLI, not the Anthropic Console. You can run this on any machine:
claude setup-token

Paste the token into OpenClaw (wizard: Anthropic token (paste setup-token)), or run it on the gateway host:
openclaw models auth setup-token --provider anthropic

If you generated the token on a different machine, paste it using:
openclaw models auth paste-token --provider anthropic

there is no auth login --provider anthropic; just use the steps above
you don't need to manually create any auth entries in the config files. If this is not working then you might have conflicts/bad config from previous tests you were doing. Start clean and give it a try

pi will check if the token is oauth and route things properly

https://docs.openclaw.ai/providers/anthropic#anthropic

maxtongwang and others added 12 commits February 18, 2026 23:23
Add refresh handler for Anthropic OAuth credentials using the Claude
platform token endpoint. This enables OpenClaw to auto-refresh expired
Anthropic OAuth tokens instead of requiring manual re-authentication
or falling back to static API keys/setup-tokens.

Changes:
- New `src/agents/anthropic-oauth.ts`: refresh handler hitting
  `platform.claude.com/v1/oauth/token` with standard OAuth2 refresh flow
- Updated `src/agents/auth-profiles/oauth.ts`: added Anthropic to the
  provider dispatch chain (before Chutes/Qwen/generic)
- New `scripts/sync-claude-oauth.{sh,ts}`: utility to seed initial OAuth
  credentials from Claude Code's credential store

Relates to openclaw#2697 openclaw#6400 openclaw#8223 openclaw#8226 openclaw#8405

Co-Authored-By: Claude Opus 4.5 <[email protected]>
- Use os.homedir() instead of process.env.HOME (P1)
- Guard Date display when expires is 0/falsy (P2)
- Parse error response JSON instead of leaking raw body (P2)
- Use #!/usr/bin/env bash for portability (P3)

Co-Authored-By: Claude Opus 4.5 <[email protected]>
Replace `as any` casts with proper ProfileStore type. Auto-format
with oxfmt.

Co-Authored-By: Claude Opus 4.5 <[email protected]>
Add Claude Code to the external CLI credential sync chain (alongside
Qwen/MiniMax) so tokens are refreshed from Keychain on store load.
When Anthropic token refresh fails, fall back to reading fresh
credentials from Claude Code's Keychain. Write refreshed tokens back
to the Keychain after successful refresh to keep both in sync. Update
sync-claude-oauth.ts to use readClaudeCliCredentials() (Keychain-first)
instead of raw file reads. Add unit tests for refreshAnthropicTokens.

Co-Authored-By: Claude Opus 4.5 <[email protected]>
maxtongwang added a commit to maxtongwang/openclaw that referenced this pull request Feb 20, 2026
- Add `refreshAnthropicTokens` to refresh expired Anthropic OAuth tokens
  using the Claude platform endpoint, mirroring Claude Code CLI's own
  refresh flow. Includes Keychain fallback: if refresh fails, reads fresh
  credentials from Claude Code CLI before throwing.
- Auto-sync Claude Code CLI OAuth credentials into `anthropic:default`
  during external CLI sync, but only when the existing profile is already
  OAuth — prevents api_key/token profiles from being auto-converted.
- Extract shared `coerceExpiresAt` helper to `src/agents/oauth-utils.ts`,
  eliminating duplication between Anthropic and Chutes OAuth modules.
- Fix `syncExternalCliCredentials`: migrate Qwen sync block to use shared
  `syncExternalCliCredentialsForProvider` helper, eliminating a double-read
  bug and ~20 lines of duplicate logic.
- Add optional `deps` parameter to `syncExternalCliCredentials` for
  testable credential-reader injection (replaces broken vi.mock ESM pattern).
- Remove redundant `String(cred.provider)` wrappers in `oauth.ts`.
- Fix unsafe cast in `anthropic-oauth.test.ts`: use `result.email` directly.

Supersedes openclaw#8602.

Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
@maxtongwang
Copy link
Author

maxtongwang commented Feb 20, 2026

Hey, we were pulling this into our fork and ran it through our @claude automated review workflow. The workflow kept catching issues and iterating until clean — ended up with a handful of real fixes. Opened #21518 as a superseding PR with everything included.

Here's what we found:

Bug — Qwen double-read in external-cli-sync.ts
store.profiles[QWEN_CLI_PROFILE_ID] was read into two separate variables (existingQwen and existing) that always point to the same value, plus the Qwen block duplicated ~20 lines of logic already in syncExternalCliCredentialsForProvider. Migrated to the shared helper (same pattern as MiniMax/Anthropic).

Bug — external-cli-sync.test.ts tests silently not running
The vi.mock("../cli-credentials.js") pattern doesn't intercept module bindings in Vitest's fork pool with ESM. The mock call count was always 0 while the real function ran. Fixed with an optional deps injection parameter on syncExternalCliCredentials — no vi.mock needed, and the test now actually exercises the sync logic.

Code quality — redundant String() casts in oauth.ts
String(cred.provider) === "anthropic" etc. — cred.provider is already string.

Test quality — unsafe cast in anthropic-oauth.test.ts
(result as Record<string, unknown>).emailresult.email directly since email? is declared on the return type.

Refactor — coerceExpiresAt duplication
Both anthropic-oauth.ts and chutes-oauth.ts had identical copies. Extracted to src/agents/oauth-utils.ts.

PR #21518 has all of the above in a single commit on top of a clean branch from main. Feel free to pull the changes into this branch or close this one in favour of that.

cc @gumadeiras for review

@gumadeiras gumadeiras closed this Feb 20, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

agents Agent runtime and tooling size: L

Projects

None yet

Development

Successfully merging this pull request may close these issues.

6 participants

Comments