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