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