1use crate::{
6 authorization_flow::{AuthorizationFlow, DefaultAuthorizationFlow},
7 client_manager::ClientManager,
8 storage::OAuthStorage,
9 types::{AuthRequest, CompleteAuthorizationOptions},
10};
11use async_trait::async_trait;
12use axum::{
13 Router,
14 extract::{Query, State},
15 http::{HeaderMap, StatusCode, Uri},
16 response::{IntoResponse, Redirect, Response},
17 routing::get,
18};
19use base64::{Engine as _, engine::general_purpose};
20use serde::Deserialize;
21use serde_json::json;
22use std::sync::Arc;
23
24use super::super::provider_trait::{
25 OAuthProviderConfig, OAuthProviderTrait, TokenResponse, UserInfo,
26};
27
28#[derive(Debug, Deserialize)]
30pub struct CallbackQuery {
31 pub code: Option<String>,
32 pub state: Option<String>,
33 pub error: Option<String>,
34 pub error_description: Option<String>,
35}
36
37#[derive(Debug, Deserialize)]
39pub struct GitHubTokenResponse {
40 pub access_token: String,
41 pub token_type: String,
42 pub scope: String,
43}
44
45#[derive(Debug, Deserialize)]
47pub struct GitHubUser {
48 pub login: String,
49 pub id: u64,
50 pub name: Option<String>,
51 pub email: Option<String>,
52 pub avatar_url: String,
53}
54
55#[derive(Clone)]
57pub struct GitHubOAuthHandler<S: OAuthStorage, CM: ClientManager> {
58 pub storage: S,
59 pub client_manager: CM,
60 pub config: OAuthProviderConfig,
61 pub http_client: reqwest::Client,
62 pub auth_flow: DefaultAuthorizationFlow<S, CM>,
63}
64
65impl<S: OAuthStorage + Clone + 'static, CM: ClientManager + Clone + 'static>
66 GitHubOAuthHandler<S, CM>
67{
68 pub fn new(storage: S, client_manager: CM, mut config: OAuthProviderConfig) -> Self {
69 config.provider.provider_name = Some("github".to_string());
71
72 let auth_flow = DefaultAuthorizationFlow::new(storage.clone(), client_manager.clone());
73 Self {
74 storage,
75 client_manager,
76 config,
77 http_client: reqwest::Client::new(),
78 auth_flow,
79 }
80 }
81
82 pub fn get_authorization_url_with_dynamic_redirect(&self, state: &str, redirect_uri: &str) -> String {
83 format!(
84 "https://github.com/login/oauth/authorize?client_id={}&redirect_uri={}&scope={}&state={}&response_type={}",
85 urlencoding::encode(&self.config.provider.client_id.as_ref().unwrap()),
86 urlencoding::encode(redirect_uri),
87 urlencoding::encode(&self.config.provider.scope.as_ref().unwrap()),
88 urlencoding::encode(state),
89 urlencoding::encode("code")
90 )
91 }
92
93 async fn exchange_code_for_token_internal(
95 &self,
96 code: &str,
97 redirect_uri: &str,
98 ) -> Result<GitHubTokenResponse, String> {
99 let params = [
100 ("client_id", self.config.provider.client_id.as_ref().unwrap().as_str()),
101 ("client_secret", self.config.provider.client_secret.as_ref().unwrap().as_str()),
102 ("code", code),
103 ("redirect_uri", redirect_uri),
104 ];
105
106 let response = self
107 .http_client
108 .post("https://github.com/login/oauth/access_token")
109 .form(¶ms)
110 .header("Accept", "application/json")
111 .send()
112 .await
113 .map_err(|e| format!("Failed to fetch access token: {}", e))?;
114
115 if !response.status().is_success() {
116 let error_text = response.text().await.unwrap_or_default();
117 return Err(format!("GitHub token exchange failed: {}", error_text));
118 }
119
120 response
121 .json::<GitHubTokenResponse>()
122 .await
123 .map_err(|e| format!("Failed to parse token response: {}", e))
124 }
125
126 async fn get_github_user_internal(&self, access_token: &str) -> Result<GitHubUser, String> {
128 let response = self
129 .http_client
130 .get("https://api.github.com/user")
131 .header("Authorization", format!("Bearer {}", access_token))
132 .header("User-Agent", "oauth-provider-rs-github")
133 .send()
134 .await
135 .map_err(|e| format!("Failed to fetch user info: {}", e))?;
136
137 if !response.status().is_success() {
138 let error_text = response.text().await.unwrap_or_default();
139 return Err(format!("GitHub user info fetch failed: {}", error_text));
140 }
141
142 response
143 .json::<GitHubUser>()
144 .await
145 .map_err(|e| format!("Failed to parse user response: {}", e))
146 }
147}
148
149#[async_trait]
151impl<
152 S: OAuthStorage + Clone + Send + Sync + 'static,
153 CM: ClientManager + Clone + Send + Sync + 'static,
154> OAuthProviderTrait<S, CM> for GitHubOAuthHandler<S, CM>
155{
156 fn provider_name(&self) -> &str {
157 "github"
158 }
159
160 fn config(&self) -> &OAuthProviderConfig {
161 &self.config
162 }
163
164 fn router(self) -> Router {
165 let state = Arc::new(self);
166
167 Router::new()
168 .route("/authorize", get(authorize_handler))
169 .route("/callback", get(callback_handler))
170 .with_state(state)
171 }
172
173 async fn get_user_info(&self, access_token: &str) -> Result<UserInfo, String> {
174 let github_user = self.get_github_user_internal(access_token).await?;
175
176 Ok(UserInfo {
177 user_id: github_user.id.to_string(),
178 username: github_user.login.clone(),
179 name: github_user.name.clone(),
180 email: github_user.email.clone(),
181 avatar_url: Some(github_user.avatar_url.clone()),
182 provider_specific: json!({
183 "id": github_user.id,
184 "login": github_user.login,
185 "avatar_url": github_user.avatar_url
186 }),
187 })
188 }
189
190 fn storage(&self) -> &S {
191 &self.storage
192 }
193
194 fn client_manager(&self) -> &CM {
195 &self.client_manager
196 }
197}
198
199async fn authorize_handler<
201 S: OAuthStorage + Clone + 'static,
202 CM: ClientManager + Clone + 'static,
203>(
204 State(handler): State<Arc<GitHubOAuthHandler<S, CM>>>,
205 Query(params): Query<AuthRequest>,
206 headers: HeaderMap,
207 uri: Uri,
208) -> Response {
209 let redirect_uri = crate::http_integration::utils::build_redirect_uri(&headers, &uri);
211
212 let state = general_purpose::STANDARD
214 .encode(serde_json::to_string(¶ms).unwrap_or_else(|_| "{}".to_string()));
215 let github_url = handler.get_authorization_url_with_dynamic_redirect(&state, &redirect_uri);
216
217 Redirect::to(&github_url).into_response()
218}
219
220async fn callback_handler<
222 S: OAuthStorage + Clone + 'static,
223 CM: ClientManager + Clone + 'static,
224>(
225 State(handler): State<Arc<GitHubOAuthHandler<S, CM>>>,
226 Query(params): Query<CallbackQuery>,
227 headers: HeaderMap,
228 uri: Uri,
229) -> Response {
230 if let Some(error) = params.error {
232 let description = params.error_description.unwrap_or_default();
233 return (
234 StatusCode::BAD_REQUEST,
235 format!("OAuth error: {} - {}", error, description),
236 )
237 .into_response();
238 }
239
240 let code = match params.code {
241 Some(code) => code,
242 None => {
243 return (StatusCode::BAD_REQUEST, "Missing authorization code").into_response();
244 }
245 };
246
247 let oauth_req_info = match params.state {
249 Some(state) => match general_purpose::STANDARD.decode(&state) {
250 Ok(decoded) => match serde_json::from_slice::<AuthRequest>(&decoded) {
251 Ok(info) => info,
252 Err(err) => {
253 return (
254 StatusCode::BAD_REQUEST,
255 format!("Invalid state parameter: {}", err),
256 )
257 .into_response();
258 }
259 },
260 Err(_) => {
261 return (StatusCode::BAD_REQUEST, "Invalid state encoding").into_response();
262 }
263 },
264 None => {
265 return (StatusCode::BAD_REQUEST, "Missing state parameter").into_response();
266 }
267 };
268
269 let redirect_uri = crate::http_integration::utils::build_redirect_uri(&headers, &uri);
271
272 let token_response = match handler.exchange_code_for_token_internal(&code, &redirect_uri).await {
274 Ok(response) => TokenResponse {
275 access_token: response.access_token,
276 token_type: response.token_type,
277 scope: response.scope,
278 expires_in: None,
279 refresh_token: None,
280 },
281 Err(e) => {
282 return (
283 StatusCode::INTERNAL_SERVER_ERROR,
284 format!("Token exchange failed: {}", e),
285 )
286 .into_response();
287 }
288 };
289
290 let user_info = match handler.get_user_info(&token_response.access_token).await {
292 Ok(user) => user,
293 Err(e) => {
294 return (
295 StatusCode::INTERNAL_SERVER_ERROR,
296 format!("User info fetch failed: {}", e),
297 )
298 .into_response();
299 }
300 };
301
302 let props = handler.user_info_to_props(&user_info, &token_response.access_token);
304
305 let metadata = json!({
307 "label": user_info.name.clone().unwrap_or_else(|| user_info.username.clone())
308 });
309
310 let complete_auth_options = CompleteAuthorizationOptions {
312 request: oauth_req_info.clone(),
313 user_id: user_info.username.clone(),
314 metadata,
315 scope: oauth_req_info.scope.clone(),
316 props: serde_json::to_value(&props).unwrap_or_default(),
317 };
318
319 let complete_result = match handler
321 .auth_flow
322 .complete_authorization(complete_auth_options)
323 .await
324 {
325 Ok(result) => result,
326 Err(e) => {
327 return (
328 StatusCode::INTERNAL_SERVER_ERROR,
329 format!("Authorization completion failed: {}", e),
330 )
331 .into_response();
332 }
333 };
334
335 Redirect::to(&complete_result.redirect_to).into_response()
337}
338
339#[cfg(test)]
340mod tests {
341 use super::*;
342 use crate::client_manager::DefaultClientManager;
343 use crate::storage::InMemoryStorage;
344
345 #[test]
346 fn test_github_oauth_config() {
347 let config = OAuthProviderConfig::with_oauth_config(
348 "test_client".to_string(),
349 "test_secret".to_string(),
350 "http://localhost:8080/callback".to_string(),
351 "read:user".to_string(),
352 "github".to_string(),
353 );
354
355 assert_eq!(config.provider.client_id.as_ref().unwrap(), "test_client");
356 assert_eq!(config.provider.scope.as_ref().unwrap(), "read:user");
357 }
358
359 #[test]
360 fn test_github_handler_creation() {
361 let storage = InMemoryStorage::new();
362 let client_manager = DefaultClientManager::new(storage.clone());
363 let config = OAuthProviderConfig::with_oauth_config(
364 "test_client".to_string(),
365 "test_secret".to_string(),
366 "http://localhost:8080/callback".to_string(),
367 "read:user".to_string(),
368 "github".to_string(),
369 );
370
371 let handler = GitHubOAuthHandler::new(storage, client_manager, config);
372 assert_eq!(handler.config.provider.client_id.as_ref().unwrap(), "test_client");
373 }
374}