Skip to main content

agent_core/core/auth/
kimi_code.rs

1//! Kimi Code OAuth (RFC 8628 device authorization; direct bearer, no mint).
2//!
3//! Experimental. Flow verified against the official Kimi Code CLI (v0.31.1):
4//! its embedded `packages/oauth` sources pin the same endpoints, client id,
5//! grant type, error codes, and refresh semantics implemented here. The
6//! endpoints are not published as a stable third-party API; do not describe
7//! them as officially supported.
8//!
9//! Flow shape (differences from GitHub Copilot):
10//! - No session mint: the device-flow `access_token` is used directly as the
11//!   API bearer for `https://api.kimi.com/coding/v1`.
12//! - Access tokens are short-lived (~15 min) and refresh tokens **rotate on
13//!   every refresh** — the rotated pair must always be persisted.
14//! - A refresh rejected with HTTP 401/403 or `invalid_grant` means the stored
15//!   refresh token is dead; the user must re-login.
16//! - Upstream sends `X-Msh-*` device-identity headers on every OAuth and API
17//!   request; we mirror that conventional surface with an honest User-Agent.
18//!
19//! Credential mapping:
20//! - `OAuthCredentials.refresh` = rotating Kimi refresh token (broker-owned)
21//! - `OAuthCredentials.access`  = short-lived Kimi access token
22//! - `OAuthCredentials.expires` = access expiry (ms, with refresh margin)
23
24use 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
41// ── Public constants (evidence: Kimi Code CLI v0.31.1 embedded oauth pkg) ────
42
43/// Canonical storage / broker / model-prefix id.
44pub const PROVIDER: &str = "kimi-code";
45
46/// Public native-client id shipped inside the official Kimi Code CLI
47/// (`KIMI_CODE_FLOW_CONFIG.clientId`). Not a client secret.
48pub const CLIENT_ID: &str = "17e5f671-d194-4dfb-9706-5516cb48c098";
49
50/// OAuth host pinned by the official CLI (`https://auth.kimi.com`).
51pub const OAUTH_HOST: &str = "https://auth.kimi.com";
52
53/// Device-authorization endpoint (RFC 8628 §3.1, Kimi path shape).
54pub const DEVICE_AUTH_URL: &str = "https://auth.kimi.com/api/oauth/device_authorization";
55
56/// Token endpoint — device-code polling AND refresh grants.
57pub const TOKEN_URL: &str = "https://auth.kimi.com/api/oauth/token";
58
59/// Device-code grant type (RFC 8628).
60pub const DEVICE_GRANT_TYPE: &str = "urn:ietf:params:oauth:grant-type:device_code";
61
62/// Managed Kimi Code API base (bearer = OAuth access token).
63pub const API_BASE_URL: &str = "https://api.kimi.com/coding/v1";
64
65/// Conventional platform tag sent by the official CLI (`X-Msh-Platform`).
66pub const MSH_PLATFORM: &str = "kimi_code_cli";
67/// Kimi Code surface version this flow was verified against (`X-Msh-Version`).
68pub const MSH_SURFACE_VERSION: &str = "0.31.1";
69/// Honest User-Agent (matches the Copilot mint precedent).
70pub const USER_AGENT: &str = "SynapsCLI/0.6.0";
71
72const DEFAULT_POLL_INTERVAL_SECS: u64 = 5;
73/// Hard ceiling so `slow_down` cannot grow unbounded.
74const MAX_POLL_INTERVAL_SECS: u64 = 30;
75/// Seconds added on each `slow_down` (upstream: +5s).
76const SLOW_DOWN_STEP_SECS: u64 = 5;
77/// Local wall-clock budget when the server omits `expires_in`
78/// (upstream uses a 15-minute device-code timeout).
79const DEFAULT_DEVICE_EXPIRES_IN_SECS: u64 = 900;
80
81/// Refresh-margin floor/ratio (upstream: `max(300, expires_in * 0.5)` secs).
82const MIN_REFRESH_MARGIN_SECS: u64 = 300;
83/// Never bake a margin that would mark a fresh token as already expired.
84const MARGIN_HEADROOM_SECS: u64 = 60;
85
86/// Refresh retry policy (upstream parity: 3 attempts, 2^n seconds backoff).
87const REFRESH_MAX_ATTEMPTS: u32 = 3;
88const REFRESH_BACKOFF_BASE_MS: u64 = 1_000;
89
90/// Connect timeout for production OAuth HTTP.
91pub const CONNECT_TIMEOUT: Duration = Duration::from_secs(10);
92/// Full request timeout for production OAuth HTTP.
93pub const REQUEST_TIMEOUT: Duration = Duration::from_secs(30);
94/// Maximum response body bytes accepted from OAuth endpoints.
95pub const MAX_RESPONSE_BODY_BYTES: usize = 64 * 1024;
96
97/// Logical sleep quantum for cancel checks (production wall-clock chunks).
98const CANCEL_POLL_QUANTUM: Duration = Duration::from_millis(200);
99
100// ── Secret-safe errors ───────────────────────────────────────────────────────
101
102/// Failures from the Kimi auth chain. Variants intentionally store **no**
103/// device codes, user codes, or tokens so Display/Debug cannot leak them.
104#[derive(Clone, PartialEq, Eq)]
105pub enum KimiAuthError {
106    UntrustedEndpoint,
107    InvalidDeviceResponse,
108    InvalidTokenResponse,
109    AccessDenied,
110    Expired,
111    Cancelled,
112    /// Refresh rejected (401/403/invalid_grant) — re-login required.
113    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// ── Injectable boundaries ────────────────────────────────────────────────────
162
163/// Minimal HTTP response used by the device-flow / refresh state machine.
164#[derive(Debug, Clone)]
165pub struct InjectedHttpResponse {
166    pub status: u16,
167    pub body: String,
168}
169
170/// Injectable HTTP surface so tests never touch the network.
171#[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/// Injectable clock (now + cancellable sleep) for deterministic poll tests.
181#[async_trait]
182pub trait KimiClock: Send + Sync {
183    fn now_millis(&self) -> u64;
184
185    /// Sleep `duration`, returning early with [`KimiAuthError::Cancelled`]
186    /// when `cancel` flips.
187    async fn sleep_cancellable<X: KimiCancel + ?Sized>(
188        &self,
189        duration: Duration,
190        cancel: &X,
191    ) -> Result<(), KimiAuthError>;
192}
193
194/// Injectable cancellation signal for device-flow polling.
195pub 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
211/// Injectable browser opener (production uses `open_browser`).
212pub trait KimiBrowser: Send + Sync {
213    fn open(&self, url: &str) -> Result<(), KimiAuthError>;
214}
215
216// ── Device-flow types ────────────────────────────────────────────────────────
217
218#[derive(Debug, Clone)]
219pub struct DeviceAuthorization {
220    /// Opaque device code — never logged via public error types.
221    pub(crate) device_code: String,
222    pub user_code: String,
223    /// Plain verification page (may be empty; server contract keeps it
224    /// optional as long as the complete URI is present).
225    pub verification_uri: String,
226    /// Verification URI with the user code embedded — preferred display/open
227    /// target (required by the upstream contract).
228    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/// Successful token grant (poll or refresh). Secret-bearing — never logged.
235#[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/// Outcome of one device-token poll (secret-safe; tokens only on Authorized).
253#[derive(Debug, Clone, PartialEq, Eq)]
254pub enum DevicePollOutcome {
255    Pending,
256    SlowDown,
257    Authorized { token: KimiTokenSet },
258    Denied,
259    Expired,
260    OtherError,
261}
262
263// ── Endpoint pinning ─────────────────────────────────────────────────────────
264
265/// Accept only the exact pinned HTTPS auth.kimi.com OAuth endpoints.
266pub 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
277/// Accept only an HTTPS kimi.com / *.kimi.com verification destination with
278/// no userinfo, no fragment, and no explicit port. The exact path is
279/// server-assigned (not a documented constant), so the host is the pin.
280pub 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
297// ── Device identity headers ──────────────────────────────────────────────────
298
299/// Strip to printable ASCII, mirroring the upstream `asciiHeader` helper.
300fn 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
314/// Path of the persistent Kimi device id (kept beside auth.json, mode 0600).
315pub fn device_id_path() -> PathBuf {
316    auth_file_path().with_file_name("kimi_device_id")
317}
318
319/// Load the stable device id, creating (and best-effort persisting) a fresh
320/// UUIDv4 on first use. Upstream parity: `createKimiDeviceId`.
321pub 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
342/// Device-identity headers sent on every OAuth and managed-API request.
343///
344/// Upstream (`createKimiDeviceHeaders`) sends this exact `X-Msh-*` surface;
345/// the User-Agent identifies Synaps honestly (Copilot mint precedent).
346/// No header value is secret.
347pub 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// ── Parsing (pure) ───────────────────────────────────────────────────────────
371
372#[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        // Upstream tolerates a missing expires_in and relies on its local
414        // 15-minute wall-clock budget; mirror that.
415        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
432/// Parse a successful token grant. The upstream contract requires all three
433/// of access_token / refresh_token / expires_in — enforce the same here so a
434/// non-rotating or non-expiring credential can never be persisted silently.
435fn 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
472/// Map a granted token set onto broker credentials.
473///
474/// The stored `expires` bakes in the upstream refresh margin
475/// (`max(300, expires_in / 2)` seconds, capped so a fresh token is never
476/// instantly "expired"), so the repo-wide bare `now >= expires` check
477/// refreshes on the same schedule as the official CLI.
478pub 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
500// ── Bounded body read ────────────────────────────────────────────────────────
501
502/// Read a response body with a hard byte cap. Fail closed if exceeded.
503pub 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
520// ── Device-flow state machine ────────────────────────────────────────────────
521
522/// Start device authorization (POST /api/oauth/device_authorization).
523pub 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
541/// One poll against the token endpoint.
542pub 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    // Upstream contract: pending/denied/expired arrive as OAuth error bodies
561    // with 4xx statuses — classify by body first, status second.
562    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
578/// Poll until authorized, denied, expired, cancelled, or deadline.
579pub 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
627// ── Refresh (rotating; retry transient failures) ─────────────────────────────
628
629/// Classify one refresh response. Pure; drives the retry loop.
630pub 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/// Whether one refresh attempt should be retried or surfaced.
656#[derive(Debug, Clone, PartialEq, Eq)]
657pub enum RefreshDisposition {
658    Retry(KimiAuthError),
659    Fatal(KimiAuthError),
660}
661
662/// Refresh with upstream retry semantics: up to 3 attempts, exponential
663/// backoff on 429/5xx, immediate failure on 401/403/`invalid_grant`.
664///
665/// The caller (`ensure_fresh_provider_token`) persists the returned
666/// credentials — including the **rotated** refresh token — atomically.
667pub 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                // Transport-level failures are retryable (upstream parity).
703                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
718// ── Full login (injectable) ──────────────────────────────────────────────────
719
720pub type UserCodeHook<'a> = &'a dyn Fn(&str, &str);
721
722pub struct LoginHooks<'a, B> {
723    pub browser: &'a B,
724    /// When set, the user_code + verification URI are written here (tests / TUI).
725    pub on_user_code: Option<UserCodeHook<'a>>,
726}
727
728/// Device start → user prompt → poll → atomic persist.
729pub 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    // Open only the validated verification URI (never an arbitrary redirect).
754    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
765/// Production login entry (real network + system browser + auth.json).
766pub 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
785/// Broker refresh: rotate the token pair (no browser). The caller persists
786/// the rotated credentials via the provider single-flight gate.
787pub 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
795// ── Production adapters ──────────────────────────────────────────────────────
796
797struct ProductionHttp {
798    /// No-redirect client with connect + request timeouts for secret-bearing calls.
799    client: Client,
800    /// Stable device id resolved once per flow.
801    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        // Redirects are disabled; a 3xx means fail closed without following.
838        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// ── Tests ────────────────────────────────────────────────────────────────────
890
891#[cfg(test)]
892mod tests {
893    use super::*;
894    use std::sync::Mutex;
895
896    // ── Test doubles ─────────────────────────────────────────────────────────
897
898    #[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    /// Scripted HTTP: pops responses front-to-back, recording each form body.
934    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    // ── Endpoint pinning ─────────────────────────────────────────────────────
1011
1012    #[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    // ── Parsing ──────────────────────────────────────────────────────────────
1051
1052    #[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        // Missing refresh_token → invalid (Kimi always rotates).
1128        assert!(parse_device_poll_response(r#"{"access_token":"a","expires_in":900}"#).is_err());
1129        // Missing/zero expires_in → invalid (upstream contract).
1130        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        // 900s token, margin = max(300, 450) = 450s → expires = now + 450s.
1140        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        // Short-lived token: margin must leave headroom, not zero the expiry.
1151        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    // ── Refresh classification ───────────────────────────────────────────────
1170
1171    #[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        // 200 with garbage body must not be accepted.
1198        assert_eq!(
1199            classify_refresh_response(200, "{}").unwrap_err(),
1200            RefreshDisposition::Fatal(KimiAuthError::InvalidTokenResponse)
1201        );
1202    }
1203
1204    // ── State machine (async, deterministic) ─────────────────────────────────
1205
1206    #[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        // Poll must carry the device grant type + device code.
1238        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        // expires_in 300s; each pending poll advances 5s of fake time.
1280        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        // Rotated refresh token must replace the old one.
1327        assert_eq!(creds.refresh, "ref-1");
1328        // Backoff: 1s then 2s.
1329        assert_eq!(*clock.sleeps.lock().unwrap(), vec![1_000, 2_000]);
1330        // Refresh form carries the refresh grant, never the device grant.
1331        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        // Exactly one request — the scripted 200 must remain unconsumed.
1346        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    // ── Identity headers ─────────────────────────────────────────────────────
1368
1369    #[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        // Compile-time shape guarantee: no variant stores a String, so no
1415        // token can transit. Spot-check Display output anyway.
1416        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}