1use serde::{Deserialize, Serialize};
10
11use crate::ServiceError;
12
13#[derive(Debug, Clone, Serialize, Deserialize)]
17pub struct OAuthProviderConfig {
18 pub id: String,
20 pub display_name: String,
22
23 pub authorize_url: String,
25 pub token_url: String,
26 pub userinfo_url: String,
27 pub email_url: Option<String>,
29
30 pub client_id: String,
31 #[serde(skip_serializing)]
32 pub client_secret: String,
33 pub scopes: String,
34
35 pub field_map: OAuthFieldMap,
37
38 #[serde(default)]
40 pub tls_skip_verify: bool,
41
42 pub external_authorize_url: Option<String>,
44}
45
46#[derive(Debug, Clone, Serialize, Deserialize)]
48pub struct OAuthFieldMap {
49 pub id: String,
51 pub username: String,
53 pub email: String,
55 pub avatar: String,
57}
58
59#[derive(Debug, Clone)]
61pub struct OAuthUserInfo {
62 pub provider_id: String,
64 pub provider_user_id: String,
66 pub username: String,
67 pub email: Option<String>,
68 pub avatar_url: Option<String>,
69}
70
71pub fn normalize_oauth_config_value(raw: &str) -> Option<String> {
77 let trimmed = raw.trim();
78 if trimmed.is_empty() {
79 None
80 } else {
81 Some(trimmed.to_string())
82 }
83}
84
85pub fn build_authorize_url(
89 config: &OAuthProviderConfig,
90 redirect_uri: &str,
91 state: &str,
92) -> String {
93 let base = config
94 .external_authorize_url
95 .as_deref()
96 .unwrap_or(&config.authorize_url);
97
98 format!(
99 "{}?client_id={}&redirect_uri={}&state={}&scope={}&response_type=code",
100 base,
101 urlencoding(&config.client_id),
102 urlencoding(redirect_uri),
103 urlencoding(state),
104 urlencoding(&config.scopes),
105 )
106}
107
108pub fn build_token_request_body(
110 config: &OAuthProviderConfig,
111 code: &str,
112 redirect_uri: &str,
113) -> serde_json::Value {
114 serde_json::json!({
115 "client_id": config.client_id,
116 "client_secret": config.client_secret,
117 "code": code,
118 "grant_type": "authorization_code",
119 "redirect_uri": redirect_uri,
120 })
121}
122
123pub fn build_token_request_form(
127 config: &OAuthProviderConfig,
128 code: &str,
129 redirect_uri: &str,
130) -> Vec<(String, String)> {
131 vec![
132 ("client_id".into(), config.client_id.clone()),
133 ("client_secret".into(), config.client_secret.clone()),
134 ("code".into(), code.to_string()),
135 ("grant_type".into(), "authorization_code".into()),
136 ("redirect_uri".into(), redirect_uri.to_string()),
137 ]
138}
139
140pub fn build_token_request_form_encoded(
142 config: &OAuthProviderConfig,
143 code: &str,
144 redirect_uri: &str,
145) -> String {
146 build_token_request_form(config, code, redirect_uri)
147 .into_iter()
148 .map(|(k, v)| format!("{}={}", urlencoding(&k), urlencoding(&v)))
149 .collect::<Vec<_>>()
150 .join("&")
151}
152
153pub fn parse_access_token_response(raw: &str) -> Result<String, ServiceError> {
158 let body = raw.trim();
159 if body.is_empty() {
160 return Err(ServiceError::Internal(
161 "OAuth token exchange failed: empty response body".into(),
162 ));
163 }
164
165 if let Ok(json) = serde_json::from_str::<serde_json::Value>(body) {
166 if let Some(token) = json
167 .get("access_token")
168 .and_then(|v| v.as_str())
169 .map(str::trim)
170 .filter(|s| !s.is_empty())
171 {
172 return Ok(token.to_string());
173 }
174
175 let err = json.get("error").and_then(|v| v.as_str());
176 let err_desc = json
177 .get("error_description")
178 .and_then(|v| v.as_str())
179 .or_else(|| json.get("error_message").and_then(|v| v.as_str()));
180
181 let detail = match (err, err_desc) {
182 (Some(e), Some(d)) if !d.is_empty() => format!("{e}: {d}"),
183 (Some(e), _) => e.to_string(),
184 (_, Some(d)) if !d.is_empty() => d.to_string(),
185 _ => "no access_token field in JSON response".to_string(),
186 };
187
188 return Err(ServiceError::Internal(format!(
189 "OAuth token exchange failed: {detail}"
190 )));
191 }
192
193 let mut access_token: Option<String> = None;
194 let mut error: Option<String> = None;
195 let mut error_description: Option<String> = None;
196
197 for pair in body.split('&') {
198 let (k, v) = pair.split_once('=').unwrap_or((pair, ""));
199 let key = decode_form_component(k);
200 let value = decode_form_component(v);
201 match key.as_str() {
202 "access_token" if !value.trim().is_empty() => access_token = Some(value),
203 "error" if !value.trim().is_empty() => error = Some(value),
204 "error_description" if !value.trim().is_empty() => error_description = Some(value),
205 _ => {}
206 }
207 }
208
209 if let Some(token) = access_token {
210 return Ok(token);
211 }
212
213 let detail = match (error, error_description) {
214 (Some(e), Some(d)) => format!("{e}: {d}"),
215 (Some(e), None) => e,
216 (None, Some(d)) => d,
217 (None, None) => "no access_token field in response".to_string(),
218 };
219
220 Err(ServiceError::Internal(format!(
221 "OAuth token exchange failed: {detail}"
222 )))
223}
224
225pub fn extract_user_info(
230 config: &OAuthProviderConfig,
231 userinfo_json: &serde_json::Value,
232 email_json: Option<&[serde_json::Value]>,
233) -> Result<OAuthUserInfo, ServiceError> {
234 let provider_user_id = match &userinfo_json[&config.field_map.id] {
236 serde_json::Value::Number(n) => n.to_string(),
237 serde_json::Value::String(s) => s.clone(),
238 _ => {
239 return Err(ServiceError::Internal(format!(
240 "OAuth userinfo missing '{}' field",
241 config.field_map.id
242 )))
243 }
244 };
245
246 let username = userinfo_json[&config.field_map.username]
247 .as_str()
248 .unwrap_or("unknown")
249 .to_string();
250
251 let email = userinfo_json[&config.field_map.email]
253 .as_str()
254 .map(|s| s.to_string())
255 .or_else(|| {
256 email_json.and_then(|emails| {
257 emails
258 .iter()
259 .find(|e| e["primary"].as_bool() == Some(true))
260 .and_then(|e| e["email"].as_str())
261 .map(|s| s.to_string())
262 })
263 });
264
265 let avatar_url = userinfo_json[&config.field_map.avatar]
266 .as_str()
267 .map(|s| s.to_string());
268
269 Ok(OAuthUserInfo {
270 provider_id: config.id.clone(),
271 provider_user_id,
272 username,
273 email,
274 avatar_url,
275 })
276}
277
278pub fn github_preset(client_id: String, client_secret: String) -> OAuthProviderConfig {
282 OAuthProviderConfig {
283 id: "github".into(),
284 display_name: "GitHub".into(),
285 authorize_url: "https://github.com/login/oauth/authorize".into(),
286 token_url: "https://github.com/login/oauth/access_token".into(),
287 userinfo_url: "https://api.github.com/user".into(),
288 email_url: Some("https://api.github.com/user/emails".into()),
289 client_id,
290 client_secret,
291 scopes: "read:user,user:email".into(),
292 field_map: OAuthFieldMap {
293 id: "id".into(),
294 username: "login".into(),
295 email: "email".into(),
296 avatar: "avatar_url".into(),
297 },
298 tls_skip_verify: false,
299 external_authorize_url: None,
300 }
301}
302
303pub fn gitlab_preset(
309 instance_url: String,
310 external_url: Option<String>,
311 client_id: String,
312 client_secret: String,
313) -> OAuthProviderConfig {
314 let base = instance_url.trim_end_matches('/');
315 let ext_base = external_url
316 .as_deref()
317 .map(|u| u.trim_end_matches('/').to_string());
318
319 OAuthProviderConfig {
320 id: "gitlab".into(),
321 display_name: "GitLab".into(),
322 authorize_url: format!("{base}/oauth/authorize"),
323 token_url: format!("{base}/oauth/token"),
324 userinfo_url: format!("{base}/api/v4/user"),
325 email_url: None, client_id,
327 client_secret,
328 scopes: "read_user".into(),
329 field_map: OAuthFieldMap {
330 id: "id".into(),
331 username: "username".into(),
332 email: "email".into(),
333 avatar: "avatar_url".into(),
334 },
335 tls_skip_verify: false,
336 external_authorize_url: ext_base.map(|b| format!("{b}/oauth/authorize")),
337 }
338}
339
340#[derive(Debug, Serialize, Deserialize)]
344#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
345#[cfg_attr(feature = "ts", ts(export))]
346pub struct AuthProvidersResponse {
347 pub email_password: bool,
348 pub oauth: Vec<OAuthProviderInfo>,
349}
350
351#[derive(Debug, Serialize, Deserialize)]
353#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
354#[cfg_attr(feature = "ts", ts(export))]
355pub struct OAuthProviderInfo {
356 pub id: String,
357 pub display_name: String,
358}
359
360#[derive(Debug, Serialize, Deserialize)]
362#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
363#[cfg_attr(feature = "ts", ts(export))]
364pub struct LinkedProvider {
365 pub provider: String,
366 pub provider_username: String,
367 pub display_name: String,
368}
369
370fn urlencoding(s: &str) -> String {
373 let mut out = String::with_capacity(s.len());
375 for b in s.bytes() {
376 match b {
377 b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
378 out.push(b as char);
379 }
380 _ => {
381 out.push('%');
382 out.push(char::from(b"0123456789ABCDEF"[(b >> 4) as usize]));
383 out.push(char::from(b"0123456789ABCDEF"[(b & 0x0f) as usize]));
384 }
385 }
386 }
387 out
388}
389
390fn decode_form_component(s: &str) -> String {
391 let bytes = s.as_bytes();
392 let mut out = Vec::with_capacity(bytes.len());
393 let mut i = 0usize;
394 while i < bytes.len() {
395 match bytes[i] {
396 b'+' => {
397 out.push(b' ');
398 i += 1;
399 }
400 b'%' if i + 2 < bytes.len() => {
401 let hi = hex_value(bytes[i + 1]);
402 let lo = hex_value(bytes[i + 2]);
403 if let (Some(h), Some(l)) = (hi, lo) {
404 out.push((h << 4) | l);
405 i += 3;
406 } else {
407 out.push(bytes[i]);
408 i += 1;
409 }
410 }
411 b => {
412 out.push(b);
413 i += 1;
414 }
415 }
416 }
417 String::from_utf8_lossy(&out).to_string()
418}
419
420fn hex_value(b: u8) -> Option<u8> {
421 match b {
422 b'0'..=b'9' => Some(b - b'0'),
423 b'a'..=b'f' => Some(10 + b - b'a'),
424 b'A'..=b'F' => Some(10 + b - b'A'),
425 _ => None,
426 }
427}
428
429#[cfg(test)]
430mod tests {
431 use super::{github_preset, normalize_oauth_config_value, parse_access_token_response};
432
433 #[test]
434 fn parse_access_token_json_ok() {
435 let raw = r#"{"access_token":"gho_123","scope":"read:user","token_type":"bearer"}"#;
436 let token = parse_access_token_response(raw).expect("token parse");
437 assert_eq!(token, "gho_123");
438 }
439
440 #[test]
441 fn parse_access_token_form_ok() {
442 let raw = "access_token=gho_abc&scope=read%3Auser&token_type=bearer";
443 let token = parse_access_token_response(raw).expect("token parse");
444 assert_eq!(token, "gho_abc");
445 }
446
447 #[test]
448 fn parse_access_token_json_error_has_reason() {
449 let raw = r#"{"error":"bad_verification_code","error_description":"The code passed is incorrect or expired."}"#;
450 let err = parse_access_token_response(raw).expect_err("must fail");
451 assert!(err.message().contains("bad_verification_code"));
452 }
453
454 #[test]
455 fn build_form_encoded_contains_required_fields() {
456 let provider = github_preset("cid".into(), "secret".into());
457 let encoded =
458 super::build_token_request_form_encoded(&provider, "code-1", "https://app/callback");
459 assert!(encoded.contains("client_id=cid"));
460 assert!(encoded.contains("client_secret=secret"));
461 assert!(encoded.contains("grant_type=authorization_code"));
462 assert!(encoded.contains("code=code-1"));
463 }
464
465 #[test]
466 fn normalize_oauth_config_value_trims_and_rejects_empty() {
467 assert_eq!(
468 normalize_oauth_config_value(" value-with-spaces\t\n"),
469 Some("value-with-spaces".to_string())
470 );
471 assert_eq!(normalize_oauth_config_value(" \n\t "), None);
472 }
473}