acton_htmx/oauth2/providers/
github.rs

1//! GitHub OAuth2 provider implementation
2//!
3//! This module implements OAuth2 authentication with GitHub using their OAuth2 API.
4
5use serde::{Deserialize, Serialize};
6
7use super::base::BaseOAuthProvider;
8use crate::oauth2::types::{OAuthError, OAuthToken, OAuthUserInfo, ProviderConfig};
9
10/// GitHub OAuth2 provider
11pub struct GitHubProvider {
12    base: BaseOAuthProvider,
13}
14
15impl GitHubProvider {
16    /// Create a new GitHub OAuth2 provider
17    ///
18    /// # Errors
19    ///
20    /// Returns error if the configuration is invalid
21    pub fn new(config: &ProviderConfig) -> Result<Self, OAuthError> {
22        Ok(Self {
23            base: BaseOAuthProvider::new(
24                "https://github.com/login/oauth/authorize",
25                "https://github.com/login/oauth/access_token",
26                config,
27                "https://api.github.com/user".to_string(),
28            )?,
29        })
30    }
31
32    /// Generate authorization URL and CSRF state
33    ///
34    /// Returns tuple of (authorization_url, csrf_state, pkce_verifier)
35    #[must_use]
36    pub fn authorization_url(&self) -> (String, String, String) {
37        self.base.authorization_url(&["read:user", "user:email"])
38    }
39
40    /// Exchange authorization code for access token
41    ///
42    /// # Errors
43    ///
44    /// Returns error if the token exchange fails
45    pub async fn exchange_code(
46        &self,
47        code: &str,
48        pkce_verifier: &str,
49    ) -> Result<OAuthToken, OAuthError> {
50        self.base.exchange_code(code, pkce_verifier).await
51    }
52
53    /// Fetch user information using access token
54    ///
55    /// # Errors
56    ///
57    /// Returns error if the user info request fails
58    pub async fn fetch_user_info(&self, access_token: &str) -> Result<OAuthUserInfo, OAuthError> {
59        // Fetch user profile
60        let user_json = self.base
61            .fetch_json_with_headers(
62                "https://api.github.com/user",
63                access_token,
64                Some(&[("User-Agent", "acton-htmx")]),
65            )
66            .await?;
67
68        let github_user: GitHubUser = serde_json::from_value(user_json)
69            .map_err(|e| OAuthError::UserInfoFailed(format!("Failed to parse GitHub user: {e}")))?;
70
71        // Fetch user emails (to get primary verified email)
72        let emails_json = self.base
73            .fetch_json_with_headers(
74                "https://api.github.com/user/emails",
75                access_token,
76                Some(&[("User-Agent", "acton-htmx")]),
77            )
78            .await;
79
80        let emails: Vec<GitHubEmail> = emails_json.map_or_else(
81            |_| vec![],
82            |json| serde_json::from_value(json).unwrap_or_default(),
83        );
84
85        // Find primary verified email
86        let primary_email = emails
87            .iter()
88            .find(|e| e.primary && e.verified)
89            .or_else(|| emails.iter().find(|e| e.verified))
90            .or_else(|| emails.first());
91
92        let email = primary_email.map_or_else(
93            || format!("{}@users.noreply.github.com", github_user.id),
94            |e| e.email.clone(),
95        );
96
97        let email_verified = primary_email.is_some_and(|e| e.verified);
98
99        Ok(OAuthUserInfo {
100            provider_user_id: github_user.id.to_string(),
101            email,
102            name: github_user.name.or(Some(github_user.login)),
103            avatar_url: Some(github_user.avatar_url),
104            email_verified,
105        })
106    }
107}
108
109/// GitHub user response
110#[derive(Debug, Deserialize, Serialize)]
111struct GitHubUser {
112    id: i64,
113    login: String,
114    name: Option<String>,
115    email: Option<String>,
116    avatar_url: String,
117}
118
119/// GitHub email response
120#[derive(Debug, Deserialize, Serialize)]
121struct GitHubEmail {
122    email: String,
123    verified: bool,
124    primary: bool,
125}
126
127#[cfg(test)]
128mod tests {
129    use super::*;
130
131    #[test]
132    fn test_github_provider_creation() {
133        let config = ProviderConfig {
134            client_id: "test-client-id".to_string(),
135            client_secret: "test-client-secret".to_string(),
136            redirect_uri: "http://localhost:3000/auth/github/callback".to_string(),
137            scopes: vec!["read:user".to_string(), "user:email".to_string()],
138            auth_url: None,
139            token_url: None,
140            userinfo_url: None,
141        };
142
143        let provider = GitHubProvider::new(&config).unwrap();
144        let (auth_url, csrf_state, pkce_verifier) = provider.authorization_url();
145
146        assert!(auth_url.starts_with("https://github.com/login/oauth/authorize"));
147        assert!(auth_url.contains("client_id=test-client-id"));
148        assert!(auth_url.contains("redirect_uri"));
149        assert!(auth_url.contains("scope=read%3Auser"));
150        assert!(!csrf_state.is_empty());
151        assert!(!pkce_verifier.is_empty());
152    }
153}