alibi_plugins/oauth/providers/
cloudflare.rs1use super::{OAuthAuthorizationPolicy, OAuthProvider, OAuthTokenEndpointAuth, OAuthUserInfo};
3use serde_json::Map;
4use serde_json::Value;
5
6#[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 pub user_info_endpoint: Option<String>,
19 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}