rig_core/providers/copilot/auth/
mod.rs1use 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
19const STORE_KEY: &str = super::PROVIDER_NAME;
21
22const GITHUB_API_KEY_URL: &str = "https://api.github.com/copilot_internal/v2/token";
23
24pub(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#[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 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 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 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 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#[derive(Debug, Clone, Deserialize, Serialize, Default)]
193struct ApiKeyRecord {
194 #[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 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
247async 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}