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