//! Kimi Code device-code login (PRD F1). //! //! Two-phase API mirroring kimi-cli's `login_kimi_code`: //! 1. [`crate::auth::kimi_oauth::request_device_authorization`] — get a //! user code + verification URL from the OAuth host //! 2. [`complete_device_code_login`] — poll the token endpoint until //! approved, then persist the token set via the `AuthManager` //! //! Poll semantics: the server-provided interval (default 5s, floored at 1s) //! paces the loop; `slow_down` bumps it by 5s; `expired_token` restarts the //! whole device authorization (fresh user code); every other non-200 outcome //! (`authorization_pending`, unknown errors) waits and continues. use std::sync::Arc; use kigi_models::OAuthConfig; use crate::auth::kimi_oauth::{DeviceAuthorization, DevicePollResult}; use crate::auth::{AuthChannels, AuthManager, AuthUrlInfo, AuthUrlMode, KimiAuth}; /// Extra wait added to the poll interval when the server answers `slow_down` /// (OAuth-standard device-flow backpressure). const SLOW_DOWN_INCREMENT_SECS: u64 = 5; /// The wire behind a device-code login. The `Kimi` arm calls the bespoke Kimi /// Code wire (X-Msh headers, `/api/oauth/*`) verbatim — byte-identical to the /// pre-generalization path; the `Generic` arm drives a registry /// [`OAuthConfig`] provider (xai-grok) through [`crate::auth::oauth_device`]. enum DeviceFlowBackend<'a> { Kimi { host: &'a str, }, Generic(&'a OAuthConfig), /// GitHub Copilot two-stage flow: the device authorization is the generic /// one, but the token POLL reads GitHub's 200-body errors, and login /// FINALIZES the durable github token into a copilot session token via the /// Stage-2 exchange (see [`DeviceFlowBackend::finalize`]). GithubCopilot(&'a OAuthConfig), } impl DeviceFlowBackend<'_> { async fn request(&self) -> anyhow::Result { match self { Self::Kimi { host } => { crate::auth::kimi_oauth::request_device_authorization(host).await } Self::Generic(cfg) | Self::GithubCopilot(cfg) => { crate::auth::oauth_device::request_device_authorization(cfg).await } } } async fn poll(&self, device_code: &str) -> anyhow::Result { match self { Self::Kimi { host } => { crate::auth::kimi_oauth::poll_device_token(host, device_code).await } Self::Generic(cfg) => { crate::auth::oauth_device::poll_device_token(cfg, device_code).await } Self::GithubCopilot(cfg) => { crate::auth::github_copilot::poll_github_device_token(cfg, device_code).await } } } /// Transform the device-grant credential before it is persisted. The Kimi /// and generic flows persist the poll result verbatim; the GitHub Copilot /// flow exchanges the durable github token (in `auth.key`) for the /// short-lived copilot token, persisting BOTH (copilot as `key`, github as /// `refresh_token`). async fn finalize(&self, auth: KimiAuth) -> anyhow::Result { match self { Self::Kimi { .. } | Self::Generic(_) => Ok(auth), Self::GithubCopilot(cfg) => { crate::auth::github_copilot::exchange_copilot_token(cfg, &auth.key).await } } } } /// Outcome of one full poll loop over a single device authorization. enum PollLoopOutcome { /// Access token issued. Done(Box), /// The device code expired before the user approved — request a fresh /// authorization and start over. Restart, } /// Device-code login shared by the TUI and CLI. /// /// With `channels` (TUI) the verification URL goes to `url_tx` and the /// browser opens automatically. Without `channels` (CLI) the URL + code are /// printed to stderr. The caller reports success (`✓ Signed in`). pub async fn run_device_code_login_channels( host: &str, auth_manager: &Arc, channels: &mut Option, ) -> anyhow::Result<(KimiAuth, bool)> { run_device_code_login_backend(DeviceFlowBackend::Kimi { host }, auth_manager, channels).await } /// Device-code login for a GENERIC [`OAuthConfig`] provider (xai-grok). Same /// TUI/CLI presentation as the Kimi login; only the wire differs. pub async fn run_device_code_login_generic( oauth: &OAuthConfig, auth_manager: &Arc, channels: &mut Option, ) -> anyhow::Result<(KimiAuth, bool)> { run_device_code_login_backend(DeviceFlowBackend::Generic(oauth), auth_manager, channels).await } /// GitHub Copilot two-stage login (github-copilot): the same device-flow /// presentation as the generic path, but the token poll reads GitHub's 200-body /// errors and the minted github token is finalized into a copilot session token /// before it is persisted (see [`DeviceFlowBackend::finalize`]). pub async fn run_device_code_login_github_copilot( oauth: &OAuthConfig, auth_manager: &Arc, channels: &mut Option, ) -> anyhow::Result<(KimiAuth, bool)> { run_device_code_login_backend( DeviceFlowBackend::GithubCopilot(oauth), auth_manager, channels, ) .await } async fn run_device_code_login_backend( backend: DeviceFlowBackend<'_>, auth_manager: &Arc, channels: &mut Option, ) -> anyhow::Result<(KimiAuth, bool)> { let interactive_tui = channels.is_some(); let mut channels = channels.take(); loop { let device_auth = backend.request().await?; let display_uri = device_auth.verification_uri_complete.clone(); if interactive_tui { // TUI: push the URL through the channel BEFORE opening the // browser, so the UI isn't blocked on a slow/hanging browser // launch (e.g. SSH/headless). if let Some(tx) = channels.as_mut().and_then(|c| c.url_tx.take()) { let _ = tx.send(AuthUrlInfo { url: display_uri.clone(), mode: AuthUrlMode::Device, }); } open_browser_detached(&display_uri).await; } else { prompt_on_stderr(&device_auth).await; } match complete_device_code_login(&backend, &device_auth).await? { PollLoopOutcome::Done(auth) => { // Finalize before persisting: the GitHub Copilot flow exchanges // the durable github token for the short-lived copilot token // here; the Kimi / generic flows pass the credential through. let auth = backend.finalize(*auth).await?; let auth = auth_manager .update(auth) .await .map_err(|e| anyhow::anyhow!("Failed to save credentials: {e}"))?; return Ok((auth, true)); } PollLoopOutcome::Restart => { tracing::info!("auth: device code expired, restarting device authorization"); if !interactive_tui { eprintln!("Device code expired — requesting a new one..."); } // The TUI already consumed url_tx; the restarted flow can // only reach the user via the (re-)opened browser page. continue; } } } } /// Print the verification URL + user code to stderr and open the browser. async fn prompt_on_stderr(device_auth: &DeviceAuthorization) { let display_uri = &device_auth.verification_uri_complete; eprintln!(); eprintln!("To sign in, open this URL in your browser:"); eprintln!(); eprintln!(" {display_uri}"); eprintln!(); if !open_browser_detached(display_uri).await { eprintln!(" (Could not open browser automatically — open the URL above manually.)"); eprintln!(); } // Show the code so the user can confirm it matches the browser // (anti-phishing): the complete URL pre-fills it. eprintln!("Confirm this code in your browser:"); eprintln!(); eprintln!(" {}", device_auth.user_code); eprintln!(); eprintln!( "\x1b[90mOnly continue with a code you requested. \ Don't share it with anyone.\x1b[0m" ); eprintln!(); eprintln!("Waiting for authorization..."); } /// Poll the token endpoint until the user approves, the device code expires /// (→ [`PollLoopOutcome::Restart`]), or the wire fails. async fn complete_device_code_login( backend: &DeviceFlowBackend<'_>, device_auth: &DeviceAuthorization, ) -> anyhow::Result { let mut poll_interval = std::time::Duration::from_secs(device_auth.interval.max(1) as u64); loop { // Sleep first: an immediate poll on a fresh code only returns // authorization_pending (and risks slow_down). tokio::time::sleep(poll_interval).await; match backend.poll(&device_auth.device_code).await? { DevicePollResult::Success(auth) => { tracing::info!("auth: device login authorized"); return Ok(PollLoopOutcome::Done(auth)); } DevicePollResult::Expired => return Ok(PollLoopOutcome::Restart), DevicePollResult::Pending { error, description } => { if error == "slow_down" { poll_interval += std::time::Duration::from_secs(SLOW_DOWN_INCREMENT_SECS); tracing::info!( new_interval_secs = poll_interval.as_secs(), "auth: server asked to slow down device polling" ); } else { tracing::debug!( error = %error, description = ?description, "auth: device authorization pending" ); } } } } } /// Open `url` in the browser off-thread: `webbrowser::open` is synchronous and /// would stall the single-threaded TUI loop. Returns `true` on success so the /// caller can decide how to notify the user (eprintln on CLI, nothing on TUI /// where the URL is already rendered in the widget). Shared with the PKCE flow. pub(super) async fn open_browser_detached(url: &str) -> bool { // Unit tests drive the full login flow against mock servers — their // fixture URLs must never reach a real browser. if cfg!(test) { return false; } let url = url.to_owned(); match tokio::task::spawn_blocking(move || webbrowser::open(&url)).await { Ok(Ok(())) => true, Ok(Err(e)) => { tracing::info!(error = %e, "device auth: could not open browser automatically"); false } Err(e) => { tracing::info!(error = %e, "device auth: browser-open task failed"); false } } } #[cfg(test)] mod tests { use super::*; use crate::auth::KimiCodeConfig; use wiremock::matchers::{body_string_contains, method, path}; use wiremock::{Mock, MockServer, ResponseTemplate}; /// Fixture mirroring the live `device_authorization` payload (verified /// against auth.kimi.com): verification URLs are passed through verbatim /// by the login flow, so they use the real shape. fn device_auth_json(code: &str) -> serde_json::Value { serde_json::json!({ "user_code": "WXYZ-6789", "device_code": code, "verification_uri": "https://www.kimi.com/code/authorize_device", "verification_uri_complete": "https://www.kimi.com/code/authorize_device?user_code=WXYZ-6789", "expires_in": 1800, "interval": 0, // floored to 1s by the poll loop }) } fn token_json(access: &str) -> serde_json::Value { serde_json::json!({ "access_token": access, "refresh_token": "rt-1", "expires_in": 3600, "scope": "kimi-code", "token_type": "bearer", }) } fn auth_manager(dir: &tempfile::TempDir) -> Arc { Arc::new(AuthManager::new(dir.path(), KimiCodeConfig::default())) } /// End-to-end (mock server): authorization → pending → token, persisting /// via the AuthManager. #[tokio::test] async fn device_login_persists_token_after_pending() { let server = MockServer::start().await; Mock::given(method("POST")) .and(path("/api/oauth/device_authorization")) .respond_with(ResponseTemplate::new(200).set_body_json(device_auth_json("dev-1"))) .expect(1) .mount(&server) .await; Mock::given(method("POST")) .and(path("/api/oauth/token")) .respond_with( ResponseTemplate::new(400) .set_body_json(serde_json::json!({ "error": "authorization_pending" })), ) .up_to_n_times(1) .mount(&server) .await; Mock::given(method("POST")) .and(path("/api/oauth/token")) .and(body_string_contains("device_code=dev-1")) .respond_with(ResponseTemplate::new(200).set_body_json(token_json("at-done"))) .mount(&server) .await; let dir = tempfile::tempdir().unwrap(); let mgr = auth_manager(&dir); let mut channels = None; let (auth, is_new) = run_device_code_login_channels(&server.uri(), &mgr, &mut channels) .await .unwrap(); assert!(is_new); assert_eq!(auth.key, "at-done"); assert_eq!( mgr.current_or_expired().map(|a| a.key), Some("at-done".into()), "login must land in the manager cache" ); assert!( dir.path().join("auth.json").exists(), "login must persist to the fallback file store" ); } /// `expired_token` during polling restarts the whole device /// authorization (fresh device code), then completes. #[tokio::test] async fn expired_token_restarts_device_authorization() { let server = MockServer::start().await; // First authorization issues dev-1; second issues dev-2. Mock::given(method("POST")) .and(path("/api/oauth/device_authorization")) .respond_with(ResponseTemplate::new(200).set_body_json(device_auth_json("dev-1"))) .up_to_n_times(1) .expect(1) .mount(&server) .await; Mock::given(method("POST")) .and(path("/api/oauth/device_authorization")) .respond_with(ResponseTemplate::new(200).set_body_json(device_auth_json("dev-2"))) .expect(1) .mount(&server) .await; // dev-1 polls expire; dev-2 polls succeed. Mock::given(method("POST")) .and(path("/api/oauth/token")) .and(body_string_contains("device_code=dev-1")) .respond_with( ResponseTemplate::new(400) .set_body_json(serde_json::json!({ "error": "expired_token" })), ) .expect(1) .mount(&server) .await; Mock::given(method("POST")) .and(path("/api/oauth/token")) .and(body_string_contains("device_code=dev-2")) .respond_with(ResponseTemplate::new(200).set_body_json(token_json("at-restarted"))) .expect(1) .mount(&server) .await; let dir = tempfile::tempdir().unwrap(); let mgr = auth_manager(&dir); let mut channels = None; let (auth, _) = run_device_code_login_channels(&server.uri(), &mgr, &mut channels) .await .unwrap(); assert_eq!(auth.key, "at-restarted"); } /// `slow_down` is wait-and-continue (interval bumped), never fatal. /// Unknown-error continuation is covered at the wire level by /// `kimi_oauth::tests::poll_maps_pending_and_unknown_errors_to_pending`. #[tokio::test] async fn slow_down_keeps_polling_then_succeeds() { let server = MockServer::start().await; Mock::given(method("POST")) .and(path("/api/oauth/device_authorization")) .respond_with(ResponseTemplate::new(200).set_body_json(device_auth_json("dev-1"))) .mount(&server) .await; Mock::given(method("POST")) .and(path("/api/oauth/token")) .respond_with( ResponseTemplate::new(400) .set_body_json(serde_json::json!({ "error": "slow_down" })), ) .up_to_n_times(1) .mount(&server) .await; Mock::given(method("POST")) .and(path("/api/oauth/token")) .respond_with(ResponseTemplate::new(200).set_body_json(token_json("at-patient"))) .expect(1) .mount(&server) .await; let dir = tempfile::tempdir().unwrap(); let mgr = auth_manager(&dir); let mut channels = None; let started = std::time::Instant::now(); let (auth, _) = run_device_code_login_channels(&server.uri(), &mgr, &mut channels) .await .unwrap(); assert_eq!(auth.key, "at-patient"); assert!( started.elapsed() >= std::time::Duration::from_secs(6), "slow_down must bump the poll interval by {SLOW_DOWN_INCREMENT_SECS}s" ); } /// GitHub Copilot two-stage login e2e (mock wire): device authorization → /// poll (pending → github token) → Stage-2 copilot-token exchange (Bearer /// github token + editor headers → copilot token + expiry). The persisted /// credential keys the COPILOT token, keeps the GITHUB token as /// `refresh_token`, and carries the copilot expiry. #[tokio::test] async fn github_copilot_two_stage_login_persists_copilot_and_github() { let server = MockServer::start().await; let host: &'static str = Box::leak(server.uri().into_boxed_str()); let cfg = OAuthConfig { auth_host: host, token_host: host, copilot_exchange: Some((host, "/copilot_internal/v2/token")), ..kigi_models::COPILOT_OAUTH_CONFIG }; // Stage 1a: device authorization (github.com/login/device/code). Mock::given(method("POST")) .and(path("/login/device/code")) .and(body_string_contains("client_id=Iv1.b507a08c87ecfe98")) .and(body_string_contains("scope=read")) .respond_with(ResponseTemplate::new(200).set_body_json(serde_json::json!({ "device_code": "gh-dev-1", "user_code": "WDJB-MJHT", "verification_uri": "https://github.com/login/device", "expires_in": 900, "interval": 0, }))) .mount(&server) .await; // Stage 1b: token poll — GitHub returns errors AND success in a 200 body. Mock::given(method("POST")) .and(path("/login/oauth/access_token")) .respond_with( ResponseTemplate::new(200) .set_body_json(serde_json::json!({ "error": "authorization_pending" })), ) .up_to_n_times(1) .mount(&server) .await; Mock::given(method("POST")) .and(path("/login/oauth/access_token")) .and(body_string_contains("device_code=gh-dev-1")) .respond_with( ResponseTemplate::new(200) .set_body_json(serde_json::json!({ "access_token": "gho_github_tok" })), ) .mount(&server) .await; // Stage 2: copilot-token exchange (Bearer github token + editor headers). let future = (chrono::Utc::now() + chrono::Duration::minutes(30)).timestamp(); Mock::given(method("GET")) .and(path("/copilot_internal/v2/token")) .and(wiremock::matchers::header( "Authorization", "Bearer gho_github_tok", )) .and(wiremock::matchers::header( "Editor-Plugin-Version", "copilot-chat/0.35.0", )) .respond_with(ResponseTemplate::new(200).set_body_json( serde_json::json!({ "token": "copilot-session-tok", "expires_at": future }), )) .expect(1) .mount(&server) .await; let dir = tempfile::tempdir().unwrap(); let mgr = auth_manager(&dir); let mut channels = None; let (auth, is_new) = run_device_code_login_github_copilot(&cfg, &mgr, &mut channels) .await .unwrap(); assert!(is_new); assert_eq!( auth.key, "copilot-session-tok", "key = copilot session token" ); assert_eq!( auth.refresh_token.as_deref(), Some("gho_github_tok"), "the durable github token is persisted as refresh_token" ); assert!( auth.expires_at.is_some_and(|e| e > chrono::Utc::now()), "the copilot expiry must be persisted" ); assert_eq!( mgr.current_or_expired().map(|a| a.key), Some("copilot-session-tok".into()), "login must land the copilot token in the manager cache" ); } /// A 5xx from the token endpoint is a hard error (kimi-cli parity). #[tokio::test] async fn server_error_during_poll_fails_login() { let server = MockServer::start().await; Mock::given(method("POST")) .and(path("/api/oauth/device_authorization")) .respond_with(ResponseTemplate::new(200).set_body_json(device_auth_json("dev-1"))) .mount(&server) .await; Mock::given(method("POST")) .and(path("/api/oauth/token")) .respond_with(ResponseTemplate::new(500)) .mount(&server) .await; let dir = tempfile::tempdir().unwrap(); let mgr = auth_manager(&dir); let mut channels = None; let err = run_device_code_login_channels(&server.uri(), &mgr, &mut channels) .await .unwrap_err(); assert!(err.to_string().contains("server error"), "{err}"); assert!( mgr.current_or_expired().is_none(), "failed login must not persist credentials" ); } }