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}