Files
Kigi-CLI/crates/codegen/kigi-shell/src/auth/github_copilot.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

486 lines
20 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! GitHub Copilot two-stage OAuth wire (github-copilot), driven by a registry
//! [`kigi_models::OAuthConfig`] whose `flow` is [`OAuthFlow::GithubDeviceCopilot`].
//!
//! Stage 1 — RFC-8628 device flow on `auth_host` (github.com). The device
//! authorization POST is the generic one ([`super::oauth_device`]); the token
//! POLL is Copilot-specific because GitHub returns its device errors in a `200`
//! body (`{error: "authorization_pending"|"slow_down"|"expired_token"}`), not a
//! `4xx`, and the success payload carries ONLY `access_token` (the DURABLE
//! GitHub token — no refresh token, no expiry).
//!
//! Stage 2 — copilot-token exchange: `GET {copilot_exchange}` bearing the GitHub
//! token + the editor headers re-mints the SHORT-LIVED copilot session token
//! (`{token, expires_at}`). This runs at login ([`exchange_copilot_token`]) and
//! on every "refresh" ([`remint_copilot_token`], dispatched by the generic
//! refresher) — the github token is unchanged and re-persisted as the
//! `refresh_token`; the copilot token becomes the `key`.
//!
//! SECURITY: the github token and the copilot token are NEVER logged (only
//! non-secret events: poll succeeded, copilot token minted/re-minted).
use chrono::{DateTime, Utc};
use kigi_models::OAuthConfig;
use serde::Deserialize;
use super::kimi_oauth::{DevicePollResult, RefreshError};
use super::model::{AuthMode, KimiAuth};
/// RFC-8628 device grant type (shared with the generic device wire).
const DEVICE_GRANT_TYPE: &str = "urn:ietf:params:oauth:grant-type:device_code";
/// Copilot-exchange retry budget over 5xx / network blips (parity with the
/// device/PKCE refresh wires); 401/403 fails fast (the github token is dead).
const MAX_EXCHANGE_RETRIES: u32 = 3;
const RETRYABLE_EXCHANGE_STATUSES: [u16; 5] = [429, 500, 502, 503, 504];
/// The four VS Code Copilot editor-identity headers every Copilot request
/// carries. Non-secret wire constants owned by `kigi_sampling_types` (the
/// single source shared with the sampler's inference gate).
fn editor_headers() -> [(&'static str, &'static str); 4] {
[
("User-Agent", kigi_sampling_types::COPILOT_USER_AGENT),
(
"Editor-Version",
kigi_sampling_types::COPILOT_EDITOR_VERSION,
),
(
"Editor-Plugin-Version",
kigi_sampling_types::COPILOT_EDITOR_PLUGIN_VERSION,
),
(
"Copilot-Integration-Id",
kigi_sampling_types::COPILOT_INTEGRATION_ID,
),
]
}
/// GitHub's device-token poll response: EITHER `access_token` (the durable
/// GitHub token) OR an `error` (in a `200` body). No refresh token / expiry.
#[derive(Deserialize, Default)]
struct GithubDeviceTokenResponse {
#[serde(default)]
access_token: Option<String>,
#[serde(default)]
error: Option<String>,
}
/// One poll of `POST {auth_host}{token_path}` (github.com/login/oauth/
/// access_token) with the device grant. GitHub answers `200` for BOTH success
/// and the pending/slow_down/expired errors, so the outcome is read from the
/// body, not the status. On success the [`KimiAuth`] carries the GitHub token as
/// `key` with NO refresh token / expiry — the caller finalizes it via the
/// copilot exchange before persisting.
pub(crate) async fn poll_github_device_token(
cfg: &OAuthConfig,
device_code: &str,
) -> anyhow::Result<DevicePollResult> {
let url = format!("{}{}", cfg.auth_host.trim_end_matches('/'), cfg.token_path);
let resp = crate::http::shared_client()
.post(&url)
.header("Accept", "application/json")
.header("User-Agent", kigi_sampling_types::COPILOT_USER_AGENT)
.form(&[
("client_id", cfg.client_id),
("device_code", device_code),
("grant_type", DEVICE_GRANT_TYPE),
])
.send()
.await
.map_err(|e| anyhow::anyhow!("Token polling request failed: {e}"))?;
let status = resp.status();
if status.is_server_error() {
anyhow::bail!("Token polling server error: {status}");
}
let body = resp.bytes().await?;
let parsed: GithubDeviceTokenResponse = serde_json::from_slice(&body).unwrap_or_default();
if let Some(access) = parsed.access_token.filter(|t| !t.is_empty()) {
tracing::info!("auth: github device poll succeeded, github token issued (copilot)");
return Ok(DevicePollResult::Success(Box::new(github_token_auth(
access,
))));
}
match parsed.error.as_deref() {
Some("expired_token") => {
tracing::info!("auth: github device code expired; restarting (copilot)");
Ok(DevicePollResult::Expired)
}
Some(error) => {
tracing::debug!(error, "auth: github device poll pending (copilot)");
Ok(DevicePollResult::Pending {
error: error.to_owned(),
description: None,
})
}
None => Ok(DevicePollResult::Pending {
error: "missing_access_token".to_owned(),
description: None,
}),
}
}
/// A transient [`KimiAuth`] holding ONLY the durable GitHub token (no refresh
/// token / expiry) — the intermediate device-flow result, finalized by the
/// copilot exchange before it is ever persisted.
fn github_token_auth(github_token: String) -> KimiAuth {
KimiAuth {
key: github_token,
auth_mode: AuthMode::OAuth,
create_time: Utc::now(),
user_id: String::new(),
email: None,
refresh_token: None,
expires_at: None,
expires_in: None,
scope: None,
token_type: None,
}
}
/// Stage-2 copilot-token exchange response (`GET copilot_internal/v2/token`).
/// `endpoints`/`proxy-ep` are ignored: Kigi resolves the base URL from the
/// platform registry (the individual-subscription endpoint, or the
/// `KIGI_COPILOT_BASE_URL` override).
#[derive(Deserialize)]
struct CopilotTokenResponse {
token: String,
/// Unix seconds when the copilot token expires (~30 min out).
expires_at: i64,
}
/// Materialize the persisted credential from a copilot-token exchange:
/// `key` = the short-lived copilot token, `refresh_token` = the DURABLE github
/// token (so every re-mint re-exchanges it), `expires_at` = the copilot expiry.
fn copilot_auth(resp: CopilotTokenResponse, github_token: &str) -> anyhow::Result<KimiAuth> {
let now = Utc::now();
// FAIL-FAST: an uninterpretable expiry means we cannot schedule the re-mint,
// so reject it rather than silently falling back to a long default TTL — which
// would let the ~30-min copilot token 401 on the wire ~30 min later.
let expires_at = DateTime::from_timestamp(resp.expires_at, 0).ok_or_else(|| {
anyhow::anyhow!(
"copilot token has an out-of-range expires_at: {}",
resp.expires_at
)
})?;
// The manager's dynamic threshold (`max(300, expires_in × 0.5)`) drives the
// proactive re-mint; `expires_in` is the copilot token's remaining life.
let expires_in = (expires_at - now).num_seconds();
Ok(KimiAuth {
key: resp.token,
auth_mode: AuthMode::OAuth,
create_time: now,
user_id: String::new(),
email: None,
refresh_token: Some(github_token.to_owned()),
expires_at: Some(expires_at),
expires_in: Some(expires_in),
scope: None,
token_type: Some("bearer".to_owned()),
})
}
/// The `(host, path)` of the copilot-token exchange endpoint, or a fatal error
/// when the config lacks it (a non-Copilot config reaching this wire is a bug).
fn exchange_url(cfg: &OAuthConfig) -> anyhow::Result<String> {
let (host, path) = cfg.copilot_exchange.ok_or_else(|| {
anyhow::anyhow!("github-copilot config missing copilot_exchange endpoint")
})?;
Ok(format!("{}{path}", host.trim_end_matches('/')))
}
/// `GET {copilot_exchange}` bearing the github token + editor headers.
async fn send_copilot_exchange(
cfg: &OAuthConfig,
github_token: &str,
) -> anyhow::Result<reqwest::Response> {
let url = exchange_url(cfg)?;
let mut req = crate::http::shared_client()
.get(&url)
.header("Accept", "application/json")
.header("Authorization", format!("Bearer {github_token}"));
for (name, value) in editor_headers() {
req = req.header(name, value);
}
req.send()
.await
.map_err(|e| anyhow::anyhow!("copilot-token exchange request failed: {e}"))
}
/// Exchange the durable GitHub token for a copilot session token (login path).
/// FAIL-FAST: a non-2xx response aborts login (never a silent fallback).
pub(crate) async fn exchange_copilot_token(
cfg: &OAuthConfig,
github_token: &str,
) -> anyhow::Result<KimiAuth> {
tracing::info!(
scope_key = cfg.scope_key,
"auth: exchanging github token for copilot token"
);
let resp = send_copilot_exchange(cfg, github_token).await?;
let status = resp.status();
if !status.is_success() {
let body = resp.text().await.unwrap_or_default();
tracing::warn!(%status, scope_key = cfg.scope_key, "auth: copilot-token exchange failed");
anyhow::bail!("Copilot token exchange failed (HTTP {status}): {body}");
}
let parsed: CopilotTokenResponse = resp
.json()
.await
.map_err(|e| anyhow::anyhow!("malformed copilot token payload: {e}"))?;
tracing::info!(scope_key = cfg.scope_key, "auth: copilot token minted");
copilot_auth(parsed, github_token)
}
/// Re-mint the copilot token from the durable GitHub token (refresh path,
/// dispatched from the generic refresher). This is NOT a `refresh_token` grant:
/// it re-runs the copilot exchange. `refresh_token` is the github token; the
/// returned [`KimiAuth`] preserves it. Retries 5xx / network blips; 401/403
/// (the github token is revoked) fails fast as [`RefreshError::Unauthorized`].
pub(crate) async fn remint_copilot_token(
cfg: &OAuthConfig,
github_token: &str,
) -> Result<KimiAuth, RefreshError> {
let mut last_error = String::from("no attempt made");
for attempt in 0..MAX_EXCHANGE_RETRIES {
if attempt > 0 {
let backoff = std::time::Duration::from_secs(1 << (attempt - 1));
tracing::warn!(
attempt,
backoff_secs = backoff.as_secs(),
"auth: retrying copilot-token re-mint"
);
tokio::time::sleep(backoff).await;
}
let resp = match send_copilot_exchange(cfg, github_token).await {
Ok(resp) => resp,
Err(e) => {
last_error = format!("{e}");
continue;
}
};
let status = resp.status().as_u16();
let bytes = resp.bytes().await.unwrap_or_default();
if status == 401 || status == 403 {
return Err(RefreshError::Unauthorized {
status,
description: "GitHub token rejected at copilot-token exchange.".to_owned(),
});
}
if status == 200 {
return match serde_json::from_slice::<CopilotTokenResponse>(&bytes) {
Ok(parsed) => match copilot_auth(parsed, github_token) {
Ok(auth) => {
tracing::info!(scope_key = cfg.scope_key, "auth: copilot token re-minted");
Ok(auth)
}
Err(e) => Err(RefreshError::Fatal {
status,
description: format!("{e}"),
}),
},
Err(e) => Err(RefreshError::Fatal {
status,
description: format!("malformed copilot token payload: {e}"),
}),
};
}
let description = format!("copilot-token exchange failed (HTTP {status}).");
if RETRYABLE_EXCHANGE_STATUSES.contains(&status) {
last_error = description;
continue;
}
return Err(RefreshError::Fatal {
status,
description,
});
}
Err(RefreshError::Exhausted { last_error })
}
#[cfg(test)]
mod tests {
use super::*;
use chrono::Duration;
use kigi_models::COPILOT_OAUTH_CONFIG;
use wiremock::matchers::{body_string_contains, header, method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
/// A COPILOT_OAUTH_CONFIG pointed at a mock server for both stages.
fn mock_cfg(host: &'static str, exchange: &'static str) -> OAuthConfig {
OAuthConfig {
auth_host: host,
token_host: host,
copilot_exchange: Some((exchange, "/copilot_internal/v2/token")),
..COPILOT_OAUTH_CONFIG
}
}
/// GitHub's device poll returns pending errors in a 200 body — mapped to
/// Pending (authorization_pending / slow_down) and Expired (expired_token),
/// never mis-read as a token.
#[tokio::test]
async fn github_device_poll_maps_200_body_errors() {
let server = MockServer::start().await;
let host: &'static str = Box::leak(server.uri().into_boxed_str());
Mock::given(method("POST"))
.and(path("/login/oauth/access_token"))
.and(body_string_contains("grant_type=urn"))
.respond_with(
ResponseTemplate::new(200)
.set_body_json(serde_json::json!({ "error": "authorization_pending" })),
)
.mount(&server)
.await;
let result = poll_github_device_token(&mock_cfg(host, host), "dev-1")
.await
.unwrap();
assert!(
matches!(result, DevicePollResult::Pending { error, .. } if error == "authorization_pending")
);
}
#[tokio::test]
async fn github_device_poll_expired_restarts() {
let server = MockServer::start().await;
let host: &'static str = Box::leak(server.uri().into_boxed_str());
Mock::given(method("POST"))
.and(path("/login/oauth/access_token"))
.respond_with(
ResponseTemplate::new(200)
.set_body_json(serde_json::json!({ "error": "expired_token" })),
)
.mount(&server)
.await;
let result = poll_github_device_token(&mock_cfg(host, host), "dev-1")
.await
.unwrap();
assert!(matches!(result, DevicePollResult::Expired));
}
/// A successful poll yields the DURABLE github token as `key` with NO
/// refresh token / expiry (the copilot exchange finalizes it next).
#[tokio::test]
async fn github_device_poll_success_is_bare_github_token() {
let server = MockServer::start().await;
let host: &'static str = Box::leak(server.uri().into_boxed_str());
Mock::given(method("POST"))
.and(path("/login/oauth/access_token"))
.respond_with(
ResponseTemplate::new(200)
.set_body_json(serde_json::json!({ "access_token": "gho_github_tok" })),
)
.mount(&server)
.await;
let DevicePollResult::Success(auth) = poll_github_device_token(&mock_cfg(host, host), "d")
.await
.unwrap()
else {
panic!("expected success");
};
assert_eq!(auth.key, "gho_github_tok");
assert_eq!(
auth.refresh_token, None,
"github token is not a refresh grant"
);
assert_eq!(auth.expires_at, None, "the github token is long-lived");
}
/// The Stage-2 exchange rides the github Bearer + editor headers and maps
/// `{token, expires_at}` onto `key=copilot`, `refresh_token=github`, with a
/// future `expires_at`.
#[tokio::test]
async fn copilot_exchange_maps_token_and_persists_github_as_refresh() {
let server = MockServer::start().await;
let host: &'static str = Box::leak(server.uri().into_boxed_str());
let future = (Utc::now() + Duration::minutes(30)).timestamp();
Mock::given(method("GET"))
.and(path("/copilot_internal/v2/token"))
.and(header("Authorization", "Bearer gho_github_tok"))
.and(header("Editor-Version", "vscode/1.107.0"))
.and(header("Copilot-Integration-Id", "vscode-chat"))
.respond_with(ResponseTemplate::new(200).set_body_json(
serde_json::json!({ "token": "tid=abc;copilot-tok", "expires_at": future }),
))
.expect(1)
.mount(&server)
.await;
let auth = exchange_copilot_token(&mock_cfg(host, host), "gho_github_tok")
.await
.unwrap();
assert_eq!(auth.key, "tid=abc;copilot-tok", "key = copilot 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 > Utc::now()),
"copilot expiry must be in the future"
);
}
/// The copilot re-mint (refresh) re-exchanges the github token for a NEW
/// copilot token, keeping the github token as refresh_token.
#[tokio::test]
async fn copilot_remint_returns_new_copilot_token() {
let server = MockServer::start().await;
let host: &'static str = Box::leak(server.uri().into_boxed_str());
let future = (Utc::now() + Duration::minutes(30)).timestamp();
Mock::given(method("GET"))
.and(path("/copilot_internal/v2/token"))
.and(header("Authorization", "Bearer gho_github_tok"))
.respond_with(ResponseTemplate::new(200).set_body_json(
serde_json::json!({ "token": "copilot-tok-2", "expires_at": future }),
))
.mount(&server)
.await;
let auth = remint_copilot_token(&mock_cfg(host, host), "gho_github_tok")
.await
.unwrap();
assert_eq!(auth.key, "copilot-tok-2");
assert_eq!(auth.refresh_token.as_deref(), Some("gho_github_tok"));
}
/// A 401 at the exchange (github token revoked) fails fast as Unauthorized
/// (drives the manager's permanent-failure / re-login path).
#[tokio::test]
async fn copilot_remint_401_is_unauthorized() {
let server = MockServer::start().await;
let host: &'static str = Box::leak(server.uri().into_boxed_str());
Mock::given(method("GET"))
.and(path("/copilot_internal/v2/token"))
.respond_with(ResponseTemplate::new(401))
.mount(&server)
.await;
let err = remint_copilot_token(&mock_cfg(host, host), "dead-github-tok")
.await
.unwrap_err();
assert!(
matches!(err, RefreshError::Unauthorized { status: 401, .. }),
"got {err:?}"
);
}
/// FAIL-FAST: an out-of-range `expires_at` is rejected rather than silently
/// degrading to a long default TTL (which would 401 on the wire ~30 min in).
#[tokio::test]
async fn copilot_exchange_rejects_out_of_range_expiry() {
let server = MockServer::start().await;
let host: &'static str = Box::leak(server.uri().into_boxed_str());
Mock::given(method("GET"))
.and(path("/copilot_internal/v2/token"))
.respond_with(ResponseTemplate::new(200).set_body_json(
serde_json::json!({ "token": "copilot-tok", "expires_at": i64::MAX }),
))
.mount(&server)
.await;
let err = exchange_copilot_token(&mock_cfg(host, host), "gho_github_tok")
.await
.unwrap_err();
assert!(
format!("{err}").contains("out-of-range expires_at"),
"got {err}"
);
}
}