Skip to main content

aither_copilot/
auth.rs

1//! OAuth device flow authentication for GitHub Copilot.
2//!
3//! GitHub Copilot uses OAuth 2.0 device flow for authentication. The flow is:
4//!
5//! 1. Request a device code from GitHub
6//! 2. Display the user code and verification URL to the user
7//! 3. Poll the token endpoint until the user completes authorization
8//!
9//! # Example
10//!
11//! ```no_run
12//! use aither_copilot::auth;
13//!
14//! # async fn demo() -> Result<(), aither_copilot::CopilotError> {
15//! // Step 1: Get device code
16//! let device = auth::request_device_code().await?;
17//!
18//! // Step 2: Show user the code
19//! println!("Visit {} and enter code: {}", device.verification_uri, device.user_code);
20//!
21//! // Step 3: Poll for token
22//! let token = auth::poll_for_token(&device.device_code, device.interval).await?;
23//!
24//! println!("Got token: {}", &token.access_token[..20]);
25//! # Ok(())
26//! # }
27//! ```
28
29use crate::{
30    CopilotError,
31    constant::{COPILOT_CLIENT_ID, GITHUB_DEVICE_CODE_URL, GITHUB_TOKEN_URL},
32};
33use aither_core::time::sleep;
34use serde::{Deserialize, Serialize};
35use std::time::{Duration, SystemTime};
36use zenwave::{Client, client};
37
38/// Response from the device code request.
39#[derive(Debug, Clone, Deserialize)]
40pub struct DeviceCodeResponse {
41    /// The device verification code.
42    pub device_code: String,
43    /// The user-facing code to enter at the verification URL.
44    pub user_code: String,
45    /// The URL where the user should enter the code.
46    pub verification_uri: String,
47    /// Number of seconds until the device code expires.
48    pub expires_in: u64,
49    /// Minimum number of seconds between polling requests.
50    pub interval: u64,
51}
52
53/// Token obtained from successful authentication.
54#[derive(Debug, Clone, Serialize, Deserialize)]
55pub struct CopilotToken {
56    /// The access token for API requests.
57    pub access_token: String,
58    /// Token type (usually "bearer").
59    pub token_type: String,
60    /// OAuth scope granted.
61    pub scope: String,
62    /// When the token expires (if known).
63    #[serde(skip_serializing_if = "Option::is_none")]
64    pub expires_at: Option<SystemTime>,
65}
66
67/// Request body for device code endpoint.
68#[derive(Serialize)]
69struct DeviceCodeRequest<'a> {
70    client_id: &'a str,
71    scope: &'a str,
72}
73
74/// Request body for token endpoint.
75#[derive(Serialize)]
76struct TokenRequest<'a> {
77    client_id: &'a str,
78    device_code: &'a str,
79    grant_type: &'a str,
80}
81
82/// Response from token endpoint.
83#[derive(Deserialize)]
84struct TokenResponse {
85    access_token: Option<String>,
86    token_type: Option<String>,
87    scope: Option<String>,
88    error: Option<String>,
89    error_description: Option<String>,
90}
91
92/// Request a device code from GitHub to start the OAuth flow.
93///
94/// Returns a [`DeviceCodeResponse`] containing the user code and verification URL.
95///
96/// # Errors
97///
98/// Returns an error if the HTTP request fails or the response is malformed.
99pub async fn request_device_code() -> Result<DeviceCodeResponse, CopilotError> {
100    let mut backend = client();
101
102    let request = DeviceCodeRequest {
103        client_id: COPILOT_CLIENT_ID,
104        scope: "copilot",
105    };
106
107    let response: DeviceCodeResponse = backend
108        .post(GITHUB_DEVICE_CODE_URL)
109        .map_err(CopilotError::Http)?
110        .header("Accept", "application/json")
111        .map_err(CopilotError::Http)?
112        .json_body(&request)
113        .map_err(CopilotError::Http)?
114        .json()
115        .await
116        .map_err(CopilotError::Http)?;
117
118    Ok(response)
119}
120
121/// Poll the token endpoint until the user completes authorization.
122///
123/// This function blocks (with async sleep) until:
124/// - The user completes authorization (returns token)
125/// - The device code expires (returns error)
126/// - The user denies access (returns error)
127///
128/// # Arguments
129///
130/// * `device_code` - The device code from [`request_device_code`]
131/// * `interval` - Minimum seconds between poll attempts (from device code response)
132///
133/// # Errors
134///
135/// Returns an error if authorization fails, expires, or is denied.
136pub async fn poll_for_token(
137    device_code: &str,
138    interval: u64,
139) -> Result<CopilotToken, CopilotError> {
140    let poll_interval = Duration::from_secs(interval.max(5)); // At least 5 seconds
141
142    loop {
143        // Wait before polling
144        sleep(poll_interval).await;
145
146        match try_get_token(device_code).await {
147            Ok(token) => return Ok(token),
148            Err(CopilotError::AuthorizationPending) => {
149                // User hasn't completed auth yet, keep polling.
150                tracing::debug!("Authorization pending, polling again...");
151            }
152            Err(e) => return Err(e),
153        }
154    }
155}
156
157/// Attempt to get a token (single poll attempt).
158///
159/// Returns `Ok(token)` if the user has completed authorization.
160/// Returns `Err(CopilotError::AuthorizationPending)` if still waiting.
161/// Returns other errors for failures.
162///
163/// This is useful when you want to implement your own polling loop
164/// with custom timeout handling.
165///
166/// # Errors
167///
168/// Returns [`CopilotError::AuthorizationPending`] while the user has not yet
169/// approved the device code, and other [`CopilotError`] variants if the request
170/// fails or GitHub rejects the code.
171pub async fn try_get_token(device_code: &str) -> Result<CopilotToken, CopilotError> {
172    let mut backend = client();
173
174    let request = TokenRequest {
175        client_id: COPILOT_CLIENT_ID,
176        device_code,
177        grant_type: "urn:ietf:params:oauth:grant-type:device_code",
178    };
179
180    let response: TokenResponse = backend
181        .post(GITHUB_TOKEN_URL)
182        .map_err(CopilotError::Http)?
183        .header("Accept", "application/json")
184        .map_err(CopilotError::Http)?
185        .json_body(&request)
186        .map_err(CopilotError::Http)?
187        .json()
188        .await
189        .map_err(CopilotError::Http)?;
190
191    // Check for errors
192    if let Some(error) = response.error {
193        return match error.as_str() {
194            // `slow_down` is still pending; the caller's poll interval handles it.
195            "authorization_pending" | "slow_down" => Err(CopilotError::AuthorizationPending),
196            "expired_token" => Err(CopilotError::DeviceCodeExpired),
197            "access_denied" => Err(CopilotError::AccessDenied),
198            _ => Err(CopilotError::Api(
199                response.error_description.unwrap_or(error),
200            )),
201        };
202    }
203
204    // Extract token
205    let access_token = response
206        .access_token
207        .ok_or_else(|| CopilotError::Api("Missing access_token in response".to_string()))?;
208    let token_type = response.token_type.unwrap_or_else(|| "bearer".to_string());
209    let scope = response.scope.unwrap_or_else(|| "copilot".to_string());
210
211    Ok(CopilotToken {
212        access_token,
213        token_type,
214        scope,
215        expires_at: None, // GitHub doesn't return expiration for Copilot tokens
216    })
217}
218
219/// Session token for Copilot API calls.
220///
221/// This is obtained by exchanging an OAuth token with the Copilot API.
222/// Session tokens are short-lived (typically ~30 minutes).
223#[derive(Debug, Clone, Serialize, Deserialize)]
224pub struct SessionToken {
225    /// The session token for API calls.
226    pub token: String,
227    /// The API endpoint to use (varies per user type).
228    pub api_endpoint: String,
229    /// When the token expires (Unix timestamp).
230    pub expires_at: i64,
231}
232
233/// Endpoints returned from the token exchange.
234#[derive(Debug, Deserialize)]
235struct EndpointsResponse {
236    api: String,
237}
238
239/// Response from the Copilot token exchange endpoint.
240#[derive(Debug, Deserialize)]
241struct SessionTokenResponse {
242    token: String,
243    endpoints: EndpointsResponse,
244    expires_at: i64,
245}
246
247/// Exchange an OAuth token for a Copilot session token.
248///
249/// The OAuth token is the `access_token` from [`poll_for_token`].
250/// The session token is what you use for actual API calls.
251///
252/// Session tokens are short-lived (typically ~30 minutes), so you may need
253/// to call this again when the token expires.
254///
255/// # Example
256///
257/// ```no_run
258/// use aither_copilot::auth;
259///
260/// # async fn demo() -> Result<(), aither_copilot::CopilotError> {
261/// let oauth_token = "gho_xxxx"; // from poll_for_token
262/// let session = auth::get_session_token(oauth_token).await?;
263/// println!("API endpoint: {}", session.api_endpoint);
264/// println!("Session token expires at: {}", session.expires_at);
265/// # Ok(())
266/// # }
267/// ```
268///
269/// # Errors
270///
271/// Returns a [`CopilotError`] if the request fails or the OAuth token is not
272/// accepted — most often because it has expired or lacks Copilot access.
273pub async fn get_session_token(oauth_token: &str) -> Result<SessionToken, CopilotError> {
274    use crate::constant::{COPILOT_TOKEN_URL, EDITOR_VERSION};
275
276    let mut backend = client();
277
278    let response: SessionTokenResponse = backend
279        .get(COPILOT_TOKEN_URL)
280        .map_err(CopilotError::Http)?
281        .header("Authorization", format!("token {oauth_token}"))
282        .map_err(CopilotError::Http)?
283        .header("Accept", "application/json")
284        .map_err(CopilotError::Http)?
285        .header(
286            "User-Agent",
287            format!("aither-copilot/0.1 ({EDITOR_VERSION})"),
288        )
289        .map_err(CopilotError::Http)?
290        .json()
291        .await
292        .map_err(CopilotError::Http)?;
293
294    Ok(SessionToken {
295        token: response.token,
296        api_endpoint: response.endpoints.api,
297        expires_at: response.expires_at,
298    })
299}