Files
Kigi-CLI/crates/codegen/kigi-shell/src/auth/refresh/mod.rs
T
ZacharyZhang-NY 021b82443d 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.
2026-07-17 07:37:29 -04:00

128 lines
4.9 KiB
Rust

mod kimi_refresher;
use std::sync::Arc;
use crate::auth::manager::AuthManager;
pub(crate) use crate::auth::manager::RefreshReason;
use crate::auth::model::KimiAuth;
pub(crate) use kimi_refresher::KimiRefresher;
/// Read-only view of `AuthManager` for refreshers. Enforces the
/// no-mutation contract on *credential* state at the type level: refreshers
/// hold `Arc<dyn AuthSnapshot>` and physically cannot call `update()`,
/// `clear()`, `hot_swap()`, or `refresh_chain()`.
pub(crate) trait AuthSnapshot: Send + Sync {
/// Read the current in-memory bearer outside the refresh threshold.
fn current(&self) -> Option<KimiAuth>;
/// Read the expired in-memory bearer (for its `refresh_token`).
fn expired_auth(&self) -> Option<KimiAuth>;
/// Re-read the persisted credential (keyring → file) for the configured
/// scope. Read-only w.r.t. credentials, but may advance disk-observation
/// state and emit transition telemetry (not credential mutation).
fn read_disk_auth(&self) -> Option<KimiAuth>;
/// Whether the in-memory bearer is expired.
fn is_expired(&self) -> bool;
}
impl AuthSnapshot for AuthManager {
fn current(&self) -> Option<KimiAuth> {
self.current()
}
fn expired_auth(&self) -> Option<KimiAuth> {
self.expired_auth()
}
fn read_disk_auth(&self) -> Option<KimiAuth> {
self.read_disk_auth()
}
fn is_expired(&self) -> bool {
self.is_expired()
}
}
/// The credential a refresh would send to the OAuth host: persisted
/// refresh-token first, then the expired in-mem bearer, then current (only on
/// `ServerRejected`). Single source of truth shared by
/// [`KimiRefresher::refresh`] (the attempt) and
/// `AuthManager::attempted_tombstone_key` (the tombstone scope), so the two
/// can't drift. The caller supplies the persisted read: the tombstone path
/// passes a side-effect-free read, the refresher the observing one.
pub(crate) fn resolve_refresh_credential(
snap: &dyn AuthSnapshot,
disk_auth: Option<KimiAuth>,
reason: RefreshReason,
) -> Option<KimiAuth> {
disk_auth
.filter(|a| a.refresh_token.is_some())
.or_else(|| snap.expired_auth())
.or_else(|| {
(reason == RefreshReason::ServerRejected)
.then(|| snap.current())
.flatten()
})
}
/// Outcome of a refresh attempt. Data only -- `refresh_chain` handles mutations.
#[derive(Debug)]
#[must_use = "RefreshOutcome encodes a state transition; route it through refresh_chain"]
pub(crate) enum RefreshOutcome {
/// Authority returned a fresh token. Caller persists via `update()`.
Success(Box<KimiAuth>),
/// Terminal failure (401/403 from the OAuth host). Caller records a
/// tombstone scoped to the rejected refresh token; the 300s cooldown (or
/// a rotated persisted refresh token) clears it.
PermanentFailure {
error: crate::auth::error::RefreshTokenFailedError,
/// The refresh-token value the refresher actually sent, so
/// `refresh_chain` scopes the tombstone to it. `None` when the
/// attempt never reached the wire.
rejected_refresh_token: Option<String>,
},
/// Transient / unknown failure. Caller may retry later. Message-only: the
/// underlying cause is logged structurally at the refresher, then flattened
/// here (the retry decision needs recoverability, not the source chain).
TransientFailure { message: String },
}
impl RefreshOutcome {
/// A fresh credential from the authority (hides the `Box`).
pub(crate) fn success(auth: KimiAuth) -> Self {
Self::Success(Box::new(auth))
}
/// Terminal failure for an already-classified reason against the
/// refresh token actually sent to the OAuth host.
pub(crate) fn permanent(
reason: crate::auth::error::RefreshTokenFailedReason,
rejected_refresh_token: Option<String>,
) -> Self {
Self::PermanentFailure {
error: reason.into(),
rejected_refresh_token,
}
}
/// A retryable failure carrying a diagnostic message.
pub(crate) fn transient(message: impl Into<String>) -> Self {
Self::TransientFailure {
message: message.into(),
}
}
}
#[async_trait::async_trait]
pub(crate) trait TokenRefresher: Send + Sync {
/// Attempt to obtain a fresh token from the authority.
///
/// Implementations MUST NOT call auth_manager.update(), clear(),
/// hot_swap(), or any other state-mutating method. Return the
/// result and let refresh_chain handle all mutations.
async fn refresh(&self, reason: RefreshReason) -> RefreshOutcome;
}
/// Build the production refresher against `kigi_env::oauth_host()`.
pub(crate) fn build_refresher(auth_manager: Arc<AuthManager>) -> Arc<dyn TokenRefresher> {
let snapshot: Arc<dyn AuthSnapshot> = auth_manager;
Arc::new(KimiRefresher::new(snapshot, kigi_env::oauth_host()))
}