use std::collections::HashMap;
use async_trait::async_trait;
use serde::Deserialize;
use serde_json::Value;
use tokio_util::sync::CancellationToken;
use crate::types::Model;
use crate::{Error, Result};
use super::*;
const GITHUB_COPILOT_CLIENT_ID: &str = "Iv1.b507a08c87ecfe98";
const GITHUB_COPILOT_USER_AGENT: &str = "GitHubCopilotChat/0.35.0";
const COPILOT_TOKEN_EXPIRY_SKEW_MS: u64 = 5 * 60 * 1000;
const DEFAULT_GITHUB_DOMAIN: &str = "github.com";
const DEFAULT_COPILOT_BASE_URL: &str = "https://api.individual.githubcopilot.com";
const GITHUB_COPILOT_REFRESH_TOKEN_KEY: &str = "githubRefreshToken";
const GITHUB_COPILOT_ACCESS_EXPIRES_KEY: &str = "githubAccessExpires";
const GITHUB_ACCESS_TOKEN_EXPIRY_SKEW_MS: u64 = 5 * 60 * 1000;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct GitHubCopilotOAuthProvider;
impl GitHubCopilotOAuthProvider {
pub const fn id(self) -> &'static str {
"github-copilot"
}
pub const fn name(self) -> &'static str {
"GitHub Copilot"
}
pub async fn login(self, callbacks: OAuthLoginCallbacks) -> Result<OAuthCredentials> {
login_github_copilot(callbacks).await
}
pub async fn refresh_token(self, credentials: &OAuthCredentials) -> Result<OAuthCredentials> {
refresh_github_copilot_credentials(credentials).await
}
pub fn get_api_key(self, credentials: &OAuthCredentials) -> String {
credentials.access.clone()
}
pub fn modify_models(
self,
models: impl IntoIterator<Item = Model>,
credentials: &OAuthCredentials,
) -> Vec<Model> {
modify_github_copilot_models(models, credentials)
}
}
pub fn github_copilot_oauth_provider() -> GitHubCopilotOAuthProvider {
GitHubCopilotOAuthProvider
}
#[async_trait]
impl OAuthProviderInterface for GitHubCopilotOAuthProvider {
fn id(&self) -> &'static str {
"github-copilot"
}
fn name(&self) -> &'static str {
"GitHub Copilot"
}
async fn login(&self, callbacks: OAuthLoginCallbacks) -> Result<OAuthCredentials> {
login_github_copilot(callbacks).await
}
async fn refresh_token(&self, credentials: &OAuthCredentials) -> Result<OAuthCredentials> {
refresh_github_copilot_credentials(credentials).await
}
fn get_api_key(&self, credentials: &OAuthCredentials) -> String {
credentials.access.clone()
}
fn modify_models(&self, models: Vec<Model>, credentials: &OAuthCredentials) -> Vec<Model> {
modify_github_copilot_models(models, credentials)
}
}
const GITHUB_COPILOT_ENTERPRISE_URL_KEY: &str = "enterpriseUrl";
pub(crate) fn github_copilot_enterprise_domain(credentials: &OAuthCredentials) -> Option<&str> {
credentials
.extra
.get(GITHUB_COPILOT_ENTERPRISE_URL_KEY)
.and_then(Value::as_str)
}
pub(crate) fn get_github_copilot_base_url(
token: Option<&str>,
enterprise_domain: Option<&str>,
) -> String {
if let Some(token) = token
&& let Some(base_url) = get_base_url_from_token(token)
{
return base_url;
}
if let Some(domain) = enterprise_domain.and_then(normalize_domain) {
return format!("https://copilot-api.{domain}");
}
DEFAULT_COPILOT_BASE_URL.to_string()
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct GithubUserToken {
access_token: String,
refresh_token: Option<String>,
expires_in: Option<u64>,
}
fn github_copilot_refresh_token(credentials: &OAuthCredentials) -> Option<&str> {
credentials
.extra
.get(GITHUB_COPILOT_REFRESH_TOKEN_KEY)
.and_then(Value::as_str)
}
fn github_copilot_access_expires(credentials: &OAuthCredentials) -> Option<u64> {
credentials
.extra
.get(GITHUB_COPILOT_ACCESS_EXPIRES_KEY)
.and_then(Value::as_u64)
}
fn github_access_expiry_ms(expires_in_seconds: u64) -> u64 {
crate::utils::time::now_millis()
.saturating_add(expires_in_seconds.saturating_mul(1000))
.saturating_sub(GITHUB_ACCESS_TOKEN_EXPIRY_SKEW_MS)
}
fn is_github_auth_error(error: &Error) -> bool {
matches!(error, Error::ApiStatus { status, .. } if matches!(status.as_u16(), 401 | 403))
}
async fn github_oauth_form_post(
client: &reqwest::Client,
url: &str,
fields: &[(&str, &str)],
) -> Result<reqwest::Response> {
let response = client
.post(url)
.header("Accept", "application/json")
.header("User-Agent", GITHUB_COPILOT_USER_AGENT)
.header("Content-Type", "application/x-www-form-urlencoded")
.body(form_body(fields))
.send()
.await?;
error_for_status(response).await
}
async fn mint_copilot_token(
github_access_token: &str,
enterprise_domain: Option<&str>,
) -> Result<CopilotTokenResponse> {
let domain = enterprise_domain
.and_then(normalize_domain)
.unwrap_or_else(|| DEFAULT_GITHUB_DOMAIN.to_string());
let urls = GitHubCopilotUrls::new(&domain);
let client = reqwest::Client::new();
let response = client
.get(urls.copilot_token_url)
.headers(copilot_headers(Some(github_access_token))?)
.send()
.await?;
let response = error_for_status(response).await?;
parse_copilot_token_response(response.json::<Value>().await?)
}
pub async fn refresh_github_copilot_token(
refresh_token: &str,
enterprise_domain: Option<&str>,
) -> Result<OAuthCredentials> {
let token = mint_copilot_token(refresh_token, enterprise_domain).await?;
Ok(copilot_credentials_from_token(
refresh_token,
None,
None,
enterprise_domain.and_then(normalize_domain),
token,
))
}
async fn refresh_github_copilot_credentials(
credentials: &OAuthCredentials,
) -> Result<OAuthCredentials> {
let enterprise_domain = github_copilot_enterprise_domain(credentials).map(str::to_string);
let domain = enterprise_domain.as_deref();
let mut github_access = credentials.refresh.clone();
let mut github_refresh = github_copilot_refresh_token(credentials).map(str::to_string);
let mut github_expires = github_copilot_access_expires(credentials);
if let Some(refresh) = github_refresh.clone()
&& github_expires.is_some_and(|expires| crate::utils::time::now_millis() >= expires)
{
let renewed = refresh_github_user_token(&refresh, domain).await?;
github_access = renewed.access_token;
github_refresh = renewed.refresh_token.or(Some(refresh));
github_expires = renewed.expires_in.map(github_access_expiry_ms);
}
let token = match mint_copilot_token(&github_access, domain).await {
Ok(token) => token,
Err(error) => {
let Some(refresh) = github_refresh.clone() else {
return Err(error);
};
if !is_github_auth_error(&error) {
return Err(error);
}
let renewed = refresh_github_user_token(&refresh, domain).await?;
github_access = renewed.access_token;
github_refresh = renewed.refresh_token.or(Some(refresh));
github_expires = renewed.expires_in.map(github_access_expiry_ms);
mint_copilot_token(&github_access, domain).await?
}
};
Ok(copilot_credentials_from_token(
&github_access,
github_refresh.as_deref(),
github_expires,
enterprise_domain,
token,
))
}
async fn refresh_github_user_token(
refresh_token: &str,
enterprise_domain: Option<&str>,
) -> Result<GithubUserToken> {
let domain = enterprise_domain
.and_then(normalize_domain)
.unwrap_or_else(|| DEFAULT_GITHUB_DOMAIN.to_string());
let urls = GitHubCopilotUrls::new(&domain);
let client = reqwest::Client::new();
refresh_github_user_token_at(&client, &urls.access_token_url, refresh_token).await
}
async fn refresh_github_user_token_at(
client: &reqwest::Client,
access_token_url: &str,
refresh_token: &str,
) -> Result<GithubUserToken> {
let response = github_oauth_form_post(
client,
access_token_url,
&[
("client_id", GITHUB_COPILOT_CLIENT_ID),
("grant_type", "refresh_token"),
("refresh_token", refresh_token),
],
)
.await?;
match parse_device_token_response(response.json::<Value>().await?) {
OAuthDeviceCodePollResult::Complete(token) => Ok(token),
OAuthDeviceCodePollResult::Failed(message) => Err(Error::Provider(message)),
OAuthDeviceCodePollResult::Pending | OAuthDeviceCodePollResult::SlowDown => {
Err(Error::Provider(
"Unexpected polling response while refreshing GitHub token".to_string(),
))
}
}
}
pub async fn login_github_copilot(callbacks: OAuthLoginCallbacks) -> Result<OAuthCredentials> {
let input = (callbacks.on_prompt)(OAuthPrompt {
message: "GitHub Enterprise URL/domain (blank for github.com)".to_string(),
placeholder: Some("company.ghe.com".to_string()),
allow_empty: true,
})
.await?;
if callbacks
.cancellation_token
.as_ref()
.is_some_and(CancellationToken::is_cancelled)
{
return Err(Error::Provider(CANCEL_MESSAGE.to_string()));
}
let trimmed = input.trim();
let enterprise_domain = normalize_domain(&input);
if !trimmed.is_empty() && enterprise_domain.is_none() {
return Err(Error::Provider(
"Invalid GitHub Enterprise URL/domain".to_string(),
));
}
let domain = enterprise_domain
.clone()
.unwrap_or_else(|| DEFAULT_GITHUB_DOMAIN.to_string());
let device = start_github_device_flow(&domain).await?;
(callbacks.on_device_code)(OAuthDeviceCodeInfo {
user_code: device.user_code.clone(),
verification_uri: device.verification_uri.clone(),
interval_seconds: device.interval,
expires_in_seconds: Some(device.expires_in),
});
let github =
poll_for_github_access_token(&domain, device, callbacks.cancellation_token.clone()).await?;
let github_expires = github.expires_in.map(github_access_expiry_ms);
let token = mint_copilot_token(&github.access_token, enterprise_domain.as_deref()).await?;
Ok(copilot_credentials_from_token(
&github.access_token,
github.refresh_token.as_deref(),
github_expires,
enterprise_domain,
token,
))
}
pub fn modify_github_copilot_models(
models: impl IntoIterator<Item = Model>,
credentials: &OAuthCredentials,
) -> Vec<Model> {
let domain = github_copilot_enterprise_domain(credentials).and_then(normalize_domain);
let base_url = get_github_copilot_base_url(Some(&credentials.access), domain.as_deref());
models
.into_iter()
.map(|mut model| {
if model.provider == "github-copilot" {
model.base_url = base_url.clone();
}
model
})
.collect()
}
async fn start_github_device_flow(domain: &str) -> Result<DeviceCodeResponse> {
let urls = GitHubCopilotUrls::new(domain);
let client = reqwest::Client::new();
let response = github_oauth_form_post(
&client,
&urls.device_code_url,
&[
("client_id", GITHUB_COPILOT_CLIENT_ID),
("scope", "read:user"),
],
)
.await?;
let device = response.json::<DeviceCodeResponse>().await?;
if device.device_code.is_empty()
|| device.user_code.is_empty()
|| device.verification_uri.is_empty()
|| device.expires_in == 0
{
return Err(Error::Provider(
"Invalid device code response fields".to_string(),
));
}
Ok(device)
}
async fn poll_for_github_access_token(
domain: &str,
device: DeviceCodeResponse,
cancellation_token: Option<CancellationToken>,
) -> Result<GithubUserToken> {
let urls = GitHubCopilotUrls::new(domain);
let client = reqwest::Client::new();
poll_oauth_device_code_flow(
device.interval,
Some(device.expires_in),
cancellation_token,
move || {
let client = client.clone();
let access_token_url = urls.access_token_url.clone();
let device_code = device.device_code.clone();
async move {
let response = github_oauth_form_post(
&client,
&access_token_url,
&[
("client_id", GITHUB_COPILOT_CLIENT_ID),
("device_code", device_code.as_str()),
("grant_type", "urn:ietf:params:oauth:grant-type:device_code"),
],
)
.await?;
Ok(parse_device_token_response(response.json::<Value>().await?))
}
},
)
.await
}
fn get_base_url_from_token(token: &str) -> Option<String> {
let proxy_host = token
.split(';')
.find_map(|part| part.strip_prefix("proxy-ep="))?;
let api_host = proxy_host
.strip_prefix("proxy.")
.map(|host| format!("api.{host}"))
.unwrap_or_else(|| proxy_host.to_string());
Some(format!("https://{api_host}"))
}
fn parse_device_token_response(raw: Value) -> OAuthDeviceCodePollResult<GithubUserToken> {
let Some(object) = raw.as_object() else {
return OAuthDeviceCodePollResult::Failed("Invalid device token response".to_string());
};
if let Some(access_token) = object.get("access_token").and_then(Value::as_str) {
return OAuthDeviceCodePollResult::Complete(GithubUserToken {
access_token: access_token.to_string(),
refresh_token: object
.get("refresh_token")
.and_then(Value::as_str)
.map(str::to_string),
expires_in: object.get("expires_in").and_then(Value::as_u64),
});
}
let Some(error) = object.get("error").and_then(Value::as_str) else {
return OAuthDeviceCodePollResult::Failed("Invalid device token response".to_string());
};
match error {
"authorization_pending" => OAuthDeviceCodePollResult::Pending,
"slow_down" => OAuthDeviceCodePollResult::SlowDown,
error => {
let suffix = object
.get("error_description")
.and_then(Value::as_str)
.map(|description| format!(": {description}"))
.unwrap_or_default();
OAuthDeviceCodePollResult::Failed(format!("Device flow failed: {error}{suffix}"))
}
}
}
fn parse_copilot_token_response(raw: Value) -> Result<CopilotTokenResponse> {
let Some(object) = raw.as_object() else {
return Err(Error::Provider(
"Invalid Copilot token response".to_string(),
));
};
let Some(token) = object.get("token").and_then(Value::as_str) else {
return Err(Error::Provider(
"Invalid Copilot token response fields".to_string(),
));
};
let Some(expires_at) = object.get("expires_at").and_then(Value::as_u64) else {
return Err(Error::Provider(
"Invalid Copilot token response fields".to_string(),
));
};
Ok(CopilotTokenResponse {
token: token.to_string(),
expires_at,
})
}
fn copilot_credentials_from_token(
github_access_token: &str,
github_refresh_token: Option<&str>,
github_access_expires_at_ms: Option<u64>,
enterprise_domain: Option<String>,
token: CopilotTokenResponse,
) -> OAuthCredentials {
let mut extra = HashMap::new();
if let Some(enterprise_domain) = enterprise_domain {
extra.insert(
GITHUB_COPILOT_ENTERPRISE_URL_KEY.to_string(),
Value::String(enterprise_domain),
);
}
if let Some(refresh_token) = github_refresh_token {
extra.insert(
GITHUB_COPILOT_REFRESH_TOKEN_KEY.to_string(),
Value::String(refresh_token.to_string()),
);
}
if let Some(expires_at) = github_access_expires_at_ms {
extra.insert(
GITHUB_COPILOT_ACCESS_EXPIRES_KEY.to_string(),
Value::from(expires_at),
);
}
OAuthCredentials {
refresh: github_access_token.to_string(),
access: token.token,
expires: token
.expires_at
.saturating_mul(1000)
.saturating_sub(COPILOT_TOKEN_EXPIRY_SKEW_MS),
extra,
}
}
fn copilot_headers(refresh_token: Option<&str>) -> Result<reqwest::header::HeaderMap> {
let mut headers = reqwest::header::HeaderMap::new();
headers.insert(
"Accept",
reqwest::header::HeaderValue::from_static("application/json"),
);
headers.insert(
"User-Agent",
reqwest::header::HeaderValue::from_static(GITHUB_COPILOT_USER_AGENT),
);
headers.insert(
"Editor-Version",
reqwest::header::HeaderValue::from_static("vscode/1.107.0"),
);
headers.insert(
"Editor-Plugin-Version",
reqwest::header::HeaderValue::from_static("copilot-chat/0.35.0"),
);
headers.insert(
"Copilot-Integration-Id",
reqwest::header::HeaderValue::from_static("vscode-chat"),
);
if let Some(token) = refresh_token {
headers.insert(
reqwest::header::AUTHORIZATION,
reqwest::header::HeaderValue::from_str(&format!("Bearer {token}"))
.map_err(|e| Error::InvalidHeaderValue("authorization".to_string(), e))?,
);
}
Ok(headers)
}
#[derive(Debug)]
struct GitHubCopilotUrls {
device_code_url: String,
access_token_url: String,
copilot_token_url: String,
}
impl GitHubCopilotUrls {
fn new(domain: &str) -> Self {
Self {
device_code_url: format!("https://{domain}/login/device/code"),
access_token_url: format!("https://{domain}/login/oauth/access_token"),
copilot_token_url: format!("https://api.{domain}/copilot_internal/v2/token"),
}
}
}
#[derive(Debug, Deserialize)]
struct DeviceCodeResponse {
device_code: String,
user_code: String,
verification_uri: String,
interval: Option<u64>,
expires_in: u64,
}
#[derive(Debug)]
struct CopilotTokenResponse {
token: String,
expires_at: u64,
}
#[cfg(test)]
mod tests {
use std::sync::{Arc, Mutex};
use super::*;
use crate::oauth::test_support::read_http_request;
use crate::types::ModelCost;
use tokio::io::AsyncWriteExt;
use tokio::net::TcpListener;
#[test]
fn resolves_copilot_base_url_from_token_or_enterprise_domain() {
assert_eq!(
get_github_copilot_base_url(
Some("tid=test;proxy-ep=proxy.individual.githubcopilot.com;exp=1"),
None,
),
DEFAULT_COPILOT_BASE_URL
);
assert_eq!(
get_github_copilot_base_url(None, Some("https://company.ghe.com/path")),
"https://copilot-api.company.ghe.com"
);
assert_eq!(
get_github_copilot_base_url(None, None),
DEFAULT_COPILOT_BASE_URL
);
}
#[test]
fn copilot_credentials_apply_expiry_skew() {
let credentials = copilot_credentials_from_token(
"refresh-token",
None,
None,
Some("company.ghe.com".to_string()),
CopilotTokenResponse {
token: "access-token".to_string(),
expires_at: 1_000,
},
);
assert_eq!(credentials.refresh, "refresh-token");
assert_eq!(credentials.access, "access-token");
assert_eq!(credentials.expires, 700_000);
assert_eq!(
github_copilot_enterprise_domain(&credentials),
Some("company.ghe.com")
);
}
#[test]
fn oauth_credentials_flatten_provider_specific_extra_fields() {
let credentials = copilot_credentials_from_token(
"refresh-token",
None,
None,
Some("company.ghe.com".to_string()),
CopilotTokenResponse {
token: "access-token".to_string(),
expires_at: 1_000,
},
);
let json = serde_json::to_value(&credentials).expect("credentials json");
assert_eq!(json["enterpriseUrl"], "company.ghe.com");
assert!(json.get("extra").is_none());
let round_tripped: OAuthCredentials =
serde_json::from_value(json).expect("round-tripped credentials");
assert_eq!(
github_copilot_enterprise_domain(&round_tripped),
Some("company.ghe.com")
);
}
#[test]
fn provider_updates_only_github_copilot_model_base_urls() {
let credentials = OAuthCredentials {
refresh: "refresh".to_string(),
access: "tid=test;proxy-ep=proxy.enterprise.example.com;exp=1".to_string(),
expires: 1,
extra: HashMap::new(),
};
let models = vec![
Model {
id: "gpt".to_string(),
name: "gpt".to_string(),
api: "openai-completions".to_string(),
provider: "github-copilot".to_string(),
base_url: "https://old.example.com".to_string(),
cost: ModelCost::default(),
..Default::default()
},
Model {
id: "claude".to_string(),
name: "claude".to_string(),
api: "anthropic-messages".to_string(),
provider: "anthropic".to_string(),
base_url: "https://api.anthropic.com".to_string(),
cost: ModelCost::default(),
..Default::default()
},
];
let updated = github_copilot_oauth_provider().modify_models(models, &credentials);
assert_eq!(updated[0].base_url, "https://api.enterprise.example.com");
assert_eq!(updated[1].base_url, "https://api.anthropic.com");
}
#[test]
fn parses_device_token_response() {
assert_eq!(
parse_device_token_response(serde_json::json!({ "access_token": "ghu_refresh" })),
OAuthDeviceCodePollResult::Complete(GithubUserToken {
access_token: "ghu_refresh".to_string(),
refresh_token: None,
expires_in: None,
})
);
assert_eq!(
parse_device_token_response(serde_json::json!({
"error": "authorization_pending",
"error_description": "pending"
})),
OAuthDeviceCodePollResult::Pending
);
assert_eq!(
parse_device_token_response(serde_json::json!({
"error": "slow_down",
"error_description": "slow down"
})),
OAuthDeviceCodePollResult::SlowDown
);
assert_eq!(
parse_device_token_response(serde_json::json!({
"error": "access_denied",
"error_description": "denied"
})),
OAuthDeviceCodePollResult::Failed(
"Device flow failed: access_denied: denied".to_string()
)
);
assert_eq!(
parse_device_token_response(serde_json::json!({ "access_token": 1 })),
OAuthDeviceCodePollResult::Failed("Invalid device token response".to_string())
);
assert_eq!(
parse_device_token_response(serde_json::Value::Null),
OAuthDeviceCodePollResult::Failed("Invalid device token response".to_string())
);
}
#[test]
fn parses_copilot_token_response() {
let token = parse_copilot_token_response(serde_json::json!({
"token": "tid=test;exp=9999999999",
"expires_at": 9999999999_u64
}))
.unwrap();
assert_eq!(token.token, "tid=test;exp=9999999999");
assert_eq!(token.expires_at, 9999999999);
assert_eq!(
parse_copilot_token_response(serde_json::Value::Null)
.unwrap_err()
.to_string(),
"provider error: Invalid Copilot token response"
);
assert_eq!(
parse_copilot_token_response(serde_json::json!({ "token": "tid=test" }))
.unwrap_err()
.to_string(),
"provider error: Invalid Copilot token response fields"
);
assert_eq!(
parse_copilot_token_response(serde_json::json!({
"token": "tid=test",
"expires_at": "later"
}))
.unwrap_err()
.to_string(),
"provider error: Invalid Copilot token response fields"
);
}
#[test]
fn provider_metadata_matches_expected_shape() {
let provider = github_copilot_oauth_provider();
assert_eq!(provider.id(), "github-copilot");
assert_eq!(provider.name(), "GitHub Copilot");
}
#[test]
fn copilot_headers_include_static_client_metadata_and_bearer() {
let headers = copilot_headers(Some("refresh-token")).unwrap();
assert_eq!(
headers
.get("user-agent")
.and_then(|value| value.to_str().ok()),
Some("GitHubCopilotChat/0.35.0")
);
assert_eq!(
headers
.get("editor-version")
.and_then(|value| value.to_str().ok()),
Some("vscode/1.107.0")
);
assert_eq!(
headers
.get("authorization")
.and_then(|value| value.to_str().ok()),
Some("Bearer refresh-token")
);
}
#[test]
fn form_body_matches_url_search_params_encoding() {
assert_eq!(
form_body(&[
("client_id", GITHUB_COPILOT_CLIENT_ID),
("scope", "read:user"),
("device_code", "abc def/ghi")
]),
"client_id=Iv1.b507a08c87ecfe98&scope=read%3Auser&device_code=abc+def%2Fghi"
);
}
#[test]
fn device_token_response_captures_refresh_token_and_expiry() {
match parse_device_token_response(serde_json::json!({
"access_token": "ghu_new",
"refresh_token": "ghr_new",
"expires_in": 28_800,
"token_type": "bearer"
})) {
OAuthDeviceCodePollResult::Complete(token) => {
assert_eq!(token.access_token, "ghu_new");
assert_eq!(token.refresh_token.as_deref(), Some("ghr_new"));
assert_eq!(token.expires_in, Some(28_800));
}
other => panic!("expected Complete, got {other:?}"),
}
}
#[test]
fn device_token_response_without_refresh_token_is_non_expiring() {
match parse_device_token_response(
serde_json::json!({ "access_token": "gho_classic", "token_type": "bearer" }),
) {
OAuthDeviceCodePollResult::Complete(token) => {
assert_eq!(token.access_token, "gho_classic");
assert!(token.refresh_token.is_none());
assert!(token.expires_in.is_none());
}
other => panic!("expected Complete, got {other:?}"),
}
}
#[test]
fn github_auth_errors_signal_token_renewal() {
assert!(is_github_auth_error(&Error::ApiStatus {
status: reqwest::StatusCode::UNAUTHORIZED,
body: String::new(),
}));
assert!(is_github_auth_error(&Error::ApiStatus {
status: reqwest::StatusCode::FORBIDDEN,
body: String::new(),
}));
assert!(!is_github_auth_error(&Error::ApiStatus {
status: reqwest::StatusCode::INTERNAL_SERVER_ERROR,
body: String::new(),
}));
assert!(!is_github_auth_error(&Error::Provider("nope".to_string())));
}
#[test]
fn copilot_credentials_persist_refresh_token_and_expiry() {
let credentials = copilot_credentials_from_token(
"ghu_access",
Some("ghr_refresh"),
Some(1_700_000_000_000),
None,
CopilotTokenResponse {
token: "copilot-token".to_string(),
expires_at: 2_000,
},
);
assert_eq!(credentials.refresh, "ghu_access");
assert_eq!(
github_copilot_refresh_token(&credentials),
Some("ghr_refresh")
);
assert_eq!(
github_copilot_access_expires(&credentials),
Some(1_700_000_000_000)
);
let json = serde_json::to_value(&credentials).expect("credentials json");
assert_eq!(json["githubRefreshToken"], "ghr_refresh");
assert_eq!(
json["githubAccessExpires"].as_u64(),
Some(1_700_000_000_000)
);
let round_tripped: OAuthCredentials =
serde_json::from_value(json).expect("round-tripped credentials");
assert_eq!(
github_copilot_refresh_token(&round_tripped),
Some("ghr_refresh")
);
}
#[test]
fn copilot_credentials_omit_refresh_metadata_when_absent() {
let credentials = copilot_credentials_from_token(
"ghu_access",
None,
None,
None,
CopilotTokenResponse {
token: "copilot-token".to_string(),
expires_at: 2_000,
},
);
assert!(github_copilot_refresh_token(&credentials).is_none());
assert!(github_copilot_access_expires(&credentials).is_none());
let json = serde_json::to_value(&credentials).expect("credentials json");
assert!(json.get("githubRefreshToken").is_none());
assert!(json.get("githubAccessExpires").is_none());
}
#[tokio::test]
async fn github_user_token_refresh_uses_refresh_token_grant() {
let captured_body = Arc::new(Mutex::new(None));
let token_url = spawn_github_refresh_server(Arc::clone(&captured_body)).await;
let client = reqwest::Client::new();
let token = refresh_github_user_token_at(&client, &token_url, "ghr_old")
.await
.unwrap();
assert_eq!(token.access_token, "ghu_refreshed");
assert_eq!(token.refresh_token.as_deref(), Some("ghr_rotated"));
assert_eq!(token.expires_in, Some(28_800));
let body = captured_body
.lock()
.expect("captured body lock poisoned")
.clone()
.expect("captured request body");
assert!(body.contains("grant_type=refresh_token"), "body: {body}");
assert!(body.contains("refresh_token=ghr_old"), "body: {body}");
assert!(
body.contains(&format!("client_id={GITHUB_COPILOT_CLIENT_ID}")),
"body: {body}"
);
}
async fn spawn_github_refresh_server(captured_body: Arc<Mutex<Option<String>>>) -> String {
let listener = TcpListener::bind("127.0.0.1:0").await.unwrap();
let addr = listener.local_addr().unwrap();
tokio::spawn(async move {
let (mut socket, _) = listener.accept().await.unwrap();
let request = read_http_request(&mut socket).await;
let body = request
.split_once("\r\n\r\n")
.map(|(_, body)| body.to_string())
.unwrap_or_default();
*captured_body.lock().expect("captured body lock poisoned") = Some(body);
let response_body = serde_json::json!({
"access_token": "ghu_refreshed",
"refresh_token": "ghr_rotated",
"expires_in": 28_800,
"token_type": "bearer",
"scope": ""
})
.to_string();
let response = format!(
"HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\n\r\n{}",
response_body.len(),
response_body
);
socket.write_all(response.as_bytes()).await.unwrap();
});
format!("http://{addr}/login/oauth/access_token")
}
}