authx_plugins/oauth/providers/
github.rs1use async_trait::async_trait;
2use tracing::instrument;
3
4use authx_core::error::{AuthError, Result};
5
6use super::{OAuthProvider, OAuthTokens, OAuthUserInfo};
7
8pub struct GitHubProvider {
9 client_id: String,
10 client_secret: String,
11 http: reqwest::Client,
12}
13
14impl GitHubProvider {
15 pub fn new(client_id: impl Into<String>, client_secret: impl Into<String>) -> Self {
16 Self {
17 client_id: client_id.into(),
18 client_secret: client_secret.into(),
19 http: reqwest::Client::new(),
20 }
21 }
22}
23
24#[async_trait]
25impl OAuthProvider for GitHubProvider {
26 fn name(&self) -> &'static str {
27 "github"
28 }
29
30 fn authorization_url(&self, state: &str, pkce_challenge: &str) -> String {
31 format!(
32 "https://github.com/login/oauth/authorize\
33 ?client_id={}\
34 &scope=read%3Auser%20user%3Aemail\
35 &state={}\
36 &code_challenge={}\
37 &code_challenge_method=S256",
38 urlencoding::encode(&self.client_id),
39 urlencoding::encode(state),
40 urlencoding::encode(pkce_challenge),
41 )
42 }
43
44 #[instrument(skip(self, code, pkce_verifier))]
45 async fn exchange_code(
46 &self,
47 code: &str,
48 pkce_verifier: &str,
49 redirect_uri: &str,
50 ) -> Result<OAuthTokens> {
51 let res = self
52 .http
53 .post("https://github.com/login/oauth/access_token")
54 .header("Accept", "application/json")
55 .form(&[
56 ("code", code),
57 ("client_id", &self.client_id),
58 ("client_secret", &self.client_secret),
59 ("redirect_uri", redirect_uri),
60 ("code_verifier", pkce_verifier),
61 ])
62 .send()
63 .await
64 .map_err(|e| AuthError::Internal(format!("github token request failed: {e}")))?;
65
66 if !res.status().is_success() {
67 let body = res.text().await.unwrap_or_default();
68 return Err(AuthError::Internal(format!(
69 "github token exchange error: {body}"
70 )));
71 }
72
73 let json: serde_json::Value = res
74 .json()
75 .await
76 .map_err(|e| AuthError::Internal(format!("github token json: {e}")))?;
77
78 if let Some(err) = json["error"].as_str() {
79 return Err(AuthError::Internal(format!("github oauth error: {err}")));
80 }
81
82 tracing::debug!("github token exchange succeeded");
83 Ok(OAuthTokens {
84 access_token: json["access_token"].as_str().unwrap_or("").to_owned(),
85 refresh_token: json["refresh_token"].as_str().map(ToOwned::to_owned),
86 expires_in: json["expires_in"].as_u64(),
87 })
88 }
89
90 #[instrument(skip(self, access_token))]
91 async fn fetch_user_info(&self, access_token: &str) -> Result<OAuthUserInfo> {
92 let user_res = self
94 .http
95 .get("https://api.github.com/user")
96 .bearer_auth(access_token)
97 .header("User-Agent", "authx-rs")
98 .send()
99 .await
100 .map_err(|e| AuthError::Internal(format!("github user request failed: {e}")))?;
101
102 if !user_res.status().is_success() {
103 return Err(AuthError::Internal("github user fetch error".into()));
104 }
105 let user_json: serde_json::Value = user_res
106 .json()
107 .await
108 .map_err(|e| AuthError::Internal(format!("github user json: {e}")))?;
109
110 let id = user_json["id"].as_u64().unwrap_or(0).to_string();
111 let name = user_json["name"].as_str().map(ToOwned::to_owned);
112
113 let emails_res = self
115 .http
116 .get("https://api.github.com/user/emails")
117 .bearer_auth(access_token)
118 .header("User-Agent", "authx-rs")
119 .send()
120 .await
121 .map_err(|e| AuthError::Internal(format!("github emails request failed: {e}")))?;
122
123 let emails: Vec<serde_json::Value> = emails_res
124 .json()
125 .await
126 .map_err(|e| AuthError::Internal(format!("github emails json: {e}")))?;
127
128 let email = emails
129 .iter()
130 .find(|e| {
131 e["primary"].as_bool().unwrap_or(false) && e["verified"].as_bool().unwrap_or(false)
132 })
133 .and_then(|e| e["email"].as_str())
134 .or_else(|| user_json["email"].as_str())
135 .ok_or_else(|| AuthError::Internal("github: no verified email found".into()))?
136 .to_owned();
137
138 tracing::debug!(provider = "github", "user info fetched");
139 Ok(OAuthUserInfo {
140 provider_user_id: id,
141 email,
142 name,
143 })
144 }
145}