Skip to main content

alibi_plugins/oauth/providers/
gitlab.rs

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