M1/F1: Kimi Code OAuth device-code flow

Replace the xAI OAuth stack with the Kimi device authorization grant:
- kimi_oauth.rs wire layer (device_authorization + token poll + refresh
  against kigi_env::oauth_host(); client_id per PRD; retryable statuses
  429/5xx with backoff; expired_token restarts authorization)
- X-Msh-Device-{Name,Model,Id} headers; device_id minted uuid4-hex at
  ~/.kigi/device_id (0600)
- Storage: system keyring service `kigi`, entry `oauth/kimi-code`
  (macOS/Windows native backends), atomic-file fallback under ~/.kigi;
  official client's keyring/~/.kimi never touched
- Refresh manager: 60s tick, threshold max(300, expires_in*0.5),
  401-tombstone keyed by rejected refresh token with 300s cooldown and
  rotation auto-clear, cross-process lock with sibling-adoption
  triple-check, sleep/wake forced refresh
- Deleted xAI machinery: enterprise OIDC (PKCE/JWKS/teams), devbox login,
  external auth provider, JWT tier gating + subscription paywall stack,
  X-XAI-Token-Auth marker headers, ZDR gates, /user enrichment
- kigi login / TUI /login both drive the device flow; login-host display
  now derives from kigi_env::oauth_host()
- 264 auth unit/wiremock tests; live contract probe of
  auth.kimi.com/api/oauth/device_authorization matches the wire shapes

Gates: check/clippy --all-targets clean, fmt, deny ok, kigi-shell lib
5131 tests green.
This commit is contained in:
ZacharyZhang-NY committed 2026-07-17 07:37:29 -04:00
1 parent d6c20fc13f
commit 021b82443d
117 files changed
+4052 -19900

No files matched your search

