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).
This commit is contained in:
@@ -27,8 +27,15 @@ const SLOW_DOWN_INCREMENT_SECS: u64 = 5;
|
||||
/// 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 },
|
||||
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<'_> {
|
||||
@@ -37,7 +44,7 @@ impl DeviceFlowBackend<'_> {
|
||||
Self::Kimi { host } => {
|
||||
crate::auth::kimi_oauth::request_device_authorization(host).await
|
||||
}
|
||||
Self::Generic(cfg) => {
|
||||
Self::Generic(cfg) | Self::GithubCopilot(cfg) => {
|
||||
crate::auth::oauth_device::request_device_authorization(cfg).await
|
||||
}
|
||||
}
|
||||
@@ -50,6 +57,22 @@ impl DeviceFlowBackend<'_> {
|
||||
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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -86,6 +109,23 @@ pub async fn run_device_code_login_generic(
|
||||
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>,
|
||||
@@ -114,8 +154,12 @@ async fn run_device_code_login_backend(
|
||||
|
||||
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)
|
||||
.update(auth)
|
||||
.await
|
||||
.map_err(|e| anyhow::anyhow!("Failed to save credentials: {e}"))?;
|
||||
return Ok((auth, true));
|
||||
@@ -388,6 +432,100 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// 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() {
|
||||
|
||||
@@ -107,6 +107,14 @@ pub async fn run_oauth_provider_flow(
|
||||
kigi_models::OAuthFlow::PkceLocalhost { redirect_port } => {
|
||||
run_pkce_localhost_login(oauth, redirect_port, auth_manager, &mut channels).await
|
||||
}
|
||||
kigi_models::OAuthFlow::GithubDeviceCopilot => {
|
||||
crate::auth::device_code::run_device_code_login_github_copilot(
|
||||
oauth,
|
||||
auth_manager,
|
||||
&mut channels,
|
||||
)
|
||||
.await
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,485 @@
|
||||
//! 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}"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -5,6 +5,7 @@ pub(crate) mod device;
|
||||
pub mod device_code;
|
||||
pub mod error;
|
||||
mod flow;
|
||||
pub(crate) mod github_copilot;
|
||||
pub(crate) mod kimi_oauth;
|
||||
pub(crate) mod manager;
|
||||
mod model;
|
||||
|
||||
@@ -147,6 +147,43 @@ mod tests {
|
||||
.expect("claude-pro-max carries an OAuthConfig")
|
||||
}
|
||||
|
||||
fn copilot_oauth() -> &'static kigi_models::OAuthConfig {
|
||||
kigi_models::PlatformId::GithubCopilot
|
||||
.oauth()
|
||||
.expect("github-copilot carries an OAuthConfig")
|
||||
}
|
||||
|
||||
/// A `github-copilot/<model>` turn resolves to the process-global pooled
|
||||
/// github-copilot manager (its OWN `oauth/github-copilot` scope), NEVER the
|
||||
/// primary Kimi manager — the same leak-safe routing as xai-grok /
|
||||
/// claude-pro-max, and a DISTINCT pool entry from either.
|
||||
#[tokio::test]
|
||||
async fn github_copilot_model_resolves_to_its_own_manager_not_kimi() {
|
||||
let (_kd, kimi) = primary_with_token("kimi-tok");
|
||||
let home = tempfile::tempdir().unwrap();
|
||||
let resolved = manager_for_model(home.path(), "github-copilot/gpt-4.1", Some(&kimi))
|
||||
.expect("github-copilot model resolves to its pooled manager");
|
||||
assert!(
|
||||
!Arc::ptr_eq(&resolved, &kimi),
|
||||
"github-copilot must NOT resolve to the Kimi manager"
|
||||
);
|
||||
assert!(
|
||||
Arc::ptr_eq(&resolved, &global_manager_for(home.path(), copilot_oauth())),
|
||||
"github-copilot must resolve to its OWN process-global pooled manager"
|
||||
);
|
||||
assert!(
|
||||
!Arc::ptr_eq(&resolved, &global_manager_for(home.path(), claude_oauth())),
|
||||
"github-copilot and claude-pro-max must not share a pooled manager"
|
||||
);
|
||||
// Fail-fast: even with a Kimi primary, a copilot turn never yields the
|
||||
// Kimi bearer — it draws from the copilot pool (its own token, or None).
|
||||
assert_ne!(
|
||||
session_key_for_model(home.path(), "github-copilot/gpt-4.1", Some(&kimi)),
|
||||
Some("kimi-tok".to_string()),
|
||||
"a github-copilot model must never receive the primary Kimi token"
|
||||
);
|
||||
}
|
||||
|
||||
/// A `claude-pro-max/<model>` turn resolves to the process-global pooled
|
||||
/// claude-pro-max manager (its OWN `oauth/claude-pro-max` scope), NEVER the
|
||||
/// primary Kimi manager — the same leak-safe routing as xai-grok, and a
|
||||
|
||||
@@ -14,7 +14,7 @@ use kigi_models::{OAuthConfig, OAuthTokenBody};
|
||||
use crate::auth::error::RefreshTokenFailedReason;
|
||||
use crate::auth::kimi_oauth::RefreshError;
|
||||
use crate::auth::manager::RefreshReason;
|
||||
use crate::auth::{oauth_device, oauth_pkce};
|
||||
use crate::auth::{github_copilot, oauth_device, oauth_pkce};
|
||||
|
||||
use super::{AuthSnapshot, RefreshOutcome, TokenRefresher};
|
||||
|
||||
@@ -105,11 +105,17 @@ impl TokenRefresher for GenericDeviceRefresher {
|
||||
);
|
||||
|
||||
// Refresh over the provider's token-body encoding: xai's endpoint is
|
||||
// form-encoded (device wire); Claude's is JSON (PKCE wire). Both return
|
||||
// the same `Result<KimiAuth, RefreshError>`.
|
||||
// form-encoded (device wire); Claude's is JSON (PKCE wire); GitHub
|
||||
// Copilot's "refresh" is a copilot-token RE-MINT — a `GET
|
||||
// copilot_internal/v2/token` bearing the durable github token (the
|
||||
// `refresh_token` field here), NOT a refresh_token grant. All three
|
||||
// return the same `Result<KimiAuth, RefreshError>`.
|
||||
let wire_result = match self.cfg.token_body {
|
||||
OAuthTokenBody::Form => oauth_device::refresh_token(self.cfg, &refresh_token).await,
|
||||
OAuthTokenBody::Json => oauth_pkce::refresh_token(self.cfg, &refresh_token).await,
|
||||
OAuthTokenBody::GithubCopilotExchange => {
|
||||
github_copilot::remint_copilot_token(self.cfg, &refresh_token).await
|
||||
}
|
||||
};
|
||||
match wire_result {
|
||||
Ok(new_auth) => {
|
||||
|
||||
Reference in New Issue
Block a user