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