Skip to main content

agent_core/core/auth/
github_copilot.rs

1//! GitHub Copilot OAuth (device authorization + broker-owned session mint).
2//!
3//! Experimental. Device-flow endpoints are official GitHub OAuth. The session-mint
4//! path (`GET /copilot_internal/v2/token`) is community-observed and is **not**
5//! documented as a stable general-purpose third-party API. Do not describe it as
6//! officially supported.
7//!
8//! Credential mapping:
9//! - `OAuthCredentials.refresh` = long-lived GitHub user token (broker-owned only)
10//! - `OAuthCredentials.access`  = short-lived Copilot session token
11//! - `OAuthCredentials.expires` = session expiry (ms, with skew)
12//!
13//! The long-lived GitHub token must never be vended, logged, or placed in broker
14//! wire types.
15
16use 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
32// ── Public constants (evidence-isolated) ─────────────────────────────────────
33
34/// Canonical storage / broker / model-prefix id.
35pub const PROVIDER: &str = "github-copilot";
36
37/// Public native-client id observed in Copilot editor clients (VS Code lineage).
38/// Not a client secret. Provenance: community consensus + device-flow examples;
39/// not a first-class constant on docs.github.com. See docs/specs/github-copilot-oauth-spec.md.
40pub const CLIENT_ID: &str = "Iv1.b507a08c87ecfe98";
41
42/// Least-privilege scope among common working values (community-observed).
43pub const SCOPE: &str = "read:user";
44
45/// Official GitHub device-authorization endpoint.
46pub const DEVICE_CODE_URL: &str = "https://github.com/login/device/code";
47
48/// Official GitHub OAuth token endpoint (device-code grant).
49pub const ACCESS_TOKEN_URL: &str = "https://github.com/login/oauth/access_token";
50
51/// Community-observed Copilot session-mint endpoint (pinned host+path).
52pub const SESSION_MINT_URL: &str = "https://api.github.com/copilot_internal/v2/token";
53
54/// Device-code grant type (RFC 8628).
55pub const DEVICE_GRANT_TYPE: &str = "urn:ietf:params:oauth:grant-type:device_code";
56
57/// Default verification page (official docs.github.com device flow).
58pub 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;
62/// Hard ceiling so `slow_down` cannot grow unbounded.
63const MAX_POLL_INTERVAL_SECS: u64 = 30;
64/// Seconds added on each `slow_down` (GitHub docs: +5s).
65const SLOW_DOWN_STEP_SECS: u64 = 5;
66
67/// Connect timeout for production device/poll/mint HTTP.
68pub const CONNECT_TIMEOUT: Duration = Duration::from_secs(10);
69/// Full request timeout for production device/poll/mint HTTP.
70pub const REQUEST_TIMEOUT: Duration = Duration::from_secs(30);
71/// Maximum response body bytes accepted from device/poll/mint endpoints.
72pub const MAX_RESPONSE_BODY_BYTES: usize = 64 * 1024;
73
74// Session-mint request headers (community-observed; required to avoid 403 on
75// /copilot_internal/v2/token after a successful device-flow authorization).
76// User-Agent identifies Synaps honestly; Copilot-Integration-Id / Editor-* match
77// the conventional vscode-chat integration surface used by public clients.
78// See docs/specs/github-copilot-oauth-spec.md §C5 / U4.
79pub 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";
83/// Logical sleep quantum for cancel checks (production wall-clock chunks).
84const CANCEL_POLL_QUANTUM: Duration = Duration::from_millis(200);
85
86// ── Secret-safe errors ───────────────────────────────────────────────────────
87
88/// Failures from the Copilot auth chain. Variants intentionally store **no**
89/// device codes, user codes, tokens, or Authorization headers so Display/Debug
90/// cannot leak them.
91#[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    /// Session mint endpoint rejected the request (often missing integration headers).
106    SessionMintRejected(u16),
107    Transport,
108    ResponseTooLarge,
109    Persist,
110    /// The credential store is corrupt; carries our own repair text
111    /// (path + `mv` command, no secrets).
112    CorruptStore(String),
113    Other(&'static str),
114}
115
116impl CopilotAuthError {
117    /// Login persistence failure: a corrupt store keeps its actionable text.
118    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    /// Typed refresh (session re-mint) failure.
170    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// ── Injectable boundaries ────────────────────────────────────────────────────
196
197/// Minimal HTTP response used by the device-flow / mint state machine.
198#[derive(Debug, Clone)]
199pub struct InjectedHttpResponse {
200    pub status: u16,
201    pub body: String,
202}
203
204/// Injectable HTTP surface so tests never touch the network.
205#[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    /// GET with bearer auth and extra request headers (mint path attaches GitHub/Copilot headers).
214    async fn get_bearer(
215        &self,
216        url: &str,
217        bearer: &str,
218        headers: &[(&str, &str)],
219    ) -> Result<InjectedHttpResponse, CopilotAuthError>;
220}
221
222/// Injectable clock (now + cancellable sleep) for deterministic poll tests.
223#[async_trait]
224pub trait CopilotClock: Send + Sync {
225    fn now_millis(&self) -> u64;
226
227    /// Sleep `duration`, returning early with [`CopilotAuthError::Cancelled`]
228    /// when `cancel` flips. Implementations must check cancellation at least
229    /// every quantum (not only after the full sleep).
230    async fn sleep_cancellable<X: CopilotCancel + ?Sized>(
231        &self,
232        duration: Duration,
233        cancel: &X,
234    ) -> Result<(), CopilotAuthError>;
235}
236
237/// Injectable cancellation signal for device-flow polling.
238pub 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
254/// Injectable browser opener (production uses `open_browser`).
255pub trait CopilotBrowser: Send + Sync {
256    fn open(&self, url: &str) -> Result<(), CopilotAuthError>;
257}
258
259// ── Device-flow types ────────────────────────────────────────────────────────
260
261#[derive(Debug, Clone)]
262pub struct DeviceAuthorization {
263    /// Opaque device code — never logged via public error types.
264    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/// Outcome of one device-token poll (secret-safe; token only on Authorized).
273#[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
291// ── Endpoint pinning ─────────────────────────────────────────────────────────
292
293/// Accept only the exact pinned HTTPS github.com device endpoints.
294pub 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
305/// Accept only the pinned session-mint host+path (no query, no alternate host).
306pub 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
320/// Accept only the documented GitHub device verification destination:
321/// `https://github.com/login/device` optionally with a single `user_code` query
322/// (verification_uri_complete). Rejects userinfo, fragments, alternate ports,
323/// deceptive paths, and arbitrary query keys.
324pub 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    // Normalize trailing slash only; reject any other path (including `..` forms
342    // after URL parsing).
343    let path = parsed.path().trim_end_matches('/');
344    if path != "/login/device" {
345        return Err(CopilotAuthError::UntrustedEndpoint);
346    }
347    // Query: none, or only user_code=... (complete URI form).
348    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
358// ── Session-mint headers ─────────────────────────────────────────────────────
359
360/// Headers attached only to the pinned session-mint request.
361///
362/// GitHub rejects API calls without a User-Agent. The Copilot internal mint
363/// endpoint additionally requires the conventional integration/editor headers
364/// (community-observed); missing them yields HTTP 403 after a successful device
365/// authorization.
366pub 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// ── Parsing (pure) ───────────────────────────────────────────────────────────
377
378#[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    /// Unix seconds (community-observed).
400    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
544// ── Bounded body read ────────────────────────────────────────────────────────
545
546/// Read a response body with a hard byte cap. Fail closed if exceeded.
547pub 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
564// ── Device-flow state machine ────────────────────────────────────────────────
565
566/// Start device authorization (POST device/code).
567pub 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
588/// One poll against the token endpoint.
589pub 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
618/// Poll until authorized, denied, expired, cancelled, or deadline.
619///
620/// Sleeps are cancellable: `cancel` is checked before each wait and during
621/// the wait via [`CopilotClock::sleep_cancellable`].
622pub 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
681// ── Session mint (pinned, no-redirect at production client) ──────────────────
682
683/// Exchange a long-lived GitHub user token for a short-lived Copilot session.
684///
685/// The GitHub token is sent only to the pinned mint URL. Callers must never log
686/// the token argument.
687pub 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        // Stage-specific: device flow already succeeded; mint is the failing step.
709        return Err(CopilotAuthError::SessionMintRejected(resp.status));
710    }
711    parse_session_mint_response(&resp.body, clock.now_millis())
712}
713
714/// Build credentials after a successful mint (GitHub token → refresh).
715pub 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
728// ── Full login (injectable) ──────────────────────────────────────────────────
729
730pub type UserCodeHook<'a> = &'a dyn Fn(&str, &str);
731
732pub struct LoginHooks<'a, B> {
733    pub browser: &'a B,
734    /// When set, the user_code + verification_uri are written here (tests / TUI).
735    pub on_user_code: Option<UserCodeHook<'a>>,
736}
737
738/// Device start → user prompt → poll → mint → atomic persist (default slot).
739pub 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
755/// Like [`login_with`] but persists ONLY into `persist_key` when given
756/// (`github-copilot` or `github-copilot@<label>`).
757pub 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    // Open only the validated verification URI (never an arbitrary redirect).
779    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
790/// Production login entry (real network + system browser + auth.json).
791pub async fn login() -> Result<OAuthCredentials, String> {
792    login_into(Some(PROVIDER)).await
793}
794
795/// Production login; `Some(key)` persists ONLY into that slot, `None`
796/// returns the credential unsaved.
797pub 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
816/// Broker refresh: re-mint session from stored GitHub user token (no browser).
817pub 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
828// ── Production adapters ──────────────────────────────────────────────────────
829
830struct ProductionHttp {
831    /// No-redirect client with connect + request timeouts for secret-bearing calls.
832    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        // Pin again immediately before attaching the GitHub token.
875        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            // Never let a caller-supplied Authorization override the bearer we set.
882            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        // Redirects are disabled; a 3xx means fail closed without following.
890        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// ── Test doubles ─────────────────────────────────────────────────────────────
942
943#[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        /// When true (default), advance `now` by each quantum during sleep.
953        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            // Quantum = 1s of logical time so cancel can interrupt mid-interval.
982            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                // Tiny real sleep so a concurrent canceller can land mid-interval.
1005                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        /// (url, bearer, headers)
1030        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// ── Unit tests ───────────────────────────────────────────────────────────────
1144
1145#[cfg(test)]
1146mod tests {
1147    use super::testutil::*;
1148    use super::*;
1149
1150    /// Round-2 I2: a corrupt store at login is reported with its repair
1151    /// command, not as "credential persistence failed".
1152    #[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        // Allowed: plain and complete (user_code only).
1218        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            // Deceptive open-redirect style query
1238            "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        // pending @5s, slow_down then next poll @10s (5+5)
1445        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        // denial
1466        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        // cancel before poll starts
1478        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        // expiry by clock
1489        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        // Long interval so cancel lands mid-sleep, not after poll starts.
1505        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        // No poll responses: cancel must interrupt the first sleep.
1515        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        // Wait until the first sleep quantum is underway, then cancel.
1525        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        // Must not have attempted a poll (queue empty would be Transport).
1531    }
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        // Authorization is never part of the static header table (bearer is separate).
1635        assert!(!map.contains_key("Authorization"));
1636        // Synaps identifies itself honestly in User-Agent.
1637        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        // Live failure mode: device flow succeeds, mint returns 403 without
1643        // Copilot-Integration-Id / editor headers. Regression: mint must attach
1644        // session_mint_request_headers() and stage-map non-2xx as SessionMintRejected.
1645        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        // Error surface must not echo body secrets / raw tokens.
1660        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        // Successful mint path must send the full header set.
1666        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}