Files
Kigi-CLI/crates/codegen/kigi-shell/src/auth/device_code.rs
T
ZacharyZhang-NY 8179438278 feat(providers): add GitHub Copilot subscription OAuth (device flow + copilot-token re-mint)
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).
2026-07-22 04:21:02 -04:00

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"
);
}
}