feat(providers): add Claude Pro/Max subscription OAuth (PKCE-localhost)

27th registry variant, 2nd subscription-OAuth provider. Log in with a Claude
Pro/Max subscription via PKCE authorization-code + S256 (loopback callback on
127.0.0.1:53692, with a manual code-paste fallback), then use it against
api.anthropic.com — reusing the existing Anthropic Messages wire + Anthropic
listing + the multi-provider OAuth foundation (dbce6bf). Sourced from Pi
(earendil-works/pi auth/oauth/anthropic.ts): client 9d1c250a..., authorize
claude.ai/oauth/authorize, token platform.claude.com/v1/oauth/token, scope
'…user:inference user:sessions:claude_code…'.

New machinery (foundation handles token routing — claude-pro-max is a
uses_oauth platform so its bearer/refresh/api_key already route to its own
pooled manager, never Kimi):
- OAuthConfig gains flow{DeviceCode|PkceLocalhost} + token_host + token_body
  {Form|JSON}; xai/kimi rows unchanged (DeviceCode/Form).
- auth/oauth_pkce.rs: PKCE S256 wire — loopback listener with STRICT state
  validation (CSRF, fail-closed), manual-paste fallback, JSON code→token
  exchange + rotating-refresh. Never logs code/verifier/tokens.
- Messages OAuth adaptation gated on SamplerConfig.anthropic_oauth (true only
  for a claude-pro-max managed key): Authorization: Bearer + anthropic-beta
  oauth + user-agent claude-cli + x-app cli, and the required 'You are Claude
  Code' system prefix. API-key anthropic/minimax Messages requests are
  BYTE-IDENTICAL (regression-guarded).
- Live /models under the OAuth Bearer + oauth-beta headers (Anthropic listing,
  enriched from models.dev anthropic); persistent 401 → 0 models + WARN, NO
  hardcoded fallback list (honest failure).

Adversarial review: no blocking findings (secret handling, CSRF/state, the
anthropic_oauth gate, token routing, non-regression all CONFIRMED). Full gate
green. Registry at 27; picker updated. Residual (unverifiable without a real
Claude Pro/Max account): whether GET /v1/models accepts the OAuth bearer, and
the real endpoint's acceptance of the OAuth Messages request.
This commit is contained in:
2026-07-22 02:53:25 -04:00
parent dbce6bf305
commit 5a9183b08b
25 changed files with 1527 additions and 73 deletions
+30 -15
View File
@@ -168,21 +168,36 @@ edges stay deterministic Rust. The harness appends a terminal
are built generically from advertised methods (`AuthMethodKind::
ApiKeyPlatform`), so new registry rows appear in the picker with no TUI
changes.
- Refreshable-OAuth providers beyond Kimi Code use a GENERIC device-code
(RFC-8628) path, NOT Kimi's bespoke wire. A `uses_oauth` platform carrying
`oauth: Some(&OAuthConfig)` (client id / auth host / device+token paths /
scope / `scope_key` / optional extra device field) drives
`auth::oauth_device` (plain kigi UA, no X-Msh headers) + a scope-keyed
`AuthManager::new_oauth_provider` + `refresh::GenericDeviceRefresher`
(selected by `build_refresher` via `oauth_config_for_scope_key`). Kimi Code
keeps `oauth: None` and its bespoke path unchanged. First such provider:
`xai-grok` (`scope_key oauth/xai`, base `api.x.ai/v1`, same wire as the
API-key `xai` row) — an INTERACTIVE login row advertised right after
`kimi-code` (`AuthMethodKind::OAuthPlatform`). Its `authenticate` arm runs
the generic device flow under its own scope; the catalog fetch resolves each
such platform's OWN session token (`resolve_generic_oauth_tokens`, refreshed
on expiry) and routes `platform.oauth().is_some()``platform.base_url()`
(kimi-code alone → `proxy_url()`). Tokens are NEVER logged.
- Refreshable-OAuth providers beyond Kimi Code use a GENERIC path, NOT Kimi's
bespoke wire. A `uses_oauth` platform carrying `oauth: Some(&OAuthConfig)`
(client id / auth host / start+token paths / `token_host` / `scope` /
`scope_key` / optional extra device field / `flow` / `token_body`) drives a
scope-keyed `AuthManager::new_oauth_provider` +
`refresh::GenericDeviceRefresher` (selected by `build_refresher` via
`oauth_config_for_scope_key`; the refresher dispatches the refresh body by
`token_body`: form → `auth::oauth_device`, JSON → `auth::oauth_pkce`). Kimi
Code keeps `oauth: None` and its bespoke path unchanged. The interactive
login is dispatched by `OAuthConfig.flow` (in `run_oauth_provider_flow`):
- `OAuthFlow::DeviceCode` `auth::oauth_device` (RFC-8628 device-code, plain
kigi UA, no X-Msh headers). Provider: `xai-grok` (`scope_key oauth/xai`,
base `api.x.ai/v1`, form token body, same wire as the API-key `xai` row).
- `OAuthFlow::PkceLocalhost { redirect_port }``auth::oauth_pkce`
(authorization-code + PKCE S256, `127.0.0.1:redirect_port/callback` loopback
with STRICT `state` validation + manual-paste fallback, JSON token body,
authorize host ≠ token host). Provider: `claude-pro-max` (`scope_key
oauth/claude-pro-max`, base `api.anthropic.com/v1`, Anthropic Messages +
listing wire reached with an OAuth `sk-ant-oat…` Bearer). Its Messages
requests take the OAuth adaptation — `anthropic-beta claude-code-…,oauth-…`
+ `claude-cli` UA + `x-app cli` + the required "You are Claude Code…" system
prefix — gated on `SamplerConfig.anthropic_oauth` (claude-pro-max only), so
API-key `anthropic`/`minimax` Messages requests stay byte-identical. Its
`/v1/models` listing rides the same Bearer + oauth-beta headers.
Both are INTERACTIVE login rows advertised right after `kimi-code`
(`AuthMethodKind::OAuthPlatform`, in `PlatformId::ALL` order: `xai-grok` then
`claude-pro-max`). The catalog fetch resolves each such platform's OWN session
token (`resolve_generic_oauth_tokens`, refreshed on expiry) and routes
`platform.oauth().is_some()``platform.base_url()` (kimi-code alone →
`proxy_url()`). Tokens/codes/verifiers are NEVER logged.
- Model metadata (context window, thinking levels) comes from the provider
wire when served; metadata-poor listings are enriched from models.dev
(`kigi-models/src/enrichment.rs` — bundled raw snapshot regenerated by