use crate::http_client::HttpClientExt;
use crate::providers::internal::auth::{request, send_json};
use crate::wire::Secret;
use futures::lock::Mutex;
use http::Method;
use serde::{Deserialize, Serialize};
use std::fmt;
use std::path::PathBuf;
use std::sync::Arc;
pub use crate::providers::internal::auth::{DeviceCodeHandler, DeviceCodePrompt};
#[cfg(not(target_family = "wasm"))]
mod native;
const GITHUB_API_KEY_URL: &str = "https://api.github.com/copilot_internal/v2/token";
pub fn default_token_dir() -> Option<PathBuf> {
crate::providers::internal::auth::config_dir().map(|dir| dir.join("github_copilot"))
}
pub(crate) fn base_url_from_token(token: &str) -> Option<String> {
let proxy_ep = token
.split(';')
.find_map(|part| part.trim().strip_prefix("proxy-ep="))?
.trim();
normalize_copilot_proxy_endpoint(proxy_ep)
}
fn normalize_copilot_proxy_endpoint(proxy_ep: &str) -> Option<String> {
if proxy_ep.is_empty() {
return None;
}
let candidate = if proxy_ep.starts_with("http://") || proxy_ep.starts_with("https://") {
proxy_ep.to_string()
} else {
format!("https://{proxy_ep}")
};
let mut url = url::Url::parse(&candidate).ok()?;
if url.scheme() != "https" || !url.username().is_empty() || url.password().is_some() {
return None;
}
if url.path() != "/" || url.query().is_some() || url.fragment().is_some() {
return None;
}
let host = url.host_str()?.to_ascii_lowercase();
if !is_allowed_token_derived_copilot_host(&host) {
return None;
}
let api_host = host
.strip_prefix("proxy.")
.map(|suffix| format!("api.{suffix}"))
.unwrap_or(host);
url.set_host(Some(&api_host)).ok()?;
Some(url.to_string().trim_end_matches('/').to_string())
}
fn is_allowed_token_derived_copilot_host(host: &str) -> bool {
host == "githubcopilot.com" || host.ends_with(".githubcopilot.com")
}
#[derive(Clone)]
pub enum AuthSource {
ApiKey(String),
GitHubAccessToken(String),
OAuth,
}
impl fmt::Debug for AuthSource {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::ApiKey(_) => f.write_str("ApiKey(<redacted>)"),
Self::GitHubAccessToken(_) => f.write_str("GitHubAccessToken(<redacted>)"),
Self::OAuth => f.write_str("OAuth"),
}
}
}
#[derive(Clone, Debug)]
#[cfg_attr(target_family = "wasm", allow(dead_code))]
pub struct Authenticator {
source: AuthSource,
access_token_file: Option<PathBuf>,
api_key_file: Option<PathBuf>,
device_code_handler: DeviceCodeHandler,
allow_device_flow: bool,
refresh_lock: Arc<Mutex<()>>,
}
pub use crate::providers::internal::auth::AuthError;
#[derive(Debug, Clone)]
pub struct AuthContext {
pub api_key: Secret,
pub api_base: Option<String>,
}
impl Authenticator {
pub fn new(
source: AuthSource,
access_token_file: Option<PathBuf>,
api_key_file: Option<PathBuf>,
device_code_handler: DeviceCodeHandler,
allow_device_flow: bool,
) -> Self {
Self {
source,
access_token_file,
api_key_file,
device_code_handler,
allow_device_flow,
refresh_lock: Arc::default(),
}
}
pub async fn auth_context<H>(&self, http: &H) -> Result<AuthContext, AuthError>
where
H: HttpClientExt,
{
match &self.source {
AuthSource::ApiKey(api_key) => Ok(AuthContext {
api_key: api_key.clone().into(),
api_base: None,
}),
#[cfg(not(target_family = "wasm"))]
AuthSource::GitHubAccessToken(access_token) => {
self.auth_context_with_github_access_token(http, access_token)
.await
}
#[cfg(target_family = "wasm")]
AuthSource::GitHubAccessToken(access_token) => {
Ok(refresh_api_key(http, access_token).await?.into_context())
}
#[cfg(not(target_family = "wasm"))]
AuthSource::OAuth => self.auth_context_oauth(http).await,
#[cfg(target_family = "wasm")]
AuthSource::OAuth => Err(AuthError::Message(
"GitHub Copilot OAuth is not supported on wasm targets".into(),
)),
}
}
}
#[derive(Debug, Clone, Deserialize, Serialize, Default)]
struct ApiKeyRecord {
token: Option<String>,
expires_at: Option<i64>,
endpoints: Option<ApiKeyEndpoints>,
bootstrap_token_fingerprint: Option<String>,
}
#[derive(Debug, Clone, Deserialize, Serialize, Default)]
struct ApiKeyEndpoints {
api: Option<String>,
}
impl ApiKeyRecord {
fn api_base(&self) -> Option<String> {
self.endpoints
.as_ref()
.and_then(|endpoints| endpoints.api.as_ref())
.cloned()
}
fn into_context(self) -> AuthContext {
AuthContext {
api_base: self.api_base(),
api_key: self.token.unwrap_or_default().into(),
}
}
}
async fn refresh_api_key<H>(http: &H, access_token: &str) -> Result<ApiKeyRecord, AuthError>
where
H: HttpClientExt,
{
let response: ApiKeyRecord = send_json(
http,
request(Method::GET, GITHUB_API_KEY_URL)
.header(http::header::ACCEPT, "application/json")
.header("editor-version", super::EDITOR_VERSION)
.header("editor-plugin-version", super::EDITOR_PLUGIN_VERSION)
.header("user-agent", super::USER_AGENT)
.header(http::header::AUTHORIZATION, format!("token {access_token}"))
.body(bytes::Bytes::new()),
)
.await?;
if response
.token
.as_ref()
.is_none_or(|token| token.trim().is_empty())
{
return Err(AuthError::Message(
"GitHub Copilot API key response did not include a token".into(),
));
}
Ok(response)
}