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
@@ -0,0 +1,626 @@
//! Kimi Code OAuth wire protocol (PRD F1).
//!
//! Three calls against `{host}` (= `kigi_env::oauth_host()`), all
//! `application/x-www-form-urlencoded` POSTs carrying the device-identity
//! headers from [`super::device`]:
//!
//! - `POST /api/oauth/device_authorization` — form `client_id`
//! - `POST /api/oauth/token` (poll) — form `client_id` + `device_code` +
//! `grant_type=urn:ietf:params:oauth:grant-type:device_code`
//! - `POST /api/oauth/token` (refresh) — form `client_id` +
//! `grant_type=refresh_token` + `refresh_token`, with exponential backoff
//! over the retryable statuses {429, 500, 502, 503, 504} (3 tries) and
//! 401/403 mapped to [`RefreshError::Unauthorized`].
//!
//! Ported from kimi-cli `auth/oauth.py` (the authoritative reference).
use chrono::{Duration, Utc};
use serde::Deserialize;
use super::device::device_headers;
use super::model::{AuthMode, KimiAuth};
/// Kimi Code OAuth client id (fixed for the official device-flow client).
pub(crate) const KIMI_CODE_CLIENT_ID: &str = "17e5f671-d194-4dfb-9706-5516cb48c098";
const DEVICE_GRANT_TYPE: &str = "urn:ietf:params:oauth:grant-type:device_code";
const REFRESH_GRANT_TYPE: &str = "refresh_token";
/// Refresh retry budget over the retryable statuses / network blips.
const MAX_REFRESH_RETRIES: u32 = 3;
/// HTTP statuses worth retrying a refresh for (kimi-cli parity).
const RETRYABLE_REFRESH_STATUSES: [u16; 5] = [429, 500, 502, 503, 504];
/// Result of `POST /api/oauth/device_authorization`.
#[derive(Debug, Clone)]
pub struct DeviceAuthorization {
pub user_code: String,
pub device_code: String,
/// Bare verification page (may be absent; the complete URI is required).
pub verification_uri: Option<String>,
/// Verification page with the user code pre-filled — what we display
/// and open in the browser.
pub verification_uri_complete: String,
/// Device-code lifetime; `None` when the server omits it.
pub expires_in: Option<i64>,
/// Poll interval in seconds (server default 5; floored at 1 by callers).
pub interval: i64,
}
#[derive(Deserialize)]
struct DeviceAuthorizationResponse {
user_code: String,
device_code: String,
#[serde(default)]
verification_uri: Option<String>,
verification_uri_complete: String,
#[serde(default)]
expires_in: Option<i64>,
#[serde(default)]
interval: Option<i64>,
}
/// Successful token payload (device grant and refresh grant share it).
#[derive(Debug, Deserialize)]
pub(crate) struct TokenResponse {
pub access_token: String,
pub refresh_token: String,
pub expires_in: i64,
#[serde(default)]
pub scope: Option<String>,
#[serde(default)]
pub token_type: Option<String>,
}
impl TokenResponse {
/// Materialize the credential: `expires_at = now + expires_in`.
pub(crate) fn into_auth(self) -> KimiAuth {
let now = Utc::now();
KimiAuth {
key: self.access_token,
auth_mode: AuthMode::OAuth,
create_time: now,
user_id: String::new(),
email: None,
refresh_token: Some(self.refresh_token),
expires_at: Some(now + Duration::seconds(self.expires_in)),
expires_in: Some(self.expires_in),
scope: self.scope,
token_type: self.token_type,
}
}
}
#[derive(Deserialize, Default)]
struct OAuthErrorBody {
#[serde(default)]
error: Option<String>,
#[serde(default)]
error_description: Option<String>,
}
/// One poll tick against the token endpoint.
#[derive(Debug)]
pub(crate) enum DevicePollResult {
/// 200 with an access token — login complete.
Success(Box<KimiAuth>),
/// `error == "expired_token"` — restart the whole device authorization.
Expired,
/// Any other non-200 outcome (`authorization_pending`, `slow_down`,
/// unknown errors) — wait and poll again. `slow_down` additionally bumps
/// the caller's interval.
Pending {
error: String,
description: Option<String>,
},
}
fn oauth_url(host: &str, path: &str) -> String {
format!("{}{path}", host.trim_end_matches('/'))
}
/// Attach the device-identity headers to a request.
fn with_device_headers(
mut builder: reqwest::RequestBuilder,
) -> anyhow::Result<reqwest::RequestBuilder> {
for (name, value) in device_headers()? {
builder = builder.header(name, value);
}
Ok(builder)
}
/// Defend against control characters / non-https redirects from a
/// compromised or mis-configured OAuth host.
fn validate_verification_uri(uri: &str) -> anyhow::Result<()> {
if uri.chars().any(|c| c.is_ascii_control()) {
anyhow::bail!("Server returned invalid verification URI");
}
let parsed = url::Url::parse(uri)
.map_err(|_| anyhow::anyhow!("Server returned invalid verification URI"))?;
match parsed.scheme() {
"https" => Ok(()),
"http" if matches!(parsed.host_str(), Some("localhost") | Some("127.0.0.1")) => Ok(()),
_ => anyhow::bail!("Server returned unsupported verification URI scheme"),
}
}
/// `POST {host}/api/oauth/device_authorization` — start a device login.
pub(crate) async fn request_device_authorization(
host: &str,
) -> anyhow::Result<DeviceAuthorization> {
let url = oauth_url(host, "/api/oauth/device_authorization");
tracing::info!(url = %url, "auth: requesting device authorization");
let resp = with_device_headers(crate::http::shared_client().post(&url))?
.form(&[("client_id", KIMI_CODE_CLIENT_ID)])
.send()
.await?;
let status = resp.status();
if !status.is_success() {
let body = resp.text().await.unwrap_or_default();
tracing::warn!(%status, "auth: device authorization failed");
anyhow::bail!("Device authorization failed (HTTP {status}): {body}");
}
let parsed: DeviceAuthorizationResponse = resp.json().await?;
if !parsed
.user_code
.chars()
.all(|c| c.is_ascii_alphanumeric() || c == '-')
{
anyhow::bail!("Server returned invalid user_code format (expected [A-Z0-9-])");
}
validate_verification_uri(&parsed.verification_uri_complete)?;
if let Some(ref uri) = parsed.verification_uri {
validate_verification_uri(uri)?;
}
tracing::info!(
user_code = %parsed.user_code,
interval = parsed.interval.unwrap_or(5),
expires_in = ?parsed.expires_in,
"auth: device authorization issued"
);
Ok(DeviceAuthorization {
user_code: parsed.user_code,
device_code: parsed.device_code,
verification_uri: parsed.verification_uri.filter(|u| !u.is_empty()),
verification_uri_complete: parsed.verification_uri_complete,
expires_in: parsed.expires_in.filter(|&e| e > 0),
interval: parsed.interval.unwrap_or(5),
})
}
/// One poll of `POST {host}/api/oauth/token` with the device grant.
///
/// 5xx and network/decode failures are errors (kimi-cli parity: the login
/// loop surfaces them); everything else maps onto [`DevicePollResult`].
pub(crate) async fn poll_device_token(
host: &str,
device_code: &str,
) -> anyhow::Result<DevicePollResult> {
let url = oauth_url(host, "/api/oauth/token");
let resp = with_device_headers(crate::http::shared_client().post(&url))?
.form(&[
("client_id", KIMI_CODE_CLIENT_ID),
("device_code", device_code),
("grant_type", DEVICE_GRANT_TYPE),
])
.send()
.await
.map_err(|e| anyhow::anyhow!("Token polling request failed: {e}"))?;
let status = resp.status();
if status.is_server_error() {
anyhow::bail!("Token polling server error: {status}");
}
let body = resp.bytes().await?;
if status.is_success() {
if let Ok(tokens) = serde_json::from_slice::<TokenResponse>(&body) {
tracing::info!("auth: device poll succeeded, access token issued");
return Ok(DevicePollResult::Success(Box::new(tokens.into_auth())));
}
// 200 without an access token: treat as still-pending (kimi-cli
// requires "access_token" in the payload before accepting).
tracing::warn!("auth: device poll returned 200 without access_token; continuing");
return Ok(DevicePollResult::Pending {
error: "missing_access_token".to_owned(),
description: None,
});
}
let err: OAuthErrorBody = serde_json::from_slice(&body).unwrap_or_default();
let error = err.error.unwrap_or_else(|| "unknown_error".to_owned());
if error == "expired_token" {
tracing::info!("auth: device code expired; restarting device authorization");
return Ok(DevicePollResult::Expired);
}
tracing::debug!(error = %error, "auth: device poll pending");
Ok(DevicePollResult::Pending {
error,
description: err.error_description,
})
}
/// Why a refresh call terminally or transiently failed.
#[derive(Debug, thiserror::Error)]
pub(crate) enum RefreshError {
/// 401/403 — the refresh token was rejected. Triggers the tombstone
/// cooldown in the manager.
#[error("token refresh unauthorized (HTTP {status}): {description}")]
Unauthorized { status: u16, description: String },
/// Non-retryable non-200 status.
#[error("token refresh failed (HTTP {status}): {description}")]
Fatal { status: u16, description: String },
/// Retry budget exhausted over retryable statuses / network blips.
#[error("token refresh failed after {MAX_REFRESH_RETRIES} attempts: {last_error}")]
Exhausted { last_error: String },
/// Local failure before the wire (e.g. device-id creation failed).
#[error(transparent)]
Local(#[from] anyhow::Error),
}
/// `POST {host}/api/oauth/token` with `grant_type=refresh_token`.
///
/// Retries the retryable statuses and network errors with exponential
/// backoff (`2^attempt` seconds); 401/403 returns immediately as
/// [`RefreshError::Unauthorized`].
pub(crate) async fn refresh_token(
host: &str,
refresh_token: &str,
) -> Result<KimiAuth, RefreshError> {
let url = oauth_url(host, "/api/oauth/token");
let mut last_error = String::from("no attempt made");
for attempt in 0..MAX_REFRESH_RETRIES {
if attempt > 0 {
let backoff = std::time::Duration::from_secs(1 << (attempt - 1));
tracing::warn!(
attempt,
backoff_secs = backoff.as_secs(),
last_error = %last_error,
"auth: retrying token refresh"
);
tokio::time::sleep(backoff).await;
}
tracing::info!(attempt, "auth: token refresh attempt");
let send_result = with_device_headers(crate::http::shared_client().post(&url))?
.form(&[
("client_id", KIMI_CODE_CLIENT_ID),
("grant_type", REFRESH_GRANT_TYPE),
("refresh_token", refresh_token),
])
.send()
.await;
let resp = match send_result {
Ok(resp) => resp,
Err(e) => {
last_error = format!("network error: {e}");
continue;
}
};
let status = resp.status().as_u16();
let body = resp.bytes().await.unwrap_or_default();
if status == 401 || status == 403 {
let err: OAuthErrorBody = serde_json::from_slice(&body).unwrap_or_default();
return Err(RefreshError::Unauthorized {
status,
description: err
.error_description
.unwrap_or_else(|| "Token refresh unauthorized.".to_owned()),
});
}
if status == 200 {
return match serde_json::from_slice::<TokenResponse>(&body) {
Ok(tokens) => Ok(tokens.into_auth()),
Err(e) => Err(RefreshError::Fatal {
status,
description: format!("malformed token payload: {e}"),
}),
};
}
let err: OAuthErrorBody = serde_json::from_slice(&body).unwrap_or_default();
let description = err
.error_description
.unwrap_or_else(|| format!("Token refresh failed (HTTP {status})."));
if RETRYABLE_REFRESH_STATUSES.contains(&status) {
last_error = description;
continue;
}
return Err(RefreshError::Fatal {
status,
description,
});
}
Err(RefreshError::Exhausted { last_error })
}
#[cfg(test)]
mod tests {
use super::*;
use wiremock::matchers::{body_string_contains, method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
fn token_json(access: &str, refresh: &str) -> serde_json::Value {
serde_json::json!({
"access_token": access,
"refresh_token": refresh,
"expires_in": 3600,
"scope": "kimi-code",
"token_type": "bearer",
})
}
#[tokio::test]
async fn device_authorization_parses_wire_payload() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/api/oauth/device_authorization"))
.and(body_string_contains(format!(
"client_id={KIMI_CODE_CLIENT_ID}"
)))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"user_code": "ABCD-1234",
"device_code": "dev-code-1",
"verification_uri": "https://auth.kimi.com/device",
"verification_uri_complete": "https://auth.kimi.com/device?code=ABCD-1234",
"expires_in": 600,
"interval": 7,
})))
.expect(1)
.mount(&server)
.await;
let auth = request_device_authorization(&server.uri()).await.unwrap();
assert_eq!(auth.user_code, "ABCD-1234");
assert_eq!(auth.device_code, "dev-code-1");
assert_eq!(auth.interval, 7);
assert_eq!(auth.expires_in, Some(600));
assert_eq!(
auth.verification_uri_complete,
"https://auth.kimi.com/device?code=ABCD-1234"
);
}
#[tokio::test]
async fn device_authorization_sends_device_headers() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/api/oauth/device_authorization"))
.and(wiremock::matchers::header_exists("X-Msh-Device-Name"))
.and(wiremock::matchers::header_exists("X-Msh-Device-Model"))
.and(wiremock::matchers::header_exists("X-Msh-Device-Id"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"user_code": "AAAA",
"device_code": "d",
"verification_uri_complete": "https://auth.kimi.com/device?code=AAAA",
"interval": 5,
})))
.expect(1)
.mount(&server)
.await;
request_device_authorization(&server.uri()).await.unwrap();
}
#[tokio::test]
async fn device_authorization_defaults_interval_to_five() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/api/oauth/device_authorization"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"user_code": "AAAA",
"device_code": "d",
"verification_uri_complete": "https://auth.kimi.com/device?code=AAAA",
})))
.mount(&server)
.await;
let auth = request_device_authorization(&server.uri()).await.unwrap();
assert_eq!(auth.interval, 5);
assert_eq!(auth.expires_in, None);
}
#[tokio::test]
async fn device_authorization_rejects_bad_verification_uri() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/api/oauth/device_authorization"))
.respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({
"user_code": "AAAA",
"device_code": "d",
"verification_uri_complete": "javascript:alert(1)",
})))
.mount(&server)
.await;
let err = request_device_authorization(&server.uri())
.await
.unwrap_err();
assert!(err.to_string().contains("verification URI"), "{err}");
}
#[tokio::test]
async fn device_authorization_surfaces_http_errors() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/api/oauth/device_authorization"))
.respond_with(ResponseTemplate::new(400).set_body_string("nope"))
.mount(&server)
.await;
let err = request_device_authorization(&server.uri())
.await
.unwrap_err();
assert!(err.to_string().contains("HTTP 400"), "{err}");
}
#[tokio::test]
async fn poll_success_builds_auth_with_expiry() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/api/oauth/token"))
.and(body_string_contains("grant_type=urn"))
.and(body_string_contains("device_code=dev-1"))
.respond_with(ResponseTemplate::new(200).set_body_json(token_json("at-1", "rt-1")))
.expect(1)
.mount(&server)
.await;
let result = poll_device_token(&server.uri(), "dev-1").await.unwrap();
let DevicePollResult::Success(auth) = result else {
panic!("expected success, got {result:?}");
};
assert_eq!(auth.key, "at-1");
assert_eq!(auth.refresh_token.as_deref(), Some("rt-1"));
assert_eq!(auth.expires_in, Some(3600));
let remaining = auth.expires_at.unwrap() - chrono::Utc::now();
assert!(
(3590..=3600).contains(&remaining.num_seconds()),
"expires_at must be ~now+expires_in, got {remaining:?}"
);
assert_eq!(auth.scope.as_deref(), Some("kimi-code"));
assert_eq!(auth.token_type.as_deref(), Some("bearer"));
}
#[tokio::test]
async fn poll_maps_expired_token_to_restart() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/api/oauth/token"))
.respond_with(
ResponseTemplate::new(400)
.set_body_json(serde_json::json!({ "error": "expired_token" })),
)
.mount(&server)
.await;
let result = poll_device_token(&server.uri(), "dev-1").await.unwrap();
assert!(matches!(result, DevicePollResult::Expired), "{result:?}");
}
#[tokio::test]
async fn poll_maps_pending_and_unknown_errors_to_pending() {
let server = MockServer::start().await;
for error in ["authorization_pending", "slow_down", "surprise_error"] {
server.reset().await;
Mock::given(method("POST"))
.and(path("/api/oauth/token"))
.respond_with(
ResponseTemplate::new(400).set_body_json(serde_json::json!({ "error": error })),
)
.mount(&server)
.await;
let result = poll_device_token(&server.uri(), "dev-1").await.unwrap();
match result {
DevicePollResult::Pending { error: got, .. } => assert_eq!(got, error),
other => panic!("expected pending for {error}, got {other:?}"),
}
}
}
#[tokio::test]
async fn poll_server_error_is_an_error() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/api/oauth/token"))
.respond_with(ResponseTemplate::new(502))
.mount(&server)
.await;
let err = poll_device_token(&server.uri(), "dev-1").await.unwrap_err();
assert!(err.to_string().contains("server error"), "{err}");
}
#[tokio::test]
async fn refresh_success_round_trip() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/api/oauth/token"))
.and(body_string_contains("grant_type=refresh_token"))
.and(body_string_contains("refresh_token=rt-old"))
.respond_with(ResponseTemplate::new(200).set_body_json(token_json("at-new", "rt-new")))
.expect(1)
.mount(&server)
.await;
let auth = refresh_token(&server.uri(), "rt-old").await.unwrap();
assert_eq!(auth.key, "at-new");
assert_eq!(auth.refresh_token.as_deref(), Some("rt-new"));
}
#[tokio::test]
async fn refresh_401_maps_to_unauthorized_without_retry() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/api/oauth/token"))
.respond_with(
ResponseTemplate::new(401).set_body_json(
serde_json::json!({ "error_description": "refresh token revoked" }),
),
)
.expect(1)
.mount(&server)
.await;
let err = refresh_token(&server.uri(), "rt-dead").await.unwrap_err();
match err {
RefreshError::Unauthorized {
status,
description,
} => {
assert_eq!(status, 401);
assert_eq!(description, "refresh token revoked");
}
other => panic!("expected Unauthorized, got {other:?}"),
}
}
#[tokio::test]
async fn refresh_retries_retryable_status_then_succeeds() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/api/oauth/token"))
.respond_with(ResponseTemplate::new(503))
.up_to_n_times(1)
.expect(1)
.mount(&server)
.await;
Mock::given(method("POST"))
.and(path("/api/oauth/token"))
.respond_with(ResponseTemplate::new(200).set_body_json(token_json("at-2", "rt-2")))
.expect(1)
.mount(&server)
.await;
let auth = refresh_token(&server.uri(), "rt-old").await.unwrap();
assert_eq!(auth.key, "at-2");
}
#[tokio::test]
async fn refresh_exhausts_after_three_retryable_failures() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/api/oauth/token"))
.respond_with(ResponseTemplate::new(500))
.expect(3)
.mount(&server)
.await;
let err = refresh_token(&server.uri(), "rt-old").await.unwrap_err();
assert!(matches!(err, RefreshError::Exhausted { .. }), "{err:?}");
}
#[tokio::test]
async fn refresh_non_retryable_status_is_fatal_without_retry() {
let server = MockServer::start().await;
Mock::given(method("POST"))
.and(path("/api/oauth/token"))
.respond_with(
ResponseTemplate::new(400)
.set_body_json(serde_json::json!({ "error_description": "bad request" })),
)
.expect(1)
.mount(&server)
.await;
let err = refresh_token(&server.uri(), "rt-old").await.unwrap_err();
match err {
RefreshError::Fatal {
status,
description,
} => {
assert_eq!(status, 400);
assert_eq!(description, "bad request");
}
other => panic!("expected Fatal, got {other:?}"),
}
}
}