acton_htmx/oauth2/providers/
github.rs1use serde::{Deserialize, Serialize};
6
7use super::base::BaseOAuthProvider;
8use crate::oauth2::types::{OAuthError, OAuthToken, OAuthUserInfo, ProviderConfig};
9
10pub struct GitHubProvider {
12 base: BaseOAuthProvider,
13}
14
15impl GitHubProvider {
16 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 #[must_use]
36 pub fn authorization_url(&self) -> (String, String, String) {
37 self.base.authorization_url(&["read:user", "user:email"])
38 }
39
40 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 pub async fn fetch_user_info(&self, access_token: &str) -> Result<OAuthUserInfo, OAuthError> {
59 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 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 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#[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#[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}