alibi_api/plugins/oauth/providers/
paypal.rs1use super::{
3 OAuthAuthorizationPolicy, OAuthProvider, OAuthTokenEndpointAuth, OAuthUserInfo,
4 OAuthUserInfoHandler, OAuthUserInfoRequest, OAuthUserInfoResponse,
5};
6use serde_json::Value;
7
8#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
9pub enum PayPalEnvironment {
10 #[default]
11 Sandbox,
12 Live,
13}
14
15#[derive(Clone)]
19pub struct PayPalOptions {
20 pub client_id: String,
21 pub client_secret: String,
22 pub environment: PayPalEnvironment,
23 pub prompt: Option<String>,
24 pub authorization_endpoint: Option<String>,
25 pub redirect_uri: Option<String>,
26 pub user_info_endpoint: Option<String>,
28 pub map_profile_to_user: Option<fn(Value) -> Result<OAuthUserInfo, String>>,
30}
31impl PayPalOptions {
32 #[must_use]
33 pub fn new(client_id: impl Into<String>, client_secret: impl Into<String>) -> Self {
34 Self {
35 client_id: client_id.into(),
36 client_secret: client_secret.into(),
37 environment: PayPalEnvironment::Sandbox,
38 prompt: None,
39 authorization_endpoint: None,
40 redirect_uri: None,
41 user_info_endpoint: None,
42 map_profile_to_user: None,
43 }
44 }
45}
46impl std::fmt::Debug for PayPalOptions {
47 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
48 formatter
49 .debug_struct("PayPalOptions")
50 .field("client_id", &self.client_id)
51 .field("environment", &self.environment)
52 .field("prompt", &self.prompt)
53 .field("authorization_endpoint", &self.authorization_endpoint)
54 .field("redirect_uri", &self.redirect_uri)
55 .finish_non_exhaustive()
56 }
57}
58impl OAuthProvider {
59 #[must_use]
60 pub fn paypal(client_id: &str, client_secret: &str) -> Self {
61 Self::paypal_with_options(PayPalOptions::new(client_id, client_secret))
62 }
63 #[must_use]
64 pub fn paypal_with_options(options: PayPalOptions) -> Self {
65 let sandbox = options.environment == PayPalEnvironment::Sandbox;
66 let web = if sandbox {
67 "www.sandbox.paypal.com"
68 } else {
69 "www.paypal.com"
70 };
71 let api = if sandbox {
72 "api-m.sandbox.paypal.com"
73 } else {
74 "api-m.paypal.com"
75 };
76 let endpoint = options
77 .user_info_endpoint
78 .unwrap_or_else(|| format!("https://{api}/v1/identity/oauth2/userinfo"));
79 Self {
80 client_id: options.client_id,
81 client_secret: options.client_secret,
82 additional_client_ids: Vec::new(),
83 hosted_domain: None,
84 require_email_verification: false,
85 auth_url: options
86 .authorization_endpoint
87 .filter(|value| !value.is_empty())
88 .unwrap_or_else(|| format!("https://{web}/signin/authorize")),
89 token_url: format!("https://{api}/v1/oauth2/token"),
90 user_info_url: Some(endpoint.clone()),
91 scopes: Vec::new(),
92 authorization: Some(OAuthAuthorizationPolicy {
93 preserve_raw_profile_scalars: true,
94 source_profile_exceptions: true,
95 allow_missing_access_token: true,
96 preserve_raw_email_errors: true,
97 omit_scopes: true,
98 require_client_id: true,
99 require_client_secret: true,
100 token_endpoint_auth: Some(OAuthTokenEndpointAuth::ClientSecretBasic),
101 redirect_uri: options.redirect_uri,
102 login_hint: false,
103 prompt: options.prompt,
104 ..OAuthAuthorizationPolicy::default()
105 }),
106 authorization_params: Vec::new(),
107 account_subject: Some(subject),
108 map_user_info: None,
109 get_user_info: Some(std::sync::Arc::new(PayPalUserInfo {
110 application_mapper: None,
111 endpoint,
112 mapper: options.map_profile_to_user,
113 })),
114 refresh_access_token: None,
115 verify_id_token: None,
116 id_token: None,
117 disable_id_token_sign_in: false,
118 disable_implicit_sign_up: false,
119 disable_sign_up: false,
120 override_user_info_on_sign_in: false,
121 }
122 }
123}
124#[derive(Clone)]
125struct PayPalUserInfo {
126 application_mapper: Option<std::sync::Arc<dyn super::OAuthProfileMapper>>,
127 endpoint: String,
128 mapper: Option<fn(Value) -> Result<OAuthUserInfo, String>>,
129}
130#[async_trait::async_trait]
131impl OAuthUserInfoHandler for PayPalUserInfo {
132 fn errors_are_exceptions(&self) -> bool {
133 false
134 }
135
136 fn mapped_handler(
137 &self,
138 mapper: std::sync::Arc<dyn super::OAuthProfileMapper>,
139 ) -> Option<std::sync::Arc<dyn OAuthUserInfoHandler>> {
140 let mut handler = self.clone();
141 handler.mapper = None;
142 handler.application_mapper = Some(mapper);
143 Some(std::sync::Arc::new(handler))
144 }
145
146 async fn get_user_info(
147 &self,
148 request: OAuthUserInfoRequest,
149 ) -> Result<OAuthUserInfoResponse, String> {
150 if !super::remaining_profile::has_access_token(&request) {
151 return Err("Missing PayPal access token".into());
152 }
153 let access_token = super::remaining_profile::bearer_access_token(&request)?;
154 let profile: Value = reqwest::Client::new()
155 .get(&self.endpoint)
156 .query(&[("schema", "paypalv1.1")])
157 .bearer_auth(access_token)
158 .header("Accept", "application/json")
159 .send()
160 .await
161 .map_err(|error| error.to_string())?
162 .error_for_status()
163 .map_err(|error| error.to_string())?
164 .json()
165 .await
166 .map_err(|error| error.to_string())?;
167 if !profile.is_object() {
168 return Err("Missing PayPal profile".into());
169 }
170 if request.id_token.is_none()
171 && request
172 .raw
173 .as_ref()
174 .and_then(|raw| raw.get("id_token"))
175 .is_some_and(truthy)
176 {
177 return Err("Invalid PayPal ID token type".into());
178 }
179 if let Some(token) = super::remaining_profile::grant_id_token(&request)? {
180 let claims = super::remaining_profile::decode_grant_jwt(&token)?;
184 let token_subject = claims
185 .get("sub")
186 .filter(|value| truthy(value))
187 .ok_or("Missing PayPal ID-token subject")?;
188 let profile_subject = profile
189 .get("sub")
190 .filter(|value| !value.is_null())
191 .or_else(|| profile.get("user_id"));
192 if !profile_subject.is_some_and(|subject| {
193 super::remaining_profile::strict_primitive_equal(subject, token_subject)
194 }) {
195 return Err("PayPal ID-token subject mismatch".into());
196 }
197 }
198 let application_output = if let Some(mapper) = &self.application_mapper {
199 Some(mapper.map_profile(profile.clone()).await?)
200 } else {
201 None
202 };
203 let mapped = self
204 .mapper
205 .map(|mapper| mapper(profile.clone()))
206 .transpose()?;
207 let mut output = serde_json::Map::new();
208 for (source, target) in [
209 ("name", "name"),
210 ("email", "email"),
211 ("picture", "image"),
212 ("email_verified", "emailVerified"),
213 ] {
214 if let Some(value) = profile.get(source) {
215 drop(output.insert(target.into(), value.clone()));
216 }
217 }
218 if let Some(user) = &mapped {
219 output.extend(user.public_profile(true));
220 }
221 let user = match mapped {
222 Some(user) => user,
223 None => OAuthUserInfo {
224 additional_fields: Default::default(),
225 id: profile
226 .get("user_id")
227 .map(super::remaining_profile::js_string)
228 .transpose()?
229 .unwrap_or_default(),
230 name: scalar(profile.get("name").filter(|value| truthy(value)))?,
231 email: profile
232 .get("email")
233 .and_then(Value::as_str)
234 .unwrap_or_default()
235 .into(),
236 image: scalar(profile.get("picture"))?,
237 email_verified: profile.get("email_verified").is_some_and(truthy),
238 },
239 };
240 let mut response = OAuthUserInfoResponse {
241 user_output: Some(output),
242 user,
243 data: profile,
244 };
245 if let Some(mapped) = application_output {
246 super::apply_application_mapping(&mut response, mapped)?;
247 }
248 Ok(response)
249 }
250}
251fn truthy(value: &Value) -> bool {
252 match value {
253 Value::Null | Value::Bool(false) => false,
254 Value::Number(value) => value.as_f64() != Some(0.0),
255 Value::String(value) => !value.is_empty(),
256 _ => true,
257 }
258}
259fn scalar(value: Option<&Value>) -> Result<Option<String>, String> {
260 match value {
261 None | Some(Value::Null) => Ok(None),
262 Some(Value::String(value)) => Ok(Some(value.clone())),
263 Some(Value::Number(value)) => alibi_core::utils::json::number_to_string(value)
264 .map(Some)
265 .map_err(|error| error.to_string()),
266 Some(Value::Bool(value)) => Ok(Some(value.to_string())),
267 _ => Err("Invalid PayPal profile field".into()),
268 }
269}
270fn subject(profile: &Value) -> Result<String, String> {
271 super::remaining_profile::raw_subject(profile.get("user_id"))
272}