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, 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    /// The credential store is corrupt; carries our own repair text
119    /// (path + `mv` command, no secrets).
120    CorruptStore(String),
121    Other(&'static str),
122}
123
124impl KimiAuthError {
125    /// Login persistence failure: a corrupt store keeps its actionable text.
126    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    /// Typed refresh failure (after `refresh_with`'s own retries).
170    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// ── Injectable boundaries ────────────────────────────────────────────────────
189
190/// Minimal HTTP response used by the device-flow / refresh state machine.
191#[derive(Debug, Clone)]
192pub struct InjectedHttpResponse {
193    pub status: u16,
194    pub body: String,
195}
196
197/// Injectable HTTP surface so tests never touch the network.
198#[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/// Injectable clock (now + cancellable sleep) for deterministic poll tests.
208#[async_trait]
209pub trait KimiClock: Send + Sync {
210    fn now_millis(&self) -> u64;
211
212    /// Sleep `duration`, returning early with [`KimiAuthError::Cancelled`]
213    /// when `cancel` flips.
214    async fn sleep_cancellable<X: KimiCancel + ?Sized>(
215        &self,
216        duration: Duration,
217        cancel: &X,
218    ) -> Result<(), KimiAuthError>;
219}
220
221/// Injectable cancellation signal for device-flow polling.
222pub 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
238/// Injectable browser opener (production uses `open_browser`).
239pub trait KimiBrowser: Send + Sync {
240    fn open(&self, url: &str) -> Result<(), KimiAuthError>;
241}
242
243// ── Device-flow types ────────────────────────────────────────────────────────
244
245#[derive(Debug, Clone)]
246pub struct DeviceAuthorization {
247    /// Opaque device code — never logged via public error types.
248    pub(crate) device_code: String,
249    pub user_code: String,
250    /// Plain verification page (may be empty; server contract keeps it
251    /// optional as long as the complete URI is present).
252    pub verification_uri: String,
253    /// Verification URI with the user code embedded — preferred display/open
254    /// target (required by the upstream contract).
255    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/// Successful token grant (poll or refresh). Secret-bearing — never logged.
262#[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/// Outcome of one device-token poll (secret-safe; tokens only on Authorized).
280#[derive(Debug, Clone, PartialEq, Eq)]
281pub enum DevicePollOutcome {
282    Pending,
283    SlowDown,
284    Authorized { token: KimiTokenSet },
285    Denied,
286    Expired,
287    OtherError,
288}
289
290// ── Endpoint pinning ─────────────────────────────────────────────────────────
291
292/// Accept only the exact pinned HTTPS auth.kimi.com OAuth endpoints.
293pub 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
304/// Accept only an HTTPS kimi.com / *.kimi.com verification destination with
305/// no userinfo, no fragment, and no explicit port. The exact path is
306/// server-assigned (not a documented constant), so the host is the pin.
307pub 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
324// ── Device identity headers ──────────────────────────────────────────────────
325
326/// Strip to printable ASCII, mirroring the upstream `asciiHeader` helper.
327fn 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
341/// Path of the persistent Kimi device id (kept beside auth.json, mode 0600).
342pub fn device_id_path() -> PathBuf {
343    auth_file_path().with_file_name("kimi_device_id")
344}
345
346/// Load the stable device id, creating (and best-effort persisting) a fresh
347/// UUIDv4 on first use. Upstream parity: `createKimiDeviceId`.
348pub 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
369/// Device-identity headers sent on every OAuth and managed-API request.
370///
371/// Upstream (`createKimiDeviceHeaders`) sends this exact `X-Msh-*` surface;
372/// the User-Agent identifies Synaps honestly (Copilot mint precedent).
373/// No header value is secret.
374pub 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// ── Parsing (pure) ───────────────────────────────────────────────────────────
398
399#[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        // Upstream tolerates a missing expires_in and relies on its local
441        // 15-minute wall-clock budget; mirror that.
442        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
459/// Parse a successful token grant. The upstream contract requires all three
460/// of access_token / refresh_token / expires_in — enforce the same here so a
461/// non-rotating or non-expiring credential can never be persisted silently.
462fn 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
499/// Map a granted token set onto broker credentials.
500///
501/// The stored `expires` bakes in the upstream refresh margin
502/// (`max(300, expires_in / 2)` seconds, capped so a fresh token is never
503/// instantly "expired"), so the repo-wide bare `now >= expires` check
504/// refreshes on the same schedule as the official CLI.
505pub 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
527// ── Bounded body read ────────────────────────────────────────────────────────
528
529/// Read a response body with a hard byte cap. Fail closed if exceeded.
530pub 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
547// ── Device-flow state machine ────────────────────────────────────────────────
548
549/// Start device authorization (POST /api/oauth/device_authorization).
550pub 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
568/// One poll against the token endpoint.
569pub 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    // Upstream contract: pending/denied/expired arrive as OAuth error bodies
588    // with 4xx statuses — classify by body first, status second.
589    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
605/// Poll until authorized, denied, expired, cancelled, or deadline.
606pub 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
654// ── Refresh (rotating; retry transient failures) ─────────────────────────────
655
656/// Classify one refresh response. Pure; drives the retry loop.
657pub 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/// Whether one refresh attempt should be retried or surfaced.
683#[derive(Debug, Clone, PartialEq, Eq)]
684pub enum RefreshDisposition {
685    Retry(KimiAuthError),
686    Fatal(KimiAuthError),
687}
688
689/// Refresh with upstream retry semantics: up to 3 attempts, exponential
690/// backoff on 429/5xx, immediate failure on 401/403/`invalid_grant`.
691///
692/// The caller (`ensure_fresh_provider_token`) persists the returned
693/// credentials — including the **rotated** refresh token — atomically.
694pub 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                // Transport-level failures are retryable (upstream parity).
730                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
745// ── Full login (injectable) ──────────────────────────────────────────────────
746
747pub type UserCodeHook<'a> = &'a dyn Fn(&str, &str);
748
749pub struct LoginHooks<'a, B> {
750    pub browser: &'a B,
751    /// When set, the user_code + verification URI are written here (tests / TUI).
752    pub on_user_code: Option<UserCodeHook<'a>>,
753}
754
755/// Device start → user prompt → poll → atomic persist (default slot).
756pub 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
772/// Like [`login_with`] but persists ONLY into `persist_key` when given
773/// (`kimi-code` or `kimi-code@<label>`).
774pub 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    // Open only the validated verification URI (never an arbitrary redirect).
799    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
810/// Production login entry (real network + system browser + auth.json).
811pub async fn login() -> Result<OAuthCredentials, String> {
812    login_into(Some(PROVIDER)).await
813}
814
815/// Production login; `Some(key)` persists ONLY into that slot, `None`
816/// returns the credential unsaved.
817pub 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
836/// Broker refresh: rotate the token pair (no browser). The caller persists
837/// the rotated credentials via the provider single-flight gate.
838pub 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
849// ── Production adapters ──────────────────────────────────────────────────────
850
851struct ProductionHttp {
852    /// No-redirect client with connect + request timeouts for secret-bearing calls.
853    client: Client,
854    /// Stable device id resolved once per flow.
855    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        // Redirects are disabled; a 3xx means fail closed without following.
892        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// ── Tests ────────────────────────────────────────────────────────────────────
944
945#[cfg(test)]
946mod tests {
947    use super::*;
948
949    /// Round-2 I2: a corrupt store at login is reported with its repair
950    /// command, not as "credential persistence failed".
951    #[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    // ── Test doubles ─────────────────────────────────────────────────────────
965
966    #[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    /// Scripted HTTP: pops responses front-to-back, recording each form body.
1002    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    // ── Endpoint pinning ─────────────────────────────────────────────────────
1079
1080    #[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    // ── Parsing ──────────────────────────────────────────────────────────────
1119
1120    #[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        // Missing refresh_token → invalid (Kimi always rotates).
1196        assert!(parse_device_poll_response(r#"{"access_token":"a","expires_in":900}"#).is_err());
1197        // Missing/zero expires_in → invalid (upstream contract).
1198        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        // 900s token, margin = max(300, 450) = 450s → expires = now + 450s.
1208        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        // Short-lived token: margin must leave headroom, not zero the expiry.
1219        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    // ── Refresh classification ───────────────────────────────────────────────
1238
1239    #[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        // 200 with garbage body must not be accepted.
1266        assert_eq!(
1267            classify_refresh_response(200, "{}").unwrap_err(),
1268            RefreshDisposition::Fatal(KimiAuthError::InvalidTokenResponse)
1269        );
1270    }
1271
1272    // ── State machine (async, deterministic) ─────────────────────────────────
1273
1274    #[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        // Poll must carry the device grant type + device code.
1306        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        // expires_in 300s; each pending poll advances 5s of fake time.
1348        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        // Rotated refresh token must replace the old one.
1395        assert_eq!(creds.refresh, "ref-1");
1396        // Backoff: 1s then 2s.
1397        assert_eq!(*clock.sleeps.lock().unwrap(), vec![1_000, 2_000]);
1398        // Refresh form carries the refresh grant, never the device grant.
1399        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        // Exactly one request — the scripted 200 must remain unconsumed.
1414        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    // ── Identity headers ─────────────────────────────────────────────────────
1436
1437    #[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        // Compile-time shape guarantee: no variant stores a String, so no
1483        // token can transit. Spot-check Display output anyway.
1484        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}