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:
2026-07-17 07:37:29 -04:00
parent d6c20fc13f
commit 021b82443d
117 changed files with 4052 additions and 19900 deletions
@@ -9,10 +9,10 @@ use std::sync::Arc;
/// an `AuthManager` (visibility checks, bundle fetches, tests).
///
/// Deployment key (enterprise) sends bare `Bearer`, routed to management key auth.
/// User token (xAI users) sends `Bearer` + `X-XAI-Token-Auth: xai-grok-cli`.
/// User tokens and deployment keys are both sent as a plain `Bearer`.
/// Deployment key takes precedence when both are present.
#[derive(Clone)]
pub struct GrokAuthCredentials {
pub struct KigiAuthCredentials {
pub user_token: Option<String>,
pub deployment_key: Option<String>,
pub alpha_test_key: Option<String>,
@@ -20,9 +20,9 @@ pub struct GrokAuthCredentials {
/// refresh chain; `resolve()` reads the in-memory cache.
auth_manager: Option<Arc<crate::auth::AuthManager>>,
}
impl std::fmt::Debug for GrokAuthCredentials {
impl std::fmt::Debug for KigiAuthCredentials {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("GrokAuthCredentials")
f.debug_struct("KigiAuthCredentials")
.field(
"user_token",
&self.user_token.as_ref().map(|_| "<redacted>"),
@@ -42,7 +42,7 @@ impl std::fmt::Debug for GrokAuthCredentials {
.finish()
}
}
impl GrokAuthCredentials {
impl KigiAuthCredentials {
/// Static credentials from a snapshot token. No refresh capability.
pub fn new(user_token: Option<String>) -> Self {
Self {
@@ -82,7 +82,7 @@ impl GrokAuthCredentials {
/// Without this, the `resolve_async()` error fallback returns
/// credentials with no token, causing requests to be sent without
/// an Authorization header.
pub fn resolve(&self) -> GrokAuthCredentials {
pub fn resolve(&self) -> KigiAuthCredentials {
if let Some(ref am) = self.auth_manager
&& let Some(auth) = am.current_or_expired()
{
@@ -97,7 +97,7 @@ impl GrokAuthCredentials {
/// (memory -> disk -> active OIDC refresh). Falls back to sync
/// `resolve()` on error so transient refresh failures don't drop
/// the bearer.
pub async fn resolve_async(&self) -> GrokAuthCredentials {
pub async fn resolve_async(&self) -> KigiAuthCredentials {
let Some(ref am) = self.auth_manager else {
return self.clone();
};
@@ -120,12 +120,7 @@ impl GrokAuthCredentials {
let builder = if let Some(ref key) = self.deployment_key {
builder.header("Authorization", format!("Bearer {}", key))
} else if let Some(ref token) = self.user_token {
builder
.header("Authorization", format!("Bearer {}", token))
.header(
obfstr::obfstr!("X-XAI-Token-Auth"),
obfstr::obfstr!("xai-grok-cli"),
)
builder.header("Authorization", format!("Bearer {}", token))
} else {
builder
};
@@ -133,28 +128,28 @@ impl GrokAuthCredentials {
builder
}
}
impl kigi_auth::HttpAuth for GrokAuthCredentials {
impl kigi_auth::HttpAuth for KigiAuthCredentials {
fn apply(&self, builder: RequestBuilder, base_url: &str) -> RequestBuilder {
GrokAuthCredentials::apply(self, builder, base_url)
KigiAuthCredentials::apply(self, builder, base_url)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::auth::{AuthManager, AuthMode, GrokAuth, GrokComConfig};
use crate::auth::{AuthManager, AuthMode, KimiAuth, KimiCodeConfig};
use chrono::{Duration, Utc};
use std::sync::Arc;
fn make_manager_with_token(
expires_at: chrono::DateTime<Utc>,
) -> (Arc<AuthManager>, tempfile::TempDir) {
let dir = tempfile::tempdir().unwrap();
let mgr = Arc::new(AuthManager::new(dir.path(), GrokComConfig::default()));
let auth = GrokAuth {
let mgr = Arc::new(AuthManager::new(dir.path(), KimiCodeConfig::default()));
let auth = KimiAuth {
key: "test-bearer-token".into(),
auth_mode: AuthMode::External,
auth_mode: AuthMode::OAuth,
expires_at: Some(expires_at),
create_time: Utc::now(),
..GrokAuth::test_default()
..KimiAuth::test_default()
};
mgr.hot_swap(auth);
(mgr, dir)
@@ -162,14 +157,14 @@ mod tests {
#[test]
fn resolve_returns_token_when_not_expired() {
let (mgr, _dir) = make_manager_with_token(Utc::now() + Duration::hours(1));
let creds = GrokAuthCredentials::new(None).with_auth_manager(mgr);
let creds = KigiAuthCredentials::new(None).with_auth_manager(mgr);
let resolved = creds.resolve();
assert_eq!(resolved.user_token.as_deref(), Some("test-bearer-token"));
}
#[test]
fn resolve_returns_token_during_early_invalidation_window() {
let (mgr, _dir) = make_manager_with_token(Utc::now() + Duration::minutes(3));
let creds = GrokAuthCredentials::new(None).with_auth_manager(mgr.clone());
let creds = KigiAuthCredentials::new(None).with_auth_manager(mgr.clone());
assert!(mgr.current().is_none());
assert!(mgr.current_or_expired().is_some());
assert_eq!(
@@ -179,14 +174,14 @@ mod tests {
}
#[test]
fn resolve_returns_static_token_when_no_auth_manager() {
let creds = GrokAuthCredentials::new(Some("static-token".into()));
let creds = KigiAuthCredentials::new(Some("static-token".into()));
assert_eq!(creds.resolve().user_token.as_deref(), Some("static-token"));
}
#[test]
fn resolve_returns_none_when_no_token_at_all() {
let dir = tempfile::tempdir().unwrap();
let mgr = Arc::new(AuthManager::new(dir.path(), GrokComConfig::default()));
let creds = GrokAuthCredentials::new(None).with_auth_manager(mgr);
let mgr = Arc::new(AuthManager::new(dir.path(), KimiCodeConfig::default()));
let creds = KigiAuthCredentials::new(None).with_auth_manager(mgr);
assert!(creds.resolve().user_token.is_none());
}
}
+1 -1
View File
@@ -1,7 +1,7 @@
pub mod agent_id;
pub mod config;
pub mod grok_auth_credentials;
pub mod hooks;
pub mod kigi_auth_credentials;
// The foundation utilities live in `kigi-shell-base` (upstream of this
// crate so they build in parallel). Re-exported at the original paths so