auths_infra_http/
github_oauth.rs1use 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
36pub struct HttpGitHubOAuthProvider {
46 client: reqwest::Client,
47}
48
49impl HttpGitHubOAuthProvider {
50 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 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 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}