Skip to main content

alibi_api/plugins/oauth/providers/
gitlab.rs

1use super::*;
2impl OAuthProvider {
3    /// GitLab.com social login with the published `read_user` scope and PKCE.
4    #[must_use]
5    pub fn gitlab(client_id: &str, client_secret: &str) -> Self {
6        Self::gitlab_with_issuer(client_id, client_secret, "https://gitlab.com")
7    }
8
9    /// GitLab social login hosted at an application-configured issuer.
10    ///
11    /// The issuer may include a deployment path. Repeated path slashes follow
12    /// the pinned provider's endpoint construction rather than URL resolution.
13    pub fn gitlab_with_issuer(client_id: &str, client_secret: &str, issuer: &str) -> Self {
14        let issuer = if issuer.is_empty() {
15            "https://gitlab.com"
16        } else {
17            issuer
18        };
19        Self {
20            client_id: client_id.into(),
21            additional_client_ids: Vec::new(),
22            hosted_domain: None,
23            require_email_verification: false,
24            client_secret: client_secret.into(),
25            auth_url: gitlab_endpoint(issuer, "/oauth/authorize"),
26            token_url: gitlab_endpoint(issuer, "/oauth/token"),
27            user_info_url: Some(gitlab_endpoint(issuer, "/api/v4/user")),
28            scopes: vec!["read_user".into()],
29            authorization: Some(OAuthAuthorizationPolicy::default()),
30            authorization_params: Vec::new(),
31            account_subject: None,
32            map_user_info: Some(gitlab_user_info),
33            get_user_info: None,
34            refresh_access_token: None,
35            verify_id_token: None,
36            id_token: None,
37            disable_id_token_sign_in: false,
38            disable_implicit_sign_up: false,
39            disable_sign_up: false,
40            override_user_info_on_sign_in: false,
41        }
42    }
43}
44
45pub(in crate::plugins::oauth::providers) fn gitlab_endpoint(issuer: &str, suffix: &str) -> String {
46    format!("{issuer}{suffix}")
47        .split("://")
48        .map(|part| {
49            let mut previous_slash = false;
50            part.chars()
51                .filter(|character| {
52                    let slash = *character == '/';
53                    let retain = !slash || !previous_slash;
54                    previous_slash = slash;
55                    retain
56                })
57                .collect::<String>()
58        })
59        .collect::<Vec<_>>()
60        .join("://")
61}
62
63#[expect(
64    clippy::needless_pass_by_value,
65    reason = "Match the public provider callback type, which owns its JSON profile"
66)]
67pub(in crate::plugins::oauth::providers) fn gitlab_user_info(
68    profile: Value,
69) -> Result<OAuthUserInfo, String> {
70    let locked = profile.get("locked").is_some_and(|value| match value {
71        Value::Null => false,
72        Value::Bool(value) => *value,
73        Value::Number(value) => value
74            .as_f64()
75            .is_some_and(|value| value != 0.0 && !value.is_nan()),
76        Value::String(value) => !value.is_empty(),
77        Value::Array(_) | Value::Object(_) => true,
78    });
79    if profile.get("state").and_then(Value::as_str) != Some("active") || locked {
80        return Err("GitLab account is inactive or locked".into());
81    }
82    let id = match profile.get("id") {
83        Some(Value::String(value)) => value.clone(),
84        Some(Value::Number(value)) => {
85            alibi_core::utils::json::number_to_string(value).map_err(|error| error.to_string())?
86        }
87        _ => return Err("Missing GitLab account ID".into()),
88    };
89    Ok(OAuthUserInfo {
90        additional_fields: Default::default(),
91        id,
92        email: profile
93            .get("email")
94            .and_then(Value::as_str)
95            .ok_or("Missing GitLab email")?
96            .into(),
97        name: Some(
98            profile
99                .get("name")
100                .filter(|value| !value.is_null())
101                .or_else(|| profile.get("username").filter(|value| !value.is_null()))
102                .and_then(Value::as_str)
103                .unwrap_or("")
104                .into(),
105        ),
106        image: profile
107            .get("avatar_url")
108            .and_then(Value::as_str)
109            .map(String::from),
110        email_verified: profile
111            .get("email_verified")
112            .and_then(Value::as_bool)
113            .unwrap_or(false),
114    })
115}