use serde::{Deserialize, Serialize};
use crate::ServiceError;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OAuthProviderConfig {
pub id: String,
pub display_name: String,
pub authorize_url: String,
pub token_url: String,
pub userinfo_url: String,
pub email_url: Option<String>,
pub client_id: String,
#[serde(skip_serializing)]
pub client_secret: String,
pub scopes: String,
pub field_map: OAuthFieldMap,
#[serde(default)]
pub tls_skip_verify: bool,
pub external_authorize_url: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OAuthFieldMap {
pub id: String,
pub username: String,
pub email: String,
pub avatar: String,
}
#[derive(Debug, Clone)]
pub struct OAuthUserInfo {
pub provider_id: String,
pub provider_user_id: String,
pub username: String,
pub email: Option<String>,
pub avatar_url: Option<String>,
}
pub fn normalize_oauth_config_value(raw: &str) -> Option<String> {
let trimmed = raw.trim();
if trimmed.is_empty() {
None
} else {
let maybe_unquoted = if trimmed.len() >= 2
&& ((trimmed.starts_with('"') && trimmed.ends_with('"'))
|| (trimmed.starts_with('\'') && trimmed.ends_with('\'')))
{
&trimmed[1..trimmed.len() - 1]
} else {
trimmed
};
let normalized = maybe_unquoted.trim();
if normalized.is_empty() {
None
} else {
Some(normalized.to_string())
}
}
}
pub fn build_authorize_url(
config: &OAuthProviderConfig,
redirect_uri: &str,
state: &str,
) -> String {
let base = config
.external_authorize_url
.as_deref()
.unwrap_or(&config.authorize_url);
format!(
"{}?client_id={}&redirect_uri={}&state={}&scope={}&response_type=code",
base,
urlencoding(&config.client_id),
urlencoding(redirect_uri),
urlencoding(state),
urlencoding(&config.scopes),
)
}
pub fn build_token_request_body(
config: &OAuthProviderConfig,
code: &str,
redirect_uri: &str,
) -> serde_json::Value {
serde_json::json!({
"client_id": config.client_id,
"client_secret": config.client_secret,
"code": code,
"grant_type": "authorization_code",
"redirect_uri": redirect_uri,
})
}
pub fn build_token_request_form(
config: &OAuthProviderConfig,
code: &str,
redirect_uri: &str,
) -> Vec<(String, String)> {
vec![
("client_id".into(), config.client_id.clone()),
("client_secret".into(), config.client_secret.clone()),
("code".into(), code.to_string()),
("grant_type".into(), "authorization_code".into()),
("redirect_uri".into(), redirect_uri.to_string()),
]
}
pub fn build_token_request_form_encoded(
config: &OAuthProviderConfig,
code: &str,
redirect_uri: &str,
) -> String {
build_token_request_form(config, code, redirect_uri)
.into_iter()
.map(|(k, v)| format!("{}={}", urlencoding(&k), urlencoding(&v)))
.collect::<Vec<_>>()
.join("&")
}
pub fn parse_access_token_response(raw: &str) -> Result<String, ServiceError> {
let body = raw.trim();
if body.is_empty() {
return Err(ServiceError::Internal(
"OAuth token exchange failed: empty response body".into(),
));
}
if let Ok(json) = serde_json::from_str::<serde_json::Value>(body) {
if let Some(token) = json
.get("access_token")
.and_then(|v| v.as_str())
.map(str::trim)
.filter(|s| !s.is_empty())
{
return Ok(token.to_string());
}
let err = json.get("error").and_then(|v| v.as_str());
let err_desc = json
.get("error_description")
.and_then(|v| v.as_str())
.or_else(|| json.get("error_message").and_then(|v| v.as_str()));
let detail = match (err, err_desc) {
(Some(e), Some(d)) if !d.is_empty() => format!("{e}: {d}"),
(Some(e), _) => e.to_string(),
(_, Some(d)) if !d.is_empty() => d.to_string(),
_ => "no access_token field in JSON response".to_string(),
};
return Err(ServiceError::Internal(format!(
"OAuth token exchange failed: {detail}"
)));
}
let mut access_token: Option<String> = None;
let mut error: Option<String> = None;
let mut error_description: Option<String> = None;
for pair in body.split('&') {
let (k, v) = pair.split_once('=').unwrap_or((pair, ""));
let key = decode_form_component(k);
let value = decode_form_component(v);
match key.as_str() {
"access_token" if !value.trim().is_empty() => access_token = Some(value),
"error" if !value.trim().is_empty() => error = Some(value),
"error_description" if !value.trim().is_empty() => error_description = Some(value),
_ => {}
}
}
if let Some(token) = access_token {
return Ok(token);
}
let detail = match (error, error_description) {
(Some(e), Some(d)) => format!("{e}: {d}"),
(Some(e), None) => e,
(None, Some(d)) => d,
(None, None) => "no access_token field in response".to_string(),
};
Err(ServiceError::Internal(format!(
"OAuth token exchange failed: {detail}"
)))
}
pub fn extract_user_info(
config: &OAuthProviderConfig,
userinfo_json: &serde_json::Value,
email_json: Option<&[serde_json::Value]>,
) -> Result<OAuthUserInfo, ServiceError> {
let provider_user_id = match &userinfo_json[&config.field_map.id] {
serde_json::Value::Number(n) => n.to_string(),
serde_json::Value::String(s) => s.clone(),
_ => {
return Err(ServiceError::Internal(format!(
"OAuth userinfo missing '{}' field",
config.field_map.id
)))
}
};
let username = userinfo_json[&config.field_map.username]
.as_str()
.unwrap_or("unknown")
.to_string();
let email = userinfo_json[&config.field_map.email]
.as_str()
.map(|s| s.to_string())
.or_else(|| {
email_json.and_then(|emails| {
emails
.iter()
.find(|e| e["primary"].as_bool() == Some(true))
.and_then(|e| e["email"].as_str())
.map(|s| s.to_string())
})
});
let avatar_url = userinfo_json[&config.field_map.avatar]
.as_str()
.map(|s| s.to_string());
Ok(OAuthUserInfo {
provider_id: config.id.clone(),
provider_user_id,
username,
email,
avatar_url,
})
}
pub fn github_preset(client_id: String, client_secret: String) -> OAuthProviderConfig {
OAuthProviderConfig {
id: "github".into(),
display_name: "GitHub".into(),
authorize_url: "https://github.com/login/oauth/authorize".into(),
token_url: "https://github.com/login/oauth/access_token".into(),
userinfo_url: "https://api.github.com/user".into(),
email_url: Some("https://api.github.com/user/emails".into()),
client_id,
client_secret,
scopes: "read:user,user:email".into(),
field_map: OAuthFieldMap {
id: "id".into(),
username: "login".into(),
email: "email".into(),
avatar: "avatar_url".into(),
},
tls_skip_verify: false,
external_authorize_url: None,
}
}
pub fn gitlab_preset(
instance_url: String,
external_url: Option<String>,
client_id: String,
client_secret: String,
) -> OAuthProviderConfig {
let base = instance_url.trim_end_matches('/');
let ext_base = external_url
.as_deref()
.map(|u| u.trim_end_matches('/').to_string());
OAuthProviderConfig {
id: "gitlab".into(),
display_name: "GitLab".into(),
authorize_url: format!("{base}/oauth/authorize"),
token_url: format!("{base}/oauth/token"),
userinfo_url: format!("{base}/api/v4/user"),
email_url: None, client_id,
client_secret,
scopes: "read_user".into(),
field_map: OAuthFieldMap {
id: "id".into(),
username: "username".into(),
email: "email".into(),
avatar: "avatar_url".into(),
},
tls_skip_verify: false,
external_authorize_url: ext_base.map(|b| format!("{b}/oauth/authorize")),
}
}
#[derive(Debug, Serialize, Deserialize)]
#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
#[cfg_attr(feature = "ts", ts(export))]
pub struct AuthProvidersResponse {
pub email_password: bool,
pub oauth: Vec<OAuthProviderInfo>,
}
#[derive(Debug, Serialize, Deserialize)]
#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
#[cfg_attr(feature = "ts", ts(export))]
pub struct OAuthProviderInfo {
pub id: String,
pub display_name: String,
}
#[derive(Debug, Serialize, Deserialize)]
#[cfg_attr(feature = "ts", derive(ts_rs::TS))]
#[cfg_attr(feature = "ts", ts(export))]
pub struct LinkedProvider {
pub provider: String,
pub provider_username: String,
pub display_name: String,
}
fn urlencoding(s: &str) -> String {
let mut out = String::with_capacity(s.len());
for b in s.bytes() {
match b {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
out.push(b as char);
}
_ => {
out.push('%');
out.push(char::from(b"0123456789ABCDEF"[(b >> 4) as usize]));
out.push(char::from(b"0123456789ABCDEF"[(b & 0x0f) as usize]));
}
}
}
out
}
fn decode_form_component(s: &str) -> String {
let bytes = s.as_bytes();
let mut out = Vec::with_capacity(bytes.len());
let mut i = 0usize;
while i < bytes.len() {
match bytes[i] {
b'+' => {
out.push(b' ');
i += 1;
}
b'%' if i + 2 < bytes.len() => {
let hi = hex_value(bytes[i + 1]);
let lo = hex_value(bytes[i + 2]);
if let (Some(h), Some(l)) = (hi, lo) {
out.push((h << 4) | l);
i += 3;
} else {
out.push(bytes[i]);
i += 1;
}
}
b => {
out.push(b);
i += 1;
}
}
}
String::from_utf8_lossy(&out).to_string()
}
fn hex_value(b: u8) -> Option<u8> {
match b {
b'0'..=b'9' => Some(b - b'0'),
b'a'..=b'f' => Some(10 + b - b'a'),
b'A'..=b'F' => Some(10 + b - b'A'),
_ => None,
}
}
#[cfg(test)]
mod tests {
use serde_json::json;
use super::{
build_authorize_url, build_token_request_body, build_token_request_form,
build_token_request_form_encoded, extract_user_info, github_preset, gitlab_preset,
normalize_oauth_config_value, parse_access_token_response,
};
#[test]
fn parse_access_token_json_ok() {
let raw = r#"{"access_token":"gho_123","scope":"read:user","token_type":"bearer"}"#;
let token = parse_access_token_response(raw).expect("token parse");
assert_eq!(token, "gho_123");
}
#[test]
fn parse_access_token_form_ok() {
let raw = "access_token=gho_abc&scope=read%3Auser&token_type=bearer";
let token = parse_access_token_response(raw).expect("token parse");
assert_eq!(token, "gho_abc");
}
#[test]
fn parse_access_token_json_error_has_reason() {
let raw = r#"{"error":"bad_verification_code","error_description":"The code passed is incorrect or expired."}"#;
let err = parse_access_token_response(raw).expect_err("must fail");
assert!(err.message().contains("bad_verification_code"));
}
#[test]
fn parse_access_token_empty_body_is_error() {
let err = parse_access_token_response(" ").expect_err("must fail");
assert!(err.message().contains("empty response body"));
}
#[test]
fn build_authorize_url_prefers_external_and_encodes_values() {
let mut provider = github_preset("cid".into(), "secret".into());
provider.external_authorize_url = Some("https://external.example/oauth/authorize".into());
provider.scopes = "read:user user:email".into();
let built = build_authorize_url(
&provider,
"https://app.local/auth/callback?mode=web",
"state value",
);
assert!(built.starts_with("https://external.example/oauth/authorize?"));
assert!(built.contains("client_id=cid"));
assert!(
built.contains("redirect_uri=https%3A%2F%2Fapp.local%2Fauth%2Fcallback%3Fmode%3Dweb")
);
assert!(built.contains("state=state%20value"));
assert!(built.contains("scope=read%3Auser%20user%3Aemail"));
}
#[test]
fn build_token_request_body_contains_required_fields() {
let provider = github_preset("cid".into(), "secret".into());
let body = build_token_request_body(&provider, "code-1", "https://app/callback");
assert_eq!(body["client_id"], "cid");
assert_eq!(body["client_secret"], "secret");
assert_eq!(body["code"], "code-1");
assert_eq!(body["grant_type"], "authorization_code");
assert_eq!(body["redirect_uri"], "https://app/callback");
}
#[test]
fn build_token_request_form_contains_required_fields() {
let provider = github_preset("cid".into(), "secret".into());
let form = build_token_request_form(&provider, "code-1", "https://app/callback");
assert_eq!(
form,
vec![
("client_id".into(), "cid".into()),
("client_secret".into(), "secret".into()),
("code".into(), "code-1".into()),
("grant_type".into(), "authorization_code".into()),
("redirect_uri".into(), "https://app/callback".into()),
]
);
}
#[test]
fn build_form_encoded_contains_required_fields() {
let provider = github_preset("cid".into(), "secret".into());
let encoded = build_token_request_form_encoded(&provider, "code-1", "https://app/callback");
assert!(encoded.contains("client_id=cid"));
assert!(encoded.contains("client_secret=secret"));
assert!(encoded.contains("grant_type=authorization_code"));
assert!(encoded.contains("code=code-1"));
}
#[test]
fn extract_user_info_prefers_primary_email() {
let provider = github_preset("cid".into(), "secret".into());
let userinfo = json!({
"id": 42,
"login": "alice",
"avatar_url": "https://avatar.example/alice.png",
"email": null
});
let emails = vec![
json!({"email":"secondary@example.com","primary":false}),
json!({"email":"primary@example.com","primary":true}),
];
let info =
extract_user_info(&provider, &userinfo, Some(&emails)).expect("userinfo should parse");
assert_eq!(info.provider_id, "github");
assert_eq!(info.provider_user_id, "42");
assert_eq!(info.username, "alice");
assert_eq!(info.email.as_deref(), Some("primary@example.com"));
assert_eq!(
info.avatar_url.as_deref(),
Some("https://avatar.example/alice.png")
);
}
#[test]
fn extract_user_info_requires_id_field() {
let provider = github_preset("cid".into(), "secret".into());
let userinfo = json!({
"login": "alice"
});
let err = extract_user_info(&provider, &userinfo, None).expect_err("must fail");
assert!(err.message().contains("missing 'id' field"));
}
#[test]
fn normalize_oauth_config_value_trims_and_rejects_empty() {
assert_eq!(
normalize_oauth_config_value(" value-with-spaces\t\n"),
Some("value-with-spaces".to_string())
);
assert_eq!(normalize_oauth_config_value(" \n\t "), None);
}
#[test]
fn normalize_oauth_config_value_strips_wrapping_quotes() {
assert_eq!(
normalize_oauth_config_value(" \"quoted-value\" "),
Some("quoted-value".to_string())
);
assert_eq!(
normalize_oauth_config_value(" 'another' "),
Some("another".to_string())
);
assert_eq!(normalize_oauth_config_value(" \" \" "), None);
}
#[test]
fn github_preset_populates_expected_defaults() {
let provider = github_preset("cid".into(), "secret".into());
assert_eq!(provider.id, "github");
assert_eq!(provider.display_name, "GitHub");
assert_eq!(
provider.email_url.as_deref(),
Some("https://api.github.com/user/emails")
);
assert_eq!(provider.scopes, "read:user,user:email");
}
#[test]
fn gitlab_preset_trims_urls_and_sets_external_authorize_url() {
let provider = gitlab_preset(
"https://gitlab.internal/".into(),
Some("https://gitlab.example.com/".into()),
"cid".into(),
"secret".into(),
);
assert_eq!(provider.id, "gitlab");
assert_eq!(
provider.authorize_url,
"https://gitlab.internal/oauth/authorize"
);
assert_eq!(provider.token_url, "https://gitlab.internal/oauth/token");
assert_eq!(provider.userinfo_url, "https://gitlab.internal/api/v4/user");
assert_eq!(
provider.external_authorize_url.as_deref(),
Some("https://gitlab.example.com/oauth/authorize")
);
}
}