Skip to main content

aiclient_api/auth/
copilot.rs

1use anyhow::{bail, Context, Result};
2use serde::{Deserialize, Serialize};
3use std::time::Duration;
4use tokio::time::sleep;
5
6use super::{TokenData, TokenStore};
7
8const CLIENT_ID: &str = "Iv1.b507a08c87ecfe98";
9const DEVICE_CODE_URL: &str = "https://github.com/login/device/code";
10const ACCESS_TOKEN_URL: &str = "https://github.com/login/oauth/access_token";
11const COPILOT_TOKEN_URL: &str =
12    "https://api.github.com/copilot_internal/v2/token";
13
14#[derive(Debug, Deserialize)]
15struct DeviceCodeResponse {
16    device_code: String,
17    user_code: String,
18    verification_uri: String,
19    interval: u64,
20}
21
22#[derive(Debug, Deserialize)]
23struct AccessTokenResponse {
24    access_token: Option<String>,
25    error: Option<String>,
26}
27
28#[derive(Debug, Deserialize, Serialize)]
29pub struct CopilotTokenResponse {
30    pub token: String,
31    pub expires_at: i64,
32    pub refresh_in: u64,
33}
34
35pub async fn authenticate(store: &dyn TokenStore) -> Result<()> {
36    let client = reqwest::Client::new();
37
38    // Step 1: Request device code
39    let body = serde_json::json!({
40        "client_id": CLIENT_ID,
41        "scope": "read:user"
42    });
43
44    let device_resp: DeviceCodeResponse = client
45        .post(DEVICE_CODE_URL)
46        .header("content-type", "application/json")
47        .header("accept", "application/json")
48        .json(&body)
49        .send()
50        .await
51        .context("Failed to request device code")?
52        .json()
53        .await
54        .context("Failed to parse device code response")?;
55
56    // Step 2: Show user code and attempt to open browser
57    println!(
58        "\nPlease visit: {}",
59        device_resp.verification_uri
60    );
61    println!("And enter code: {}", device_resp.user_code);
62    println!("\nAttempting to open browser...");
63
64    let _ = open::that(&device_resp.verification_uri);
65
66    // Step 3: Poll for access token
67    let mut poll_interval = Duration::from_secs(device_resp.interval + 1);
68    let poll_body = serde_json::json!({
69        "client_id": CLIENT_ID,
70        "device_code": device_resp.device_code,
71        "grant_type": "urn:ietf:params:oauth:grant-type:device_code"
72    });
73
74    println!("\nWaiting for authorization...");
75
76    loop {
77        sleep(poll_interval).await;
78
79        let token_resp: AccessTokenResponse = client
80            .post(ACCESS_TOKEN_URL)
81            .header("content-type", "application/json")
82            .header("accept", "application/json")
83            .json(&poll_body)
84            .send()
85            .await
86            .context("Failed to poll for access token")?
87            .json()
88            .await
89            .context("Failed to parse access token response")?;
90
91        if let Some(access_token) = token_resp.access_token {
92            // Save token to store
93            let token_data = TokenData::Copilot {
94                github_token: access_token,
95                copilot_token: None,
96                expires_at: None,
97            };
98            store
99                .save("copilot", &token_data)
100                .await
101                .context("Failed to save token")?;
102            return Ok(());
103        }
104
105        match token_resp.error.as_deref() {
106            Some("authorization_pending") => {
107                // Continue polling
108            }
109            Some("slow_down") => {
110                poll_interval += Duration::from_secs(5);
111            }
112            Some("expired_token") => {
113                bail!("Device code expired. Please try again.");
114            }
115            Some("access_denied") => {
116                bail!("Authorization denied by user.");
117            }
118            Some(err) => {
119                bail!("Unexpected error: {}", err);
120            }
121            None => {
122                bail!("Unexpected empty response from OAuth server");
123            }
124        }
125    }
126}
127
128pub async fn fetch_copilot_token(
129    client: &reqwest::Client,
130    github_token: &str,
131) -> Result<CopilotTokenResponse> {
132    let resp = client
133        .get(COPILOT_TOKEN_URL)
134        .header("authorization", format!("token {}", github_token))
135        .header("content-type", "application/json")
136        .header("accept", "application/json")
137        .header("editor-version", "vscode/1.110.1")
138        .header("editor-plugin-version", "copilot-chat/0.38.2")
139        .header("user-agent", "GitHubCopilotChat/0.38.2")
140        .header("x-github-api-version", "2025-10-01")
141        .header("x-vscode-user-agent-library-version", "electron-fetch")
142        .send()
143        .await
144        .context("Failed to fetch Copilot token")?;
145
146    if !resp.status().is_success() {
147        let status = resp.status();
148        let body = resp.text().await.unwrap_or_default();
149        bail!("Failed to fetch Copilot token: HTTP {} - {}", status, body);
150    }
151
152    let token_resp: CopilotTokenResponse = resp
153        .json()
154        .await
155        .context("Failed to parse Copilot token response")?;
156
157    Ok(token_resp)
158}