1use std::{
25 fmt,
26 path::PathBuf,
27 sync::{
28 atomic::{AtomicBool, Ordering},
29 Arc,
30 },
31 time::Duration,
32};
33
34use async_trait::async_trait;
35use reqwest::Client;
36use serde::Deserialize;
37use url::Url;
38
39use super::{auth_file_path, now_millis, open_browser, save_provider_auth, OAuthCredentials};
40
41pub const PROVIDER: &str = "kimi-code";
45
46pub const CLIENT_ID: &str = "17e5f671-d194-4dfb-9706-5516cb48c098";
49
50pub const OAUTH_HOST: &str = "https://auth.kimi.com";
52
53pub const DEVICE_AUTH_URL: &str = "https://auth.kimi.com/api/oauth/device_authorization";
55
56pub const TOKEN_URL: &str = "https://auth.kimi.com/api/oauth/token";
58
59pub const DEVICE_GRANT_TYPE: &str = "urn:ietf:params:oauth:grant-type:device_code";
61
62pub const API_BASE_URL: &str = "https://api.kimi.com/coding/v1";
64
65pub const MSH_PLATFORM: &str = "kimi_code_cli";
67pub const MSH_SURFACE_VERSION: &str = "0.31.1";
69pub const USER_AGENT: &str = "SynapsCLI/0.6.0";
71
72const DEFAULT_POLL_INTERVAL_SECS: u64 = 5;
73const MAX_POLL_INTERVAL_SECS: u64 = 30;
75const SLOW_DOWN_STEP_SECS: u64 = 5;
77const DEFAULT_DEVICE_EXPIRES_IN_SECS: u64 = 900;
80
81const MIN_REFRESH_MARGIN_SECS: u64 = 300;
83const MARGIN_HEADROOM_SECS: u64 = 60;
85
86const REFRESH_MAX_ATTEMPTS: u32 = 3;
88const REFRESH_BACKOFF_BASE_MS: u64 = 1_000;
89
90pub const CONNECT_TIMEOUT: Duration = Duration::from_secs(10);
92pub const REQUEST_TIMEOUT: Duration = Duration::from_secs(30);
94pub const MAX_RESPONSE_BODY_BYTES: usize = 64 * 1024;
96
97const CANCEL_POLL_QUANTUM: Duration = Duration::from_millis(200);
99
100#[derive(Clone, PartialEq, Eq)]
105pub enum KimiAuthError {
106 UntrustedEndpoint,
107 InvalidDeviceResponse,
108 InvalidTokenResponse,
109 AccessDenied,
110 Expired,
111 Cancelled,
112 Unauthorized,
114 HttpStatus(u16),
115 Transport,
116 ResponseTooLarge,
117 Persist,
118 Other(&'static str),
119}
120
121impl KimiAuthError {
122 fn label(&self) -> &'static str {
123 match self {
124 Self::UntrustedEndpoint => "untrusted endpoint",
125 Self::InvalidDeviceResponse => "invalid device authorization response",
126 Self::InvalidTokenResponse => "invalid token response",
127 Self::AccessDenied => "access denied",
128 Self::Expired => "device code expired",
129 Self::Cancelled => "login cancelled",
130 Self::Unauthorized => "kimi-code refresh token rejected; run `synaps login kimi-code`",
131 Self::HttpStatus(_) => "HTTP error",
132 Self::Transport => "transport error",
133 Self::ResponseTooLarge => "response body too large",
134 Self::Persist => "credential persistence failed",
135 Self::Other(msg) => msg,
136 }
137 }
138}
139
140impl fmt::Display for KimiAuthError {
141 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
142 match self {
143 Self::HttpStatus(code) => write!(f, "HTTP error {code}"),
144 other => f.write_str(other.label()),
145 }
146 }
147}
148
149impl fmt::Debug for KimiAuthError {
150 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
151 write!(f, "KimiAuthError({self})")
152 }
153}
154
155impl From<KimiAuthError> for String {
156 fn from(value: KimiAuthError) -> Self {
157 value.to_string()
158 }
159}
160
161#[derive(Debug, Clone)]
165pub struct InjectedHttpResponse {
166 pub status: u16,
167 pub body: String,
168}
169
170#[async_trait]
172pub trait KimiHttp: Send + Sync {
173 async fn post_form(
174 &self,
175 url: &str,
176 form: &[(&str, &str)],
177 ) -> Result<InjectedHttpResponse, KimiAuthError>;
178}
179
180#[async_trait]
182pub trait KimiClock: Send + Sync {
183 fn now_millis(&self) -> u64;
184
185 async fn sleep_cancellable<X: KimiCancel + ?Sized>(
188 &self,
189 duration: Duration,
190 cancel: &X,
191 ) -> Result<(), KimiAuthError>;
192}
193
194pub trait KimiCancel: Send + Sync {
196 fn is_cancelled(&self) -> bool;
197}
198
199impl KimiCancel for AtomicBool {
200 fn is_cancelled(&self) -> bool {
201 self.load(Ordering::SeqCst)
202 }
203}
204
205impl KimiCancel for Arc<AtomicBool> {
206 fn is_cancelled(&self) -> bool {
207 self.load(Ordering::SeqCst)
208 }
209}
210
211pub trait KimiBrowser: Send + Sync {
213 fn open(&self, url: &str) -> Result<(), KimiAuthError>;
214}
215
216#[derive(Debug, Clone)]
219pub struct DeviceAuthorization {
220 pub(crate) device_code: String,
222 pub user_code: String,
223 pub verification_uri: String,
226 pub verification_uri_complete: String,
229 pub expires_in_secs: u64,
230 pub interval_secs: u64,
231 pub issued_at_ms: u64,
232}
233
234#[derive(Clone, PartialEq, Eq)]
236pub struct KimiTokenSet {
237 pub access_token: String,
238 pub refresh_token: String,
239 pub expires_in_secs: u64,
240}
241
242impl fmt::Debug for KimiTokenSet {
243 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
244 f.debug_struct("KimiTokenSet")
245 .field("access_token", &"[REDACTED]")
246 .field("refresh_token", &"[REDACTED]")
247 .field("expires_in_secs", &self.expires_in_secs)
248 .finish()
249 }
250}
251
252#[derive(Debug, Clone, PartialEq, Eq)]
254pub enum DevicePollOutcome {
255 Pending,
256 SlowDown,
257 Authorized { token: KimiTokenSet },
258 Denied,
259 Expired,
260 OtherError,
261}
262
263pub fn validate_oauth_endpoint(url: &str) -> Result<(), KimiAuthError> {
267 if url != DEVICE_AUTH_URL && url != TOKEN_URL {
268 return Err(KimiAuthError::UntrustedEndpoint);
269 }
270 let parsed = Url::parse(url).map_err(|_| KimiAuthError::UntrustedEndpoint)?;
271 if parsed.scheme() != "https" || parsed.host_str() != Some("auth.kimi.com") {
272 return Err(KimiAuthError::UntrustedEndpoint);
273 }
274 Ok(())
275}
276
277pub fn validate_verification_uri(uri: &str) -> Result<(), KimiAuthError> {
281 let parsed = Url::parse(uri).map_err(|_| KimiAuthError::UntrustedEndpoint)?;
282 if parsed.scheme() != "https" {
283 return Err(KimiAuthError::UntrustedEndpoint);
284 }
285 if !parsed.username().is_empty() || parsed.password().is_some() {
286 return Err(KimiAuthError::UntrustedEndpoint);
287 }
288 if parsed.port().is_some() || parsed.fragment().is_some() {
289 return Err(KimiAuthError::UntrustedEndpoint);
290 }
291 match parsed.host_str() {
292 Some(host) if host == "kimi.com" || host.ends_with(".kimi.com") => Ok(()),
293 _ => Err(KimiAuthError::UntrustedEndpoint),
294 }
295}
296
297fn ascii_header(value: &str, fallback: &str) -> String {
301 let cleaned: String = value
302 .chars()
303 .filter(|c| (' '..='~').contains(c))
304 .collect::<String>()
305 .trim()
306 .to_string();
307 if cleaned.is_empty() {
308 fallback.to_string()
309 } else {
310 cleaned
311 }
312}
313
314pub fn device_id_path() -> PathBuf {
316 auth_file_path().with_file_name("kimi_device_id")
317}
318
319pub fn load_or_create_device_id() -> String {
322 let path = device_id_path();
323 if let Ok(text) = std::fs::read_to_string(&path) {
324 let trimmed = text.trim();
325 if !trimmed.is_empty() {
326 return trimmed.to_string();
327 }
328 }
329 let id = uuid::Uuid::new_v4().to_string();
330 if let Some(parent) = path.parent() {
331 let _ = std::fs::create_dir_all(parent);
332 }
333 let _ = std::fs::write(&path, &id);
334 #[cfg(unix)]
335 {
336 use std::os::unix::fs::PermissionsExt;
337 let _ = std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600));
338 }
339 id
340}
341
342pub fn identity_request_headers(device_id: &str) -> Vec<(&'static str, String)> {
348 let host_name = std::env::var("HOSTNAME")
349 .ok()
350 .filter(|v| !v.trim().is_empty())
351 .unwrap_or_else(|| "synaps".to_string());
352 vec![
353 ("User-Agent", USER_AGENT.to_string()),
354 ("Accept", "application/json".to_string()),
355 ("X-Msh-Platform", MSH_PLATFORM.to_string()),
356 ("X-Msh-Version", MSH_SURFACE_VERSION.to_string()),
357 ("X-Msh-Device-Id", ascii_header(device_id, "unknown")),
358 ("X-Msh-Device-Name", ascii_header(&host_name, "synaps")),
359 (
360 "X-Msh-Device-Model",
361 ascii_header(
362 &format!("{} {}", std::env::consts::OS, std::env::consts::ARCH),
363 "unknown",
364 ),
365 ),
366 ("X-Msh-Os-Version", std::env::consts::OS.to_string()),
367 ]
368}
369
370#[derive(Debug, Deserialize)]
373struct DeviceCodeJson {
374 device_code: String,
375 user_code: String,
376 #[serde(default)]
377 verification_uri: Option<String>,
378 verification_uri_complete: String,
379 #[serde(default)]
380 expires_in: Option<u64>,
381 #[serde(default)]
382 interval: Option<u64>,
383}
384
385#[derive(Debug, Deserialize)]
386struct TokenJson {
387 access_token: Option<String>,
388 refresh_token: Option<String>,
389 expires_in: Option<u64>,
390 error: Option<String>,
391}
392
393pub fn parse_device_code_response(
394 body: &str,
395 now_ms: u64,
396) -> Result<DeviceAuthorization, KimiAuthError> {
397 let raw: DeviceCodeJson =
398 serde_json::from_str(body).map_err(|_| KimiAuthError::InvalidDeviceResponse)?;
399 if raw.device_code.trim().is_empty()
400 || raw.user_code.trim().is_empty()
401 || raw.verification_uri_complete.trim().is_empty()
402 {
403 return Err(KimiAuthError::InvalidDeviceResponse);
404 }
405 validate_verification_uri(&raw.verification_uri_complete)?;
406 let verification_uri = raw.verification_uri.unwrap_or_default();
407 if !verification_uri.is_empty() {
408 validate_verification_uri(&verification_uri)?;
409 }
410 let expires_in = match raw.expires_in {
411 Some(0) => return Err(KimiAuthError::InvalidDeviceResponse),
412 Some(v) => v,
413 None => DEFAULT_DEVICE_EXPIRES_IN_SECS,
416 };
417 let interval = raw
418 .interval
419 .unwrap_or(DEFAULT_POLL_INTERVAL_SECS)
420 .clamp(1, MAX_POLL_INTERVAL_SECS);
421 Ok(DeviceAuthorization {
422 device_code: raw.device_code,
423 user_code: raw.user_code,
424 verification_uri,
425 verification_uri_complete: raw.verification_uri_complete,
426 expires_in_secs: expires_in,
427 interval_secs: interval,
428 issued_at_ms: now_ms,
429 })
430}
431
432fn parse_token_success(raw: TokenJson) -> Result<KimiTokenSet, KimiAuthError> {
436 let access = raw
437 .access_token
438 .filter(|t| !t.trim().is_empty())
439 .ok_or(KimiAuthError::InvalidTokenResponse)?;
440 let refresh = raw
441 .refresh_token
442 .filter(|t| !t.trim().is_empty())
443 .ok_or(KimiAuthError::InvalidTokenResponse)?;
444 let expires_in = match raw.expires_in {
445 Some(v) if v > 0 => v,
446 _ => return Err(KimiAuthError::InvalidTokenResponse),
447 };
448 Ok(KimiTokenSet {
449 access_token: access,
450 refresh_token: refresh,
451 expires_in_secs: expires_in,
452 })
453}
454
455pub fn parse_device_poll_response(body: &str) -> Result<DevicePollOutcome, KimiAuthError> {
456 let raw: TokenJson =
457 serde_json::from_str(body).map_err(|_| KimiAuthError::InvalidTokenResponse)?;
458 if raw.access_token.is_some() {
459 return Ok(DevicePollOutcome::Authorized {
460 token: parse_token_success(raw)?,
461 });
462 }
463 Ok(match raw.error.as_deref().unwrap_or("") {
464 "authorization_pending" => DevicePollOutcome::Pending,
465 "slow_down" => DevicePollOutcome::SlowDown,
466 "access_denied" => DevicePollOutcome::Denied,
467 "expired_token" => DevicePollOutcome::Expired,
468 _ => DevicePollOutcome::OtherError,
469 })
470}
471
472pub fn credentials_from_token(token: KimiTokenSet, now_ms: u64) -> OAuthCredentials {
479 let margin_secs = MIN_REFRESH_MARGIN_SECS
480 .max(token.expires_in_secs / 2)
481 .min(token.expires_in_secs.saturating_sub(MARGIN_HEADROOM_SECS));
482 let expires = now_ms
483 .saturating_add(token.expires_in_secs.saturating_mul(1000))
484 .saturating_sub(margin_secs.saturating_mul(1000));
485 OAuthCredentials {
486 auth_type: "oauth".into(),
487 refresh: token.refresh_token,
488 access: token.access_token,
489 expires,
490 account_id: None,
491 }
492}
493
494pub fn apply_slow_down(interval_secs: u64) -> u64 {
495 interval_secs
496 .saturating_add(SLOW_DOWN_STEP_SECS)
497 .min(MAX_POLL_INTERVAL_SECS)
498}
499
500pub async fn read_body_capped(
504 response: reqwest::Response,
505 cap: usize,
506) -> Result<String, KimiAuthError> {
507 use futures::StreamExt;
508 let mut buf = Vec::new();
509 let mut stream = response.bytes_stream();
510 while let Some(chunk) = stream.next().await {
511 let chunk = chunk.map_err(|_| KimiAuthError::Transport)?;
512 if buf.len().saturating_add(chunk.len()) > cap {
513 return Err(KimiAuthError::ResponseTooLarge);
514 }
515 buf.extend_from_slice(&chunk);
516 }
517 String::from_utf8(buf).map_err(|_| KimiAuthError::Transport)
518}
519
520pub async fn start_device_authorization<H, C>(
524 http: &H,
525 clock: &C,
526) -> Result<DeviceAuthorization, KimiAuthError>
527where
528 H: KimiHttp,
529 C: KimiClock,
530{
531 validate_oauth_endpoint(DEVICE_AUTH_URL)?;
532 let resp = http
533 .post_form(DEVICE_AUTH_URL, &[("client_id", CLIENT_ID)])
534 .await?;
535 if !(200..300).contains(&resp.status) {
536 return Err(KimiAuthError::HttpStatus(resp.status));
537 }
538 parse_device_code_response(&resp.body, clock.now_millis())
539}
540
541pub async fn poll_device_token<H>(
543 http: &H,
544 device_code: &str,
545) -> Result<DevicePollOutcome, KimiAuthError>
546where
547 H: KimiHttp,
548{
549 validate_oauth_endpoint(TOKEN_URL)?;
550 let resp = http
551 .post_form(
552 TOKEN_URL,
553 &[
554 ("client_id", CLIENT_ID),
555 ("device_code", device_code),
556 ("grant_type", DEVICE_GRANT_TYPE),
557 ],
558 )
559 .await?;
560 if resp.status >= 500 {
563 return Err(KimiAuthError::HttpStatus(resp.status));
564 }
565 match parse_device_poll_response(&resp.body) {
566 Ok(DevicePollOutcome::OtherError) if !(200..300).contains(&resp.status) => {
567 Err(KimiAuthError::HttpStatus(resp.status))
568 }
569 Ok(outcome) => Ok(outcome),
570 Err(err) if !(200..300).contains(&resp.status) => {
571 let _ = err;
572 Err(KimiAuthError::HttpStatus(resp.status))
573 }
574 Err(err) => Err(err),
575 }
576}
577
578pub async fn wait_for_device_authorization<H, C, X>(
580 http: &H,
581 clock: &C,
582 authz: &DeviceAuthorization,
583 cancel: &X,
584) -> Result<KimiTokenSet, KimiAuthError>
585where
586 H: KimiHttp,
587 C: KimiClock,
588 X: KimiCancel + ?Sized,
589{
590 let deadline_ms = authz
591 .issued_at_ms
592 .saturating_add(authz.expires_in_secs.saturating_mul(1000));
593 let mut interval_secs = authz.interval_secs.clamp(1, MAX_POLL_INTERVAL_SECS);
594
595 loop {
596 if cancel.is_cancelled() {
597 return Err(KimiAuthError::Cancelled);
598 }
599 if clock.now_millis() >= deadline_ms {
600 return Err(KimiAuthError::Expired);
601 }
602
603 clock
604 .sleep_cancellable(Duration::from_secs(interval_secs), cancel)
605 .await?;
606
607 if cancel.is_cancelled() {
608 return Err(KimiAuthError::Cancelled);
609 }
610 if clock.now_millis() >= deadline_ms {
611 return Err(KimiAuthError::Expired);
612 }
613
614 match poll_device_token(http, &authz.device_code).await? {
615 DevicePollOutcome::Pending => continue,
616 DevicePollOutcome::SlowDown => {
617 interval_secs = apply_slow_down(interval_secs);
618 }
619 DevicePollOutcome::Authorized { token } => return Ok(token),
620 DevicePollOutcome::Denied => return Err(KimiAuthError::AccessDenied),
621 DevicePollOutcome::Expired => return Err(KimiAuthError::Expired),
622 DevicePollOutcome::OtherError => return Err(KimiAuthError::InvalidTokenResponse),
623 }
624 }
625}
626
627pub fn classify_refresh_response(
631 status: u16,
632 body: &str,
633) -> Result<KimiTokenSet, RefreshDisposition> {
634 let raw: Option<TokenJson> = serde_json::from_str(body).ok();
635 if (200..300).contains(&status) {
636 if let Some(raw) = raw {
637 if raw.access_token.is_some() {
638 return parse_token_success(raw).map_err(RefreshDisposition::Fatal);
639 }
640 }
641 return Err(RefreshDisposition::Fatal(
642 KimiAuthError::InvalidTokenResponse,
643 ));
644 }
645 let error_code = raw.and_then(|r| r.error).unwrap_or_default();
646 if status == 401 || status == 403 || error_code == "invalid_grant" {
647 return Err(RefreshDisposition::Fatal(KimiAuthError::Unauthorized));
648 }
649 if matches!(status, 429 | 500 | 502 | 503 | 504) {
650 return Err(RefreshDisposition::Retry(KimiAuthError::HttpStatus(status)));
651 }
652 Err(RefreshDisposition::Fatal(KimiAuthError::HttpStatus(status)))
653}
654
655#[derive(Debug, Clone, PartialEq, Eq)]
657pub enum RefreshDisposition {
658 Retry(KimiAuthError),
659 Fatal(KimiAuthError),
660}
661
662pub async fn refresh_with<H, C>(
668 http: &H,
669 clock: &C,
670 refresh_token: &str,
671) -> Result<OAuthCredentials, KimiAuthError>
672where
673 H: KimiHttp,
674 C: KimiClock,
675{
676 if refresh_token.trim().is_empty() {
677 return Err(KimiAuthError::Unauthorized);
678 }
679 validate_oauth_endpoint(TOKEN_URL)?;
680 let never_cancelled = AtomicBool::new(false);
681 let mut last_err = KimiAuthError::Transport;
682 for attempt in 0..REFRESH_MAX_ATTEMPTS {
683 if attempt > 0 {
684 let backoff_ms = REFRESH_BACKOFF_BASE_MS.saturating_mul(1 << (attempt - 1));
685 clock
686 .sleep_cancellable(Duration::from_millis(backoff_ms), &never_cancelled)
687 .await?;
688 }
689 let resp = match http
690 .post_form(
691 TOKEN_URL,
692 &[
693 ("client_id", CLIENT_ID),
694 ("grant_type", "refresh_token"),
695 ("refresh_token", refresh_token),
696 ],
697 )
698 .await
699 {
700 Ok(resp) => resp,
701 Err(err) => {
702 last_err = err;
704 continue;
705 }
706 };
707 match classify_refresh_response(resp.status, &resp.body) {
708 Ok(token) => return Ok(credentials_from_token(token, clock.now_millis())),
709 Err(RefreshDisposition::Retry(err)) => {
710 last_err = err;
711 }
712 Err(RefreshDisposition::Fatal(err)) => return Err(err),
713 }
714 }
715 Err(last_err)
716}
717
718pub type UserCodeHook<'a> = &'a dyn Fn(&str, &str);
721
722pub struct LoginHooks<'a, B> {
723 pub browser: &'a B,
724 pub on_user_code: Option<UserCodeHook<'a>>,
726}
727
728pub async fn login_with<H, C, B, X>(
730 http: &H,
731 clock: &C,
732 hooks: LoginHooks<'_, B>,
733 cancel: &X,
734 persist: bool,
735) -> Result<OAuthCredentials, KimiAuthError>
736where
737 H: KimiHttp,
738 C: KimiClock,
739 B: KimiBrowser,
740 X: KimiCancel + ?Sized,
741{
742 let authz = start_device_authorization(http, clock).await?;
743 if let Some(cb) = hooks.on_user_code {
744 cb(&authz.user_code, &authz.verification_uri_complete);
745 } else {
746 eprintln!("\n\x1b[1mKimi Code device login\x1b[0m");
747 eprintln!(" Confirm code: \x1b[36m{}\x1b[0m", authz.user_code);
748 eprintln!(
749 " At: \x1b[36m{}\x1b[0m\n",
750 authz.verification_uri_complete
751 );
752 }
753 validate_verification_uri(&authz.verification_uri_complete)?;
755 let _ = hooks.browser.open(&authz.verification_uri_complete);
756
757 let token = wait_for_device_authorization(http, clock, &authz, cancel).await?;
758 let creds = credentials_from_token(token, clock.now_millis());
759 if persist {
760 save_provider_auth(PROVIDER, &creds).map_err(|_| KimiAuthError::Persist)?;
761 }
762 Ok(creds)
763}
764
765pub async fn login() -> Result<OAuthCredentials, String> {
767 let http = ProductionHttp::new().map_err(|e| e.to_string())?;
768 let clock = ProductionClock;
769 let browser = ProductionBrowser;
770 let cancel = AtomicBool::new(false);
771 login_with(
772 &http,
773 &clock,
774 LoginHooks {
775 browser: &browser,
776 on_user_code: None,
777 },
778 &cancel,
779 true,
780 )
781 .await
782 .map_err(|e| e.to_string())
783}
784
785pub async fn refresh_token(_client: &Client, refresh: &str) -> Result<OAuthCredentials, String> {
788 let http = ProductionHttp::new().map_err(|e| e.to_string())?;
789 let clock = ProductionClock;
790 refresh_with(&http, &clock, refresh)
791 .await
792 .map_err(|e| e.to_string())
793}
794
795struct ProductionHttp {
798 client: Client,
800 device_id: String,
802}
803
804impl ProductionHttp {
805 fn new() -> Result<Self, KimiAuthError> {
806 let client = Client::builder()
807 .redirect(reqwest::redirect::Policy::none())
808 .connect_timeout(CONNECT_TIMEOUT)
809 .timeout(REQUEST_TIMEOUT)
810 .build()
811 .map_err(|_| KimiAuthError::Transport)?;
812 Ok(Self {
813 client,
814 device_id: load_or_create_device_id(),
815 })
816 }
817}
818
819#[async_trait]
820impl KimiHttp for ProductionHttp {
821 async fn post_form(
822 &self,
823 url: &str,
824 form: &[(&str, &str)],
825 ) -> Result<InjectedHttpResponse, KimiAuthError> {
826 validate_oauth_endpoint(url)?;
827 let mut req = self.client.post(url);
828 for (name, value) in identity_request_headers(&self.device_id) {
829 req = req.header(name, value);
830 }
831 let response = req
832 .form(form)
833 .send()
834 .await
835 .map_err(|_| KimiAuthError::Transport)?;
836 let status = response.status().as_u16();
837 if (300..400).contains(&status) {
839 return Err(KimiAuthError::UntrustedEndpoint);
840 }
841 let body = read_body_capped(response, MAX_RESPONSE_BODY_BYTES).await?;
842 Ok(InjectedHttpResponse { status, body })
843 }
844}
845
846struct ProductionClock;
847
848#[async_trait]
849impl KimiClock for ProductionClock {
850 fn now_millis(&self) -> u64 {
851 now_millis()
852 }
853
854 async fn sleep_cancellable<X: KimiCancel + ?Sized>(
855 &self,
856 duration: Duration,
857 cancel: &X,
858 ) -> Result<(), KimiAuthError> {
859 let mut remaining = duration;
860 while remaining > Duration::ZERO {
861 if cancel.is_cancelled() {
862 return Err(KimiAuthError::Cancelled);
863 }
864 let chunk = if remaining > CANCEL_POLL_QUANTUM {
865 CANCEL_POLL_QUANTUM
866 } else {
867 remaining
868 };
869 tokio::time::sleep(chunk).await;
870 remaining = remaining.saturating_sub(chunk);
871 }
872 if cancel.is_cancelled() {
873 return Err(KimiAuthError::Cancelled);
874 }
875 Ok(())
876 }
877}
878
879struct ProductionBrowser;
880
881impl KimiBrowser for ProductionBrowser {
882 fn open(&self, url: &str) -> Result<(), KimiAuthError> {
883 validate_verification_uri(url)?;
884 let _ = open_browser(url);
885 Ok(())
886 }
887}
888
889#[cfg(test)]
892mod tests {
893 use super::*;
894 use std::sync::Mutex;
895
896 #[derive(Default)]
899 struct FakeClock {
900 now: Mutex<u64>,
901 sleeps: Mutex<Vec<u64>>,
902 }
903
904 impl FakeClock {
905 fn new(now: u64) -> Self {
906 Self {
907 now: Mutex::new(now),
908 sleeps: Mutex::new(Vec::new()),
909 }
910 }
911 }
912
913 #[async_trait]
914 impl KimiClock for FakeClock {
915 fn now_millis(&self) -> u64 {
916 *self.now.lock().unwrap()
917 }
918 async fn sleep_cancellable<X: KimiCancel + ?Sized>(
919 &self,
920 duration: Duration,
921 cancel: &X,
922 ) -> Result<(), KimiAuthError> {
923 if cancel.is_cancelled() {
924 return Err(KimiAuthError::Cancelled);
925 }
926 let ms = duration.as_millis() as u64;
927 self.sleeps.lock().unwrap().push(ms);
928 *self.now.lock().unwrap() += ms;
929 Ok(())
930 }
931 }
932
933 type RecordedForm = (String, Vec<(String, String)>);
935
936 #[derive(Default)]
937 struct FakeHttp {
938 responses: Mutex<Vec<InjectedHttpResponse>>,
939 requests: Mutex<Vec<RecordedForm>>,
940 }
941
942 impl FakeHttp {
943 fn scripted(responses: Vec<InjectedHttpResponse>) -> Self {
944 Self {
945 responses: Mutex::new(responses),
946 requests: Mutex::new(Vec::new()),
947 }
948 }
949 }
950
951 #[async_trait]
952 impl KimiHttp for FakeHttp {
953 async fn post_form(
954 &self,
955 url: &str,
956 form: &[(&str, &str)],
957 ) -> Result<InjectedHttpResponse, KimiAuthError> {
958 self.requests.lock().unwrap().push((
959 url.to_string(),
960 form.iter()
961 .map(|(k, v)| (k.to_string(), v.to_string()))
962 .collect(),
963 ));
964 let mut responses = self.responses.lock().unwrap();
965 if responses.is_empty() {
966 return Err(KimiAuthError::Transport);
967 }
968 Ok(responses.remove(0))
969 }
970 }
971
972 struct NoopBrowser;
973 impl KimiBrowser for NoopBrowser {
974 fn open(&self, _url: &str) -> Result<(), KimiAuthError> {
975 Ok(())
976 }
977 }
978
979 fn ok(body: &str) -> InjectedHttpResponse {
980 InjectedHttpResponse {
981 status: 200,
982 body: body.to_string(),
983 }
984 }
985
986 fn err(status: u16, body: &str) -> InjectedHttpResponse {
987 InjectedHttpResponse {
988 status,
989 body: body.to_string(),
990 }
991 }
992
993 const DEVICE_OK: &str = r#"{
994 "device_code": "dev-123",
995 "user_code": "ABCD-EFGH",
996 "verification_uri": "https://www.kimi.com/code/device",
997 "verification_uri_complete": "https://www.kimi.com/code/device?user_code=ABCD-EFGH",
998 "expires_in": 300,
999 "interval": 5
1000 }"#;
1001
1002 const TOKEN_OK: &str = r#"{
1003 "access_token": "acc-1",
1004 "refresh_token": "ref-1",
1005 "expires_in": 900,
1006 "scope": "kimi-code",
1007 "token_type": "Bearer"
1008 }"#;
1009
1010 #[test]
1013 fn oauth_endpoint_pinning_accepts_only_exact_urls() {
1014 assert!(validate_oauth_endpoint(DEVICE_AUTH_URL).is_ok());
1015 assert!(validate_oauth_endpoint(TOKEN_URL).is_ok());
1016 for bad in [
1017 "https://auth.kimi.com/api/oauth/other",
1018 "http://auth.kimi.com/api/oauth/token",
1019 "https://auth.kimi.com.evil.example/api/oauth/token",
1020 "https://api.kimi.com/api/oauth/token",
1021 ] {
1022 assert_eq!(
1023 validate_oauth_endpoint(bad),
1024 Err(KimiAuthError::UntrustedEndpoint),
1025 "must reject {bad}"
1026 );
1027 }
1028 }
1029
1030 #[test]
1031 fn verification_uri_pinning() {
1032 assert!(validate_verification_uri("https://www.kimi.com/code/device?user_code=X").is_ok());
1033 assert!(validate_verification_uri("https://kimi.com/device").is_ok());
1034 for bad in [
1035 "http://www.kimi.com/device",
1036 "https://kimi.com.evil.example/device",
1037 "https://evilkimi.com/device",
1038 "https://user@www.kimi.com/device",
1039 "https://www.kimi.com:8443/device",
1040 "https://www.kimi.com/device#frag",
1041 ] {
1042 assert_eq!(
1043 validate_verification_uri(bad),
1044 Err(KimiAuthError::UntrustedEndpoint),
1045 "must reject {bad}"
1046 );
1047 }
1048 }
1049
1050 #[test]
1053 fn parses_device_code_response() {
1054 let authz = parse_device_code_response(DEVICE_OK, 1_000).expect("parse");
1055 assert_eq!(authz.user_code, "ABCD-EFGH");
1056 assert_eq!(authz.device_code, "dev-123");
1057 assert_eq!(authz.expires_in_secs, 300);
1058 assert_eq!(authz.interval_secs, 5);
1059 assert_eq!(authz.issued_at_ms, 1_000);
1060 assert!(authz.verification_uri_complete.contains("user_code"));
1061 }
1062
1063 #[test]
1064 fn device_response_defaults_missing_expires_and_interval() {
1065 let body = r#"{
1066 "device_code": "d",
1067 "user_code": "u",
1068 "verification_uri_complete": "https://www.kimi.com/code"
1069 }"#;
1070 let authz = parse_device_code_response(body, 0).expect("parse");
1071 assert_eq!(authz.expires_in_secs, DEFAULT_DEVICE_EXPIRES_IN_SECS);
1072 assert_eq!(authz.interval_secs, DEFAULT_POLL_INTERVAL_SECS);
1073 assert_eq!(authz.verification_uri, "");
1074 }
1075
1076 #[test]
1077 fn device_response_requires_complete_uri_and_codes() {
1078 for body in [
1079 r#"{"device_code":"", "user_code":"u", "verification_uri_complete":"https://kimi.com/x"}"#,
1080 r#"{"device_code":"d", "user_code":" ", "verification_uri_complete":"https://kimi.com/x"}"#,
1081 r#"{"device_code":"d", "user_code":"u", "verification_uri_complete":""}"#,
1082 r#"{"device_code":"d", "user_code":"u"}"#,
1083 "not-json",
1084 ] {
1085 assert!(parse_device_code_response(body, 0).is_err(), "body: {body}");
1086 }
1087 }
1088
1089 #[test]
1090 fn device_response_rejects_foreign_verification_host() {
1091 let body = r#"{
1092 "device_code": "d",
1093 "user_code": "u",
1094 "verification_uri_complete": "https://phish.example/kimi"
1095 }"#;
1096 assert_eq!(
1097 parse_device_code_response(body, 0).unwrap_err(),
1098 KimiAuthError::UntrustedEndpoint
1099 );
1100 }
1101
1102 #[test]
1103 fn poll_outcomes_map_oauth_error_codes() {
1104 for (code, expected) in [
1105 ("authorization_pending", DevicePollOutcome::Pending),
1106 ("slow_down", DevicePollOutcome::SlowDown),
1107 ("access_denied", DevicePollOutcome::Denied),
1108 ("expired_token", DevicePollOutcome::Expired),
1109 ("some_other", DevicePollOutcome::OtherError),
1110 ] {
1111 let body = format!(r#"{{"error":"{code}"}}"#);
1112 assert_eq!(parse_device_poll_response(&body).unwrap(), expected);
1113 }
1114 }
1115
1116 #[test]
1117 fn poll_success_requires_rotating_refresh_and_expiry() {
1118 let ok = parse_device_poll_response(TOKEN_OK).unwrap();
1119 match ok {
1120 DevicePollOutcome::Authorized { token } => {
1121 assert_eq!(token.access_token, "acc-1");
1122 assert_eq!(token.refresh_token, "ref-1");
1123 assert_eq!(token.expires_in_secs, 900);
1124 }
1125 other => panic!("expected Authorized, got {other:?}"),
1126 }
1127 assert!(parse_device_poll_response(r#"{"access_token":"a","expires_in":900}"#).is_err());
1129 assert!(parse_device_poll_response(r#"{"access_token":"a","refresh_token":"r"}"#).is_err());
1131 assert!(parse_device_poll_response(
1132 r#"{"access_token":"a","refresh_token":"r","expires_in":0}"#
1133 )
1134 .is_err());
1135 }
1136
1137 #[test]
1138 fn credentials_bake_upstream_refresh_margin() {
1139 let creds = credentials_from_token(
1141 KimiTokenSet {
1142 access_token: "a".into(),
1143 refresh_token: "r".into(),
1144 expires_in_secs: 900,
1145 },
1146 1_000_000,
1147 );
1148 assert_eq!(creds.expires, 1_000_000 + 450 * 1000);
1149 assert_eq!(creds.auth_type, "oauth");
1150 let short = credentials_from_token(
1152 KimiTokenSet {
1153 access_token: "a".into(),
1154 refresh_token: "r".into(),
1155 expires_in_secs: 120,
1156 },
1157 0,
1158 );
1159 assert_eq!(short.expires, (120 - 60) * 1000);
1160 }
1161
1162 #[test]
1163 fn slow_down_grows_and_caps() {
1164 assert_eq!(apply_slow_down(5), 10);
1165 assert_eq!(apply_slow_down(28), 30);
1166 assert_eq!(apply_slow_down(30), 30);
1167 }
1168
1169 #[test]
1172 fn refresh_classification_matrix() {
1173 assert!(classify_refresh_response(200, TOKEN_OK).is_ok());
1174 assert_eq!(
1175 classify_refresh_response(401, "{}").unwrap_err(),
1176 RefreshDisposition::Fatal(KimiAuthError::Unauthorized)
1177 );
1178 assert_eq!(
1179 classify_refresh_response(403, "{}").unwrap_err(),
1180 RefreshDisposition::Fatal(KimiAuthError::Unauthorized)
1181 );
1182 assert_eq!(
1183 classify_refresh_response(400, r#"{"error":"invalid_grant"}"#).unwrap_err(),
1184 RefreshDisposition::Fatal(KimiAuthError::Unauthorized)
1185 );
1186 for status in [429u16, 500, 502, 503, 504] {
1187 assert_eq!(
1188 classify_refresh_response(status, "{}").unwrap_err(),
1189 RefreshDisposition::Retry(KimiAuthError::HttpStatus(status)),
1190 "status {status} must be retryable"
1191 );
1192 }
1193 assert_eq!(
1194 classify_refresh_response(418, "{}").unwrap_err(),
1195 RefreshDisposition::Fatal(KimiAuthError::HttpStatus(418))
1196 );
1197 assert_eq!(
1199 classify_refresh_response(200, "{}").unwrap_err(),
1200 RefreshDisposition::Fatal(KimiAuthError::InvalidTokenResponse)
1201 );
1202 }
1203
1204 #[tokio::test]
1207 async fn login_happy_path_persist_disabled() {
1208 let http = FakeHttp::scripted(vec![
1209 ok(DEVICE_OK),
1210 err(400, r#"{"error":"authorization_pending"}"#),
1211 ok(TOKEN_OK),
1212 ]);
1213 let clock = FakeClock::new(0);
1214 let cancel = AtomicBool::new(false);
1215 let creds = login_with(
1216 &http,
1217 &clock,
1218 LoginHooks {
1219 browser: &NoopBrowser,
1220 on_user_code: Some(&|code, uri| {
1221 assert_eq!(code, "ABCD-EFGH");
1222 assert!(uri.starts_with("https://www.kimi.com/"));
1223 }),
1224 },
1225 &cancel,
1226 false,
1227 )
1228 .await
1229 .expect("login");
1230 assert_eq!(creds.access, "acc-1");
1231 assert_eq!(creds.refresh, "ref-1");
1232
1233 let requests = http.requests.lock().unwrap();
1234 assert_eq!(requests.len(), 3);
1235 assert_eq!(requests[0].0, DEVICE_AUTH_URL);
1236 assert_eq!(requests[1].0, TOKEN_URL);
1237 let poll_form = &requests[1].1;
1239 assert!(poll_form.contains(&("grant_type".into(), DEVICE_GRANT_TYPE.into())));
1240 assert!(poll_form.contains(&("device_code".into(), "dev-123".into())));
1241 }
1242
1243 #[tokio::test]
1244 async fn slow_down_extends_poll_interval() {
1245 let http = FakeHttp::scripted(vec![
1246 ok(DEVICE_OK),
1247 err(400, r#"{"error":"slow_down"}"#),
1248 ok(TOKEN_OK),
1249 ]);
1250 let clock = FakeClock::new(0);
1251 let cancel = AtomicBool::new(false);
1252 let authz = start_device_authorization(&http, &clock).await.unwrap();
1253 wait_for_device_authorization(&http, &clock, &authz, &cancel)
1254 .await
1255 .expect("authorized");
1256 let sleeps = clock.sleeps.lock().unwrap();
1257 assert_eq!(*sleeps, vec![5_000, 10_000]);
1258 }
1259
1260 #[tokio::test]
1261 async fn denied_and_expired_terminate() {
1262 for (body, expected) in [
1263 (r#"{"error":"access_denied"}"#, KimiAuthError::AccessDenied),
1264 (r#"{"error":"expired_token"}"#, KimiAuthError::Expired),
1265 ] {
1266 let http = FakeHttp::scripted(vec![ok(DEVICE_OK), err(400, body)]);
1267 let clock = FakeClock::new(0);
1268 let cancel = AtomicBool::new(false);
1269 let authz = start_device_authorization(&http, &clock).await.unwrap();
1270 let got = wait_for_device_authorization(&http, &clock, &authz, &cancel)
1271 .await
1272 .unwrap_err();
1273 assert_eq!(got, expected);
1274 }
1275 }
1276
1277 #[tokio::test]
1278 async fn deadline_expires_without_server_grant() {
1279 let mut responses = vec![ok(DEVICE_OK)];
1281 for _ in 0..70 {
1282 responses.push(err(400, r#"{"error":"authorization_pending"}"#));
1283 }
1284 let http = FakeHttp::scripted(responses);
1285 let clock = FakeClock::new(0);
1286 let cancel = AtomicBool::new(false);
1287 let authz = start_device_authorization(&http, &clock).await.unwrap();
1288 let got = wait_for_device_authorization(&http, &clock, &authz, &cancel)
1289 .await
1290 .unwrap_err();
1291 assert_eq!(got, KimiAuthError::Expired);
1292 }
1293
1294 #[tokio::test]
1295 async fn cancellation_stops_polling() {
1296 let http = FakeHttp::scripted(vec![ok(DEVICE_OK)]);
1297 let clock = FakeClock::new(0);
1298 let cancel = AtomicBool::new(true);
1299 let authz = start_device_authorization(&http, &clock).await.unwrap();
1300 let got = wait_for_device_authorization(&http, &clock, &authz, &cancel)
1301 .await
1302 .unwrap_err();
1303 assert_eq!(got, KimiAuthError::Cancelled);
1304 }
1305
1306 #[tokio::test]
1307 async fn server_5xx_during_poll_is_fatal() {
1308 let http = FakeHttp::scripted(vec![ok(DEVICE_OK), err(502, "bad gateway")]);
1309 let clock = FakeClock::new(0);
1310 let cancel = AtomicBool::new(false);
1311 let authz = start_device_authorization(&http, &clock).await.unwrap();
1312 let got = wait_for_device_authorization(&http, &clock, &authz, &cancel)
1313 .await
1314 .unwrap_err();
1315 assert_eq!(got, KimiAuthError::HttpStatus(502));
1316 }
1317
1318 #[tokio::test]
1319 async fn refresh_retries_transient_then_succeeds() {
1320 let http = FakeHttp::scripted(vec![err(503, "{}"), err(429, "{}"), ok(TOKEN_OK)]);
1321 let clock = FakeClock::new(0);
1322 let creds = refresh_with(&http, &clock, "old-refresh")
1323 .await
1324 .expect("refresh");
1325 assert_eq!(creds.access, "acc-1");
1326 assert_eq!(creds.refresh, "ref-1");
1328 assert_eq!(*clock.sleeps.lock().unwrap(), vec![1_000, 2_000]);
1330 let requests = http.requests.lock().unwrap();
1332 for (_, form) in requests.iter() {
1333 assert!(form.contains(&("grant_type".into(), "refresh_token".into())));
1334 }
1335 }
1336
1337 #[tokio::test]
1338 async fn refresh_unauthorized_is_immediate_no_retry() {
1339 let http = FakeHttp::scripted(vec![err(401, "{}"), ok(TOKEN_OK)]);
1340 let clock = FakeClock::new(0);
1341 let got = refresh_with(&http, &clock, "dead-refresh")
1342 .await
1343 .unwrap_err();
1344 assert_eq!(got, KimiAuthError::Unauthorized);
1345 assert_eq!(http.requests.lock().unwrap().len(), 1);
1347 }
1348
1349 #[tokio::test]
1350 async fn refresh_exhausts_retries() {
1351 let http = FakeHttp::scripted(vec![err(500, "{}"), err(500, "{}"), err(500, "{}")]);
1352 let clock = FakeClock::new(0);
1353 let got = refresh_with(&http, &clock, "r").await.unwrap_err();
1354 assert_eq!(got, KimiAuthError::HttpStatus(500));
1355 assert_eq!(http.requests.lock().unwrap().len(), 3);
1356 }
1357
1358 #[tokio::test]
1359 async fn refresh_rejects_empty_refresh_token() {
1360 let http = FakeHttp::scripted(vec![ok(TOKEN_OK)]);
1361 let clock = FakeClock::new(0);
1362 let got = refresh_with(&http, &clock, " ").await.unwrap_err();
1363 assert_eq!(got, KimiAuthError::Unauthorized);
1364 assert!(http.requests.lock().unwrap().is_empty());
1365 }
1366
1367 #[test]
1370 fn identity_headers_cover_msh_surface_without_secrets() {
1371 let headers = identity_request_headers("4d2b0725-9e2e-4b8f-b57b-6011ecf1c3eb");
1372 let names: Vec<&str> = headers.iter().map(|(name, _)| *name).collect();
1373 for required in [
1374 "User-Agent",
1375 "X-Msh-Platform",
1376 "X-Msh-Version",
1377 "X-Msh-Device-Id",
1378 "X-Msh-Device-Name",
1379 "X-Msh-Device-Model",
1380 "X-Msh-Os-Version",
1381 ] {
1382 assert!(names.contains(&required), "missing header {required}");
1383 }
1384 let get = |name: &str| {
1385 headers
1386 .iter()
1387 .find(|(n, _)| *n == name)
1388 .map(|(_, v)| v.clone())
1389 .unwrap()
1390 };
1391 assert_eq!(get("X-Msh-Platform"), MSH_PLATFORM);
1392 assert_eq!(get("User-Agent"), USER_AGENT);
1393 assert_eq!(
1394 get("X-Msh-Device-Id"),
1395 "4d2b0725-9e2e-4b8f-b57b-6011ecf1c3eb"
1396 );
1397 for (_, value) in &headers {
1398 assert!(
1399 value.chars().all(|c| (' '..='~').contains(&c)),
1400 "header values must be printable ASCII"
1401 );
1402 }
1403 }
1404
1405 #[test]
1406 fn ascii_header_sanitizes_and_falls_back() {
1407 assert_eq!(ascii_header(" héllo wörld ", "x"), "hllo wrld");
1408 assert_eq!(ascii_header("\u{7f}\n\t", "fallback"), "fallback");
1409 assert_eq!(ascii_header("plain", "x"), "plain");
1410 }
1411
1412 #[test]
1413 fn error_display_never_contains_token_material() {
1414 for err in [
1417 KimiAuthError::Unauthorized,
1418 KimiAuthError::InvalidTokenResponse,
1419 KimiAuthError::HttpStatus(500),
1420 ] {
1421 let text = format!("{err} / {err:?}");
1422 assert!(!text.contains("acc-"), "{text}");
1423 assert!(!text.contains("ref-"), "{text}");
1424 }
1425 }
1426}