@@ -320,14 +320,14 @@ pub struct FeedbackClient {
http: reqwest::Client,
client: reqwest_middleware::ClientWithMiddleware,
base_url: String,
credentials: crate::util::grok_auth_credentials::GrokAuthCredentials,
credentials: crate::util::kigi_auth_credentials::KigiAuthCredentials,
session_id: Option<String>,
}
impl FeedbackClient {
pub fn new(base_url: impl Into<String>, user_token: Option<String>) -> Self {
let http = crate::http::shared_client();
let credentials = crate::util::grok_auth_credentials::GrokAuthCredentials::new(user_token);
let credentials = crate::util::kigi_auth_credentials::KigiAuthCredentials::new(user_token);
let client = Self::build_middleware_client(&http, &credentials);
Self {
http,
@@ -361,7 +361,7 @@ impl FeedbackClient {
base_url: impl Into<String>,
user_token: Option<String>,
) -> Self {
let credentials = crate::util::grok_auth_credentials::GrokAuthCredentials::new(user_token);
let credentials = crate::util::kigi_auth_credentials::KigiAuthCredentials::new(user_token);
let client = Self::build_middleware_client(&http, &credentials);
Self {
http,
@@ -398,7 +398,7 @@ impl FeedbackClient {
fn build_middleware_client(
http: &reqwest::Client,
credentials: &crate::util::grok_auth_credentials::GrokAuthCredentials,
credentials: &crate::util::kigi_auth_credentials::KigiAuthCredentials,
) -> reqwest_middleware::ClientWithMiddleware {
let provider = Self::make_auth_provider(credentials);
// max_retries=0: the middleware stamps the auth header but does NOT
@@ -415,7 +415,7 @@ impl FeedbackClient {
}
fn make_auth_provider(
credentials: &crate::util::grok_auth_credentials::GrokAuthCredentials,
credentials: &crate::util::kigi_auth_credentials::KigiAuthCredentials,
) -> Arc<dyn kigi_auth::AuthCredentialProvider> {
if let Some(am) = credentials.auth_manager() {
Arc::new(
@@ -1118,7 +1118,7 @@ mod forbidden_tests {
#[cfg(test)]
mod auth_refresh_tests {
use super::*;
use crate::auth::{AuthManager, AuthMode, GrokAuth, GrokComConfig};
use crate::auth::{AuthManager, AuthMode, KimiAuth, KimiCodeConfig};
use axum::{Router, routing::get};
use chrono::{Duration, Utc};
use std::net::SocketAddr;
@@ -1156,14 +1156,14 @@ mod auth_refresh_tests {
let (addr, _server) = start_server(router).await;
let dir = tempfile::tempdir().unwrap();
let am = Arc::new(AuthManager::new(dir.path(), GrokComConfig::default()));
am.hot_swap(GrokAuth {
let am = Arc::new(AuthManager::new(dir.path(), KimiCodeConfig::default()));
am.hot_swap(KimiAuth {
key: "fresh-from-auth-manager".into(),
auth_mode: AuthMode::ApiKey,
create_time: Utc::now(),
user_id: "user-42".into(),
expires_at: Some(Utc::now() + Duration::hours(1)),
..GrokAuth::test_default()
..KimiAuth::test_default()
});
let client = FeedbackClient::new(
@@ -1208,14 +1208,14 @@ mod auth_refresh_tests {
let (addr, _server) = start_server(router).await;
let dir = tempfile::tempdir().unwrap();
let am = Arc::new(AuthManager::new(dir.path(), GrokComConfig::default()));
let fresh = GrokAuth {
let am = Arc::new(AuthManager::new(dir.path(), KimiCodeConfig::default()));
let fresh = KimiAuth {
key: "fresh-from-auth-manager".into(),
auth_mode: AuthMode::ApiKey,
create_time: Utc::now(),
user_id: "user-42".into(),
expires_at: Some(Utc::now() + Duration::hours(1)),
..GrokAuth::test_default()
..KimiAuth::test_default()
};
am.hot_swap(fresh);
@@ -1247,16 +1247,14 @@ mod auth_refresh_tests {
_reason: crate::auth::refresh::RefreshReason,
) -> crate::auth::refresh::RefreshOutcome {
self.calls.fetch_add(1, Ordering::SeqCst);
crate::auth::refresh::RefreshOutcome::Success(Box::new(GrokAuth {
crate::auth::refresh::RefreshOutcome::Success(Box::new(KimiAuth {
key: "fresh-from-refresher".into(),
auth_mode: AuthMode::Oidc,
auth_mode: AuthMode::OAuth,
create_time: Utc::now(),
user_id: "user-42".into(),
refresh_token: Some("rt-fresh".into()),
expires_at: Some(Utc::now() + Duration::hours(1)),
oidc_issuer: Some("https://issuer.example".into()),
oidc_client_id: Some("test-client".into()),
..GrokAuth::test_default()
..KimiAuth::test_default()
}))
}
}
@@ -1266,34 +1264,30 @@ mod auth_refresh_tests {
#[tokio::test]
async fn try_refresh_credentials_picks_up_disk_rotation_without_hitting_idp() {
let dir = tempfile::tempdir().unwrap();
let cfg = GrokComConfig::default();
let cfg = KimiCodeConfig::default();
let scope = cfg.auth_scope();
let am = Arc::new(AuthManager::new(dir.path(), cfg));
// In-memory: stale token (the one the server rejected).
am.hot_swap(GrokAuth {
am.hot_swap(KimiAuth {
key: "stale-rejected".into(),
auth_mode: AuthMode::Oidc,
auth_mode: AuthMode::OAuth,
create_time: Utc::now() - Duration::hours(2),
user_id: "user-42".into(),
refresh_token: Some("rt-stale".into()),
expires_at: Some(Utc::now() + Duration::hours(1)),
oidc_issuer: Some("https://issuer.example".into()),
oidc_client_id: Some("test-client".into()),
..GrokAuth::test_default()
..KimiAuth::test_default()
});
// Disk: a sibling already rotated to a fresh token.
let disk_auth = GrokAuth {
let disk_auth = KimiAuth {
key: "fresh-from-sibling-on-disk".into(),
auth_mode: AuthMode::Oidc,
auth_mode: AuthMode::OAuth,
create_time: Utc::now(),
user_id: "user-42".into(),
refresh_token: Some("rt-fresh".into()),
expires_at: Some(Utc::now() + Duration::hours(1)),
oidc_issuer: Some("https://issuer.example".into()),
oidc_client_id: Some("test-client".into()),
..GrokAuth::test_default()
..KimiAuth::test_default()
};
let mut store = std::collections::BTreeMap::new();
store.insert(scope, disk_auth);
@@ -1329,15 +1323,15 @@ mod auth_refresh_tests {
#[tokio::test]
async fn try_refresh_credentials_returns_false_on_terminal_failure() {
let dir = tempfile::tempdir().unwrap();
let am = Arc::new(AuthManager::new(dir.path(), GrokComConfig::default()));
let am = Arc::new(AuthManager::new(dir.path(), KimiCodeConfig::default()));
// LegacySession: no refresh_token, no recovery possible.
am.hot_swap(GrokAuth {
am.hot_swap(KimiAuth {
key: "legacy-rejected".into(),
auth_mode: AuthMode::WebLogin,
auth_mode: AuthMode::OAuth,
create_time: Utc::now() - Duration::days(60),
user_id: "user-42".into(),
..GrokAuth::test_default()
..KimiAuth::test_default()
});
let client = FeedbackClient::new("http://example/v1", Some("legacy-rejected".into()))