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:
+20
-25
@@ -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,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
|
||||
|
||||
Reference in New Issue
Block a user