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:
@@ -21,7 +21,6 @@ pub async fn handle(agent: &MvpAgent, args: &acp::ExtRequest) -> ExtResult {
|
||||
"x.ai/auth/get_url" => handle_get_url(agent).await,
|
||||
"x.ai/auth/logout" => handle_logout(agent, args).await,
|
||||
"x.ai/auth/info" => handle_info(agent),
|
||||
"x.ai/auth/check_subscription" => handle_check_subscription(agent).await,
|
||||
_ => Err(acp::Error::method_not_found()),
|
||||
}
|
||||
}
|
||||
@@ -111,7 +110,7 @@ async fn handle_get_url(agent: &MvpAgent) -> ExtResult {
|
||||
to_raw_response(&serde_json::json!({
|
||||
"auth_url": auth_url,
|
||||
// `external_provider` kept for older clients; `mode` is authoritative.
|
||||
"external_provider": mode.is_some_and(|m| m.is_external_provider()),
|
||||
"external_provider": false,
|
||||
"mode": mode.map(|m| m.as_wire_str()),
|
||||
}))
|
||||
}
|
||||
@@ -141,43 +140,16 @@ async fn handle_logout(agent: &MvpAgent, args: &acp::ExtRequest) -> ExtResult {
|
||||
}))
|
||||
}
|
||||
|
||||
/// Single-shot subscription re-check (retry button on paywall screen).
|
||||
///
|
||||
/// Calls `retry_subscription_check()`, then returns the updated auth
|
||||
/// response with gate info so the pager can refresh the gate state.
|
||||
async fn handle_check_subscription(agent: &MvpAgent) -> ExtResult {
|
||||
agent.retry_subscription_check().await;
|
||||
let response = agent.auth_response_with_meta();
|
||||
to_raw_response(&serde_json::json!({
|
||||
"authenticated": response.meta.is_some(),
|
||||
"meta": response.meta,
|
||||
}))
|
||||
}
|
||||
|
||||
/// Returns current auth method ID, user profile fields, and team/principal
|
||||
/// metadata.
|
||||
/// Returns current auth method ID and the account fields the Kimi flow
|
||||
/// exposes (email/user id are empty until a later feature surfaces them).
|
||||
fn handle_info(agent: &MvpAgent) -> ExtResult {
|
||||
#[derive(Serialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct AuthInfoResponse {
|
||||
method_id: Option<String>,
|
||||
email: Option<String>,
|
||||
first_name: Option<String>,
|
||||
last_name: Option<String>,
|
||||
/// `grok-asset://` URL resolved by the Electron protocol handler,
|
||||
/// or a full `http(s)://` URL passed through unchanged.
|
||||
profile_image_url: Option<String>,
|
||||
team_id: Option<String>,
|
||||
team_name: Option<String>,
|
||||
team_role: Option<String>,
|
||||
organization_id: Option<String>,
|
||||
organization_name: Option<String>,
|
||||
organization_role: Option<String>,
|
||||
principal_type: Option<String>,
|
||||
principal_id: Option<String>,
|
||||
user_blocked_reason: Option<String>,
|
||||
team_blocked_reasons: Vec<String>,
|
||||
coding_data_retention_opt_out: bool,
|
||||
user_id: Option<String>,
|
||||
auth_mode: Option<String>,
|
||||
}
|
||||
|
||||
let method_id = agent
|
||||
@@ -186,40 +158,13 @@ fn handle_info(agent: &MvpAgent) -> ExtResult {
|
||||
.as_ref()
|
||||
.map(|m| m.0.to_string());
|
||||
let auth = agent.auth_manager.current();
|
||||
let raw_asset_id = auth.as_ref().and_then(|a| a.profile_image_asset_id.clone());
|
||||
|
||||
// Return a grok-asset:// URL that the Electron renderer resolves at
|
||||
// display time via a custom protocol handler. The handler proxies
|
||||
// through cli-chat-proxy's /asset endpoint; Electron's HTTP cache
|
||||
// handles reuse. No disk-cache or network call needed here.
|
||||
let profile_image_url = match raw_asset_id.as_deref().filter(|k| !k.is_empty()) {
|
||||
Some(key) if key.starts_with("http://") || key.starts_with("https://") => {
|
||||
Some(key.to_owned())
|
||||
}
|
||||
Some(key) => Some(format!("grok-asset:///{key}")),
|
||||
None => None,
|
||||
};
|
||||
to_raw_response(&AuthInfoResponse {
|
||||
method_id,
|
||||
email: auth.as_ref().and_then(|a| a.email.clone()),
|
||||
first_name: auth.as_ref().and_then(|a| a.first_name.clone()),
|
||||
last_name: auth.as_ref().and_then(|a| a.last_name.clone()),
|
||||
profile_image_url,
|
||||
team_id: auth.as_ref().and_then(|a| a.team_id.clone()),
|
||||
team_name: auth.as_ref().and_then(|a| a.team_name.clone()),
|
||||
team_role: auth.as_ref().and_then(|a| a.team_role.clone()),
|
||||
organization_id: auth.as_ref().and_then(|a| a.organization_id.clone()),
|
||||
organization_name: auth.as_ref().and_then(|a| a.organization_name.clone()),
|
||||
organization_role: auth.as_ref().and_then(|a| a.organization_role.clone()),
|
||||
principal_type: auth.as_ref().and_then(|a| a.principal_type.clone()),
|
||||
principal_id: auth.as_ref().and_then(|a| a.principal_id.clone()),
|
||||
user_blocked_reason: auth.as_ref().and_then(|a| a.user_blocked_reason.clone()),
|
||||
team_blocked_reasons: auth
|
||||
user_id: auth
|
||||
.as_ref()
|
||||
.map(|a| a.team_blocked_reasons.clone())
|
||||
.unwrap_or_default(),
|
||||
coding_data_retention_opt_out: auth
|
||||
.as_ref()
|
||||
.is_some_and(|a| a.coding_data_retention_opt_out),
|
||||
.map(|a| a.user_id.clone())
|
||||
.filter(|id| !id.is_empty()),
|
||||
auth_mode: auth.as_ref().map(|a| format!("{:?}", a.auth_mode)),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1,17 +1,17 @@
|
||||
use agent_client_protocol as acp;
|
||||
|
||||
use crate::auth::{AuthManager, GrokAuth};
|
||||
use crate::auth::{AuthManager, KimiAuth};
|
||||
|
||||
/// Require xAI auth from a sync context, accepting tokens in the client-side buffer window.
|
||||
/// Require a Kimi Code session from a sync context, accepting tokens in the client-side buffer window.
|
||||
pub(crate) fn require_xai_auth(
|
||||
auth_manager: &AuthManager,
|
||||
missing_message: &'static str,
|
||||
non_xai_message: &'static str,
|
||||
) -> Result<GrokAuth, acp::Error> {
|
||||
) -> Result<KimiAuth, acp::Error> {
|
||||
let auth = auth_manager
|
||||
.current_or_expired()
|
||||
.ok_or_else(|| acp::Error::auth_required().data(missing_message))?;
|
||||
if !auth.is_xai_auth() {
|
||||
if !auth.is_session_auth() {
|
||||
return Err(acp::Error::auth_required().data(non_xai_message));
|
||||
}
|
||||
Ok(auth)
|
||||
|
||||
@@ -213,10 +213,6 @@ async fn handle_get_billing(agent: &MvpAgent) -> ExtResult {
|
||||
let credits_resp = crate::http::shared_client()
|
||||
.get(&credits_url)
|
||||
.header("Authorization", format!("Bearer {}", auth.key))
|
||||
.header(
|
||||
"X-XAI-Token-Auth",
|
||||
crate::auth::GrokComConfig::default().token_header,
|
||||
)
|
||||
.header("x-userid", &auth.user_id)
|
||||
.header("x-grok-client-version", kigi_version::VERSION)
|
||||
.header(
|
||||
@@ -304,10 +300,6 @@ async fn handle_get_auto_topup_rule(agent: &MvpAgent) -> ExtResult {
|
||||
let response = crate::http::shared_client()
|
||||
.get(&url)
|
||||
.header("Authorization", format!("Bearer {}", auth.key))
|
||||
.header(
|
||||
"X-XAI-Token-Auth",
|
||||
crate::auth::GrokComConfig::default().token_header,
|
||||
)
|
||||
.header("x-userid", &auth.user_id)
|
||||
.header("x-grok-client-version", kigi_version::VERSION)
|
||||
.header(
|
||||
|
||||
@@ -484,37 +484,23 @@ mod tests {
|
||||
.insert("review".to_string(), "# Review skill\n".to_string());
|
||||
bundle
|
||||
}
|
||||
fn test_auth() -> crate::auth::GrokAuth {
|
||||
crate::auth::GrokAuth {
|
||||
fn test_auth() -> crate::auth::KimiAuth {
|
||||
crate::auth::KimiAuth {
|
||||
key: "token".to_string(),
|
||||
auth_mode: crate::auth::AuthMode::Oidc,
|
||||
auth_mode: crate::auth::AuthMode::OAuth,
|
||||
create_time: chrono::Utc::now(),
|
||||
user_id: "user-1".to_string(),
|
||||
email: Some("test@example.com".to_string()),
|
||||
first_name: None,
|
||||
last_name: None,
|
||||
profile_image_asset_id: None,
|
||||
principal_type: None,
|
||||
principal_id: None,
|
||||
team_id: None,
|
||||
team_name: None,
|
||||
team_role: None,
|
||||
organization_id: None,
|
||||
organization_name: None,
|
||||
organization_role: None,
|
||||
user_blocked_reason: None,
|
||||
team_blocked_reasons: vec![],
|
||||
coding_data_retention_opt_out: false,
|
||||
has_grok_code_access: None,
|
||||
refresh_token: None,
|
||||
expires_at: Some(chrono::Utc::now() + chrono::Duration::hours(1)),
|
||||
oidc_issuer: None,
|
||||
oidc_client_id: None,
|
||||
expires_in: Some(3600),
|
||||
scope: None,
|
||||
token_type: None,
|
||||
}
|
||||
}
|
||||
fn test_auth_manager() -> Arc<crate::auth::AuthManager> {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let mgr = crate::auth::AuthManager::new(dir.path(), crate::auth::GrokComConfig::default());
|
||||
let mgr = crate::auth::AuthManager::new(dir.path(), crate::auth::KimiCodeConfig::default());
|
||||
mgr.hot_swap(test_auth());
|
||||
std::mem::forget(dir);
|
||||
Arc::new(mgr)
|
||||
|
||||
@@ -17,7 +17,6 @@ pub mod memory;
|
||||
pub mod notification;
|
||||
pub mod plugins;
|
||||
pub mod pr;
|
||||
pub mod privacy;
|
||||
pub mod prompt_history;
|
||||
pub mod prompt_meta;
|
||||
pub mod recap;
|
||||
|
||||
@@ -1,91 +0,0 @@
|
||||
//! `x.ai/privacy/setCodingDataRetention` extension handler.
|
||||
//!
|
||||
//! PUTs the new opt-out flag to cli-chat-proxy and updates local auth state
|
||||
//! to match. The local update is fire-and-forget (best-effort cache refresh).
|
||||
|
||||
use agent_client_protocol as acp;
|
||||
use serde::Deserialize;
|
||||
|
||||
use super::{ExtResult, parse_params, to_raw_response};
|
||||
use crate::agent::MvpAgent;
|
||||
|
||||
#[tracing::instrument(skip_all, fields(method = %args.method))]
|
||||
pub async fn handle(agent: &MvpAgent, args: &acp::ExtRequest) -> ExtResult {
|
||||
match args.method.as_ref() {
|
||||
"x.ai/privacy/setCodingDataRetention" => handle_set(agent, args).await,
|
||||
_ => Err(acp::Error::method_not_found()),
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_set(agent: &MvpAgent, args: &acp::ExtRequest) -> ExtResult {
|
||||
#[derive(Deserialize)]
|
||||
#[serde(rename_all = "camelCase")]
|
||||
struct Params {
|
||||
coding_data_retention_opt_out: bool,
|
||||
}
|
||||
|
||||
let params: Params = parse_params(args)?;
|
||||
|
||||
let auth = agent.auth_manager.auth().await.map_err(|e| {
|
||||
tracing::warn!(error = %e, "privacy: auth resolution failed");
|
||||
acp::Error::auth_required()
|
||||
.data("Authentication required. Run `grok login` to re-authenticate.")
|
||||
})?;
|
||||
|
||||
let proxy_url = agent.cfg.borrow().endpoints.proxy_url();
|
||||
let url = format!("{proxy_url}/privacy/coding-data-retention");
|
||||
let token_header = agent.auth_manager.grok_com_config().token_header.clone();
|
||||
|
||||
let body = serde_json::json!({
|
||||
"codingDataRetentionOptOut": params.coding_data_retention_opt_out,
|
||||
});
|
||||
|
||||
let provider: std::sync::Arc<dyn kigi_auth::AuthCredentialProvider> = std::sync::Arc::new(
|
||||
crate::auth::credential_provider::ShellAuthCredentialProvider::new(
|
||||
agent.auth_manager.clone(),
|
||||
None,
|
||||
None,
|
||||
),
|
||||
);
|
||||
let client = crate::http::with_auth_retry(crate::http::shared_client(), provider);
|
||||
|
||||
let resp = client
|
||||
.put(&url)
|
||||
.header("X-XAI-Token-Auth", &token_header)
|
||||
.header("x-grok-client-version", kigi_version::VERSION)
|
||||
.header(
|
||||
crate::http::CLIENT_MODE_HEADER,
|
||||
crate::http::process_client_mode(),
|
||||
)
|
||||
.json(&body)
|
||||
.send()
|
||||
.await
|
||||
.map_err(|e| acp::Error::internal_error().data(format!("HTTP request failed: {e}")))?;
|
||||
|
||||
if !resp.status().is_success() {
|
||||
let status = resp.status().as_u16();
|
||||
let body = resp.text().await.unwrap_or_default();
|
||||
tracing::warn!(status, "setCodingDataRetention request failed");
|
||||
let friendly = serde_json::from_str::<serde_json::Value>(&body)
|
||||
.ok()
|
||||
.and_then(|v| {
|
||||
v.get("error")
|
||||
.or_else(|| v.get("message"))
|
||||
.and_then(|e| e.as_str().map(String::from))
|
||||
})
|
||||
.unwrap_or_else(|| format!("server returned HTTP {status}"));
|
||||
return Err(acp::Error::internal_error().data(friendly));
|
||||
}
|
||||
|
||||
// Update local auth state to reflect the change.
|
||||
// Use save_without_enrichment to avoid a race: update() spawns a
|
||||
// background GET /user enrichment that may read stale ACL state
|
||||
// and overwrite the opt-out flag back to its previous value.
|
||||
let mut updated = auth.clone();
|
||||
updated.coding_data_retention_opt_out = params.coding_data_retention_opt_out;
|
||||
let _ = agent.auth_manager.save_without_enrichment(updated).await;
|
||||
|
||||
to_raw_response(&serde_json::json!({
|
||||
"codingDataRetentionOptOut": params.coding_data_retention_opt_out,
|
||||
}))
|
||||
}
|
||||
@@ -111,10 +111,7 @@ async fn handle_session_rename(agent: &MvpAgent, args: &acp::ExtRequest) -> ExtR
|
||||
// Send a SessionSummaryGenerated notification so the TUI updates its title
|
||||
notify_session_title(agent, session_id, &req.title).await;
|
||||
|
||||
if agent.is_writeback_storage()
|
||||
&& let Some(auth) = agent.current_auth()
|
||||
&& !auth.is_zdr_team()
|
||||
{
|
||||
if agent.is_writeback_storage() && agent.current_auth().is_some() {
|
||||
use crate::remote::client::BackendClient;
|
||||
use crate::session::export::ExportedMetadata;
|
||||
|
||||
@@ -133,15 +130,7 @@ async fn handle_session_rename(agent: &MvpAgent, args: &acp::ExtRequest) -> ExtR
|
||||
// Hook 2: update session replica with summary (fire-and-forget)
|
||||
if let Some(client) = agent.session_registry_client() {
|
||||
let sid = req.session_id.to_string();
|
||||
let title = if agent
|
||||
.auth_manager
|
||||
.current_or_expired()
|
||||
.is_some_and(|a| a.is_zdr_team())
|
||||
{
|
||||
None
|
||||
} else {
|
||||
Some(req.title.clone())
|
||||
};
|
||||
let title = Some(req.title.clone());
|
||||
tokio::spawn(async move {
|
||||
let update = crate::agent::session_registry_client::UpdateRequest {
|
||||
summary: title,
|
||||
@@ -248,8 +237,7 @@ async fn handle_session_delete(agent: &MvpAgent, args: &acp::ExtRequest) -> ExtR
|
||||
// For writeback storage (non-ZDR): remote delete is authoritative for
|
||||
// the cloud history and runs first; on failure no local bits are
|
||||
// touched so the pager does not remove the row or toast success.
|
||||
let needs_remote =
|
||||
agent.is_writeback_storage() && agent.current_auth().is_some_and(|a| !a.is_zdr_team());
|
||||
let needs_remote = agent.is_writeback_storage() && agent.current_auth().is_some();
|
||||
|
||||
// Shared delete: remote-first, then local disk + FTS eviction.
|
||||
// Mirrored by the `grok sessions delete <id>` CLI path.
|
||||
|
||||
@@ -45,13 +45,6 @@ async fn handle_share_session(agent: &MvpAgent, args: &acp::ExtRequest) -> ExtRe
|
||||
);
|
||||
}
|
||||
|
||||
// Only block for ZDR teams (hard data-retention policy), not for
|
||||
// coding-data-retention opt-out — sharing is user-initiated.
|
||||
if auth.is_zdr_team() {
|
||||
return Err(acp::Error::invalid_params()
|
||||
.data("Session sharing is disabled for your team's data retention policy"));
|
||||
}
|
||||
|
||||
// Find session info by searching through summaries
|
||||
let summaries = list_summaries(None).await.map_err(|e| {
|
||||
acp::Error::internal_error().data(format!("Failed to list sessions: {}", e))
|
||||
@@ -94,7 +87,7 @@ async fn handle_share_session(agent: &MvpAgent, args: &acp::ExtRequest) -> ExtRe
|
||||
|
||||
fn require_xai_auth_for_share(
|
||||
auth_manager: &crate::auth::AuthManager,
|
||||
) -> Result<crate::auth::GrokAuth, acp::Error> {
|
||||
) -> Result<crate::auth::KimiAuth, acp::Error> {
|
||||
super::auth_gate::require_xai_auth(
|
||||
auth_manager,
|
||||
"Authentication required to share session",
|
||||
@@ -105,8 +98,8 @@ fn require_xai_auth_for_share(
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::auth::GrokComConfig;
|
||||
use crate::auth::{AuthMode, GrokAuth};
|
||||
use crate::auth::KimiCodeConfig;
|
||||
use crate::auth::{AuthMode, KimiAuth};
|
||||
use chrono::{Duration, Utc};
|
||||
use std::sync::Arc;
|
||||
use tempfile::tempdir;
|
||||
@@ -117,7 +110,7 @@ mod tests {
|
||||
let dir = tempdir().expect("tempdir for share auth test");
|
||||
let mgr = Arc::new(crate::auth::AuthManager::new(
|
||||
dir.path(),
|
||||
GrokComConfig::default(),
|
||||
KimiCodeConfig::default(),
|
||||
));
|
||||
|
||||
let expires_at = Utc::now() + ttl;
|
||||
@@ -126,9 +119,8 @@ mod tests {
|
||||
// Only OIDC tokens against https://auth.x.ai (or the local-dev equivalent)
|
||||
// return true from is_xai_auth(). This is required for the share tests to
|
||||
// exercise the happy path through require_xai_auth_for_share.
|
||||
let auth = GrokAuth {
|
||||
auth_mode: AuthMode::Oidc,
|
||||
oidc_issuer: Some("https://auth.x.ai".to_string()),
|
||||
let auth = KimiAuth {
|
||||
auth_mode: AuthMode::OAuth,
|
||||
key: "test-key".into(),
|
||||
expires_at: Some(expires_at),
|
||||
create_time: Utc::now() - Duration::hours(1),
|
||||
@@ -168,7 +160,7 @@ mod tests {
|
||||
let dir = tempdir().expect("tempdir");
|
||||
let mgr = Arc::new(crate::auth::AuthManager::new(
|
||||
dir.path(),
|
||||
GrokComConfig::default(),
|
||||
KimiCodeConfig::default(),
|
||||
));
|
||||
assert!(require_xai_auth_for_share(&mgr).is_err());
|
||||
}
|
||||
@@ -178,12 +170,12 @@ mod tests {
|
||||
let dir = tempdir().expect("tempdir");
|
||||
let mgr = Arc::new(crate::auth::AuthManager::new(
|
||||
dir.path(),
|
||||
GrokComConfig::default(),
|
||||
KimiCodeConfig::default(),
|
||||
));
|
||||
|
||||
// API key is the simplest non-xAI credential (External and enterprise OIDC
|
||||
// are also rejected the same way).
|
||||
let non_xai = GrokAuth {
|
||||
let non_xai = KimiAuth {
|
||||
auth_mode: AuthMode::ApiKey,
|
||||
key: "xai-test-key".into(),
|
||||
create_time: Utc::now(),
|
||||
|
||||
Reference in New Issue
Block a user