28th platform `github-copilot` (uses_oauth, ChatCompletions wire). Two-stage auth: RFC-8628 GitHub device flow (client Iv1.b507a08c87ecfe98, scope read:user, errors in a 200 body) mints the DURABLE github token; a GET api.github.com/copilot_internal/ v2/token exchange re-mints the SHORT-LIVED copilot token. Persisted as key=copilot token, refresh_token=github token, expires_at=copilot expiry; the "refresh" is a copilot-token re-mint (not a refresh_token grant), dispatched via OAuthTokenBody::GithubCopilotExchange in the generic refresher. VS Code editor-identity headers on /models + /chat/completions, gated on SamplerConfig.github_copilot / PlatformId::sends_copilot_editor_headers() so every other ChatCompletions provider stays byte-identical. Live /models filtered (parse_github_copilot_listing) to the openai-completions-served models: keep iff model_picker_enabled && policy.state!="disabled" && tool_calls!=false AND not a claude-4.x/5.x (messages) or gpt-5/oswe/mai- (responses-only) id — those need per-model wire routing (documented debt), excluded rather than mis-routed. Inherits the leak-safe pooled routing (scope oauth/github-copilot); its bearer/ refresh/api_key never touch the Kimi token (regression test added). Fail-fast on an out-of-range copilot expires_at (would otherwise silently 401 mid-session). Adversarial security review: GO, no CRITICAL/HIGH. Known limitation: Pi's per-model policy-enablement POST is not ported (documented in AGENTS.md).
557 lines
22 KiB
Rust
557 lines
22 KiB
Rust
//! 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<DeviceAuthorization> {
|
|
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<DevicePollResult> {
|
|
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<KimiAuth> {
|
|
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<KimiAuth>),
|
|
/// 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<AuthManager>,
|
|
channels: &mut Option<AuthChannels>,
|
|
) -> 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<AuthManager>,
|
|
channels: &mut Option<AuthChannels>,
|
|
) -> 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<AuthManager>,
|
|
channels: &mut Option<AuthChannels>,
|
|
) -> 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<AuthManager>,
|
|
channels: &mut Option<AuthChannels>,
|
|
) -> 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<PollLoopOutcome> {
|
|
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<AuthManager> {
|
|
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"
|
|
);
|
|
}
|
|
}
|