//! 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, /// 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, /// 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, verification_uri_complete: String, #[serde(default)] expires_in: Option, #[serde(default)] interval: Option, } /// 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, #[serde(default)] pub token_type: Option, } 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, #[serde(default)] error_description: Option, } /// One poll tick against the token endpoint. #[derive(Debug)] pub(crate) enum DevicePollResult { /// 200 with an access token — login complete. Success(Box), /// `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, }, } 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 { 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. Shared with the generic /// device-code wire ([`super::oauth_device`]). pub(crate) 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 { 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 { 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::(&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 { 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::(&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": "WXYZ-6789", "device_code": "dev-code-1", "verification_uri": "https://www.kimi.com/code/authorize_device", "verification_uri_complete": "https://www.kimi.com/code/authorize_device?user_code=WXYZ-6789", "expires_in": 1800, "interval": 7, }))) .expect(1) .mount(&server) .await; let auth = request_device_authorization(&server.uri()).await.unwrap(); assert_eq!(auth.user_code, "WXYZ-6789"); assert_eq!(auth.device_code, "dev-code-1"); assert_eq!(auth.interval, 7); assert_eq!(auth.expires_in, Some(1800)); assert_eq!( auth.verification_uri_complete, "https://www.kimi.com/code/authorize_device?user_code=WXYZ-6789" ); } #[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://www.kimi.com/code/authorize_device?user_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://www.kimi.com/code/authorize_device?user_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:?}"), } } }