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
+175 -10
View File
@@ -96,8 +96,35 @@ enum BaseUrlSource {
},
}
/// Generic RFC-8628 device-code OAuth configuration carried by a `uses_oauth`
/// platform whose login is the GENERIC device-code path (xai-grok today).
/// The interactive login mechanism a `uses_oauth` [`OAuthConfig`] provider
/// drives. `DeviceCode` is the RFC-8628 device flow (xai-grok); `PkceLocalhost`
/// is the authorization-code + PKCE (S256) flow with a loopback callback
/// (Claude Pro/Max). Kimi Code carries neither (its bespoke flow lives in
/// kigi-shell with `oauth: None`).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum OAuthFlow {
/// RFC-8628 device-code: POST `device_path`, poll `token_path`.
DeviceCode,
/// Authorization-code + PKCE (S256): browser hits `auth_host`+`device_path`
/// (the authorize endpoint); the code returns to a `127.0.0.1:redirect_port`
/// loopback listener, then is exchanged at `token_host`+`token_path`.
PkceLocalhost { redirect_port: u16 },
}
/// Body encoding a provider's token endpoint expects for the code-exchange and
/// refresh POSTs. xAI's `/oauth2/token` is form-encoded; Claude's
/// `/v1/oauth/token` is JSON.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum OAuthTokenBody {
/// `application/x-www-form-urlencoded` (xai-grok device wire).
Form,
/// `application/json` (Claude PKCE wire).
Json,
}
/// Generic OAuth configuration carried by a `uses_oauth` platform whose login
/// is the GENERIC device-code path (xai-grok) or the PKCE-localhost path
/// (claude-pro-max).
///
/// Kimi Code keeps its bespoke device flow (client id, `/api/oauth/*` paths,
/// X-Msh device headers, `kigi_env::oauth_host()`); its `oauth` field stays
@@ -105,15 +132,22 @@ enum BaseUrlSource {
/// tokens they mint are NEVER stored in this struct and never logged.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct OAuthConfig {
/// OAuth client id sent on every device-authorization / token call.
/// OAuth client id sent on every authorize / token call.
pub client_id: &'static str,
/// Authorization-server origin (no trailing slash), e.g. `https://auth.x.ai`.
/// Authorization-server origin (no trailing slash), e.g. `https://auth.x.ai`
/// (device) or `https://claude.ai` (PKCE authorize host).
pub auth_host: &'static str,
/// Device-authorization path (POST), relative to `auth_host`.
/// Start-endpoint path (POST for `DeviceCode` device-authorization; the
/// browser authorize path for `PkceLocalhost`), relative to `auth_host`.
pub device_path: &'static str,
/// Token path (POST) — used for BOTH the device grant and refresh.
/// Token-endpoint origin (no trailing slash). Equals `auth_host` for the
/// device wire; for Claude the token host (`https://platform.claude.com`)
/// differs from the authorize host (`https://claude.ai`).
pub token_host: &'static str,
/// Token path (POST) — used for BOTH the initial grant and refresh,
/// relative to `token_host`.
pub token_path: &'static str,
/// OAuth scope string requested at device authorization.
/// OAuth scope string requested at authorization.
pub scope: &'static str,
/// auth.json map key + keyring entry name for this provider's persisted
/// session (e.g. `oauth/xai`).
@@ -121,6 +155,10 @@ pub struct OAuthConfig {
/// A non-standard extra form field sent ONLY on the device-authorization
/// request (e.g. `("referrer", "kigi")`). `None` = no extra field.
pub extra_device_field: Option<(&'static str, &'static str)>,
/// Interactive login mechanism (device-code vs PKCE-localhost).
pub flow: OAuthFlow,
/// Body encoding the token endpoint expects (form vs JSON).
pub token_body: OAuthTokenBody,
}
/// xAI / Grok subscription device-code OAuth (ported from Pi
@@ -129,10 +167,36 @@ pub const XAI_OAUTH_CONFIG: OAuthConfig = OAuthConfig {
client_id: "b1a00492-073a-47ea-816f-4c329264a828",
auth_host: "https://auth.x.ai",
device_path: "/oauth2/device/code",
token_host: "https://auth.x.ai",
token_path: "/oauth2/token",
scope: "openid profile email offline_access grok-cli:access api:access",
scope_key: "oauth/xai",
extra_device_field: Some(("referrer", "kigi")),
flow: OAuthFlow::DeviceCode,
token_body: OAuthTokenBody::Form,
};
/// Base-URL override for the Claude Pro/Max OAuth channel (dev/test escape
/// hatch). Production defaults to `https://api.anthropic.com/v1`.
pub const CLAUDE_OAUTH_BASE_URL_ENV: &str = "KIGI_CLAUDE_OAUTH_BASE_URL";
/// Claude Pro/Max subscription OAuth (authorization-code + PKCE S256, loopback
/// callback). Authoritative constants from Pi `earendil-works/pi`
/// `auth/oauth/anthropic.ts`: authorize host `https://claude.ai`, token host
/// `https://platform.claude.com` (JSON body), public Claude Code client id.
pub const CLAUDE_OAUTH_CONFIG: OAuthConfig = OAuthConfig {
client_id: "9d1c250a-e61b-44d9-88ed-5944d1962f5e",
auth_host: "https://claude.ai",
device_path: "/oauth/authorize",
token_host: "https://platform.claude.com",
token_path: "/v1/oauth/token",
scope: "org:create_api_key user:profile user:inference user:sessions:claude_code user:mcp_servers user:file_upload",
scope_key: "oauth/claude-pro-max",
extra_device_field: None,
flow: OAuthFlow::PkceLocalhost {
redirect_port: 53692,
},
token_body: OAuthTokenBody::Json,
};
/// The generic device-code OAuth config for a platform, or `None` for API-key
@@ -1001,6 +1065,42 @@ const MINIMAX_CN_SPEC: PlatformSpec = PlatformSpec {
restrict_to_enriched: false,
};
/// Claude Pro/Max subscription via PKCE-localhost OAuth. Reaches
/// `api.anthropic.com` with an OAuth `sk-ant-oat…` bearer (NOT an API key) —
/// same Anthropic Messages + listing wire as the API-key `anthropic` row, plus
/// the OAuth identity headers + "You are Claude Code" system prefix (gated on
/// this platform's OAuth path in the sampler/fetch, so the API-key rows stay
/// byte-identical).
const CLAUDE_PRO_MAX_SPEC: PlatformSpec = PlatformSpec {
id: "claude-pro-max",
display_name: "Claude Pro/Max",
// WITH /v1 so listing → /v1/models and inference → /v1/messages, matching
// ANTHROPIC_SPEC's base handling.
base_url: BaseUrlSource::EnvOr {
env: CLAUDE_OAUTH_BASE_URL_ENV,
default: "https://api.anthropic.com/v1",
},
uses_oauth: true,
oauth: Some(&CLAUDE_OAUTH_CONFIG),
allowed_model_prefixes: None,
// OAuth channel: no API key envs (the PKCE session is the bearer).
api_key_envs: &[],
vendor: "Anthropic",
console_host: None,
login_label: Some("Claude Pro/Max (subscription)"),
models_dev_id: Some("anthropic"),
wire_serves_metadata: false,
wire_api: PlatformWireApi::Messages,
listing: ListingDialect::Anthropic,
// Passthrough is ignored for the Messages backend.
chat_compat: PlatformChatCompat::Passthrough,
// OAuth uses Authorization: Bearer, NOT x-api-key.
key_header: PlatformKeyHeader::Bearer,
restrict_to_enriched: false,
key_validation_path: None,
strip_listing_id_prefix: None,
};
/// The platform registry. Platforms are compiled-in spec rows; there is no
/// dynamic provider registration (PRD F2).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
@@ -1057,12 +1157,15 @@ pub enum PlatformId {
MinimaxCn,
/// xAI Grok subscription via device-code OAuth (same wire as `Xai`).
XaiGrok,
/// Claude Pro/Max subscription via PKCE-localhost OAuth (Anthropic Messages
/// wire reached with an OAuth bearer instead of an API key).
ClaudeProMax,
}
impl PlatformId {
/// All platforms, in catalog precedence order: the subscription channel
/// first so "default model = first list item" favors it when present.
pub const ALL: [PlatformId; 26] = [
pub const ALL: [PlatformId; 27] = [
Self::KimiCode,
Self::MoonshotCn,
Self::MoonshotAi,
@@ -1089,6 +1192,7 @@ impl PlatformId {
Self::Minimax,
Self::MinimaxCn,
Self::XaiGrok,
Self::ClaudeProMax,
];
/// The registry row backing this platform (single source of per-platform
@@ -1121,6 +1225,7 @@ impl PlatformId {
Self::Minimax => &MINIMAX_SPEC,
Self::MinimaxCn => &MINIMAX_CN_SPEC,
Self::XaiGrok => &XAI_GROK_SPEC,
Self::ClaudeProMax => &CLAUDE_PRO_MAX_SPEC,
}
}
@@ -1899,7 +2004,7 @@ mod tests {
let g = PlatformId::XaiGrok;
assert_eq!(g.as_str(), "xai-grok");
assert!(g.uses_oauth());
// The only two uses_oauth platforms; only xai-grok carries a config.
// Kimi Code is uses_oauth yet carries no generic config (bespoke flow).
assert!(PlatformId::KimiCode.uses_oauth());
assert_eq!(PlatformId::KimiCode.oauth(), None);
let cfg = g
@@ -1933,6 +2038,65 @@ mod tests {
assert_eq!(g.base_url(), "https://mock.grok/v1");
}
/// claude-pro-max is the first PKCE-localhost OAuth platform: it carries a
/// PKCE `OAuthConfig` (authorize host ≠ token host, JSON token body), reuses
/// the Anthropic Messages + listing wire with a Bearer key header (OAuth,
/// NOT x-api-key), enriches from models.dev "anthropic", and keys its models
/// under `claude-pro-max/`.
#[test]
fn claude_pro_max_is_a_pkce_oauth_platform() {
let c = PlatformId::ClaudeProMax;
assert_eq!(c.as_str(), "claude-pro-max");
assert!(c.uses_oauth());
let cfg = c
.oauth()
.expect("claude-pro-max carries a PKCE OAuthConfig");
assert_eq!(cfg, &CLAUDE_OAUTH_CONFIG);
assert_eq!(cfg.client_id, "9d1c250a-e61b-44d9-88ed-5944d1962f5e");
// Authorize host ≠ token host (the distinguishing PKCE trait).
assert_eq!(cfg.auth_host, "https://claude.ai");
assert_eq!(cfg.device_path, "/oauth/authorize");
assert_eq!(cfg.token_host, "https://platform.claude.com");
assert_eq!(cfg.token_path, "/v1/oauth/token");
assert_eq!(
cfg.scope,
"org:create_api_key user:profile user:inference \
user:sessions:claude_code user:mcp_servers user:file_upload"
);
assert_eq!(cfg.scope_key, "oauth/claude-pro-max");
assert_eq!(cfg.extra_device_field, None);
assert_eq!(
cfg.flow,
OAuthFlow::PkceLocalhost {
redirect_port: 53692
}
);
assert_eq!(cfg.token_body, OAuthTokenBody::Json);
// xai stays the device-code / form contract — unaffected.
assert_eq!(XAI_OAUTH_CONFIG.flow, OAuthFlow::DeviceCode);
assert_eq!(XAI_OAUTH_CONFIG.token_body, OAuthTokenBody::Form);
assert_eq!(XAI_OAUTH_CONFIG.token_host, "https://auth.x.ai");
// Scope-key lookup resolves the config (drives the generic refresher).
assert_eq!(
oauth_config_for_scope_key("oauth/claude-pro-max"),
Some(&CLAUDE_OAUTH_CONFIG)
);
// Anthropic Messages + listing wire, reached with a Bearer OAuth token.
assert_eq!(c.models_dev_id(), Some("anthropic"));
assert!(!c.restrict_to_enriched());
assert_eq!(c.key_header(), PlatformKeyHeader::Bearer);
assert_eq!(c.wire_api(), PlatformWireApi::Messages);
assert_eq!(c.listing(), ListingDialect::Anthropic);
assert_eq!(c.api_key_env_names(), &[] as &[&str]);
assert_eq!(
c.managed_model_key("claude-opus-4-8"),
"claude-pro-max/claude-opus-4-8"
);
let _guard =
kigi_env::EnvVarGuard::set(CLAUDE_OAUTH_BASE_URL_ENV, "https://mock.claude/v1");
assert_eq!(c.base_url(), "https://mock.claude/v1");
}
/// A variant missing from `ALL` compiles fine (`ALL`'s length is a plain
/// literal) but is silently unparseable and excluded from model sync.
/// The exhaustive match below fails compilation when a variant is added,
@@ -1967,9 +2131,10 @@ mod tests {
PlatformId::Minimax => 23,
PlatformId::MinimaxCn => 24,
PlatformId::XaiGrok => 25,
PlatformId::ClaudeProMax => 26,
}
}
const VARIANT_COUNT: usize = 26; // update together with `ordinal`
const VARIANT_COUNT: usize = 27; // update together with `ordinal`
let mut seen: Vec<usize> = PlatformId::ALL.iter().map(|&p| ordinal(p)).collect();
seen.sort_unstable();
seen.dedup();