1#[cfg(not(target_arch = "wasm32"))]
5#[allow(clippy::disallowed_types)]
6use std::time::Duration;
7use std::{fmt, sync::Arc};
8
9use percent_encoding::{NON_ALPHANUMERIC, utf8_percent_encode};
10use reifydb_value::{Result, error::Error};
11#[cfg(not(target_arch = "wasm32"))]
12use reqwest::{
13 blocking::Client,
14 header::{ACCEPT, USER_AGENT},
15};
16#[cfg(not(target_arch = "wasm32"))]
17use serde_json::{Value as JsonValue, json};
18
19use crate::error::GithubError;
20
21#[derive(Clone)]
22pub struct GithubConfig {
23 pub client_id: String,
24 pub client_secret: String,
25 pub redirect_uri: String,
26}
27
28impl fmt::Debug for GithubConfig {
29 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
30 f.debug_struct("GithubConfig")
31 .field("client_id", &self.client_id)
32 .field("client_secret", &"<redacted>")
33 .field("redirect_uri", &self.redirect_uri)
34 .finish()
35 }
36}
37
38#[derive(Debug, Clone, PartialEq, Eq)]
39pub struct GithubUser {
40 pub id: u64,
41 pub login: String,
42}
43
44pub trait GithubApi: Send + Sync {
45 fn exchange_code(&self, config: &GithubConfig, code: &str) -> Result<String>;
46
47 fn fetch_user(&self, access_token: &str) -> Result<GithubUser>;
48}
49
50pub fn build_authorize_url(config: &GithubConfig, state: &str) -> String {
51 format!(
52 "https://github.com/login/oauth/authorize?client_id={}&redirect_uri={}&state={}",
53 utf8_percent_encode(&config.client_id, NON_ALPHANUMERIC),
54 utf8_percent_encode(&config.redirect_uri, NON_ALPHANUMERIC),
55 utf8_percent_encode(state, NON_ALPHANUMERIC),
56 )
57}
58
59#[cfg(not(target_arch = "wasm32"))]
60pub struct HttpGithubApi;
61
62#[cfg(not(target_arch = "wasm32"))]
63impl HttpGithubApi {
64 #[allow(clippy::disallowed_types)]
65 fn client(&self) -> Result<Client> {
66 Client::builder()
67 .connect_timeout(Duration::from_secs(10))
68 .timeout(Duration::from_secs(10))
69 .build()
70 .map_err(|e| api_failed(e.to_string()))
71 }
72}
73
74#[cfg(not(target_arch = "wasm32"))]
75impl GithubApi for HttpGithubApi {
76 fn exchange_code(&self, config: &GithubConfig, code: &str) -> Result<String> {
77 let response = self
78 .client()?
79 .post("https://github.com/login/oauth/access_token")
80 .header(ACCEPT, "application/json")
81 .header(USER_AGENT, "reifydb")
82 .json(&json!({
83 "client_id": config.client_id,
84 "client_secret": config.client_secret,
85 "code": code,
86 "redirect_uri": config.redirect_uri,
87 }))
88 .send()
89 .map_err(|e| exchange_failed(e.to_string()))?;
90
91 let body: JsonValue = response.json().map_err(|e| exchange_failed(e.to_string()))?;
92 if let Some(token) = body.get("access_token").and_then(JsonValue::as_str) {
93 return Ok(token.to_string());
94 }
95
96 let reason = body
97 .get("error_description")
98 .or_else(|| body.get("error"))
99 .and_then(JsonValue::as_str)
100 .unwrap_or("missing access_token in response")
101 .to_string();
102 Err(exchange_failed(reason))
103 }
104
105 fn fetch_user(&self, access_token: &str) -> Result<GithubUser> {
106 let response = self
107 .client()?
108 .get("https://api.github.com/user")
109 .bearer_auth(access_token)
110 .header(ACCEPT, "application/vnd.github+json")
111 .header(USER_AGENT, "reifydb")
112 .send()
113 .map_err(|e| api_failed(e.to_string()))?;
114
115 let status = response.status();
116 if !status.is_success() {
117 return Err(api_failed(format!("unexpected status {}", status)));
118 }
119
120 let body: JsonValue = response.json().map_err(|e| api_failed(e.to_string()))?;
121 let id = body
122 .get("id")
123 .and_then(JsonValue::as_u64)
124 .ok_or_else(|| api_failed("missing numeric id in user response".to_string()))?;
125 let login = body.get("login").and_then(JsonValue::as_str).unwrap_or_default().to_string();
126
127 Ok(GithubUser {
128 id,
129 login,
130 })
131 }
132}
133
134#[cfg(not(target_arch = "wasm32"))]
135fn exchange_failed(reason: String) -> Error {
136 Error::from(GithubError::ExchangeFailed {
137 reason,
138 })
139}
140
141#[cfg(not(target_arch = "wasm32"))]
142fn api_failed(reason: String) -> Error {
143 Error::from(GithubError::ApiFailed {
144 reason,
145 })
146}
147
148#[cfg(target_arch = "wasm32")]
149pub struct UnsupportedGithubApi;
150
151#[cfg(target_arch = "wasm32")]
152impl GithubApi for UnsupportedGithubApi {
153 fn exchange_code(&self, _config: &GithubConfig, _code: &str) -> Result<String> {
154 Err(unsupported())
155 }
156
157 fn fetch_user(&self, _access_token: &str) -> Result<GithubUser> {
158 Err(unsupported())
159 }
160}
161
162#[cfg(target_arch = "wasm32")]
163fn unsupported() -> Error {
164 Error::from(GithubError::ApiFailed {
165 reason: "github authentication is not available in this build".to_string(),
166 })
167}
168
169#[cfg(not(target_arch = "wasm32"))]
170pub(crate) fn default_api() -> Arc<dyn GithubApi> {
171 Arc::new(HttpGithubApi)
172}
173
174#[cfg(target_arch = "wasm32")]
175pub(crate) fn default_api() -> Arc<dyn GithubApi> {
176 Arc::new(UnsupportedGithubApi)
177}
178
179#[cfg(test)]
180mod tests {
181 use super::*;
182
183 fn test_config() -> GithubConfig {
184 GithubConfig {
185 client_id: "Iv1.abc123".to_string(),
186 client_secret: "super-secret-value".to_string(),
187 redirect_uri: "http://localhost:8080/auth/github/callback?next=/dashboard".to_string(),
188 }
189 }
190
191 #[test]
192 fn test_authorize_url_encodes_redirect_uri() {
193 let url = build_authorize_url(&test_config(), "abc123");
194
195 assert!(url.starts_with("https://github.com/login/oauth/authorize?"));
198 assert!(url.contains(
199 "redirect_uri=http%3A%2F%2Flocalhost%3A8080%2Fauth%2Fgithub%2Fcallback%3Fnext%3D%2Fdashboard"
200 ));
201 assert!(!url.contains("callback?next"));
202 }
203
204 #[test]
205 fn test_authorize_url_contains_client_id_and_state() {
206 let url = build_authorize_url(&test_config(), "state-nonce-42");
207
208 assert!(url.contains("client_id=Iv1%2Eabc123"));
209 assert!(url.contains("state=state%2Dnonce%2D42"));
210 }
211
212 #[test]
213 fn test_authorize_url_never_leaks_client_secret() {
214 let url = build_authorize_url(&test_config(), "abc123");
216 assert!(!url.contains("secret"));
217 }
218
219 #[test]
220 fn test_debug_redacts_client_secret() {
221 let rendered = format!("{:?}", test_config());
224 assert!(rendered.contains("<redacted>"));
225 assert!(!rendered.contains("super-secret-value"));
226 }
227}