//! Kimi Code OAuth credentials (login session) for usage queries. //! //! The official `kimi` CLI stores its OAuth session in //! `~/.kimi-code/credentials/kimi-code.json` after `kimi login`: //! ```json //! { //! "access_token": "eyJ...", // JWT, 15 min TTL (expires_in 900) //! "refresh_token": "eyJ...", // 30 day TTL, rotated on refresh //! "expires_at": 1785510484, // unix seconds //! "scope": "kimi-code", //! "token_type": "Bearer", //! "expires_in": 900 //! } //! ``` //! //! v2 refreshes expired tokens via the OAuth refresh_token grant and writes //! the rotated tokens back to the credentials file. Concurrency safety: //! - a process-wide single-flight mutex serializes refreshes, and the file is //! re-read under the lock so a refresh done by the running `kimi` CLI (or //! another waiter) is adopted instead of duplicated; //! - right before writing back, the file is re-read once more — if the CLI //! refreshed meanwhile its newer tokens win (refresh tokens rotate on use, //! so clobbering the CLI's write would break its next refresh). //! //! v3 adds the in-app sign-in flow: a Device Code Flow (RFC 8628) identical //! to the official `kimi` CLI, so users can authorize Kimi from the app //! instead of running `kimi login` in a terminal. Flow: //! 1. `start_device_authorization()` → POST /api/oauth/device_authorization, //! returns user_code + device_code + verification_uri; //! 2. user opens the verification URI in a browser and approves; //! 3. `poll_device_token()` polls POST /api/oauth/token with the device_code //! grant until the tokens arrive, then writes them to the same //! `~/.kimi-code/credentials/kimi-code.json` file the CLI uses. use serde::{Deserialize, Serialize}; use crate::kimi_code_io::kimi_code_config_dir; /// Seconds of leeway when checking expiry, so a token that is about to die /// mid-request counts as expired. const EXPIRY_LEEWAY_SECS: i64 = 30; /// OAuth token endpoint (confirmed in the official kimi.exe binary). const TOKEN_ENDPOINT: &str = "https://auth.kimi.com/api/oauth/token"; /// Device authorization endpoint (RFC 8628). const DEVICE_AUTHORIZATION_ENDPOINT: &str = "https://auth.kimi.com/api/oauth/device_authorization"; /// Public OAuth client id used by the official CLI (from kimi.exe). const CLIENT_ID: &str = "17e5f671-d194-4dfb-9706-5516cb48c098"; /// Refresh request timeout; refresh is rare, a bit more headroom than the 8s /// query default is fine. const REFRESH_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); /// Device grant type (RFC 8628). const DEVICE_GRANT_TYPE: &str = "urn:ietf:params:oauth:grant-type:device_code"; /// Total polling budget for the device flow, matching the official CLI. const POLL_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(15 * 60); #[derive(Debug, Clone, Deserialize)] pub struct OAuthCredentials { pub access_token: String, pub refresh_token: Option, pub expires_at: Option, #[allow(dead_code)] pub scope: Option, #[allow(dead_code)] pub token_type: Option, #[allow(dead_code)] pub expires_in: Option, } impl OAuthCredentials { /// True when the token is expired or expires within the leeway window. /// Missing `expires_at` is treated as valid (fail open to the API call, /// which returns a definitive 401 if the token is bad). pub fn is_expired(&self) -> bool { let Some(expires_at) = self.expires_at else { return false; }; let now = chrono::Utc::now().timestamp(); now >= expires_at - EXPIRY_LEEWAY_SECS } } fn credentials_path() -> std::path::PathBuf { kimi_code_config_dir() .join("credentials") .join("kimi-code.json") } /// Load the Kimi Code OAuth session. Errors are deterministic (missing file / /// unreadable JSON), never transient — the caller turns them into /// `Ok(success:false)`. pub fn load_kimi_code_credentials() -> Result { let path = credentials_path(); let content = std::fs::read_to_string(&path).map_err(|e| { format!( "Kimi Code OAuth credentials not found at {}: {e}. Run `kimi login` first.", path.display() ) })?; serde_json::from_str(&content) .map_err(|e| format!("Failed to parse Kimi Code OAuth credentials: {e}")) } #[derive(Debug, Deserialize)] struct TokenResponse { access_token: String, refresh_token: Option, expires_in: Option, scope: Option, token_type: Option, } /// Process-wide single-flight lock for token refresh. Refresh tokens rotate /// on use, so two concurrent refreshes would invalidate one of them. fn refresh_lock() -> &'static tokio::sync::Mutex<()> { static LOCK: std::sync::OnceLock> = std::sync::OnceLock::new(); LOCK.get_or_init(|| tokio::sync::Mutex::new(())) } /// Return a usable access token, refreshing via the refresh_token grant when /// the stored one is expired. Errors are deterministic (missing/dead session) /// — the caller surfaces them as `Ok(success:false)`. pub async fn get_valid_access_token() -> Result { let creds = load_kimi_code_credentials()?; if !creds.is_expired() { return Ok(creds.access_token); } let _guard = refresh_lock().lock().await; // Re-read under the lock: the CLI or a previous waiter may have refreshed // while we were waiting. let creds = load_kimi_code_credentials()?; if !creds.is_expired() { return Ok(creds.access_token); } refresh_credentials(&creds).await } /// Merge a token endpoint response into the existing credentials JSON, /// preserving unrelated fields. Pure function for testability. fn merge_token_response(current_json: &str, resp: &TokenResponse) -> String { let mut merged: serde_json::Value = serde_json::from_str(current_json).unwrap_or_else(|_| serde_json::json!({})); let obj = match merged.as_object_mut() { Some(o) => o, None => return current_json.to_string(), }; obj.insert( "access_token".to_string(), serde_json::Value::String(resp.access_token.clone()), ); if let Some(rt) = &resp.refresh_token { obj.insert( "refresh_token".to_string(), serde_json::Value::String(rt.clone()), ); } if let Some(expires_in) = resp.expires_in { obj.insert( "expires_in".to_string(), serde_json::Value::Number(expires_in.into()), ); obj.insert( "expires_at".to_string(), serde_json::Value::Number((chrono::Utc::now().timestamp() + expires_in).into()), ); } if let Some(scope) = &resp.scope { obj.insert( "scope".to_string(), serde_json::Value::String(scope.clone()), ); } if let Some(token_type) = &resp.token_type { obj.insert( "token_type".to_string(), serde_json::Value::String(token_type.clone()), ); } serde_json::to_string_pretty(&merged).unwrap_or_else(|_| current_json.to_string()) } /// Call the token endpoint with the refresh_token grant and persist the /// rotated tokens. Caller must hold [`refresh_lock`]. async fn refresh_credentials(creds: &OAuthCredentials) -> Result { let refresh_token = creds .refresh_token .clone() .filter(|t| !t.is_empty()) .ok_or_else(|| { "Kimi Code session has no refresh token; run `kimi login`".to_string() })?; let client = reqwest::Client::builder() .timeout(REFRESH_TIMEOUT) .build() .map_err(|e| format!("Failed to build HTTP client: {e}"))?; // Tokens only ever go into the request body — never into logs or errors. let resp = client .post(TOKEN_ENDPOINT) .form(&[ ("grant_type", "refresh_token"), ("client_id", CLIENT_ID), ("refresh_token", refresh_token.as_str()), ]) .send() .await .map_err(|e| format!("Token refresh request failed: {e}"))?; if !resp.status().is_success() { let status = resp.status(); // OAuth error bodies are tiny ({"error":"invalid_grant", ...}); keep // the first 200 chars for diagnosability. They never contain tokens. let body = resp.text().await.unwrap_or_default(); let body: String = body.chars().take(200).collect(); return Err(format!( "Kimi Code login expired and refresh failed (HTTP {status}): {body}. Run `kimi login` to sign in again" )); } let token: TokenResponse = resp .json() .await .map_err(|e| format!("Failed to parse token refresh response: {e}"))?; // Re-read right before writing: if the CLI refreshed meanwhile, its newer // (rotated) tokens win — overwriting them would kill its next refresh. let path = credentials_path(); let current = std::fs::read_to_string(&path).unwrap_or_default(); if let Ok(latest) = serde_json::from_str::(¤t) { if !latest.is_expired() && latest.access_token != creds.access_token { return Ok(latest.access_token); } } let merged = merge_token_response(¤t, &token); if let Err(e) = std::fs::write(&path, merged) { // Non-fatal: the new access token is still usable for this query; the // next one will just refresh again. eprintln!("[oauth] failed to write refreshed credentials: {e}"); } Ok(token.access_token) } // --------------------------------------------------------------------------- // Device Code Flow (RFC 8628) — in-app sign-in, mirrors the official CLI // --------------------------------------------------------------------------- /// Response of POST /api/oauth/device_authorization. #[derive(Debug, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub struct DeviceAuthorization { pub user_code: String, pub device_code: String, pub verification_uri: Option, pub verification_uri_complete: Option, pub expires_in: Option, pub interval: Option, } /// Poll outcome, serialized back to the frontend so it can drive the dialog. #[derive(Debug, Serialize)] #[serde(tag = "status", rename_all = "snake_case")] pub enum DevicePollStatus { /// Keep polling with the returned interval (seconds). Pending { interval: i64 }, /// Server asked to slow down; keep polling with interval + 5. SlowDown { interval: i64 }, /// Tokens obtained and written to the credentials file. Success, /// Device code expired; the user must start over. Expired, /// User denied the authorization. AccessDenied, /// Polling budget exhausted. Timeout, } /// `~/.kimi-code/device_id` — reused by the CLI so the app looks like the /// same device. Created on first use. fn device_id_path() -> std::path::PathBuf { kimi_code_config_dir().join("device_id") } fn load_or_create_device_id() -> Option { let path = device_id_path(); if let Ok(content) = std::fs::read_to_string(&path) { let id = content.trim().to_string(); if !id.is_empty() { return Some(id); } } let nanos = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .map(|d| d.as_nanos()) .unwrap_or(0); let id = format!("kimi-switch-{nanos:x}-{}", std::process::id()); if let Some(dir) = path.parent() { let _ = std::fs::create_dir_all(dir); } // Best-effort write; a missing file just means the header is omitted. let _ = std::fs::write(&path, &id); Some(id) } /// Device identity headers matching the official CLI (`X-Msh-*`). fn identity_headers() -> Vec<(&'static str, String)> { let hostname = std::env::var("COMPUTERNAME") .or_else(|_| std::env::var("HOSTNAME")) .unwrap_or_else(|_| "kimi-switch".to_string()); let os_version = std::env::var("OS").unwrap_or_else(|_| std::env::consts::OS.to_string()); let mut headers = vec![ ("X-Msh-Platform", "kimi_code_cli".to_string()), ("X-Msh-Version", env!("CARGO_PKG_VERSION").to_string()), ("X-Msh-Device-Name", hostname.clone()), ( "X-Msh-Device-Model", format!("{} {}", std::env::consts::OS, hostname), ), ("X-Msh-Os-Version", os_version), ]; if let Some(device_id) = load_or_create_device_id() { headers.push(("X-Msh-Device-Id", device_id)); } headers } /// Step 1 of the device flow: ask the server for a user_code + device_code. /// No user interaction required here; the frontend shows the code + URL. pub async fn start_device_authorization() -> Result { let client = reqwest::Client::builder() .timeout(REFRESH_TIMEOUT) .build() .map_err(|e| format!("Failed to build HTTP client: {e}"))?; let mut req = client .post(DEVICE_AUTHORIZATION_ENDPOINT) .form(&[("client_id", CLIENT_ID)]); for (name, value) in identity_headers() { req = req.header(name, value); } let resp = req .send() .await .map_err(|e| format!("Device authorization request failed: {e}"))?; if !resp.status().is_success() { let status = resp.status(); let body = resp.text().await.unwrap_or_default(); let body: String = body.chars().take(200).collect(); return Err(format!("Device authorization failed (HTTP {status}): {body}")); } resp.json::() .await .map_err(|e| format!("Failed to parse device authorization response: {e}")) } /// Step 2 of the device flow: poll the token endpoint until the user /// approves (or the flow fails). On success the tokens are merged into the /// CLI credentials file, making `kimi login` unnecessary. pub async fn poll_device_token( device_code: &str, initial_interval: i64, ) -> Result { let client = reqwest::Client::builder() .timeout(REFRESH_TIMEOUT) .build() .map_err(|e| format!("Failed to build HTTP client: {e}"))?; let deadline = std::time::Instant::now() + POLL_TIMEOUT; let mut interval = initial_interval.max(1); loop { let mut req = client .post(TOKEN_ENDPOINT) .form(&[ ("grant_type", DEVICE_GRANT_TYPE), ("client_id", CLIENT_ID), ("device_code", device_code), ]); for (name, value) in identity_headers() { req = req.header(name, value); } let resp = match req.send().await { Ok(r) => r, Err(e) => return Err(format!("Token polling request failed: {e}")), }; if resp.status().is_success() { let token: TokenResponse = match resp.json().await { Ok(t) => t, Err(e) => return Err(format!("Failed to parse token response: {e}")), }; persist_device_token(&token)?; return Ok(DevicePollStatus::Success); } let status = resp.status(); let body = resp.text().await.unwrap_or_default(); let error: Option = serde_json::from_str::(&body) .ok() .and_then(|v| v.get("error").and_then(|e| e.as_str()).map(String::from)); match error.as_deref() { Some("authorization_pending") => {} Some("slow_down") => interval += 5, Some("expired_token") => return Ok(DevicePollStatus::Expired), Some("access_denied") => return Ok(DevicePollStatus::AccessDenied), Some(other) => { let body: String = body.chars().take(200).collect(); return Err(format!("Token polling error ({other}): {body}")); } None => { let body: String = body.chars().take(200).collect(); return Err(format!("Token polling failed (HTTP {status}): {body}")); } } if std::time::Instant::now() + std::time::Duration::from_secs(interval as u64) >= deadline { return Ok(DevicePollStatus::Timeout); } tokio::time::sleep(std::time::Duration::from_secs(interval as u64)).await; } } /// Write a freshly obtained token set into the CLI credentials file, /// preserving unrelated fields and using the same snake_case shape. fn persist_device_token(token: &TokenResponse) -> Result<(), String> { let path = credentials_path(); if let Some(dir) = path.parent() { let _ = std::fs::create_dir_all(dir); } let current = std::fs::read_to_string(&path).unwrap_or_default(); let merged = merge_token_response(¤t, token); std::fs::write(&path, merged) .map_err(|e| format!("Failed to write Kimi credentials: {e}")) } #[cfg(test)] mod tests { use super::*; #[test] fn expired_token_detected_with_leeway() { let past = chrono::Utc::now().timestamp() - 60; let creds = OAuthCredentials { access_token: "t".to_string(), refresh_token: None, expires_at: Some(past), scope: None, token_type: None, expires_in: None, }; assert!(creds.is_expired()); } #[test] fn valid_token_not_expired() { let future = chrono::Utc::now().timestamp() + 3600; let creds = OAuthCredentials { access_token: "t".to_string(), refresh_token: None, expires_at: Some(future), scope: None, token_type: None, expires_in: None, }; assert!(!creds.is_expired()); } #[test] fn missing_expires_at_fails_open() { let creds = OAuthCredentials { access_token: "t".to_string(), refresh_token: None, expires_at: None, scope: None, token_type: None, expires_in: None, }; assert!(!creds.is_expired()); } #[test] fn credentials_file_shape_parses() { let json = serde_json::json!({ "access_token": "eyJhbGciOiJFUzI1NiIs...", "refresh_token": "eyJhbGciOiJFUzI1NiIs...", "expires_at": 1785510484i64, "scope": "kimi-code", "token_type": "Bearer", "expires_in": 900 }); let creds: OAuthCredentials = serde_json::from_value(json).unwrap(); assert_eq!(creds.access_token, "eyJhbGciOiJFUzI1NiIs..."); assert_eq!(creds.expires_at, Some(1785510484)); } #[test] fn merge_preserves_unrelated_fields_and_rotates() { let current = r#"{ "access_token": "old-access", "refresh_token": "old-refresh", "expires_at": 1, "scope": "kimi-code", "token_type": "Bearer", "expires_in": 900, "custom_field": "keep-me" }"#; let resp = TokenResponse { access_token: "new-access".to_string(), refresh_token: Some("new-refresh".to_string()), expires_in: Some(900), scope: None, token_type: None, }; let merged = merge_token_response(current, &resp); let v: serde_json::Value = serde_json::from_str(&merged).unwrap(); assert_eq!(v["access_token"], "new-access"); assert_eq!(v["refresh_token"], "new-refresh"); assert_eq!(v["custom_field"], "keep-me"); assert_eq!(v["scope"], "kimi-code"); // resp.scope None → 原值保留 let now = chrono::Utc::now().timestamp(); let expires_at = v["expires_at"].as_i64().unwrap(); assert!(expires_at > now + 800 && expires_at <= now + 900); } #[test] fn merge_without_rotated_refresh_keeps_old_refresh_token() { let current = r#"{"access_token": "old", "refresh_token": "old-refresh", "expires_at": 1}"#; let resp = TokenResponse { access_token: "new-access".to_string(), refresh_token: None, expires_in: Some(900), scope: None, token_type: None, }; let merged = merge_token_response(current, &resp); let v: serde_json::Value = serde_json::from_str(&merged).unwrap(); assert_eq!(v["refresh_token"], "old-refresh"); } #[test] fn device_authorization_serializes_snake_case() { let auth = DeviceAuthorization { user_code: "ABC-DEF".to_string(), device_code: "dev-1".to_string(), verification_uri: Some("https://auth.kimi.com/device".to_string()), verification_uri_complete: None, expires_in: Some(1800), interval: Some(5), }; let v = serde_json::to_value(&auth).unwrap(); assert_eq!(v["user_code"], "ABC-DEF"); assert_eq!(v["device_code"], "dev-1"); assert_eq!(v["verification_uri_complete"], serde_json::Value::Null); assert_eq!(v["interval"], 5); assert!(v.get("userCode").is_none(), "must be snake_case for the frontend"); } #[test] fn device_poll_status_serializes_snake_case() { // Internally tagged: {"status":"pending","interval":5} — easy for the // frontend to switch on. let pending = serde_json::to_value(DevicePollStatus::Pending { interval: 5 }).unwrap(); assert_eq!(pending["status"], "pending"); assert_eq!(pending["interval"], 5); let success = serde_json::to_value(DevicePollStatus::Success).unwrap(); assert_eq!(success["status"], "success"); } }