Skip to main content

oauth_provider_rs/providers/github/
github_handler.rs

1//! GitHub OAuth handler for OAuth Provider
2//!
3//! This module provides GitHub OAuth integration following the trait abstraction pattern.
4
5use 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/// GitHub callback parameters
29#[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/// GitHub access token response
38#[derive(Debug, Deserialize)]
39pub struct GitHubTokenResponse {
40    pub access_token: String,
41    pub token_type: String,
42    pub scope: String,
43}
44
45/// GitHub user information
46#[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/// GitHub OAuth handler state
56#[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        // Ensure the provider name is set to "github"
70        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    /// Exchange authorization code for access token (internal implementation)
94    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(&params)
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    /// Get GitHub user information (internal implementation)
127    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/// Implement the OAuthProviderTrait trait for GitHubOAuthHandler
150#[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
199/// Handle GET /authorize - shows approval dialog
200async 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    // Build dynamic redirect URI from request headers
210    let redirect_uri = crate::http_integration::utils::build_redirect_uri(&headers, &uri);
211    
212    // For now, just redirect to GitHub OAuth
213    let state = general_purpose::STANDARD
214        .encode(serde_json::to_string(&params).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
220/// Handle GET /callback - GitHub OAuth callback
221async 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    // Handle OAuth errors
231    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    // Decode the state parameter to get the original OAuth request info
248    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    // Build dynamic redirect URI from request headers
270    let redirect_uri = crate::http_integration::utils::build_redirect_uri(&headers, &uri);
271    
272    // Exchange code for access token
273    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    // Get user information from GitHub
291    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    // Create props for the authorization completion
303    let props = handler.user_info_to_props(&user_info, &token_response.access_token);
304
305    // Create metadata for the authorization completion
306    let metadata = json!({
307        "label": user_info.name.clone().unwrap_or_else(|| user_info.username.clone())
308    });
309
310    // Prepare complete authorization options
311    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    // Complete the authorization flow
320    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 the client callback URL
336    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}