Skip to main content

auths_infra_http/
github_oauth.rs

1//! GitHub OAuth 2.0 device authorization flow (RFC 8628) HTTP implementation.
2
3use std::future::Future;
4use std::time::Duration;
5
6use serde::Deserialize;
7
8use auths_core::ports::platform::{
9    DeviceCodeResponse, OAuthDeviceFlowProvider, PlatformError, PlatformUserProfile,
10};
11
12use crate::default_http_client;
13use crate::error::{map_reqwest_error, map_status_error};
14
15#[derive(Deserialize)]
16struct RawDeviceCodeResponse {
17    device_code: String,
18    user_code: String,
19    verification_uri: String,
20    expires_in: u64,
21    interval: u64,
22}
23
24#[derive(Deserialize)]
25struct TokenPollResponse {
26    access_token: Option<String>,
27    error: Option<String>,
28}
29
30#[derive(Deserialize)]
31struct GitHubUserResponse {
32    login: String,
33    name: Option<String>,
34}
35
36/// HTTP implementation of the GitHub device authorization flow (RFC 8628).
37///
38/// Usage:
39/// ```ignore
40/// let provider = HttpGitHubOAuthProvider::new();
41/// let code = provider.request_device_code("Ov23li...", "read:user gist").await?;
42/// let token = provider.poll_for_token("Ov23li...", &code.device_code,
43///     Duration::from_secs(code.interval), Duration::from_secs(code.expires_in)).await?;
44/// ```
45pub struct HttpGitHubOAuthProvider {
46    client: reqwest::Client,
47}
48
49impl HttpGitHubOAuthProvider {
50    /// Create a new provider with a default HTTP client.
51    pub fn new() -> Self {
52        Self {
53            client: default_http_client(),
54        }
55    }
56}
57
58impl Default for HttpGitHubOAuthProvider {
59    fn default() -> Self {
60        Self::new()
61    }
62}
63
64impl OAuthDeviceFlowProvider for HttpGitHubOAuthProvider {
65    fn request_device_code(
66        &self,
67        client_id: &str,
68        scopes: &str,
69    ) -> impl Future<Output = Result<DeviceCodeResponse, PlatformError>> + Send {
70        let client = self.client.clone();
71        let client_id = client_id.to_string();
72        let scopes = scopes.to_string();
73        async move {
74            let raw: RawDeviceCodeResponse = client
75                .post("https://github.com/login/device/code")
76                .header("Accept", "application/json")
77                .form(&[
78                    ("client_id", client_id.as_str()),
79                    ("scope", scopes.as_str()),
80                ])
81                .send()
82                .await
83                .map_err(|e| PlatformError::Network(map_reqwest_error(e, "github.com")))?
84                .json()
85                .await
86                .map_err(|e| PlatformError::Platform {
87                    message: format!("failed to parse device code response: {e}"),
88                })?;
89
90            Ok(DeviceCodeResponse {
91                device_code: raw.device_code,
92                user_code: raw.user_code,
93                verification_uri: raw.verification_uri,
94                expires_in: raw.expires_in,
95                interval: raw.interval,
96            })
97        }
98    }
99
100    fn poll_for_token(
101        &self,
102        client_id: &str,
103        device_code: &str,
104        interval: Duration,
105        expires_in: Duration,
106    ) -> impl Future<Output = Result<String, PlatformError>> + Send {
107        let client = self.client.clone();
108        let client_id = client_id.to_string();
109        let device_code = device_code.to_string();
110        async move {
111            // RFC 8628 §3.5: enforce minimum 5s interval
112            let mut poll_interval = interval.max(Duration::from_secs(5));
113
114            let inner = async {
115                loop {
116                    tokio::time::sleep(poll_interval).await;
117
118                    let resp: TokenPollResponse = client
119                        .post("https://github.com/login/oauth/access_token")
120                        .header("Accept", "application/json")
121                        .form(&[
122                            ("client_id", client_id.as_str()),
123                            ("device_code", device_code.as_str()),
124                            ("grant_type", "urn:ietf:params:oauth:grant-type:device_code"),
125                        ])
126                        .send()
127                        .await
128                        .map_err(|e| PlatformError::Network(map_reqwest_error(e, "github.com")))?
129                        .json()
130                        .await
131                        .map_err(|e| PlatformError::Platform {
132                            message: format!("failed to parse token poll response: {e}"),
133                        })?;
134
135                    match resp.error.as_deref() {
136                        Some("authorization_pending") => continue,
137                        Some("slow_down") => {
138                            // RFC 8628 §3.5: increase interval by 5s on slow_down
139                            poll_interval += Duration::from_secs(5);
140                            continue;
141                        }
142                        Some("expired_token") => return Err(PlatformError::ExpiredToken),
143                        Some("access_denied") => return Err(PlatformError::AccessDenied),
144                        Some(other) => {
145                            return Err(PlatformError::Platform {
146                                message: format!("GitHub OAuth error: {other}"),
147                            });
148                        }
149                        None => {}
150                    }
151
152                    if let Some(token) = resp.access_token {
153                        return Ok(token);
154                    }
155                }
156            };
157
158            tokio::time::timeout(expires_in, inner)
159                .await
160                .unwrap_or(Err(PlatformError::ExpiredToken))
161        }
162    }
163
164    fn fetch_user_profile(
165        &self,
166        access_token: &str,
167    ) -> impl Future<Output = Result<PlatformUserProfile, PlatformError>> + Send {
168        let client = self.client.clone();
169        let access_token = access_token.to_string();
170        async move {
171            let resp = client
172                .get("https://api.github.com/user")
173                .header("Authorization", format!("Bearer {access_token}"))
174                .header("User-Agent", "auths-cli")
175                .send()
176                .await
177                .map_err(|e| PlatformError::Network(map_reqwest_error(e, "api.github.com")))?;
178
179            if !resp.status().is_success() {
180                let status = resp.status().as_u16();
181                return Err(PlatformError::Network(map_status_error(status, "/user")));
182            }
183
184            let user: GitHubUserResponse =
185                resp.json().await.map_err(|e| PlatformError::Platform {
186                    message: format!("failed to parse user profile: {e}"),
187                })?;
188
189            Ok(PlatformUserProfile {
190                login: user.login,
191                name: user.name,
192            })
193        }
194    }
195}