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            authorization_params: Vec::new(),
97            account_subject: None,
98            map_user_info: None,
99            get_user_info: Some(std::sync::Arc::new(CloudflareUserInfo {
100                url: user_info_url,
101                mapper: options.map_profile_to_user,
102            })),
103            refresh_access_token: None,
104            verify_id_token: None,
105            id_token: None,
106            disable_id_token_sign_in: false,
107            disable_implicit_sign_up: false,
108            disable_sign_up: false,
109            allow_idp_initiated: false,
110            override_user_info_on_sign_in: false,
111        }
112    }
113}
114fn scalar(value: Option<&Value>) -> Result<Option<String>, String> {
115    match value {
116        Some(Value::String(value)) => Ok(Some(value.clone())),
117        Some(Value::Number(value)) => alibi_core::utils::json::number_to_string(value)
118            .map(Some)
119            .map_err(|error| error.to_string()),
120        Some(Value::Bool(value)) => Ok(Some(value.to_string())),
121        None | Some(Value::Null) => Ok(None),
122        _ => Err("Invalid Cloudflare profile field".into()),
123    }
124}
125fn truthy(value: &Value) -> bool {
126    match value {
127        Value::Null | Value::Bool(false) => false,
128        Value::Number(value) => value.as_f64() != Some(0.0),
129        Value::String(value) => !value.is_empty(),
130        _ => true,
131    }
132}
133fn subject(profile: &Value) -> Result<String, String> {
134    let id = scalar(profile.get("id"))?.ok_or("Missing Cloudflare ID")?;
135    if id
136        .trim_matches(|character: char| {
137            (character.is_whitespace() && character != '\u{85}') || character == '\u{feff}'
138        })
139        .is_empty()
140        || id == "null"
141        || id == "undefined"
142    {
143        return Err("Invalid Cloudflare ID".into());
144    }
145    Ok(id)
146}
147struct CloudflareUserInfo {
148    url: String,
149    mapper: Option<fn(Value) -> Result<OAuthUserInfo, String>>,
150}
151#[async_trait::async_trait]
152impl super::OAuthUserInfoHandler for CloudflareUserInfo {
153    async fn get_user_info(
154        &self,
155        request: super::OAuthUserInfoRequest,
156    ) -> Result<super::OAuthUserInfoResponse, String> {
157        let access_token = request
158            .access_token
159            .ok_or("Missing Cloudflare access token")?;
160        let response = reqwest::Client::new()
161            .get(&self.url)
162            .bearer_auth(access_token)
163            .send()
164            .await
165            .map_err(|error| error.to_string())?
166            .error_for_status()
167            .map_err(|error| error.to_string())?;
168        let envelope: Value = response.json().await.map_err(|error| error.to_string())?;
169        if !envelope.get("success").is_some_and(truthy)
170            || !envelope.get("result").is_some_and(truthy)
171        {
172            return Err("Cloudflare API rejected user info".into());
173        }
174        let profile = envelope
175            .get("result")
176            .ok_or("Missing Cloudflare API result")?;
177        let email = profile
178            .get("email")
179            .and_then(Value::as_str)
180            .unwrap_or_default()
181            .to_owned();
182        let names = ["first_name", "last_name"]
183            .into_iter()
184            .filter_map(|key| profile.get(key))
185            .filter(|value| truthy(value))
186            .map(|value| scalar(Some(value)).map(Option::unwrap_or_default))
187            .collect::<Result<Vec<_>, _>>()?;
188        let name = names.join(" ");
189        let mapped = self
190            .mapper
191            .map(|mapper| mapper(profile.clone()))
192            .transpose()?;
193        let user_output = mapped.as_ref().map(|user| user.public_profile(true));
194        let id = subject(profile)?;
195        let mut user = mapped.unwrap_or_else(|| OAuthUserInfo {
196            additional_fields: Map::default(),
197            id: id.clone(),
198            email: email.clone(),
199            name: Some(if name.is_empty() { email } else { name }),
200            image: None,
201            email_verified: false,
202        });
203        user.id = id;
204        Ok(super::OAuthUserInfoResponse {
205            user_output,
206            user,
207            data: profile.clone(),
208        })
209    }
210}