Skip to main content

reifydb_auth/
github.rs

1// SPDX-License-Identifier: Apache-2.0
2// Copyright (c) 2026 ReifyDB
3
4#[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		// The redirect uri must arrive as ONE query parameter: raw ':', '/', '?', '&'
196		// would split it and let it inject extra parameters into the authorize URL.
197		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		// The authorize URL is handed to the browser; the secret must stay server-side.
215		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		// GithubConfig is embedded in AuthServiceConfig which derives Debug and may be
222		// logged; the secret must never appear in that output.
223		let rendered = format!("{:?}", test_config());
224		assert!(rendered.contains("<redacted>"));
225		assert!(!rendered.contains("super-secret-value"));
226	}
227}