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 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}