1use std::{
17 fmt,
18 sync::{
19 atomic::{AtomicBool, Ordering},
20 Arc,
21 },
22 time::Duration,
23};
24
25use async_trait::async_trait;
26use reqwest::Client;
27use serde::Deserialize;
28use url::Url;
29
30use super::{now_millis, open_browser, save_provider_auth, OAuthCredentials};
31
32pub const PROVIDER: &str = "github-copilot";
36
37pub const CLIENT_ID: &str = "Iv1.b507a08c87ecfe98";
41
42pub const SCOPE: &str = "read:user";
44
45pub const DEVICE_CODE_URL: &str = "https://github.com/login/device/code";
47
48pub const ACCESS_TOKEN_URL: &str = "https://github.com/login/oauth/access_token";
50
51pub const SESSION_MINT_URL: &str = "https://api.github.com/copilot_internal/v2/token";
53
54pub const DEVICE_GRANT_TYPE: &str = "urn:ietf:params:oauth:grant-type:device_code";
56
57pub const DEFAULT_VERIFICATION_URI: &str = "https://github.com/login/device";
59
60const EXPIRY_SKEW_MS: u64 = 5 * 60 * 1000;
61const DEFAULT_POLL_INTERVAL_SECS: u64 = 5;
62const MAX_POLL_INTERVAL_SECS: u64 = 30;
64const SLOW_DOWN_STEP_SECS: u64 = 5;
66
67pub const CONNECT_TIMEOUT: Duration = Duration::from_secs(10);
69pub const REQUEST_TIMEOUT: Duration = Duration::from_secs(30);
71pub const MAX_RESPONSE_BODY_BYTES: usize = 64 * 1024;
73
74pub const MINT_USER_AGENT: &str = "SynapsCLI/0.6.0";
80pub const MINT_EDITOR_VERSION: &str = "vscode/1.107.0";
81pub const MINT_EDITOR_PLUGIN_VERSION: &str = "copilot-chat/0.35.0";
82pub const MINT_COPILOT_INTEGRATION_ID: &str = "vscode-chat";
83const CANCEL_POLL_QUANTUM: Duration = Duration::from_millis(200);
85
86#[derive(Clone, PartialEq, Eq)]
92pub enum CopilotAuthError {
93 UntrustedEndpoint,
94 InvalidDeviceResponse,
95 InvalidTokenResponse,
96 InvalidSessionResponse,
97 AccessDenied,
98 Expired,
99 IncorrectDeviceCode,
100 DeviceFlowDisabled,
101 Cancelled,
102 EmptyGitHubToken,
103 EmptySessionToken,
104 HttpStatus(u16),
105 SessionMintRejected(u16),
107 Transport,
108 ResponseTooLarge,
109 Persist,
110 Other(&'static str),
111}
112
113impl CopilotAuthError {
114 fn label(&self) -> &'static str {
115 match self {
116 Self::UntrustedEndpoint => "untrusted endpoint",
117 Self::InvalidDeviceResponse => "invalid device authorization response",
118 Self::InvalidTokenResponse => "invalid device token response",
119 Self::InvalidSessionResponse => "invalid Copilot session response",
120 Self::AccessDenied => "access denied",
121 Self::Expired => "device code expired",
122 Self::IncorrectDeviceCode => "incorrect device code",
123 Self::DeviceFlowDisabled => "device flow disabled",
124 Self::Cancelled => "login cancelled",
125 Self::EmptyGitHubToken => "empty GitHub user token",
126 Self::EmptySessionToken => "empty Copilot session token",
127 Self::HttpStatus(_) => "HTTP error",
128 Self::SessionMintRejected(_) => "Copilot session mint rejected",
129 Self::Transport => "transport error",
130 Self::ResponseTooLarge => "response body too large",
131 Self::Persist => "credential persistence failed",
132 Self::Other(msg) => msg,
133 }
134 }
135}
136
137impl fmt::Display for CopilotAuthError {
138 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
139 match self {
140 Self::HttpStatus(code) => write!(f, "HTTP error {code}"),
141 Self::SessionMintRejected(code) => {
142 write!(f, "Copilot session mint rejected (HTTP {code})")
143 }
144 other => f.write_str(other.label()),
145 }
146 }
147}
148
149impl fmt::Debug for CopilotAuthError {
150 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
151 write!(f, "CopilotAuthError({self})")
152 }
153}
154
155impl From<CopilotAuthError> for String {
156 fn from(value: CopilotAuthError) -> Self {
157 value.to_string()
158 }
159}
160
161#[derive(Debug, Clone)]
165pub struct InjectedHttpResponse {
166 pub status: u16,
167 pub body: String,
168}
169
170#[async_trait]
172pub trait CopilotHttp: Send + Sync {
173 async fn post_form(
174 &self,
175 url: &str,
176 form: &[(&str, &str)],
177 ) -> Result<InjectedHttpResponse, CopilotAuthError>;
178
179 async fn get_bearer(
181 &self,
182 url: &str,
183 bearer: &str,
184 headers: &[(&str, &str)],
185 ) -> Result<InjectedHttpResponse, CopilotAuthError>;
186}
187
188#[async_trait]
190pub trait CopilotClock: Send + Sync {
191 fn now_millis(&self) -> u64;
192
193 async fn sleep_cancellable<X: CopilotCancel + ?Sized>(
197 &self,
198 duration: Duration,
199 cancel: &X,
200 ) -> Result<(), CopilotAuthError>;
201}
202
203pub trait CopilotCancel: Send + Sync {
205 fn is_cancelled(&self) -> bool;
206}
207
208impl CopilotCancel for AtomicBool {
209 fn is_cancelled(&self) -> bool {
210 self.load(Ordering::SeqCst)
211 }
212}
213
214impl CopilotCancel for Arc<AtomicBool> {
215 fn is_cancelled(&self) -> bool {
216 self.load(Ordering::SeqCst)
217 }
218}
219
220pub trait CopilotBrowser: Send + Sync {
222 fn open(&self, url: &str) -> Result<(), CopilotAuthError>;
223}
224
225#[derive(Debug, Clone)]
228pub struct DeviceAuthorization {
229 pub(crate) device_code: String,
231 pub user_code: String,
232 pub verification_uri: String,
233 pub expires_in_secs: u64,
234 pub interval_secs: u64,
235 pub issued_at_ms: u64,
236}
237
238#[derive(Debug, Clone, PartialEq, Eq)]
240pub enum DevicePollOutcome {
241 Pending,
242 SlowDown,
243 Authorized { github_token: String },
244 Denied,
245 Expired,
246 IncorrectDeviceCode,
247 DeviceFlowDisabled,
248 OtherError,
249}
250
251#[derive(Debug, Clone)]
252pub struct SessionToken {
253 pub token: String,
254 pub expires_at_ms: u64,
255}
256
257pub fn validate_device_endpoint(url: &str) -> Result<(), CopilotAuthError> {
261 if url != DEVICE_CODE_URL && url != ACCESS_TOKEN_URL {
262 return Err(CopilotAuthError::UntrustedEndpoint);
263 }
264 let parsed = Url::parse(url).map_err(|_| CopilotAuthError::UntrustedEndpoint)?;
265 if parsed.scheme() != "https" || parsed.host_str() != Some("github.com") {
266 return Err(CopilotAuthError::UntrustedEndpoint);
267 }
268 Ok(())
269}
270
271pub fn validate_session_mint_endpoint(url: &str) -> Result<(), CopilotAuthError> {
273 if url != SESSION_MINT_URL {
274 return Err(CopilotAuthError::UntrustedEndpoint);
275 }
276 let parsed = Url::parse(url).map_err(|_| CopilotAuthError::UntrustedEndpoint)?;
277 if parsed.scheme() != "https"
278 || parsed.host_str() != Some("api.github.com")
279 || parsed.path() != "/copilot_internal/v2/token"
280 {
281 return Err(CopilotAuthError::UntrustedEndpoint);
282 }
283 Ok(())
284}
285
286pub fn validate_verification_uri(uri: &str) -> Result<(), CopilotAuthError> {
291 let parsed = Url::parse(uri).map_err(|_| CopilotAuthError::UntrustedEndpoint)?;
292 if parsed.scheme() != "https" {
293 return Err(CopilotAuthError::UntrustedEndpoint);
294 }
295 if !parsed.username().is_empty() || parsed.password().is_some() {
296 return Err(CopilotAuthError::UntrustedEndpoint);
297 }
298 if parsed.host_str() != Some("github.com") {
299 return Err(CopilotAuthError::UntrustedEndpoint);
300 }
301 if parsed.port().is_some() {
302 return Err(CopilotAuthError::UntrustedEndpoint);
303 }
304 if parsed.fragment().is_some() {
305 return Err(CopilotAuthError::UntrustedEndpoint);
306 }
307 let path = parsed.path().trim_end_matches('/');
310 if path != "/login/device" {
311 return Err(CopilotAuthError::UntrustedEndpoint);
312 }
313 let mut saw_user_code = false;
315 for (key, value) in parsed.query_pairs() {
316 if key != "user_code" || value.is_empty() || saw_user_code {
317 return Err(CopilotAuthError::UntrustedEndpoint);
318 }
319 saw_user_code = true;
320 }
321 Ok(())
322}
323
324pub fn session_mint_request_headers() -> &'static [(&'static str, &'static str)] {
333 &[
334 ("Accept", "application/json"),
335 ("User-Agent", MINT_USER_AGENT),
336 ("Editor-Version", MINT_EDITOR_VERSION),
337 ("Editor-Plugin-Version", MINT_EDITOR_PLUGIN_VERSION),
338 ("Copilot-Integration-Id", MINT_COPILOT_INTEGRATION_ID),
339 ]
340}
341
342#[derive(Debug, Deserialize)]
345struct DeviceCodeJson {
346 device_code: String,
347 user_code: String,
348 verification_uri: String,
349 #[serde(default)]
350 verification_uri_complete: Option<String>,
351 expires_in: u64,
352 #[serde(default)]
353 interval: Option<u64>,
354}
355
356#[derive(Debug, Deserialize)]
357struct DeviceTokenJson {
358 access_token: Option<String>,
359 error: Option<String>,
360}
361
362#[derive(Debug, Deserialize)]
363struct SessionMintJson {
364 token: Option<String>,
365 expires_at: Option<u64>,
367 expires_in: Option<u64>,
368}
369
370pub fn parse_device_code_response(
371 body: &str,
372 now_ms: u64,
373) -> Result<DeviceAuthorization, CopilotAuthError> {
374 let raw: DeviceCodeJson =
375 serde_json::from_str(body).map_err(|_| CopilotAuthError::InvalidDeviceResponse)?;
376 if raw.device_code.trim().is_empty() || raw.user_code.trim().is_empty() {
377 return Err(CopilotAuthError::InvalidDeviceResponse);
378 }
379 if raw.expires_in == 0 {
380 return Err(CopilotAuthError::InvalidDeviceResponse);
381 }
382 validate_verification_uri(&raw.verification_uri)?;
383 if let Some(ref complete) = raw.verification_uri_complete {
384 validate_verification_uri(complete)?;
385 }
386 let interval = raw
387 .interval
388 .unwrap_or(DEFAULT_POLL_INTERVAL_SECS)
389 .clamp(1, MAX_POLL_INTERVAL_SECS);
390 Ok(DeviceAuthorization {
391 device_code: raw.device_code,
392 user_code: raw.user_code,
393 verification_uri: raw.verification_uri,
394 expires_in_secs: raw.expires_in,
395 interval_secs: interval,
396 issued_at_ms: now_ms,
397 })
398}
399
400pub fn parse_device_poll_response(body: &str) -> Result<DevicePollOutcome, CopilotAuthError> {
401 if let Ok(raw) = serde_json::from_str::<DeviceTokenJson>(body) {
402 if let Some(token) = raw.access_token {
403 if token.trim().is_empty() {
404 return Err(CopilotAuthError::EmptyGitHubToken);
405 }
406 return Ok(DevicePollOutcome::Authorized {
407 github_token: token,
408 });
409 }
410 return Ok(match raw.error.as_deref().unwrap_or("") {
411 "authorization_pending" => DevicePollOutcome::Pending,
412 "slow_down" => DevicePollOutcome::SlowDown,
413 "access_denied" => DevicePollOutcome::Denied,
414 "expired_token" => DevicePollOutcome::Expired,
415 "incorrect_device_code" => DevicePollOutcome::IncorrectDeviceCode,
416 "device_flow_disabled" => DevicePollOutcome::DeviceFlowDisabled,
417 _ => DevicePollOutcome::OtherError,
418 });
419 }
420 let mut access_token = None;
421 let mut error = None;
422 for pair in body.split('&') {
423 let mut it = pair.splitn(2, '=');
424 let k = it.next().unwrap_or("");
425 let v = it.next().unwrap_or("");
426 let v = urlencoding::decode(v)
427 .map(|c| c.into_owned())
428 .unwrap_or_else(|_| v.to_string());
429 match k {
430 "access_token" => access_token = Some(v),
431 "error" => error = Some(v),
432 _ => {}
433 }
434 }
435 if let Some(token) = access_token {
436 if token.trim().is_empty() {
437 return Err(CopilotAuthError::EmptyGitHubToken);
438 }
439 return Ok(DevicePollOutcome::Authorized {
440 github_token: token,
441 });
442 }
443 Ok(match error.as_deref().unwrap_or("") {
444 "authorization_pending" => DevicePollOutcome::Pending,
445 "slow_down" => DevicePollOutcome::SlowDown,
446 "access_denied" => DevicePollOutcome::Denied,
447 "expired_token" => DevicePollOutcome::Expired,
448 "incorrect_device_code" => DevicePollOutcome::IncorrectDeviceCode,
449 "device_flow_disabled" => DevicePollOutcome::DeviceFlowDisabled,
450 _ => DevicePollOutcome::OtherError,
451 })
452}
453
454pub fn parse_session_mint_response(
455 body: &str,
456 now_ms: u64,
457) -> Result<SessionToken, CopilotAuthError> {
458 let raw: SessionMintJson =
459 serde_json::from_str(body).map_err(|_| CopilotAuthError::InvalidSessionResponse)?;
460 let token = raw
461 .token
462 .filter(|t| !t.trim().is_empty())
463 .ok_or(CopilotAuthError::EmptySessionToken)?;
464 let expires_at_ms = if let Some(secs) = raw.expires_at {
465 if secs > 10_000_000_000 {
466 secs
467 } else {
468 secs.saturating_mul(1000)
469 }
470 } else if let Some(expires_in) = raw.expires_in {
471 now_ms.saturating_add(expires_in.saturating_mul(1000))
472 } else {
473 return Err(CopilotAuthError::InvalidSessionResponse);
474 };
475 let expires_at_ms = expires_at_ms.saturating_sub(EXPIRY_SKEW_MS);
476 if expires_at_ms <= now_ms {
477 return Err(CopilotAuthError::Expired);
478 }
479 Ok(SessionToken {
480 token,
481 expires_at_ms,
482 })
483}
484
485pub fn credentials_from_session(
486 github_token: &str,
487 session: SessionToken,
488) -> Result<OAuthCredentials, CopilotAuthError> {
489 if github_token.trim().is_empty() {
490 return Err(CopilotAuthError::EmptyGitHubToken);
491 }
492 if session.token.trim().is_empty() {
493 return Err(CopilotAuthError::EmptySessionToken);
494 }
495 Ok(OAuthCredentials {
496 auth_type: "oauth".into(),
497 refresh: github_token.to_string(),
498 access: session.token,
499 expires: session.expires_at_ms,
500 account_id: None,
501 })
502}
503
504pub fn apply_slow_down(interval_secs: u64) -> u64 {
505 interval_secs
506 .saturating_add(SLOW_DOWN_STEP_SECS)
507 .min(MAX_POLL_INTERVAL_SECS)
508}
509
510pub async fn read_body_capped(
514 response: reqwest::Response,
515 cap: usize,
516) -> Result<String, CopilotAuthError> {
517 use futures::StreamExt;
518 let mut buf = Vec::new();
519 let mut stream = response.bytes_stream();
520 while let Some(chunk) = stream.next().await {
521 let chunk = chunk.map_err(|_| CopilotAuthError::Transport)?;
522 if buf.len().saturating_add(chunk.len()) > cap {
523 return Err(CopilotAuthError::ResponseTooLarge);
524 }
525 buf.extend_from_slice(&chunk);
526 }
527 String::from_utf8(buf).map_err(|_| CopilotAuthError::Transport)
528}
529
530pub async fn start_device_authorization<H, C>(
534 http: &H,
535 clock: &C,
536) -> Result<DeviceAuthorization, CopilotAuthError>
537where
538 H: CopilotHttp,
539 C: CopilotClock,
540{
541 validate_device_endpoint(DEVICE_CODE_URL)?;
542 let resp = http
543 .post_form(
544 DEVICE_CODE_URL,
545 &[("client_id", CLIENT_ID), ("scope", SCOPE)],
546 )
547 .await?;
548 if !(200..300).contains(&resp.status) {
549 return Err(CopilotAuthError::HttpStatus(resp.status));
550 }
551 parse_device_code_response(&resp.body, clock.now_millis())
552}
553
554pub async fn poll_device_token<H>(
556 http: &H,
557 device_code: &str,
558) -> Result<DevicePollOutcome, CopilotAuthError>
559where
560 H: CopilotHttp,
561{
562 validate_device_endpoint(ACCESS_TOKEN_URL)?;
563 let resp = http
564 .post_form(
565 ACCESS_TOKEN_URL,
566 &[
567 ("client_id", CLIENT_ID),
568 ("device_code", device_code),
569 ("grant_type", DEVICE_GRANT_TYPE),
570 ],
571 )
572 .await?;
573 if !(200..300).contains(&resp.status) {
574 if let Ok(outcome) = parse_device_poll_response(&resp.body) {
575 if !matches!(outcome, DevicePollOutcome::OtherError) {
576 return Ok(outcome);
577 }
578 }
579 return Err(CopilotAuthError::HttpStatus(resp.status));
580 }
581 parse_device_poll_response(&resp.body)
582}
583
584pub async fn wait_for_device_authorization<H, C, X>(
589 http: &H,
590 clock: &C,
591 authz: &DeviceAuthorization,
592 cancel: &X,
593) -> Result<String, CopilotAuthError>
594where
595 H: CopilotHttp,
596 C: CopilotClock,
597 X: CopilotCancel + ?Sized,
598{
599 let deadline_ms = authz
600 .issued_at_ms
601 .saturating_add(authz.expires_in_secs.saturating_mul(1000));
602 let mut interval_secs = authz.interval_secs.clamp(1, MAX_POLL_INTERVAL_SECS);
603
604 loop {
605 if cancel.is_cancelled() {
606 return Err(CopilotAuthError::Cancelled);
607 }
608 if clock.now_millis() >= deadline_ms {
609 return Err(CopilotAuthError::Expired);
610 }
611
612 clock
613 .sleep_cancellable(Duration::from_secs(interval_secs), cancel)
614 .await?;
615
616 if cancel.is_cancelled() {
617 return Err(CopilotAuthError::Cancelled);
618 }
619 if clock.now_millis() >= deadline_ms {
620 return Err(CopilotAuthError::Expired);
621 }
622
623 match poll_device_token(http, &authz.device_code).await? {
624 DevicePollOutcome::Pending => continue,
625 DevicePollOutcome::SlowDown => {
626 interval_secs = apply_slow_down(interval_secs);
627 }
628 DevicePollOutcome::Authorized { github_token } => {
629 if github_token.trim().is_empty() {
630 return Err(CopilotAuthError::EmptyGitHubToken);
631 }
632 return Ok(github_token);
633 }
634 DevicePollOutcome::Denied => return Err(CopilotAuthError::AccessDenied),
635 DevicePollOutcome::Expired => return Err(CopilotAuthError::Expired),
636 DevicePollOutcome::IncorrectDeviceCode => {
637 return Err(CopilotAuthError::IncorrectDeviceCode)
638 }
639 DevicePollOutcome::DeviceFlowDisabled => {
640 return Err(CopilotAuthError::DeviceFlowDisabled)
641 }
642 DevicePollOutcome::OtherError => return Err(CopilotAuthError::InvalidTokenResponse),
643 }
644 }
645}
646
647pub async fn mint_session_token<H, C>(
654 http: &H,
655 clock: &C,
656 github_token: &str,
657) -> Result<SessionToken, CopilotAuthError>
658where
659 H: CopilotHttp,
660 C: CopilotClock,
661{
662 if github_token.trim().is_empty() {
663 return Err(CopilotAuthError::EmptyGitHubToken);
664 }
665 validate_session_mint_endpoint(SESSION_MINT_URL)?;
666 let resp = http
667 .get_bearer(
668 SESSION_MINT_URL,
669 github_token,
670 session_mint_request_headers(),
671 )
672 .await?;
673 if !(200..300).contains(&resp.status) {
674 return Err(CopilotAuthError::SessionMintRejected(resp.status));
676 }
677 parse_session_mint_response(&resp.body, clock.now_millis())
678}
679
680pub async fn mint_credentials<H, C>(
682 http: &H,
683 clock: &C,
684 github_token: &str,
685) -> Result<OAuthCredentials, CopilotAuthError>
686where
687 H: CopilotHttp,
688 C: CopilotClock,
689{
690 let session = mint_session_token(http, clock, github_token).await?;
691 credentials_from_session(github_token, session)
692}
693
694pub type UserCodeHook<'a> = &'a dyn Fn(&str, &str);
697
698pub struct LoginHooks<'a, B> {
699 pub browser: &'a B,
700 pub on_user_code: Option<UserCodeHook<'a>>,
702}
703
704pub async fn login_with<H, C, B, X>(
706 http: &H,
707 clock: &C,
708 hooks: LoginHooks<'_, B>,
709 cancel: &X,
710 persist: bool,
711) -> Result<OAuthCredentials, CopilotAuthError>
712where
713 H: CopilotHttp,
714 C: CopilotClock,
715 B: CopilotBrowser,
716 X: CopilotCancel + ?Sized,
717{
718 let authz = start_device_authorization(http, clock).await?;
719 if let Some(cb) = hooks.on_user_code {
720 cb(&authz.user_code, &authz.verification_uri);
721 } else {
722 eprintln!("\n\x1b[1mGitHub Copilot device login\x1b[0m");
723 eprintln!(" Enter code: \x1b[36m{}\x1b[0m", authz.user_code);
724 eprintln!(" At: \x1b[36m{}\x1b[0m\n", authz.verification_uri);
725 }
726 validate_verification_uri(&authz.verification_uri)?;
728 let _ = hooks.browser.open(&authz.verification_uri);
729
730 let github_token = wait_for_device_authorization(http, clock, &authz, cancel).await?;
731 let creds = mint_credentials(http, clock, &github_token).await?;
732 if persist {
733 save_provider_auth(PROVIDER, &creds).map_err(|_| CopilotAuthError::Persist)?;
734 }
735 Ok(creds)
736}
737
738pub async fn login() -> Result<OAuthCredentials, String> {
740 let http = ProductionHttp::new().map_err(|e| e.to_string())?;
741 let clock = ProductionClock;
742 let browser = ProductionBrowser;
743 let cancel = AtomicBool::new(false);
744 login_with(
745 &http,
746 &clock,
747 LoginHooks {
748 browser: &browser,
749 on_user_code: None,
750 },
751 &cancel,
752 true,
753 )
754 .await
755 .map_err(|e| e.to_string())
756}
757
758pub async fn refresh_token(
760 _client: &Client,
761 github_token: &str,
762) -> Result<OAuthCredentials, String> {
763 let http = ProductionHttp::new().map_err(|e| e.to_string())?;
764 let clock = ProductionClock;
765 mint_credentials(&http, &clock, github_token)
766 .await
767 .map_err(|e| e.to_string())
768}
769
770struct ProductionHttp {
773 client: Client,
775}
776
777impl ProductionHttp {
778 fn new() -> Result<Self, CopilotAuthError> {
779 let client = Client::builder()
780 .redirect(reqwest::redirect::Policy::none())
781 .connect_timeout(CONNECT_TIMEOUT)
782 .timeout(REQUEST_TIMEOUT)
783 .build()
784 .map_err(|_| CopilotAuthError::Transport)?;
785 Ok(Self { client })
786 }
787}
788
789#[async_trait]
790impl CopilotHttp for ProductionHttp {
791 async fn post_form(
792 &self,
793 url: &str,
794 form: &[(&str, &str)],
795 ) -> Result<InjectedHttpResponse, CopilotAuthError> {
796 validate_device_endpoint(url)?;
797 let response = self
798 .client
799 .post(url)
800 .header("Accept", "application/json")
801 .form(form)
802 .send()
803 .await
804 .map_err(|_| CopilotAuthError::Transport)?;
805 let status = response.status().as_u16();
806 let body = read_body_capped(response, MAX_RESPONSE_BODY_BYTES).await?;
807 Ok(InjectedHttpResponse { status, body })
808 }
809
810 async fn get_bearer(
811 &self,
812 url: &str,
813 bearer: &str,
814 headers: &[(&str, &str)],
815 ) -> Result<InjectedHttpResponse, CopilotAuthError> {
816 validate_session_mint_endpoint(url)?;
818 let mut req = self
819 .client
820 .get(url)
821 .header("Authorization", format!("Bearer {bearer}"));
822 for (name, value) in headers {
823 if name.eq_ignore_ascii_case("authorization") {
825 continue;
826 }
827 req = req.header(*name, *value);
828 }
829 let response = req.send().await.map_err(|_| CopilotAuthError::Transport)?;
830 let status = response.status().as_u16();
831 if (300..400).contains(&status) {
833 return Err(CopilotAuthError::UntrustedEndpoint);
834 }
835 let body = read_body_capped(response, MAX_RESPONSE_BODY_BYTES).await?;
836 Ok(InjectedHttpResponse { status, body })
837 }
838}
839
840struct ProductionClock;
841
842#[async_trait]
843impl CopilotClock for ProductionClock {
844 fn now_millis(&self) -> u64 {
845 now_millis()
846 }
847
848 async fn sleep_cancellable<X: CopilotCancel + ?Sized>(
849 &self,
850 duration: Duration,
851 cancel: &X,
852 ) -> Result<(), CopilotAuthError> {
853 let mut remaining = duration;
854 while remaining > Duration::ZERO {
855 if cancel.is_cancelled() {
856 return Err(CopilotAuthError::Cancelled);
857 }
858 let chunk = if remaining > CANCEL_POLL_QUANTUM {
859 CANCEL_POLL_QUANTUM
860 } else {
861 remaining
862 };
863 tokio::time::sleep(chunk).await;
864 remaining = remaining.saturating_sub(chunk);
865 }
866 if cancel.is_cancelled() {
867 return Err(CopilotAuthError::Cancelled);
868 }
869 Ok(())
870 }
871}
872
873struct ProductionBrowser;
874
875impl CopilotBrowser for ProductionBrowser {
876 fn open(&self, url: &str) -> Result<(), CopilotAuthError> {
877 validate_verification_uri(url)?;
878 let _ = open_browser(url);
879 Ok(())
880 }
881}
882
883#[cfg(test)]
886pub mod testutil {
887 use super::*;
888 use std::sync::Mutex;
889
890 #[derive(Default)]
891 pub struct FakeClock {
892 pub now: Mutex<u64>,
893 pub sleeps: Mutex<Vec<u64>>,
894 pub auto_advance: Mutex<bool>,
896 }
897
898 impl FakeClock {
899 pub fn new(now: u64) -> Self {
900 Self {
901 now: Mutex::new(now),
902 sleeps: Mutex::new(Vec::new()),
903 auto_advance: Mutex::new(true),
904 }
905 }
906
907 pub fn set_now(&self, v: u64) {
908 *self.now.lock().unwrap() = v;
909 }
910 }
911
912 #[async_trait]
913 impl CopilotClock for FakeClock {
914 fn now_millis(&self) -> u64 {
915 *self.now.lock().unwrap()
916 }
917
918 async fn sleep_cancellable<X: CopilotCancel + ?Sized>(
919 &self,
920 duration: Duration,
921 cancel: &X,
922 ) -> Result<(), CopilotAuthError> {
923 let total_secs = duration
925 .as_secs()
926 .max(if duration.is_zero() { 0 } else { 1 });
927 if total_secs == 0 {
928 if cancel.is_cancelled() {
929 return Err(CopilotAuthError::Cancelled);
930 }
931 return Ok(());
932 }
933 let mut slept = 0u64;
934 for _ in 0..total_secs {
935 if cancel.is_cancelled() {
936 if slept > 0 {
937 self.sleeps.lock().unwrap().push(slept);
938 }
939 return Err(CopilotAuthError::Cancelled);
940 }
941 slept += 1;
942 if *self.auto_advance.lock().unwrap() {
943 let mut now = self.now.lock().unwrap();
944 *now = now.saturating_add(1000);
945 }
946 tokio::time::sleep(Duration::from_millis(1)).await;
948 }
949 self.sleeps.lock().unwrap().push(slept);
950 if cancel.is_cancelled() {
951 return Err(CopilotAuthError::Cancelled);
952 }
953 Ok(())
954 }
955 }
956
957 #[derive(Clone, Debug)]
958 pub enum ScriptedResponse {
959 Ok(String),
960 Status(u16, String),
961 }
962
963 #[derive(Default)]
964 pub struct FakeHttp {
965 pub post_queue: Mutex<Vec<ScriptedResponse>>,
966 pub get_queue: Mutex<Vec<ScriptedResponse>>,
967 pub posts: Mutex<Vec<(String, Vec<(String, String)>)>>,
968 pub gets: Mutex<Vec<(String, String, Vec<(String, String)>)>>,
970 }
971
972 impl FakeHttp {
973 pub fn push_post(&self, r: ScriptedResponse) {
974 self.post_queue.lock().unwrap().push(r);
975 }
976 pub fn push_get(&self, r: ScriptedResponse) {
977 self.get_queue.lock().unwrap().push(r);
978 }
979 }
980
981 #[async_trait]
982 impl CopilotHttp for FakeHttp {
983 async fn post_form(
984 &self,
985 url: &str,
986 form: &[(&str, &str)],
987 ) -> Result<InjectedHttpResponse, CopilotAuthError> {
988 validate_device_endpoint(url)?;
989 self.posts.lock().unwrap().push((
990 url.to_string(),
991 form.iter()
992 .map(|(k, v)| ((*k).to_string(), (*v).to_string()))
993 .collect(),
994 ));
995 let next = {
996 let mut q = self.post_queue.lock().unwrap();
997 if q.is_empty() {
998 return Err(CopilotAuthError::Transport);
999 }
1000 q.remove(0)
1001 };
1002 match next {
1003 ScriptedResponse::Ok(body) => Ok(InjectedHttpResponse { status: 200, body }),
1004 ScriptedResponse::Status(status, body) => Ok(InjectedHttpResponse { status, body }),
1005 }
1006 }
1007
1008 async fn get_bearer(
1009 &self,
1010 url: &str,
1011 bearer: &str,
1012 headers: &[(&str, &str)],
1013 ) -> Result<InjectedHttpResponse, CopilotAuthError> {
1014 validate_session_mint_endpoint(url)?;
1015 self.gets.lock().unwrap().push((
1016 url.to_string(),
1017 bearer.to_string(),
1018 headers
1019 .iter()
1020 .map(|(k, v)| ((*k).to_string(), (*v).to_string()))
1021 .collect(),
1022 ));
1023 let next = {
1024 let mut q = self.get_queue.lock().unwrap();
1025 if q.is_empty() {
1026 return Err(CopilotAuthError::Transport);
1027 }
1028 q.remove(0)
1029 };
1030 match next {
1031 ScriptedResponse::Ok(body) => Ok(InjectedHttpResponse { status: 200, body }),
1032 ScriptedResponse::Status(status, body) => Ok(InjectedHttpResponse { status, body }),
1033 }
1034 }
1035 }
1036
1037 #[derive(Default)]
1038 pub struct RecordingBrowser {
1039 pub opened: Mutex<Vec<String>>,
1040 }
1041
1042 impl CopilotBrowser for RecordingBrowser {
1043 fn open(&self, url: &str) -> Result<(), CopilotAuthError> {
1044 validate_verification_uri(url)?;
1045 self.opened.lock().unwrap().push(url.to_string());
1046 Ok(())
1047 }
1048 }
1049
1050 pub fn device_code_body() -> String {
1051 serde_json::json!({
1052 "device_code": "device-secret-code-xxxxx",
1053 "user_code": "ABCD-1234",
1054 "verification_uri": "https://github.com/login/device",
1055 "expires_in": 900,
1056 "interval": 5
1057 })
1058 .to_string()
1059 }
1060
1061 pub fn pending_body() -> String {
1062 r#"{"error":"authorization_pending"}"#.into()
1063 }
1064 pub fn slow_down_body() -> String {
1065 r#"{"error":"slow_down"}"#.into()
1066 }
1067 pub fn denied_body() -> String {
1068 r#"{"error":"access_denied"}"#.into()
1069 }
1070 pub fn expired_body() -> String {
1071 r#"{"error":"expired_token"}"#.into()
1072 }
1073 pub fn authorized_body(token: &str) -> String {
1074 serde_json::json!({ "access_token": token, "token_type": "bearer", "scope": "read:user" })
1075 .to_string()
1076 }
1077 pub fn session_body(token: &str, expires_at_secs: u64) -> String {
1078 serde_json::json!({ "token": token, "expires_at": expires_at_secs }).to_string()
1079 }
1080}
1081
1082#[cfg(test)]
1085mod tests {
1086 use super::testutil::*;
1087 use super::*;
1088 use std::sync::Mutex;
1089
1090 #[test]
1091 fn github_copilot_constants_are_pinned_https_github() {
1092 assert_eq!(PROVIDER, "github-copilot");
1093 assert_eq!(CLIENT_ID, "Iv1.b507a08c87ecfe98");
1094 assert_eq!(SCOPE, "read:user");
1095 assert_eq!(DEVICE_CODE_URL, "https://github.com/login/device/code");
1096 assert_eq!(
1097 ACCESS_TOKEN_URL,
1098 "https://github.com/login/oauth/access_token"
1099 );
1100 assert_eq!(
1101 SESSION_MINT_URL,
1102 "https://api.github.com/copilot_internal/v2/token"
1103 );
1104 assert_eq!(DEFAULT_VERIFICATION_URI, "https://github.com/login/device");
1105 validate_device_endpoint(DEVICE_CODE_URL).unwrap();
1106 validate_device_endpoint(ACCESS_TOKEN_URL).unwrap();
1107 validate_session_mint_endpoint(SESSION_MINT_URL).unwrap();
1108 validate_verification_uri(DEFAULT_VERIFICATION_URI).unwrap();
1109 assert!(CONNECT_TIMEOUT.as_secs() > 0);
1110 assert!(REQUEST_TIMEOUT.as_secs() > 0);
1111 assert!(MAX_RESPONSE_BODY_BYTES >= 1024);
1112 }
1113
1114 #[test]
1115 fn github_copilot_rejects_non_pinned_endpoints() {
1116 for bad in [
1117 "http://github.com/login/device/code",
1118 "https://evil.com/login/device/code",
1119 "https://github.com.evil/login/device/code",
1120 "https://api.github.com/copilot_internal/v2/token",
1121 ] {
1122 assert!(
1123 validate_device_endpoint(bad).is_err(),
1124 "should reject {bad}"
1125 );
1126 }
1127 for bad in [
1128 "http://api.github.com/copilot_internal/v2/token",
1129 "https://evil.com/copilot_internal/v2/token",
1130 "https://api.github.com/user",
1131 "https://github.com/login/device/code",
1132 ] {
1133 assert!(
1134 validate_session_mint_endpoint(bad).is_err(),
1135 "should reject {bad}"
1136 );
1137 }
1138 }
1139
1140 #[test]
1141 fn github_copilot_verification_uri_is_strictly_device_page() {
1142 validate_verification_uri("https://github.com/login/device").unwrap();
1144 validate_verification_uri("https://github.com/login/device/").unwrap();
1145 validate_verification_uri("https://github.com/login/device?user_code=ABCD-1234").unwrap();
1146
1147 for bad in [
1148 "http://github.com/login/device",
1149 "https://evil.com/login/device",
1150 "https://github.com.evil/login/device",
1151 "https://github.com/settings",
1152 "https://github.com/login",
1153 "https://github.com/login/device/extra",
1154 "https://github.com/login/oauth/authorize",
1155 "https://github.com/login/device/../../../etc/passwd",
1156 "https://user:pass@github.com/login/device",
1157 "https://github.com/login/device#fragment",
1158 "https://github.com:8443/login/device",
1159 "https://github.com/login/device?foo=bar",
1160 "https://github.com/login/device?user_code=A&user_code=B",
1161 "https://github.com/login/device?user_code=",
1162 "https://github.com/login/device?user_code=x&return_to=https://evil",
1164 ] {
1165 assert!(
1166 validate_verification_uri(bad).is_err(),
1167 "should reject verification uri {bad}"
1168 );
1169 }
1170 }
1171
1172 #[test]
1173 fn github_copilot_no_client_secret_in_production_surface() {
1174 let src = include_str!("github_copilot.rs");
1175 let production = src
1176 .split("// ── Unit tests ─")
1177 .next()
1178 .expect("production section");
1179 assert!(
1180 !production.contains("CLIENT_SECRET"),
1181 "device flow must not embed a CLIENT_SECRET constant"
1182 );
1183 assert!(
1184 !production.contains("client_secret"),
1185 "device flow must not send or store a client_secret"
1186 );
1187 }
1188
1189 #[test]
1190 fn github_copilot_production_http_configures_timeouts_and_body_cap() {
1191 let src = include_str!("github_copilot.rs");
1192 let production = src
1193 .split("// ── Unit tests ─")
1194 .next()
1195 .expect("production section");
1196 assert!(production.contains("connect_timeout(CONNECT_TIMEOUT)"));
1197 assert!(production.contains(".timeout(REQUEST_TIMEOUT)"));
1198 assert!(production.contains("read_body_capped(response, MAX_RESPONSE_BODY_BYTES)"));
1199 assert!(!production.contains(".text()"));
1200 }
1201
1202 #[test]
1203 fn github_copilot_parse_device_code_and_reject_bad_uri() {
1204 let authz = parse_device_code_response(&device_code_body(), 1_000).unwrap();
1205 assert_eq!(authz.user_code, "ABCD-1234");
1206 assert_eq!(authz.interval_secs, 5);
1207 assert_eq!(authz.verification_uri, DEFAULT_VERIFICATION_URI);
1208
1209 let evil = r#"{
1210 "device_code":"x","user_code":"Y","verification_uri":"https://evil.example/device",
1211 "expires_in":100,"interval":5
1212 }"#;
1213 assert!(parse_device_code_response(evil, 0).is_err());
1214
1215 let deceptive = r#"{
1216 "device_code":"x","user_code":"Y",
1217 "verification_uri":"https://github.com/login/device?user_code=A&next=https://evil",
1218 "expires_in":100,"interval":5
1219 }"#;
1220 assert!(parse_device_code_response(deceptive, 0).is_err());
1221 }
1222
1223 #[test]
1224 fn github_copilot_parse_poll_outcomes() {
1225 assert_eq!(
1226 parse_device_poll_response(&pending_body()).unwrap(),
1227 DevicePollOutcome::Pending
1228 );
1229 assert_eq!(
1230 parse_device_poll_response(&slow_down_body()).unwrap(),
1231 DevicePollOutcome::SlowDown
1232 );
1233 assert_eq!(
1234 parse_device_poll_response(&denied_body()).unwrap(),
1235 DevicePollOutcome::Denied
1236 );
1237 assert_eq!(
1238 parse_device_poll_response(&expired_body()).unwrap(),
1239 DevicePollOutcome::Expired
1240 );
1241 match parse_device_poll_response(&authorized_body("gho_test_token")).unwrap() {
1242 DevicePollOutcome::Authorized { github_token } => {
1243 assert_eq!(github_token, "gho_test_token")
1244 }
1245 other => panic!("expected authorized, got {other:?}"),
1246 }
1247 assert_eq!(
1248 parse_device_poll_response("error=authorization_pending").unwrap(),
1249 DevicePollOutcome::Pending
1250 );
1251 }
1252
1253 #[test]
1254 fn github_copilot_slow_down_is_bounded() {
1255 let mut i = 5u64;
1256 for _ in 0..20 {
1257 i = apply_slow_down(i);
1258 }
1259 assert_eq!(i, MAX_POLL_INTERVAL_SECS);
1260 }
1261
1262 #[test]
1263 fn github_copilot_session_mint_parse_applies_skew() {
1264 let now = 1_700_000_000_000u64;
1265 let expires_at_secs = now / 1000 + 1800;
1266 let session =
1267 parse_session_mint_response(&session_body("tid=session;exp=1", expires_at_secs), now)
1268 .unwrap();
1269 assert_eq!(session.token, "tid=session;exp=1");
1270 assert_eq!(
1271 session.expires_at_ms,
1272 expires_at_secs * 1000 - EXPIRY_SKEW_MS
1273 );
1274 }
1275
1276 #[test]
1277 fn github_copilot_credentials_map_github_to_refresh_session_to_access() {
1278 let creds = credentials_from_session(
1279 "gho_long_lived",
1280 SessionToken {
1281 token: "tid=short".into(),
1282 expires_at_ms: 99,
1283 },
1284 )
1285 .unwrap();
1286 assert_eq!(creds.refresh, "gho_long_lived");
1287 assert_eq!(creds.access, "tid=short");
1288 assert_eq!(creds.expires, 99);
1289 assert_eq!(creds.auth_type, "oauth");
1290 assert!(credentials_from_session(
1291 "",
1292 SessionToken {
1293 token: "x".into(),
1294 expires_at_ms: 1
1295 }
1296 )
1297 .is_err());
1298 assert!(credentials_from_session(
1299 "g",
1300 SessionToken {
1301 token: "".into(),
1302 expires_at_ms: 1
1303 }
1304 )
1305 .is_err());
1306 }
1307
1308 #[test]
1309 fn github_copilot_errors_debug_display_are_secret_free() {
1310 let secrets = [
1311 "gho_SUPER_SECRET_TOKEN",
1312 "ghu_another_secret",
1313 "tid=session-secret",
1314 "device-secret-code-xxxxx",
1315 "ABCD-1234",
1316 "Bearer abc",
1317 "Authorization",
1318 ];
1319 let errors = [
1320 CopilotAuthError::UntrustedEndpoint,
1321 CopilotAuthError::InvalidDeviceResponse,
1322 CopilotAuthError::InvalidTokenResponse,
1323 CopilotAuthError::InvalidSessionResponse,
1324 CopilotAuthError::AccessDenied,
1325 CopilotAuthError::Expired,
1326 CopilotAuthError::IncorrectDeviceCode,
1327 CopilotAuthError::DeviceFlowDisabled,
1328 CopilotAuthError::Cancelled,
1329 CopilotAuthError::EmptyGitHubToken,
1330 CopilotAuthError::EmptySessionToken,
1331 CopilotAuthError::HttpStatus(401),
1332 CopilotAuthError::SessionMintRejected(403),
1333 CopilotAuthError::Transport,
1334 CopilotAuthError::ResponseTooLarge,
1335 CopilotAuthError::Persist,
1336 CopilotAuthError::Other("generic failure"),
1337 ];
1338 for e in errors {
1339 let d = format!("{e:?}");
1340 let s = e.to_string();
1341 for secret in secrets {
1342 assert!(
1343 !d.contains(secret) && !s.contains(secret),
1344 "leak in {d:?} / {s:?}"
1345 );
1346 }
1347 }
1348 }
1349
1350 #[tokio::test]
1351 async fn github_copilot_device_flow_pending_slow_down_then_authorize() {
1352 let http = FakeHttp::default();
1353 http.push_post(ScriptedResponse::Ok(device_code_body()));
1354 http.push_post(ScriptedResponse::Ok(pending_body()));
1355 http.push_post(ScriptedResponse::Ok(slow_down_body()));
1356 http.push_post(ScriptedResponse::Ok(authorized_body("gho_user_token")));
1357
1358 let clock = FakeClock::new(1_000);
1359 let authz = start_device_authorization(&http, &clock).await.unwrap();
1360 assert_eq!(authz.user_code, "ABCD-1234");
1361
1362 let cancel = AtomicBool::new(false);
1363 let token = wait_for_device_authorization(&http, &clock, &authz, &cancel)
1364 .await
1365 .unwrap();
1366 assert_eq!(token, "gho_user_token");
1367
1368 let sleeps = clock.sleeps.lock().unwrap().clone();
1369 assert!(sleeps.len() >= 3);
1371 assert_eq!(sleeps[0], 5);
1372 assert_eq!(sleeps[1], 5);
1373 assert_eq!(sleeps[2], 10);
1374
1375 let posts = http.posts.lock().unwrap().clone();
1376 assert_eq!(posts[0].0, DEVICE_CODE_URL);
1377 assert!(posts[0]
1378 .1
1379 .iter()
1380 .any(|(k, v)| k == "client_id" && v == CLIENT_ID));
1381 assert!(posts[0].1.iter().any(|(k, v)| k == "scope" && v == SCOPE));
1382 assert_eq!(posts[1].0, ACCESS_TOKEN_URL);
1383 for (_, form) in &posts {
1384 assert!(!form.iter().any(|(k, _)| k == "client_secret"));
1385 }
1386 }
1387
1388 #[tokio::test]
1389 async fn github_copilot_device_flow_denial_and_expiry_and_cancel() {
1390 let http = FakeHttp::default();
1392 http.push_post(ScriptedResponse::Ok(device_code_body()));
1393 http.push_post(ScriptedResponse::Ok(denied_body()));
1394 let clock = FakeClock::new(0);
1395 let authz = start_device_authorization(&http, &clock).await.unwrap();
1396 let cancel = AtomicBool::new(false);
1397 let err = wait_for_device_authorization(&http, &clock, &authz, &cancel)
1398 .await
1399 .unwrap_err();
1400 assert_eq!(err, CopilotAuthError::AccessDenied);
1401
1402 let http = FakeHttp::default();
1404 http.push_post(ScriptedResponse::Ok(device_code_body()));
1405 let clock = FakeClock::new(0);
1406 let authz = start_device_authorization(&http, &clock).await.unwrap();
1407 let cancel = AtomicBool::new(true);
1408 let err = wait_for_device_authorization(&http, &clock, &authz, &cancel)
1409 .await
1410 .unwrap_err();
1411 assert_eq!(err, CopilotAuthError::Cancelled);
1412
1413 let http = FakeHttp::default();
1415 http.push_post(ScriptedResponse::Ok(device_code_body()));
1416 let clock = FakeClock::new(0);
1417 let authz = start_device_authorization(&http, &clock).await.unwrap();
1418 clock.set_now(authz.issued_at_ms + authz.expires_in_secs * 1000 + 1);
1419 let cancel = AtomicBool::new(false);
1420 let err = wait_for_device_authorization(&http, &clock, &authz, &cancel)
1421 .await
1422 .unwrap_err();
1423 assert_eq!(err, CopilotAuthError::Expired);
1424 }
1425
1426 #[tokio::test]
1427 async fn github_copilot_cancellation_interrupts_pending_sleep() {
1428 let http = FakeHttp::default();
1429 let body = serde_json::json!({
1431 "device_code": "device-secret-code-xxxxx",
1432 "user_code": "ABCD-1234",
1433 "verification_uri": "https://github.com/login/device",
1434 "expires_in": 900,
1435 "interval": 30
1436 })
1437 .to_string();
1438 http.push_post(ScriptedResponse::Ok(body));
1439 let clock = FakeClock::new(0);
1441 let authz = start_device_authorization(&http, &clock).await.unwrap();
1442 let cancel = Arc::new(AtomicBool::new(false));
1443 let cancel_flag = Arc::clone(&cancel);
1444
1445 let waiter = tokio::spawn(async move {
1446 wait_for_device_authorization(&http, &clock, &authz, cancel_flag.as_ref()).await
1447 });
1448
1449 tokio::time::sleep(Duration::from_millis(10)).await;
1451 cancel.store(true, Ordering::SeqCst);
1452
1453 let err = waiter.await.unwrap().unwrap_err();
1454 assert_eq!(err, CopilotAuthError::Cancelled);
1455 }
1457
1458 #[tokio::test]
1459 async fn github_copilot_session_mint_pins_host_and_maps_credentials() {
1460 let http = FakeHttp::default();
1461 let now = 1_700_000_000_000u64;
1462 let exp = now / 1000 + 1800;
1463 http.push_get(ScriptedResponse::Ok(session_body("tid=sess", exp)));
1464 let clock = FakeClock::new(now);
1465 let creds = mint_credentials(&http, &clock, "gho_refresh_material")
1466 .await
1467 .unwrap();
1468 assert_eq!(creds.access, "tid=sess");
1469 assert_eq!(creds.refresh, "gho_refresh_material");
1470 let gets = http.gets.lock().unwrap().clone();
1471 assert_eq!(gets.len(), 1);
1472 assert_eq!(gets[0].0, SESSION_MINT_URL);
1473 assert_eq!(gets[0].1, "gho_refresh_material");
1474 }
1475
1476 #[tokio::test]
1477 async fn github_copilot_login_requires_successful_mint_before_return() {
1478 let http = FakeHttp::default();
1479 http.push_post(ScriptedResponse::Ok(device_code_body()));
1480 http.push_post(ScriptedResponse::Ok(authorized_body("gho_ok")));
1481 http.push_get(ScriptedResponse::Status(401, r#"{"message":"bad"}"#.into()));
1482 let clock = FakeClock::new(0);
1483 let browser = RecordingBrowser::default();
1484 let cancel = AtomicBool::new(false);
1485 let err = login_with(
1486 &http,
1487 &clock,
1488 LoginHooks {
1489 browser: &browser,
1490 on_user_code: Some(&|_, _| {}),
1491 },
1492 &cancel,
1493 false,
1494 )
1495 .await
1496 .unwrap_err();
1497 assert!(matches!(err, CopilotAuthError::SessionMintRejected(401)));
1498 assert_eq!(
1499 browser.opened.lock().unwrap().as_slice(),
1500 &[DEFAULT_VERIFICATION_URI.to_string()]
1501 );
1502 }
1503
1504 #[tokio::test]
1505 async fn github_copilot_full_login_happy_path_without_persist() {
1506 let http = FakeHttp::default();
1507 http.push_post(ScriptedResponse::Ok(device_code_body()));
1508 http.push_post(ScriptedResponse::Ok(pending_body()));
1509 http.push_post(ScriptedResponse::Ok(authorized_body("gho_live")));
1510 let now = 1_700_000_000_000u64;
1511 http.push_get(ScriptedResponse::Ok(session_body(
1512 "tid=live-session",
1513 now / 1000 + 2000,
1514 )));
1515 let clock = FakeClock::new(now);
1516 let browser = RecordingBrowser::default();
1517 let cancel = AtomicBool::new(false);
1518 let seen = Mutex::new(None);
1519 let creds = login_with(
1520 &http,
1521 &clock,
1522 LoginHooks {
1523 browser: &browser,
1524 on_user_code: Some(&|code, uri| {
1525 *seen.lock().unwrap() = Some((code.to_string(), uri.to_string()));
1526 }),
1527 },
1528 &cancel,
1529 false,
1530 )
1531 .await
1532 .unwrap();
1533 assert_eq!(creds.refresh, "gho_live");
1534 assert_eq!(creds.access, "tid=live-session");
1535 assert_eq!(
1536 seen.lock().unwrap().clone().unwrap(),
1537 (
1538 "ABCD-1234".to_string(),
1539 DEFAULT_VERIFICATION_URI.to_string()
1540 )
1541 );
1542 }
1543
1544 #[test]
1545 fn github_copilot_session_mint_headers_include_required_integration_fields() {
1546 let headers = session_mint_request_headers();
1547 let map: std::collections::HashMap<_, _> = headers.iter().copied().collect();
1548 assert_eq!(map.get("Accept"), Some(&"application/json"));
1549 assert_eq!(map.get("User-Agent"), Some(&MINT_USER_AGENT));
1550 assert_eq!(map.get("Editor-Version"), Some(&MINT_EDITOR_VERSION));
1551 assert_eq!(
1552 map.get("Editor-Plugin-Version"),
1553 Some(&MINT_EDITOR_PLUGIN_VERSION)
1554 );
1555 assert_eq!(
1556 map.get("Copilot-Integration-Id"),
1557 Some(&MINT_COPILOT_INTEGRATION_ID)
1558 );
1559 assert!(!map.contains_key("Authorization"));
1561 assert!(MINT_USER_AGENT.contains("Synaps"));
1563 }
1564
1565 #[tokio::test]
1566 async fn github_copilot_session_mint_sends_integration_headers_or_maps_403() {
1567 let http = FakeHttp::default();
1571 let now = 1_700_000_000_000u64;
1572 http.push_get(ScriptedResponse::Status(
1573 403,
1574 r#"{"message":"token not authorized for this integration"}"#.into(),
1575 ));
1576 let clock = FakeClock::new(now);
1577 let err = mint_session_token(&http, &clock, "gho_after_device_ok")
1578 .await
1579 .unwrap_err();
1580 assert!(
1581 matches!(err, CopilotAuthError::SessionMintRejected(403)),
1582 "mint 403 must be staged as SessionMintRejected, not generic HttpStatus: {err:?}"
1583 );
1584 let rendered = err.to_string();
1586 assert!(!rendered.contains("gho_"));
1587 assert!(!rendered.contains("authorized for this integration"));
1588 assert!(rendered.contains("403"));
1589
1590 let http = FakeHttp::default();
1592 http.push_get(ScriptedResponse::Ok(session_body(
1593 "tid=ok",
1594 now / 1000 + 1800,
1595 )));
1596 let creds = mint_credentials(&http, &clock, "gho_after_device_ok")
1597 .await
1598 .unwrap();
1599 assert_eq!(creds.access, "tid=ok");
1600 let gets = http.gets.lock().unwrap().clone();
1601 assert_eq!(gets.len(), 1);
1602 assert_eq!(gets[0].0, SESSION_MINT_URL);
1603 assert_eq!(gets[0].1, "gho_after_device_ok");
1604 let hdrs: std::collections::HashMap<_, _> = gets[0].2.iter().cloned().collect();
1605 assert_eq!(
1606 hdrs.get("Copilot-Integration-Id").map(String::as_str),
1607 Some(MINT_COPILOT_INTEGRATION_ID)
1608 );
1609 assert_eq!(
1610 hdrs.get("Editor-Version").map(String::as_str),
1611 Some(MINT_EDITOR_VERSION)
1612 );
1613 assert_eq!(
1614 hdrs.get("User-Agent").map(String::as_str),
1615 Some(MINT_USER_AGENT)
1616 );
1617 assert_eq!(
1618 hdrs.get("Accept").map(String::as_str),
1619 Some("application/json")
1620 );
1621 }
1622
1623 #[tokio::test]
1624 async fn github_copilot_empty_github_token_rejected_before_mint() {
1625 let http = FakeHttp::default();
1626 let clock = FakeClock::new(0);
1627 let err = mint_credentials(&http, &clock, " ").await.unwrap_err();
1628 assert_eq!(err, CopilotAuthError::EmptyGitHubToken);
1629 assert!(http.gets.lock().unwrap().is_empty());
1630 }
1631
1632 #[tokio::test]
1633 async fn github_copilot_refresh_remints_without_browser() {
1634 let http = FakeHttp::default();
1635 let now = 1_700_000_000_000u64;
1636 http.push_get(ScriptedResponse::Ok(session_body(
1637 "tid=refreshed-session",
1638 now / 1000 + 1800,
1639 )));
1640 let clock = FakeClock::new(now);
1641 let creds = mint_credentials(&http, &clock, "gho_stored_refresh")
1642 .await
1643 .unwrap();
1644 assert_eq!(creds.access, "tid=refreshed-session");
1645 assert_eq!(creds.refresh, "gho_stored_refresh");
1646 assert!(
1647 http.posts.lock().unwrap().is_empty(),
1648 "refresh must not re-run device flow"
1649 );
1650 assert_eq!(http.gets.lock().unwrap().len(), 1);
1651 assert_eq!(http.gets.lock().unwrap()[0].0, SESSION_MINT_URL);
1652 }
1653
1654 #[tokio::test]
1655 async fn github_copilot_login_persist_is_atomic_and_preserves_siblings() {
1656 use super::super::storage::save_provider_auth_at_test_hook;
1657 use super::super::OAuthCredentials;
1658
1659 let dir = tempfile::tempdir().unwrap();
1660 let path = dir.path().join("auth.json");
1661 let sibling = OAuthCredentials {
1662 auth_type: "oauth".into(),
1663 refresh: "anth-refresh".into(),
1664 access: "anth-access".into(),
1665 expires: now_millis() + 3_600_000,
1666 account_id: None,
1667 };
1668 save_provider_auth_at_test_hook(&path, "anthropic", &sibling).unwrap();
1669 save_provider_auth_at_test_hook(
1670 &path,
1671 "xai-auth",
1672 &OAuthCredentials {
1673 auth_type: "oauth".into(),
1674 refresh: "xai-r".into(),
1675 access: "xai-a".into(),
1676 expires: now_millis() + 3_600_000,
1677 account_id: None,
1678 },
1679 )
1680 .unwrap();
1681
1682 let creds = credentials_from_session(
1683 "gho_long_lived_never_vend",
1684 SessionToken {
1685 token: "tid=session_only".into(),
1686 expires_at_ms: now_millis() + 1_500_000,
1687 },
1688 )
1689 .unwrap();
1690 save_provider_auth_at_test_hook(&path, PROVIDER, &creds).unwrap();
1691
1692 let content = std::fs::read_to_string(&path).unwrap();
1693 let parsed: serde_json::Value = serde_json::from_str(&content).unwrap();
1694 assert_eq!(parsed["github-copilot"]["access"], "tid=session_only");
1695 assert_eq!(
1696 parsed["github-copilot"]["refresh"],
1697 "gho_long_lived_never_vend"
1698 );
1699 assert_eq!(parsed["anthropic"]["refresh"], "anth-refresh");
1700 assert_eq!(parsed["xai-auth"]["access"], "xai-a");
1701 assert!(!path.with_extension("json.tmp").exists());
1702 }
1703
1704 #[test]
1705 fn github_copilot_broker_access_token_shape_excludes_refresh() {
1706 let v = serde_json::json!({
1707 "token": "tid=session",
1708 "expires": 123u64,
1709 "refresh": "gho_MUST_NOT_DESERIALIZE",
1710 });
1711 let tok: super::super::AccessToken = serde_json::from_value(v).unwrap();
1712 assert_eq!(tok.token, "tid=session");
1713 assert_eq!(tok.expires, 123);
1714 let round = serde_json::to_value(&tok).unwrap();
1715 assert!(round.get("refresh").is_none());
1716 assert!(!round.to_string().contains("gho_"));
1717 }
1718
1719 #[tokio::test]
1720 async fn github_copilot_mint_rejects_redirect_status() {
1721 let http = FakeHttp::default();
1722 http.push_get(ScriptedResponse::Status(302, "redirected".into()));
1723 let clock = FakeClock::new(0);
1724 let err = mint_session_token(&http, &clock, "gho_x")
1725 .await
1726 .unwrap_err();
1727 assert!(matches!(err, CopilotAuthError::SessionMintRejected(302)));
1728 }
1729}