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:
@@ -198,8 +198,8 @@ async fn complete_device_code_login(
|
||||
/// Open `url` in the browser off-thread: `webbrowser::open` is synchronous and
|
||||
/// would stall the single-threaded TUI loop. Returns `true` on success so the
|
||||
/// caller can decide how to notify the user (eprintln on CLI, nothing on TUI
|
||||
/// where the URL is already rendered in the widget).
|
||||
async fn open_browser_detached(url: &str) -> bool {
|
||||
/// where the URL is already rendered in the widget). Shared with the PKCE flow.
|
||||
pub(super) async fn open_browser_detached(url: &str) -> bool {
|
||||
// Unit tests drive the full login flow against mock servers — their
|
||||
// fixture URLs must never reach a real browser.
|
||||
if cfg!(test) {
|
||||
|
||||
@@ -67,10 +67,11 @@ pub async fn run_auth_flow(
|
||||
run_auth_flow_inner(auth_manager, kimi_code_config, reauth, false, channels).await
|
||||
}
|
||||
|
||||
/// Login flow for a GENERIC device-code OAuth provider (xai-grok): use a valid
|
||||
/// cached session unless re-authing, otherwise run the generic device flow
|
||||
/// Login flow for a GENERIC OAuth provider (xai-grok device-code,
|
||||
/// claude-pro-max PKCE-localhost): use a valid cached session unless re-authing,
|
||||
/// otherwise dispatch by `oauth.flow` to the device-code or PKCE-localhost login
|
||||
/// (persisting under the provider's own scope via `auth_manager`). Unlike the
|
||||
/// Kimi flow this does not run the silent-refresh dance — the device flow's
|
||||
/// Kimi flow this does not run the silent-refresh dance — the login's
|
||||
/// `AuthManager::update` persists a fresh token set directly.
|
||||
pub async fn run_oauth_provider_flow(
|
||||
auth_manager: &Arc<AuthManager>,
|
||||
@@ -94,8 +95,96 @@ pub async fn run_oauth_provider_flow(
|
||||
return Ok((auth, false));
|
||||
}
|
||||
let mut channels = channels;
|
||||
crate::auth::device_code::run_device_code_login_generic(oauth, auth_manager, &mut channels)
|
||||
match oauth.flow {
|
||||
kigi_models::OAuthFlow::DeviceCode => {
|
||||
crate::auth::device_code::run_device_code_login_generic(
|
||||
oauth,
|
||||
auth_manager,
|
||||
&mut channels,
|
||||
)
|
||||
.await
|
||||
}
|
||||
kigi_models::OAuthFlow::PkceLocalhost { redirect_port } => {
|
||||
run_pkce_localhost_login(oauth, redirect_port, auth_manager, &mut channels).await
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// PKCE-localhost login (claude-pro-max): generate PKCE, present the browser
|
||||
/// authorize URL (TUI channel or stderr), open the browser, then await the code
|
||||
/// from EITHER the `127.0.0.1:{redirect_port}` loopback callback OR a manual
|
||||
/// paste (headless fallback). Exchange it at the token endpoint and persist.
|
||||
///
|
||||
/// SECURITY: the verifier / code / tokens are never logged; the loopback binds
|
||||
/// `127.0.0.1` only and validates `state` strictly.
|
||||
async fn run_pkce_localhost_login(
|
||||
oauth: &'static kigi_models::OAuthConfig,
|
||||
redirect_port: u16,
|
||||
auth_manager: &Arc<AuthManager>,
|
||||
channels: &mut Option<AuthChannels>,
|
||||
) -> anyhow::Result<(KimiAuth, bool)> {
|
||||
use crate::auth::oauth_pkce;
|
||||
|
||||
let pkce = oauth_pkce::generate_pkce();
|
||||
let redirect = oauth_pkce::redirect_uri(redirect_port);
|
||||
let authorize_url = oauth_pkce::build_authorize_url(oauth, &redirect, &pkce);
|
||||
|
||||
let mut chans = channels.take();
|
||||
if let Some(tx) = chans.as_mut().and_then(|c| c.url_tx.take()) {
|
||||
// TUI: push the URL BEFORE opening the browser (never block the UI on a
|
||||
// slow/headless browser launch).
|
||||
let _ = tx.send(AuthUrlInfo {
|
||||
url: authorize_url.clone(),
|
||||
mode: AuthUrlMode::Device,
|
||||
});
|
||||
crate::auth::device_code::open_browser_detached(&authorize_url).await;
|
||||
} else {
|
||||
eprintln!();
|
||||
eprintln!("To sign in to Claude Pro/Max, open this URL in your browser:");
|
||||
eprintln!();
|
||||
eprintln!(" {authorize_url}");
|
||||
eprintln!();
|
||||
if !crate::auth::device_code::open_browser_detached(&authorize_url).await {
|
||||
eprintln!(" (Could not open the browser automatically — open the URL above.)");
|
||||
eprintln!();
|
||||
}
|
||||
eprintln!("Waiting for the sign-in to complete...");
|
||||
}
|
||||
|
||||
let code = await_pkce_code(redirect_port, &pkce, chans.as_mut()).await?;
|
||||
let auth = oauth_pkce::exchange_code(oauth, &code, &pkce, &redirect).await?;
|
||||
let auth = auth_manager
|
||||
.update(auth)
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("Failed to save credentials: {e}"))?;
|
||||
Ok((auth, true))
|
||||
}
|
||||
|
||||
/// Await the authorization code: the loopback callback is primary; when a TUI
|
||||
/// channel is present, a pasted code (redirect URL / `code#state` / bare code)
|
||||
/// is accepted concurrently as a headless fallback. State is validated in both
|
||||
/// arms (strict on the loopback, mismatch-rejecting on the paste).
|
||||
async fn await_pkce_code(
|
||||
redirect_port: u16,
|
||||
pkce: &crate::auth::oauth_pkce::PkceCodes,
|
||||
channels: Option<&mut AuthChannels>,
|
||||
) -> anyhow::Result<String> {
|
||||
use crate::auth::oauth_pkce;
|
||||
match channels {
|
||||
Some(ch) => {
|
||||
tokio::select! {
|
||||
code = oauth_pkce::await_loopback_code(redirect_port, &pkce.state) => code,
|
||||
pasted = ch.code_rx.recv() => {
|
||||
let pasted = pasted
|
||||
.ok_or_else(|| anyhow::anyhow!("auth code channel closed before a code arrived"))?;
|
||||
let params = oauth_pkce::parse_manual_paste(&pasted)?;
|
||||
oauth_pkce::validate_pasted_state(¶ms, &pkce.state)?;
|
||||
Ok(params.code)
|
||||
}
|
||||
}
|
||||
}
|
||||
None => oauth_pkce::await_loopback_code(redirect_port, &pkce.state).await,
|
||||
}
|
||||
}
|
||||
|
||||
async fn run_auth_flow_inner(
|
||||
|
||||
@@ -9,6 +9,7 @@ pub(crate) mod kimi_oauth;
|
||||
pub(crate) mod manager;
|
||||
mod model;
|
||||
pub(crate) mod oauth_device;
|
||||
pub(crate) mod oauth_pkce;
|
||||
pub(crate) mod oauth_registry;
|
||||
pub(crate) mod recovery;
|
||||
pub(crate) mod refresh;
|
||||
|
||||
@@ -0,0 +1,687 @@
|
||||
//! Generic authorization-code + PKCE (S256) OAuth wire with a `127.0.0.1`
|
||||
//! loopback callback, driven by a registry [`kigi_models::OAuthConfig`] whose
|
||||
//! `flow` is [`OAuthFlow::PkceLocalhost`] (claude-pro-max today).
|
||||
//!
|
||||
//! Shape (Pi `earendil-works/pi` `auth/oauth/anthropic.ts`):
|
||||
//! - `verifier = base64url(32 random bytes)`; `challenge = base64url(SHA-256(
|
||||
//! verifier))`; `state = verifier`.
|
||||
//! - Browser opens `{auth_host}{device_path}?client_id&response_type=code&
|
||||
//! scope&redirect_uri&state&code_challenge&code_challenge_method=S256`.
|
||||
//! - The code returns to a loopback listener on `127.0.0.1:{redirect_port}`
|
||||
//! answering ONLY `/callback`, with STRICT `state` validation (a mismatch is
|
||||
//! rejected — CSRF guard). A manual paste (redirect URL / `code#state` / bare
|
||||
//! code) is accepted as a headless fallback.
|
||||
//! - Code → token exchange and refresh POST `{token_host}{token_path}` as JSON
|
||||
//! (per `token_body`); the refresh token ROTATES.
|
||||
//!
|
||||
//! SECURITY: the verifier, authorization code, access token, and refresh token
|
||||
//! are NEVER logged (only non-secret events: authorize URL requested, callback
|
||||
//! received, token issued, token refreshed).
|
||||
|
||||
use anyhow::Context;
|
||||
use base64::Engine;
|
||||
use kigi_models::{OAuthConfig, OAuthTokenBody};
|
||||
use serde::Deserialize;
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
use super::kimi_oauth::{RefreshError, TokenResponse};
|
||||
use super::model::KimiAuth;
|
||||
|
||||
const CODE_GRANT_TYPE: &str = "authorization_code";
|
||||
const REFRESH_GRANT_TYPE: &str = "refresh_token";
|
||||
/// Refresh retry budget over the retryable statuses / network blips.
|
||||
const MAX_REFRESH_RETRIES: u32 = 3;
|
||||
/// HTTP statuses worth retrying a refresh for (parity with the device wire).
|
||||
const RETRYABLE_REFRESH_STATUSES: [u16; 5] = [429, 500, 502, 503, 504];
|
||||
|
||||
/// PKCE secrets for one login attempt. `state == verifier` (Pi's convention:
|
||||
/// the state is the verifier, so a returned state binds the callback to this
|
||||
/// attempt AND doubles as the CSRF token).
|
||||
#[derive(Debug, Clone)]
|
||||
pub(crate) struct PkceCodes {
|
||||
/// `code_verifier` — the 43-char base64url secret, sent at token exchange.
|
||||
pub verifier: String,
|
||||
/// `code_challenge = base64url(SHA-256(verifier))`, sent at authorize.
|
||||
pub challenge: String,
|
||||
/// `state` — equal to `verifier`; validated on the callback (CSRF guard).
|
||||
pub state: String,
|
||||
}
|
||||
|
||||
/// Generate PKCE S256 codes: `verifier = base64url(32 random bytes)`,
|
||||
/// `challenge = base64url(SHA-256(verifier))`, `state = verifier`.
|
||||
pub(crate) fn generate_pkce() -> PkceCodes {
|
||||
use rand::RngCore;
|
||||
let mut raw = [0u8; 32];
|
||||
rand::rng().fill_bytes(&mut raw);
|
||||
let verifier = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(raw);
|
||||
let digest = Sha256::digest(verifier.as_bytes());
|
||||
let challenge = base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(digest);
|
||||
PkceCodes {
|
||||
state: verifier.clone(),
|
||||
verifier,
|
||||
challenge,
|
||||
}
|
||||
}
|
||||
|
||||
/// The loopback redirect URI for a PKCE-localhost provider.
|
||||
pub(crate) fn redirect_uri(redirect_port: u16) -> String {
|
||||
format!("http://localhost:{redirect_port}/callback")
|
||||
}
|
||||
|
||||
/// Build the browser authorize URL:
|
||||
/// `{auth_host}{device_path}?client_id&response_type=code&scope&redirect_uri&
|
||||
/// state&code_challenge&code_challenge_method=S256`.
|
||||
pub(crate) fn build_authorize_url(
|
||||
cfg: &OAuthConfig,
|
||||
redirect_uri: &str,
|
||||
pkce: &PkceCodes,
|
||||
) -> String {
|
||||
let base = format!("{}{}", cfg.auth_host.trim_end_matches('/'), cfg.device_path);
|
||||
let query = url::form_urlencoded::Serializer::new(String::new())
|
||||
.append_pair("client_id", cfg.client_id)
|
||||
.append_pair("response_type", "code")
|
||||
.append_pair("scope", cfg.scope)
|
||||
.append_pair("redirect_uri", redirect_uri)
|
||||
.append_pair("state", &pkce.state)
|
||||
.append_pair("code_challenge", &pkce.challenge)
|
||||
.append_pair("code_challenge_method", "S256")
|
||||
.finish();
|
||||
format!("{base}?{query}")
|
||||
}
|
||||
|
||||
/// `code` + `state` extracted from a callback (loopback query OR manual paste).
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub(crate) struct CallbackParams {
|
||||
pub code: String,
|
||||
pub state: Option<String>,
|
||||
}
|
||||
|
||||
/// Parse `code`/`state` from the raw query string of a `/callback?…` request
|
||||
/// (e.g. `code=abc&state=xyz`). An `error=` param surfaces as an `Err`.
|
||||
pub(crate) fn parse_callback_query(query: &str) -> anyhow::Result<CallbackParams> {
|
||||
let mut code = None;
|
||||
let mut state = None;
|
||||
let mut error = None;
|
||||
for (k, v) in url::form_urlencoded::parse(query.as_bytes()) {
|
||||
match k.as_ref() {
|
||||
"code" => code = Some(v.into_owned()),
|
||||
"state" => state = Some(v.into_owned()),
|
||||
"error" => error = Some(v.into_owned()),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
if let Some(error) = error {
|
||||
anyhow::bail!("Authorization server returned an error: {error}");
|
||||
}
|
||||
let code = code.context("callback missing authorization code")?;
|
||||
if code.is_empty() {
|
||||
anyhow::bail!("callback authorization code was empty");
|
||||
}
|
||||
Ok(CallbackParams { code, state })
|
||||
}
|
||||
|
||||
/// Parse a MANUAL paste (headless fallback). Accepts, in order:
|
||||
/// - a full redirect URL (`http://localhost:…/callback?code=…&state=…`),
|
||||
/// - a `code#state` pair (Anthropic's console shows this form),
|
||||
/// - a bare `code` (state then unknown → `None`, caller validation applies).
|
||||
pub(crate) fn parse_manual_paste(input: &str) -> anyhow::Result<CallbackParams> {
|
||||
let trimmed = input.trim();
|
||||
if trimmed.is_empty() {
|
||||
anyhow::bail!("empty paste");
|
||||
}
|
||||
// Full redirect URL.
|
||||
if trimmed.starts_with("http://") || trimmed.starts_with("https://") {
|
||||
let url = url::Url::parse(trimmed).context("pasted value is not a valid URL")?;
|
||||
return parse_callback_query(url.query().unwrap_or_default());
|
||||
}
|
||||
// `code#state`.
|
||||
if let Some((code, state)) = trimmed.split_once('#') {
|
||||
if code.is_empty() {
|
||||
anyhow::bail!("pasted code was empty");
|
||||
}
|
||||
return Ok(CallbackParams {
|
||||
code: code.to_owned(),
|
||||
state: (!state.is_empty()).then(|| state.to_owned()),
|
||||
});
|
||||
}
|
||||
// Bare code.
|
||||
Ok(CallbackParams {
|
||||
code: trimmed.to_owned(),
|
||||
state: None,
|
||||
})
|
||||
}
|
||||
|
||||
/// STRICT state validation (CSRF guard): the callback `state` MUST be present
|
||||
/// AND equal to the expected value. A mismatch (or absence, when a paste has no
|
||||
/// state) is rejected — the flow NEVER proceeds on an unverified callback.
|
||||
pub(crate) fn validate_state(params: &CallbackParams, expected_state: &str) -> anyhow::Result<()> {
|
||||
match params.state.as_deref() {
|
||||
Some(state) if state == expected_state => Ok(()),
|
||||
Some(_) => anyhow::bail!("OAuth state mismatch — rejecting callback (CSRF guard)"),
|
||||
None => anyhow::bail!("OAuth callback carried no state — rejecting (CSRF guard)"),
|
||||
}
|
||||
}
|
||||
|
||||
/// State validation for a MANUAL paste (headless fallback): a present state
|
||||
/// MUST match (mismatch rejected — CSRF guard), but an ABSENT state is allowed
|
||||
/// — a bare-code paste is user-initiated (not a network-reachable callback), so
|
||||
/// there is no state to check. The loopback path uses the stricter
|
||||
/// [`validate_state`] (an absent state there IS rejected).
|
||||
pub(crate) fn validate_pasted_state(
|
||||
params: &CallbackParams,
|
||||
expected_state: &str,
|
||||
) -> anyhow::Result<()> {
|
||||
match params.state.as_deref() {
|
||||
Some(state) if state == expected_state => Ok(()),
|
||||
Some(_) => anyhow::bail!("OAuth state mismatch — rejecting pasted code (CSRF guard)"),
|
||||
None => Ok(()),
|
||||
}
|
||||
}
|
||||
|
||||
/// The token-endpoint URL (`{token_host}{token_path}`).
|
||||
fn token_url(cfg: &OAuthConfig) -> String {
|
||||
format!("{}{}", cfg.token_host.trim_end_matches('/'), cfg.token_path)
|
||||
}
|
||||
|
||||
/// POST the token endpoint with a JSON body, honoring `cfg.token_body`. Claude
|
||||
/// is JSON; a `Form`-bodied config would be handled by the device wire, so the
|
||||
/// PKCE path asserts JSON (never silently mis-encodes).
|
||||
async fn post_token_json(
|
||||
cfg: &OAuthConfig,
|
||||
body: serde_json::Value,
|
||||
) -> reqwest::Result<reqwest::Response> {
|
||||
debug_assert!(
|
||||
matches!(cfg.token_body, OAuthTokenBody::Json),
|
||||
"PKCE token exchange expects a JSON token body"
|
||||
);
|
||||
crate::http::shared_client()
|
||||
.post(token_url(cfg))
|
||||
.header("Accept", "application/json")
|
||||
.json(&body)
|
||||
.send()
|
||||
.await
|
||||
}
|
||||
|
||||
/// Exchange an authorization `code` for a token set (JSON body):
|
||||
/// `{grant_type:"authorization_code", code, state, client_id, redirect_uri,
|
||||
/// code_verifier}`. Returns the materialized [`KimiAuth`].
|
||||
pub(crate) async fn exchange_code(
|
||||
cfg: &OAuthConfig,
|
||||
code: &str,
|
||||
pkce: &PkceCodes,
|
||||
redirect_uri: &str,
|
||||
) -> anyhow::Result<KimiAuth> {
|
||||
let body = serde_json::json!({
|
||||
"grant_type": CODE_GRANT_TYPE,
|
||||
"code": code,
|
||||
"state": pkce.state,
|
||||
"client_id": cfg.client_id,
|
||||
"redirect_uri": redirect_uri,
|
||||
"code_verifier": pkce.verifier,
|
||||
});
|
||||
tracing::info!(
|
||||
scope_key = cfg.scope_key,
|
||||
"auth: exchanging code for token (pkce)"
|
||||
);
|
||||
let resp = post_token_json(cfg, body)
|
||||
.await
|
||||
.context("token exchange request failed")?;
|
||||
let status = resp.status();
|
||||
if !status.is_success() {
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
tracing::warn!(%status, scope_key = cfg.scope_key, "auth: code exchange failed (pkce)");
|
||||
anyhow::bail!("Token exchange failed (HTTP {status}): {body}");
|
||||
}
|
||||
let tokens: TokenResponse = resp.json().await.context("malformed token payload")?;
|
||||
tracing::info!(
|
||||
scope_key = cfg.scope_key,
|
||||
"auth: pkce code exchange succeeded"
|
||||
);
|
||||
Ok(tokens.into_auth())
|
||||
}
|
||||
|
||||
/// `POST {token_host}{token_path}` with `grant_type=refresh_token` (JSON body).
|
||||
/// Claude ROTATES the refresh token, so the caller MUST persist the returned
|
||||
/// one. Retries the retryable statuses / network errors with exponential
|
||||
/// backoff; 401/403 returns immediately as [`RefreshError::Unauthorized`].
|
||||
pub(crate) async fn refresh_token(
|
||||
cfg: &OAuthConfig,
|
||||
refresh_token: &str,
|
||||
) -> Result<KimiAuth, RefreshError> {
|
||||
let mut last_error = String::from("no attempt made");
|
||||
for attempt in 0..MAX_REFRESH_RETRIES {
|
||||
if attempt > 0 {
|
||||
let backoff = std::time::Duration::from_secs(1 << (attempt - 1));
|
||||
tracing::warn!(
|
||||
attempt,
|
||||
backoff_secs = backoff.as_secs(),
|
||||
last_error = %last_error,
|
||||
"auth: retrying token refresh (pkce)"
|
||||
);
|
||||
tokio::time::sleep(backoff).await;
|
||||
}
|
||||
tracing::info!(
|
||||
attempt,
|
||||
scope_key = cfg.scope_key,
|
||||
"auth: token refresh attempt (pkce)"
|
||||
);
|
||||
let body = serde_json::json!({
|
||||
"grant_type": REFRESH_GRANT_TYPE,
|
||||
"client_id": cfg.client_id,
|
||||
"refresh_token": refresh_token,
|
||||
});
|
||||
let resp = match post_token_json(cfg, body).await {
|
||||
Ok(resp) => resp,
|
||||
Err(e) => {
|
||||
last_error = format!("network error: {e}");
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let status = resp.status().as_u16();
|
||||
let bytes = resp.bytes().await.unwrap_or_default();
|
||||
if status == 401 || status == 403 {
|
||||
let err: OAuthErrorBody = serde_json::from_slice(&bytes).unwrap_or_default();
|
||||
return Err(RefreshError::Unauthorized {
|
||||
status,
|
||||
description: err
|
||||
.error_description
|
||||
.unwrap_or_else(|| "Token refresh unauthorized.".to_owned()),
|
||||
});
|
||||
}
|
||||
if status == 200 {
|
||||
return match serde_json::from_slice::<TokenResponse>(&bytes) {
|
||||
Ok(tokens) => Ok(tokens.into_auth()),
|
||||
Err(e) => Err(RefreshError::Fatal {
|
||||
status,
|
||||
description: format!("malformed token payload: {e}"),
|
||||
}),
|
||||
};
|
||||
}
|
||||
let err: OAuthErrorBody = serde_json::from_slice(&bytes).unwrap_or_default();
|
||||
let description = err
|
||||
.error_description
|
||||
.unwrap_or_else(|| format!("Token refresh failed (HTTP {status})."));
|
||||
if RETRYABLE_REFRESH_STATUSES.contains(&status) {
|
||||
last_error = description;
|
||||
continue;
|
||||
}
|
||||
return Err(RefreshError::Fatal {
|
||||
status,
|
||||
description,
|
||||
});
|
||||
}
|
||||
Err(RefreshError::Exhausted { last_error })
|
||||
}
|
||||
|
||||
#[derive(Deserialize, Default)]
|
||||
struct OAuthErrorBody {
|
||||
#[serde(default)]
|
||||
error_description: Option<String>,
|
||||
}
|
||||
|
||||
/// Bind a loopback HTTP listener on `127.0.0.1:{redirect_port}` and wait for a
|
||||
/// single `GET /callback?code=…&state=…`, validating `state` STRICTLY against
|
||||
/// `expected_state` (mismatch → rejected). Returns the authorization code.
|
||||
///
|
||||
/// The listener answers ONLY `/callback`; any other path gets 404. It binds
|
||||
/// `127.0.0.1` (never `0.0.0.0`), so no non-loopback host can reach it.
|
||||
pub(crate) async fn await_loopback_code(
|
||||
redirect_port: u16,
|
||||
expected_state: &str,
|
||||
) -> anyhow::Result<String> {
|
||||
let listener = tokio::net::TcpListener::bind(("127.0.0.1", redirect_port))
|
||||
.await
|
||||
.with_context(|| format!("could not bind loopback 127.0.0.1:{redirect_port}"))?;
|
||||
tracing::info!(port = redirect_port, "auth: pkce loopback listener bound");
|
||||
loop {
|
||||
let (stream, _peer) = listener.accept().await.context("loopback accept failed")?;
|
||||
match handle_loopback_conn(stream, expected_state).await {
|
||||
LoopbackOutcome::Code(code) => return Ok(code),
|
||||
LoopbackOutcome::Rejected(err) => return Err(err),
|
||||
// Not the /callback GET (favicon, health probe): keep listening.
|
||||
LoopbackOutcome::Ignore => continue,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum LoopbackOutcome {
|
||||
Code(String),
|
||||
Rejected(anyhow::Error),
|
||||
Ignore,
|
||||
}
|
||||
|
||||
/// Read the request line of one loopback connection, answer with a small HTML
|
||||
/// page, and classify the outcome. STRICT: a `/callback` with a bad/missing
|
||||
/// state is [`LoopbackOutcome::Rejected`] (the browser sees an error page).
|
||||
async fn handle_loopback_conn(
|
||||
mut stream: tokio::net::TcpStream,
|
||||
expected_state: &str,
|
||||
) -> LoopbackOutcome {
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
|
||||
// Read only enough for the request line — a GET has no body.
|
||||
let mut buf = [0u8; 8192];
|
||||
let n = match stream.read(&mut buf).await {
|
||||
Ok(0) => return LoopbackOutcome::Ignore,
|
||||
Ok(n) => n,
|
||||
Err(_) => return LoopbackOutcome::Ignore,
|
||||
};
|
||||
let head = String::from_utf8_lossy(&buf[..n]);
|
||||
let Some(request_line) = head.lines().next() else {
|
||||
return LoopbackOutcome::Ignore;
|
||||
};
|
||||
// `GET /callback?code=…&state=… HTTP/1.1`
|
||||
let mut parts = request_line.split_whitespace();
|
||||
let (Some(method), Some(target)) = (parts.next(), parts.next()) else {
|
||||
return LoopbackOutcome::Ignore;
|
||||
};
|
||||
if method != "GET" {
|
||||
let _ = write_http(&mut stream, 405, "Method Not Allowed").await;
|
||||
return LoopbackOutcome::Ignore;
|
||||
}
|
||||
let (path, query) = target.split_once('?').unwrap_or((target, ""));
|
||||
if path != "/callback" {
|
||||
let _ = write_http(&mut stream, 404, "Not Found").await;
|
||||
return LoopbackOutcome::Ignore;
|
||||
}
|
||||
|
||||
let result = parse_callback_query(query)
|
||||
.and_then(|params| validate_state(¶ms, expected_state).map(|()| params.code));
|
||||
match result {
|
||||
Ok(code) => {
|
||||
let _ = write_http(
|
||||
&mut stream,
|
||||
200,
|
||||
"Signed in to Claude Pro/Max. You can close this window and return to kigi.",
|
||||
)
|
||||
.await;
|
||||
let _ = stream.flush().await;
|
||||
LoopbackOutcome::Code(code)
|
||||
}
|
||||
Err(e) => {
|
||||
let _ = write_http(&mut stream, 400, "Login failed — return to kigi and retry.").await;
|
||||
let _ = stream.flush().await;
|
||||
LoopbackOutcome::Rejected(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Write a minimal HTTP/1.1 response with an HTML body.
|
||||
async fn write_http(
|
||||
stream: &mut tokio::net::TcpStream,
|
||||
status: u16,
|
||||
message: &str,
|
||||
) -> std::io::Result<()> {
|
||||
use tokio::io::AsyncWriteExt;
|
||||
let reason = match status {
|
||||
200 => "OK",
|
||||
400 => "Bad Request",
|
||||
404 => "Not Found",
|
||||
405 => "Method Not Allowed",
|
||||
_ => "Error",
|
||||
};
|
||||
let body = format!("<!doctype html><meta charset=utf-8><p>{message}</p>");
|
||||
let response = format!(
|
||||
"HTTP/1.1 {status} {reason}\r\nContent-Type: text/html; charset=utf-8\r\n\
|
||||
Content-Length: {}\r\nConnection: close\r\n\r\n{body}",
|
||||
body.len()
|
||||
);
|
||||
stream.write_all(response.as_bytes()).await
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use kigi_models::CLAUDE_OAUTH_CONFIG;
|
||||
use wiremock::matchers::{body_string_contains, method, path};
|
||||
use wiremock::{Mock, MockServer, ResponseTemplate};
|
||||
|
||||
/// A config pointed at a mock token host (copies Claude's client_id/scope/
|
||||
/// paths but overrides the token host).
|
||||
fn mock_cfg(token_host: &'static str) -> OAuthConfig {
|
||||
OAuthConfig {
|
||||
token_host,
|
||||
..CLAUDE_OAUTH_CONFIG
|
||||
}
|
||||
}
|
||||
|
||||
/// PKCE codes: verifier/challenge are non-empty base64url (no padding), the
|
||||
/// challenge is the base64url SHA-256 of the verifier, and state == verifier.
|
||||
#[test]
|
||||
fn generate_pkce_produces_valid_s256_codes() {
|
||||
let pkce = generate_pkce();
|
||||
assert_eq!(pkce.state, pkce.verifier, "state must equal the verifier");
|
||||
assert!(!pkce.verifier.is_empty() && !pkce.challenge.is_empty());
|
||||
for s in [&pkce.verifier, &pkce.challenge] {
|
||||
assert!(
|
||||
s.chars()
|
||||
.all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_'),
|
||||
"base64url (no pad) only: {s}"
|
||||
);
|
||||
assert!(!s.contains('='), "no padding: {s}");
|
||||
}
|
||||
// challenge == base64url(SHA-256(verifier)).
|
||||
let expect = base64::engine::general_purpose::URL_SAFE_NO_PAD
|
||||
.encode(Sha256::digest(pkce.verifier.as_bytes()));
|
||||
assert_eq!(pkce.challenge, expect);
|
||||
// Fresh entropy each call.
|
||||
assert_ne!(pkce.verifier, generate_pkce().verifier);
|
||||
}
|
||||
|
||||
/// The authorize URL carries the fixed params + the PKCE state and S256
|
||||
/// challenge, and targets `claude.ai/oauth/authorize`.
|
||||
#[test]
|
||||
fn authorize_url_has_state_and_s256_challenge() {
|
||||
let pkce = generate_pkce();
|
||||
let redirect = redirect_uri(53692);
|
||||
let url = build_authorize_url(&CLAUDE_OAUTH_CONFIG, &redirect, &pkce);
|
||||
let parsed = url::Url::parse(&url).expect("valid URL");
|
||||
assert_eq!(parsed.host_str(), Some("claude.ai"));
|
||||
assert_eq!(parsed.path(), "/oauth/authorize");
|
||||
let q: std::collections::HashMap<_, _> = parsed.query_pairs().into_owned().collect();
|
||||
assert_eq!(q.get("response_type").map(String::as_str), Some("code"));
|
||||
assert_eq!(
|
||||
q.get("code_challenge_method").map(String::as_str),
|
||||
Some("S256")
|
||||
);
|
||||
assert_eq!(
|
||||
q.get("state").map(String::as_str),
|
||||
Some(pkce.state.as_str())
|
||||
);
|
||||
assert_eq!(
|
||||
q.get("code_challenge").map(String::as_str),
|
||||
Some(pkce.challenge.as_str())
|
||||
);
|
||||
assert_eq!(
|
||||
q.get("client_id").map(String::as_str),
|
||||
Some(CLAUDE_OAUTH_CONFIG.client_id)
|
||||
);
|
||||
assert_eq!(
|
||||
q.get("redirect_uri").map(String::as_str),
|
||||
Some(redirect.as_str())
|
||||
);
|
||||
// The verifier itself must NEVER appear in the browser URL.
|
||||
assert!(
|
||||
!url.contains("code_verifier"),
|
||||
"the verifier must not ride the authorize URL"
|
||||
);
|
||||
}
|
||||
|
||||
/// STRICT state validation: an exact match passes; a mismatch or an absent
|
||||
/// state is REJECTED (CSRF guard — the flow must never proceed).
|
||||
#[test]
|
||||
fn state_validation_is_strict() {
|
||||
let ok = CallbackParams {
|
||||
code: "c".into(),
|
||||
state: Some("expected".into()),
|
||||
};
|
||||
assert!(validate_state(&ok, "expected").is_ok());
|
||||
let mismatch = CallbackParams {
|
||||
code: "c".into(),
|
||||
state: Some("attacker".into()),
|
||||
};
|
||||
assert!(
|
||||
validate_state(&mismatch, "expected").is_err(),
|
||||
"a state mismatch MUST be rejected"
|
||||
);
|
||||
let missing = CallbackParams {
|
||||
code: "c".into(),
|
||||
state: None,
|
||||
};
|
||||
assert!(
|
||||
validate_state(&missing, "expected").is_err(),
|
||||
"an absent state MUST be rejected"
|
||||
);
|
||||
}
|
||||
|
||||
/// A loopback `/callback` with the WRONG state is rejected end-to-end (the
|
||||
/// listener returns an error, never a code) — the CSRF guard on the wire.
|
||||
#[tokio::test]
|
||||
async fn loopback_rejects_state_mismatch() {
|
||||
// Ephemeral port: bind, learn the port, then drive a client at it.
|
||||
let probe = tokio::net::TcpListener::bind(("127.0.0.1", 0))
|
||||
.await
|
||||
.unwrap();
|
||||
let port = probe.local_addr().unwrap().port();
|
||||
drop(probe);
|
||||
|
||||
let server = tokio::spawn(async move { await_loopback_code(port, "the-real-state").await });
|
||||
// Give the listener a moment to bind.
|
||||
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
|
||||
// Attacker callback: valid code, WRONG state.
|
||||
let _ = reqwest::get(format!(
|
||||
"http://127.0.0.1:{port}/callback?code=stolen&state=wrong-state"
|
||||
))
|
||||
.await;
|
||||
let outcome = server.await.unwrap();
|
||||
let err = outcome.expect_err("a state mismatch must be rejected, never yield a code");
|
||||
assert!(err.to_string().contains("state mismatch"), "{err}");
|
||||
}
|
||||
|
||||
/// A loopback `/callback` with the MATCHING state yields the code.
|
||||
#[tokio::test]
|
||||
async fn loopback_returns_code_on_valid_state() {
|
||||
let probe = tokio::net::TcpListener::bind(("127.0.0.1", 0))
|
||||
.await
|
||||
.unwrap();
|
||||
let port = probe.local_addr().unwrap().port();
|
||||
drop(probe);
|
||||
|
||||
let server = tokio::spawn(async move { await_loopback_code(port, "good-state").await });
|
||||
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
|
||||
let _ = reqwest::get(format!(
|
||||
"http://127.0.0.1:{port}/callback?code=auth-code-123&state=good-state"
|
||||
))
|
||||
.await;
|
||||
let code = server.await.unwrap().expect("valid state yields the code");
|
||||
assert_eq!(code, "auth-code-123");
|
||||
}
|
||||
|
||||
/// Manual-paste parsing: full redirect URL, `code#state`, and bare code.
|
||||
#[test]
|
||||
fn manual_paste_parses_all_three_forms() {
|
||||
let from_url =
|
||||
parse_manual_paste("http://localhost:53692/callback?code=abc123&state=st-9").unwrap();
|
||||
assert_eq!(from_url.code, "abc123");
|
||||
assert_eq!(from_url.state.as_deref(), Some("st-9"));
|
||||
|
||||
let from_hash = parse_manual_paste("abc123#st-9").unwrap();
|
||||
assert_eq!(from_hash.code, "abc123");
|
||||
assert_eq!(from_hash.state.as_deref(), Some("st-9"));
|
||||
|
||||
let bare = parse_manual_paste(" abc123 ").unwrap();
|
||||
assert_eq!(bare.code, "abc123");
|
||||
assert_eq!(bare.state, None);
|
||||
|
||||
assert!(parse_manual_paste("").is_err());
|
||||
// A pasted redirect that carries an error param surfaces the error.
|
||||
assert!(parse_manual_paste("http://localhost/callback?error=access_denied").is_err());
|
||||
}
|
||||
|
||||
/// Code → token exchange: JSON body carries the grant + verifier, response
|
||||
/// materializes a `KimiAuth` with the rotating refresh token.
|
||||
#[tokio::test]
|
||||
async fn exchange_code_posts_json_and_returns_auth() {
|
||||
let server = MockServer::start().await;
|
||||
let host: &'static str = Box::leak(server.uri().into_boxed_str());
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/v1/oauth/token"))
|
||||
.and(body_string_contains(
|
||||
"\"grant_type\":\"authorization_code\"",
|
||||
))
|
||||
.and(body_string_contains("\"code\":\"auth-code-xyz\""))
|
||||
.and(body_string_contains("\"code_verifier\""))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
|
||||
"access_token": "sk-ant-oat-new",
|
||||
"refresh_token": "sk-ant-ort-new",
|
||||
"expires_in": 3600,
|
||||
"token_type": "bearer",
|
||||
})))
|
||||
.expect(1)
|
||||
.mount(&server)
|
||||
.await;
|
||||
let cfg = mock_cfg(host);
|
||||
let pkce = generate_pkce();
|
||||
let auth = exchange_code(&cfg, "auth-code-xyz", &pkce, &redirect_uri(53692))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(auth.key, "sk-ant-oat-new");
|
||||
assert_eq!(auth.refresh_token.as_deref(), Some("sk-ant-ort-new"));
|
||||
assert_eq!(auth.expires_in, Some(3600));
|
||||
}
|
||||
|
||||
/// Refresh rotates the refresh token (JSON body, refresh grant).
|
||||
#[tokio::test]
|
||||
async fn refresh_rotates_refresh_token() {
|
||||
let server = MockServer::start().await;
|
||||
let host: &'static str = Box::leak(server.uri().into_boxed_str());
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/v1/oauth/token"))
|
||||
.and(body_string_contains("\"grant_type\":\"refresh_token\""))
|
||||
.and(body_string_contains("\"refresh_token\":\"ort-old\""))
|
||||
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
|
||||
"access_token": "oat-fresh",
|
||||
"refresh_token": "ort-rotated",
|
||||
"expires_in": 3600,
|
||||
})))
|
||||
.expect(1)
|
||||
.mount(&server)
|
||||
.await;
|
||||
let auth = refresh_token(&mock_cfg(host), "ort-old").await.unwrap();
|
||||
assert_eq!(auth.key, "oat-fresh");
|
||||
assert_eq!(
|
||||
auth.refresh_token.as_deref(),
|
||||
Some("ort-rotated"),
|
||||
"the rotated refresh token must be adopted"
|
||||
);
|
||||
}
|
||||
|
||||
/// A 401 on refresh maps to Unauthorized (drives the permanent-failure path).
|
||||
#[tokio::test]
|
||||
async fn refresh_401_maps_to_unauthorized() {
|
||||
let server = MockServer::start().await;
|
||||
let host: &'static str = Box::leak(server.uri().into_boxed_str());
|
||||
Mock::given(method("POST"))
|
||||
.and(path("/v1/oauth/token"))
|
||||
.respond_with(
|
||||
ResponseTemplate::new(401)
|
||||
.set_body_json(serde_json::json!({ "error_description": "refresh revoked" })),
|
||||
)
|
||||
.expect(1)
|
||||
.mount(&server)
|
||||
.await;
|
||||
let err = refresh_token(&mock_cfg(host), "ort-dead")
|
||||
.await
|
||||
.unwrap_err();
|
||||
match err {
|
||||
RefreshError::Unauthorized {
|
||||
status,
|
||||
description,
|
||||
} => {
|
||||
assert_eq!(status, 401);
|
||||
assert_eq!(description, "refresh revoked");
|
||||
}
|
||||
other => panic!("expected Unauthorized, got {other:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -141,6 +141,52 @@ mod tests {
|
||||
.expect("xai-grok carries an OAuthConfig")
|
||||
}
|
||||
|
||||
fn claude_oauth() -> &'static kigi_models::OAuthConfig {
|
||||
kigi_models::PlatformId::ClaudeProMax
|
||||
.oauth()
|
||||
.expect("claude-pro-max carries an OAuthConfig")
|
||||
}
|
||||
|
||||
/// A `claude-pro-max/<model>` turn resolves to the process-global pooled
|
||||
/// claude-pro-max manager (its OWN `oauth/claude-pro-max` scope), NEVER the
|
||||
/// primary Kimi manager — the same leak-safe routing as xai-grok, and a
|
||||
/// DISTINCT pool entry from the xai manager.
|
||||
#[tokio::test]
|
||||
async fn claude_pro_max_model_resolves_to_its_own_manager_not_kimi() {
|
||||
let (_kd, kimi) = primary_with_token("kimi-tok");
|
||||
let home = tempfile::tempdir().unwrap();
|
||||
let resolved =
|
||||
manager_for_model(home.path(), "claude-pro-max/claude-opus-4-8", Some(&kimi))
|
||||
.expect("claude-pro-max model resolves to its pooled manager");
|
||||
assert!(
|
||||
!Arc::ptr_eq(&resolved, &kimi),
|
||||
"claude-pro-max must NOT resolve to the Kimi manager"
|
||||
);
|
||||
assert!(
|
||||
Arc::ptr_eq(&resolved, &global_manager_for(home.path(), claude_oauth())),
|
||||
"claude-pro-max must resolve to its OWN process-global pooled manager"
|
||||
);
|
||||
// And it is a DIFFERENT manager than xai-grok's pooled one.
|
||||
assert!(
|
||||
!Arc::ptr_eq(&resolved, &global_manager_for(home.path(), xai_oauth())),
|
||||
"claude-pro-max and xai-grok must not share a pooled manager"
|
||||
);
|
||||
}
|
||||
|
||||
/// Fail-fast (no Kimi fallback): a claude-pro-max key with a Kimi primary
|
||||
/// never yields the Kimi session token — it draws from the claude pool (its
|
||||
/// own token, or `None`), so the Kimi bearer can never reach api.anthropic.
|
||||
#[tokio::test]
|
||||
async fn session_key_for_claude_pro_max_is_never_the_kimi_primary() {
|
||||
let (_kd, kimi) = primary_with_token("kimi-tok");
|
||||
let home = tempfile::tempdir().unwrap();
|
||||
assert_ne!(
|
||||
session_key_for_model(home.path(), "claude-pro-max/claude-opus-4-8", Some(&kimi)),
|
||||
Some("kimi-tok".to_string()),
|
||||
"a claude-pro-max model must never receive the primary Kimi session token"
|
||||
);
|
||||
}
|
||||
|
||||
/// A non-OAuth managed key (moonshot-cn/…) and an unprefixed bare id both
|
||||
/// route to the primary Kimi manager — the Kimi / first-party path is
|
||||
/// untouched and never consults the pool (no runtime needed).
|
||||
|
||||
@@ -9,12 +9,12 @@
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use kigi_models::OAuthConfig;
|
||||
use kigi_models::{OAuthConfig, OAuthTokenBody};
|
||||
|
||||
use crate::auth::error::RefreshTokenFailedReason;
|
||||
use crate::auth::kimi_oauth::RefreshError;
|
||||
use crate::auth::manager::RefreshReason;
|
||||
use crate::auth::oauth_device::{self};
|
||||
use crate::auth::{oauth_device, oauth_pkce};
|
||||
|
||||
use super::{AuthSnapshot, RefreshOutcome, TokenRefresher};
|
||||
|
||||
@@ -104,7 +104,14 @@ impl TokenRefresher for GenericDeviceRefresher {
|
||||
"auth: sending refresh_token grant (generic oauth)"
|
||||
);
|
||||
|
||||
match oauth_device::refresh_token(self.cfg, &refresh_token).await {
|
||||
// Refresh over the provider's token-body encoding: xai's endpoint is
|
||||
// form-encoded (device wire); Claude's is JSON (PKCE wire). Both return
|
||||
// the same `Result<KimiAuth, RefreshError>`.
|
||||
let wire_result = match self.cfg.token_body {
|
||||
OAuthTokenBody::Form => oauth_device::refresh_token(self.cfg, &refresh_token).await,
|
||||
OAuthTokenBody::Json => oauth_pkce::refresh_token(self.cfg, &refresh_token).await,
|
||||
};
|
||||
match wire_result {
|
||||
Ok(new_auth) => {
|
||||
kigi_log::unified_log::info(
|
||||
"auth.refresh.token_rotated",
|
||||
|
||||
Reference in New Issue
Block a user