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}