Skip to main content

alibi_plugins/oauth/providers/
cloudflare.rs

1//! Cloudflare's API profile, scope ordering and OAuth credential transport.
2use super::{OAuthAuthorizationPolicy, OAuthProvider, OAuthTokenEndpointAuth, OAuthUserInfo};
3use serde_json::Map;
4use serde_json::Value;
5
6/// Cloudflare configuration. Generic callback/signup policies remain available
7/// on the returned provider.
8#[derive(Clone)]
9pub struct CloudflareOptions {
10    pub client_id: String,
11    pub client_secret: Option<String>,
12    pub scope: Vec<String>,
13    pub disable_default_scope: bool,
14    pub authorization_endpoint: Option<String>,
15    pub redirect_uri: Option<String>,
16    pub token_endpoint_auth_method: Option<OAuthTokenEndpointAuth>,
17    /// Trusted transport override, retaining Cloudflare's API envelope mapping.
18    pub user_info_endpoint: Option<String>,
19    /// Receives the original API result. Its ID cannot replace the raw subject.
20    pub map_profile_to_user: Option<fn(Value) -> Result<OAuthUserInfo, String>>,
21}
22impl CloudflareOptions {
23    #[must_use]
24    pub fn new(client_id: impl Into<String>, client_secret: Option<String>) -> Self {
25        Self {
26            client_id: client_id.into(),
27            client_secret,
28            scope: Vec::new(),
29            disable_default_scope: false,
30            authorization_endpoint: None,
31            redirect_uri: None,
32            token_endpoint_auth_method: None,
33            user_info_endpoint: None,
34            map_profile_to_user: None,
35        }
36    }
37}
38impl std::fmt::Debug for CloudflareOptions {
39    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
40        formatter
41            .debug_struct("CloudflareOptions")
42            .field("client_id", &self.client_id)
43            .field("scope", &self.scope)
44            .field("disable_default_scope", &self.disable_default_scope)
45            .field(
46                "token_endpoint_auth_method",
47                &self.token_endpoint_auth_method,
48            )
49            .finish_non_exhaustive()
50    }
51}
52impl OAuthProvider {
53    #[must_use]
54    pub fn cloudflare(client_id: &str, client_secret: Option<&str>) -> Self {
55        Self::cloudflare_with_options(CloudflareOptions::new(
56            client_id,
57            client_secret.map(str::to_owned),
58        ))
59    }
60    #[must_use]
61    pub fn cloudflare_with_options(options: CloudflareOptions) -> Self {
62        let client_secret = options.client_secret.unwrap_or_default();
63        let authentication = options.token_endpoint_auth_method.unwrap_or({
64            if client_secret.is_empty() {
65                OAuthTokenEndpointAuth::None
66            } else {
67                OAuthTokenEndpointAuth::ClientSecretBasic
68            }
69        });
70        let user_info_url = options
71            .user_info_endpoint
72            .unwrap_or_else(|| "https://api.cloudflare.com/client/v4/user".into());
73        Self {
74            client_id: options.client_id,
75            client_secret,
76            additional_client_ids: Vec::new(),
77            hosted_domain: None,
78            require_email_verification: false,
79            auth_url: options
80                .authorization_endpoint
81                .filter(|value| !value.is_empty())
82                .unwrap_or_else(|| "https://dash.cloudflare.com/oauth2/auth".into()),
83            token_url: "https://dash.cloudflare.com/oauth2/token".into(),
84            user_info_url: Some(user_info_url.clone()),
85            scopes: vec!["user-details.read".into()],
86            authorization: Some(OAuthAuthorizationPolicy {
87                configured_scopes: options.scope,
88                disable_default_scopes: options.disable_default_scope,
89                deduplicate_scopes: true,
90                require_client_id: true,
91                token_endpoint_auth: Some(authentication),
92                redirect_uri: options.redirect_uri,
93                login_hint: false,
94                ..OAuthAuthorizationPolicy::default()
95            }),
96            allowed_request_params: Vec::new(),
97            authorization_params: Vec::new(),
98            account_subject: None,
99            map_user_info: None,
100            get_user_info: Some(std::sync::Arc::new(CloudflareUserInfo {
101                url: user_info_url,
102                mapper: options.map_profile_to_user,
103            })),
104            refresh_access_token: None,
105            verify_id_token: None,
106            id_token: None,
107            disable_id_token_sign_in: false,
108            disable_implicit_sign_up: false,
109            disable_sign_up: false,
110            allow_idp_initiated: false,
111            override_user_info_on_sign_in: false,
112        }
113    }
114}
115fn scalar(value: Option<&Value>) -> Result<Option<String>, String> {
116    match value {
117        Some(Value::String(value)) => Ok(Some(value.clone())),
118        Some(Value::Number(value)) => alibi_core::utils::json::number_to_string(value)
119            .map(Some)
120            .map_err(|error| error.to_string()),
121        Some(Value::Bool(value)) => Ok(Some(value.to_string())),
122        None | Some(Value::Null) => Ok(None),
123        _ => Err("Invalid Cloudflare profile field".into()),
124    }
125}
126fn truthy(value: &Value) -> bool {
127    match value {
128        Value::Null | Value::Bool(false) => false,
129        Value::Number(value) => value.as_f64() != Some(0.0),
130        Value::String(value) => !value.is_empty(),
131        _ => true,
132    }
133}
134fn subject(profile: &Value) -> Result<String, String> {
135    let id = scalar(profile.get("id"))?.ok_or("Missing Cloudflare ID")?;
136    if id
137        .trim_matches(|character: char| {
138            (character.is_whitespace() && character != '\u{85}') || character == '\u{feff}'
139        })
140        .is_empty()
141        || id == "null"
142        || id == "undefined"
143    {
144        return Err("Invalid Cloudflare ID".into());
145    }
146    Ok(id)
147}
148struct CloudflareUserInfo {
149    url: String,
150    mapper: Option<fn(Value) -> Result<OAuthUserInfo, String>>,
151}
152#[async_trait::async_trait]
153impl super::OAuthUserInfoHandler for CloudflareUserInfo {
154    async fn get_user_info(
155        &self,
156        request: super::OAuthUserInfoRequest,
157    ) -> Result<super::OAuthUserInfoResponse, String> {
158        let access_token = request
159            .access_token
160            .ok_or("Missing Cloudflare access token")?;
161        let response = reqwest::Client::new()
162            .get(&self.url)
163            .bearer_auth(access_token)
164            .send()
165            .await
166            .map_err(|error| error.to_string())?
167            .error_for_status()
168            .map_err(|error| error.to_string())?;
169        let envelope: Value = response.json().await.map_err(|error| error.to_string())?;
170        if !envelope.get("success").is_some_and(truthy)
171            || !envelope.get("result").is_some_and(truthy)
172        {
173            return Err("Cloudflare API rejected user info".into());
174        }
175        let profile = envelope
176            .get("result")
177            .ok_or("Missing Cloudflare API result")?;
178        let email = profile
179            .get("email")
180            .and_then(Value::as_str)
181            .unwrap_or_default()
182            .to_owned();
183        let names = ["first_name", "last_name"]
184            .into_iter()
185            .filter_map(|key| profile.get(key))
186            .filter(|value| truthy(value))
187            .map(|value| scalar(Some(value)).map(Option::unwrap_or_default))
188            .collect::<Result<Vec<_>, _>>()?;
189        let name = names.join(" ");
190        let mapped = self
191            .mapper
192            .map(|mapper| mapper(profile.clone()))
193            .transpose()?;
194        let user_output = mapped.as_ref().map(|user| user.public_profile(true));
195        let id = subject(profile)?;
196        let mut user = mapped.unwrap_or_else(|| OAuthUserInfo {
197            additional_fields: Map::default(),
198            id: id.clone(),
199            email: email.clone(),
200            name: Some(if name.is_empty() { email } else { name }),
201            image: None,
202            email_verified: false,
203        });
204        user.id = id;
205        Ok(super::OAuthUserInfoResponse {
206            user_output,
207            user,
208            data: profile.clone(),
209        })
210    }
211}