Skip to main content

rig_core/providers/copilot/auth/
mod.rs

1use crate::http_client::HttpClientExt;
2use crate::providers::internal::auth::{
3    Shell, load_record, request, save_record, send_json, token_expired,
4};
5use http::Method;
6use serde::{Deserialize, Serialize};
7use std::fmt;
8use std::hash::{DefaultHasher, Hash, Hasher};
9
10#[cfg(not(target_family = "wasm"))]
11pub use crate::providers::internal::auth::FileTokenStore;
12pub use crate::providers::internal::auth::{
13    DeviceCodeHandler, DeviceCodePrompt, MemoryTokenStore, StoreLock, TokenStore,
14};
15
16#[cfg(not(target_family = "wasm"))]
17mod native;
18
19/// The key the record is kept under in the store.
20const STORE_KEY: &str = super::PROVIDER_NAME;
21
22const GITHUB_API_KEY_URL: &str = "https://api.github.com/copilot_internal/v2/token";
23
24/// Derive the Copilot REST base URL from a chat token's `proxy-ep=` segment.
25///
26/// The endpoint is parsed from a credential string, not from explicit caller
27/// configuration. For that reason, token-derived routing is limited to GitHub
28/// Copilot service hosts and HTTPS. Callers that need a custom non-GitHub host
29/// can still opt in explicitly with [`OpenAIConfig::with_base_url`](crate::providers::openai::OpenAIConfig::with_base_url).
30pub(crate) fn base_url_from_token(token: &str) -> Option<String> {
31    let proxy_ep = token
32        .split(';')
33        .find_map(|part| part.trim().strip_prefix("proxy-ep="))?
34        .trim();
35
36    normalize_copilot_proxy_endpoint(proxy_ep)
37}
38
39fn normalize_copilot_proxy_endpoint(proxy_ep: &str) -> Option<String> {
40    if proxy_ep.is_empty() {
41        return None;
42    }
43
44    let candidate = if proxy_ep.starts_with("http://") || proxy_ep.starts_with("https://") {
45        proxy_ep.to_string()
46    } else {
47        format!("https://{proxy_ep}")
48    };
49
50    let mut url = url::Url::parse(&candidate).ok()?;
51    if url.scheme() != "https" || !url.username().is_empty() || url.password().is_some() {
52        return None;
53    }
54    if url.path() != "/" || url.query().is_some() || url.fragment().is_some() {
55        return None;
56    }
57
58    let host = url.host_str()?.to_ascii_lowercase();
59    if !is_allowed_token_derived_copilot_host(&host) {
60        return None;
61    }
62
63    let api_host = host
64        .strip_prefix("proxy.")
65        .map(|suffix| format!("api.{suffix}"))
66        .unwrap_or(host);
67    url.set_host(Some(&api_host)).ok()?;
68
69    Some(url.to_string().trim_end_matches('/').to_string())
70}
71
72fn is_allowed_token_derived_copilot_host(host: &str) -> bool {
73    host == "githubcopilot.com" || host.ends_with(".githubcopilot.com")
74}
75
76#[derive(Clone)]
77pub enum AuthSource {
78    ApiKey(String),
79    GitHubAccessToken(String),
80    OAuth,
81}
82
83impl fmt::Debug for AuthSource {
84    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
85        match self {
86            Self::ApiKey(_) => f.write_str("ApiKey(<redacted>)"),
87            Self::GitHubAccessToken(_) => f.write_str("GitHubAccessToken(<redacted>)"),
88            Self::OAuth => f.write_str("OAuth"),
89        }
90    }
91}
92
93/// Signs in to GitHub, exchanges the GitHub token for a Copilot session, and
94/// keeps both in its [`TokenStore`]. Clones share the store and the refresh
95/// lock.
96#[derive(Clone)]
97pub struct Authenticator(Shell<AuthSource>);
98
99impl fmt::Debug for Authenticator {
100    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
101        self.0.fmt(f)
102    }
103}
104
105pub use crate::providers::internal::auth::{AuthContext, AuthError};
106
107impl Authenticator {
108    /// An authenticator over `source`, keeping the GitHub token and the
109    /// session it exchanges for in `store` under
110    /// [`PROVIDER_NAME`](super::PROVIDER_NAME), `copilot`;
111    /// `device_code_handler` shows a device code when the device flow is
112    /// allowed.
113    pub fn new(
114        source: AuthSource,
115        store: impl TokenStore + 'static,
116        device_code_handler: DeviceCodeHandler,
117        allow_device_flow: bool,
118    ) -> Self {
119        Self(Shell::new(
120            source,
121            store,
122            device_code_handler,
123            allow_device_flow,
124        ))
125    }
126
127    /// Resolve the API key and optional API base, refreshing through `http` as needed.
128    /// Return store, transport, or authorization errors. OAuth device login is
129    /// unsupported on WASM; the GitHub token exchange remains available.
130    pub async fn auth_context<H>(&self, http: &H) -> Result<AuthContext, AuthError>
131    where
132        H: HttpClientExt,
133    {
134        match &self.0.source {
135            AuthSource::ApiKey(api_key) => Ok(AuthContext {
136                api_key: api_key.clone().into(),
137                account_id: None,
138                api_base: None,
139            }),
140            AuthSource::GitHubAccessToken(access_token) => {
141                self.auth_context_with_github_access_token(http, access_token)
142                    .await
143            }
144            #[cfg(not(target_family = "wasm"))]
145            AuthSource::OAuth => self.auth_context_oauth(http).await,
146            #[cfg(target_family = "wasm")]
147            AuthSource::OAuth => Err(AuthError::Message(
148                "GitHub Copilot OAuth is not supported on wasm targets".into(),
149            )),
150        }
151    }
152
153    async fn auth_context_with_github_access_token<H>(
154        &self,
155        http: &H,
156        access_token: &str,
157    ) -> Result<AuthContext, AuthError>
158    where
159        H: HttpClientExt,
160    {
161        if let Some(context) = self.0.remembered.live(0) {
162            return Ok(context);
163        }
164        let _held = self.0.hold(STORE_KEY).await?;
165        let record: ApiKeyRecord = load_record(&*self.0.store, STORE_KEY).await?;
166        if record.can_reuse_for_bootstrap_token(access_token) {
167            return Ok(self.remember(record));
168        }
169
170        // A GitHub token an OAuth sign-in kept stays for that sign-in.
171        let session = refresh_api_key(http, access_token)
172            .await?
173            .bound_to(access_token, record.github_token);
174        save_record(&*self.0.store, STORE_KEY, &session).await?;
175        Ok(self.remember(session))
176    }
177
178    /// `session`'s context, remembered until the session expires.
179    fn remember(&self, session: ApiKeyRecord) -> AuthContext {
180        let context = AuthContext {
181            api_base: session.api_base(),
182            api_key: session.token.unwrap_or_default().into(),
183            account_id: None,
184        };
185        self.0.remembered.keep(&context, session.expires_at);
186        context
187    }
188}
189
190/// Copilot API key exchanged for a GitHub access token, and the record kept
191/// under the provider's name.
192#[derive(Debug, Clone, Deserialize, Serialize, Default)]
193struct ApiKeyRecord {
194    /// The GitHub token an OAuth sign-in got, which the exchange takes.
195    #[serde(default, skip_serializing_if = "Option::is_none")]
196    github_token: Option<String>,
197    token: Option<String>,
198    expires_at: Option<i64>,
199    endpoints: Option<ApiKeyEndpoints>,
200    bootstrap_token_fingerprint: Option<String>,
201}
202
203#[derive(Debug, Clone, Deserialize, Serialize, Default)]
204struct ApiKeyEndpoints {
205    api: Option<String>,
206}
207
208impl ApiKeyRecord {
209    fn api_base(&self) -> Option<String> {
210        self.endpoints
211            .as_ref()
212            .and_then(|endpoints| endpoints.api.as_ref())
213            .cloned()
214    }
215
216    fn can_reuse_for_bootstrap_token(&self, bootstrap_token: &str) -> bool {
217        self.has_live_api_key() && self.matches_bootstrap_token(bootstrap_token)
218    }
219
220    /// This session, exchanged for `bootstrap_token`, kept with
221    /// `github_token`.
222    fn bound_to(mut self, bootstrap_token: &str, github_token: Option<String>) -> Self {
223        self.bootstrap_token_fingerprint = Some(bootstrap_token_fingerprint(bootstrap_token));
224        self.github_token = github_token;
225        self
226    }
227
228    fn has_live_api_key(&self) -> bool {
229        self.token
230            .as_ref()
231            .is_some_and(|token| !token.trim().is_empty())
232            && !token_expired(self.expires_at, 0)
233    }
234
235    fn matches_bootstrap_token(&self, bootstrap_token: &str) -> bool {
236        self.bootstrap_token_fingerprint.as_deref()
237            == Some(bootstrap_token_fingerprint(bootstrap_token).as_str())
238    }
239}
240
241fn bootstrap_token_fingerprint(bootstrap_token: &str) -> String {
242    let mut hasher = DefaultHasher::new();
243    bootstrap_token.hash(&mut hasher);
244    format!("{:016x}", hasher.finish())
245}
246
247/// Exchange a GitHub access token for a Copilot API key.
248/// Return transport errors, or an error when the response has no non-blank token.
249async fn refresh_api_key<H>(http: &H, access_token: &str) -> Result<ApiKeyRecord, AuthError>
250where
251    H: HttpClientExt,
252{
253    let response: ApiKeyRecord = send_json(
254        http,
255        request(Method::GET, GITHUB_API_KEY_URL)
256            .header(http::header::ACCEPT, "application/json")
257            .header("editor-version", super::EDITOR_VERSION)
258            .header("editor-plugin-version", super::EDITOR_PLUGIN_VERSION)
259            .header("user-agent", super::USER_AGENT)
260            .header(http::header::AUTHORIZATION, format!("token {access_token}"))
261            .body(bytes::Bytes::new()),
262    )
263    .await?;
264
265    if response
266        .token
267        .as_ref()
268        .is_none_or(|token| token.trim().is_empty())
269    {
270        return Err(AuthError::Message(
271            "GitHub Copilot API key response did not include a token".into(),
272        ));
273    }
274
275    Ok(response)
276}