Skip to main content

better_auth_api/plugins/oauth/providers/
paypal.rs

1use better_auth_core::{AuthError, AuthResult, SchemaValue};
2use serde_json::Value;
3
4use super::{OAuthProvider, OAuthUserInfo, OAuthUserInfoRequest, OAuthUserInfoResponse};
5use crate::plugins::oauth::google::AcceptedIdToken;
6use crate::plugins::oauth::social_profile::{apply_mapped_profile, fetch_http_profile};
7
8impl OAuthProvider {
9    /// Configure PayPal sandbox code login with PKCE and HTTP Basic grants.
10    /// Optional ID tokens require an explicit application `verify_id_token` callback.
11    pub fn paypal(client_id: &str, client_secret: &str) -> Self {
12        Self {
13            kind: super::ProviderKind::PayPal,
14            user_info_url: Some(
15                "https://api-m.sandbox.paypal.com/v1/identity/oauth2/userinfo".into(),
16            ),
17            ..Self::custom(
18                client_id,
19                client_secret,
20                "https://www.sandbox.paypal.com/signin/authorize",
21                "https://api-m.sandbox.paypal.com/v1/oauth2/token",
22            )
23        }
24    }
25
26    /// Configure PayPal live code login with the same explicit ID-token verification policy.
27    pub fn paypal_live(client_id: &str, client_secret: &str) -> Self {
28        Self {
29            auth_url: "https://www.paypal.com/signin/authorize".into(),
30            token_url: "https://api-m.paypal.com/v1/oauth2/token".into(),
31            user_info_url: Some("https://api-m.paypal.com/v1/identity/oauth2/userinfo".into()),
32            ..Self::paypal(client_id, client_secret)
33        }
34    }
35
36    pub(in crate::plugins::oauth) fn is_paypal(&self) -> bool {
37        matches!(self.kind, super::ProviderKind::PayPal)
38    }
39}
40
41pub(super) fn decode_profile(profile: Value) -> Result<OAuthUserInfo, String> {
42    Ok(OAuthUserInfo {
43        id: profile
44            .get("user_id")
45            .and_then(Value::as_str)
46            .ok_or("Missing PayPal user_id")?
47            .into(),
48        name: profile
49            .get("name")
50            .cloned()
51            .map(serde_json::from_value::<Option<String>>)
52            .transpose()
53            .map_err(|error| format!("Invalid PayPal name: {error}"))?
54            .map(Into::into)
55            .unwrap_or_default(),
56        email: super::defaults::profile_email(&profile)?,
57        image: profile
58            .get("picture")
59            .cloned()
60            .map(serde_json::from_value)
61            .transpose()
62            .map_err(|error| format!("Invalid PayPal picture: {error}"))?,
63        email_verified: SchemaValue::from_json(profile.get("email_verified").cloned())
64            .map_err(|error| error.to_string())?,
65        additional_fields: Default::default(),
66    })
67}
68
69pub(in crate::plugins::oauth) async fn fetch_user_info(
70    provider: &OAuthProvider,
71    request: OAuthUserInfoRequest,
72    expected_nonce: Option<&str>,
73    claims: Option<Value>,
74) -> AuthResult<Option<OAuthUserInfoResponse>> {
75    let logger = better_auth_core::observability::logger::current();
76    let Some(access_token) = request
77        .access_token
78        .as_deref()
79        .filter(|token| !token.is_empty())
80    else {
81        logger.error("Access token is required to fetch PayPal user info", &[]);
82        return Ok(None);
83    };
84    let result: AuthResult<Option<OAuthUserInfoResponse>> = async {
85        let url = provider
86            .user_info_url
87            .as_deref()
88            .ok_or_else(|| AuthError::internal("Missing PayPal user_info_url"))?;
89        let profile = fetch_http_profile(
90            provider
91                .user_info_request(url, access_token)
92                .query(&[("schema", "paypalv1.1")]),
93        )
94        .await?;
95        let Some(profile) = profile.filter(|profile| !profile.is_null()) else {
96            logger.error("Failed to fetch user info from PayPal", &[]);
97            return Ok(None);
98        };
99        if let Some(token) = request
100            .id_token
101            .as_deref()
102            .filter(|token| !token.is_empty())
103        {
104            let claims = match claims {
105                Some(claims) => claims,
106                None => {
107                    let verifier = provider.verify_id_token.as_deref().ok_or_else(|| {
108                        AuthError::internal(
109                            "PayPal ID token requires an explicit application verifier",
110                        )
111                    })?;
112                    AcceptedIdToken::verify(verifier, token, expected_nonce, None)
113                        .await
114                        .ok_or_else(crate::plugins::oauth::id_token::invalid)?
115                        .value()?
116                }
117            };
118            let subject = claims
119                .get("sub")
120                .and_then(Value::as_str)
121                .filter(|subject| !subject.is_empty());
122            let profile_subject = profile
123                .get("sub")
124                .filter(|value| !value.is_null())
125                .or_else(|| profile.get("user_id"))
126                .and_then(Value::as_str);
127            if subject.is_none() || subject != profile_subject {
128                logger.error(
129                    "PayPal user info subject does not match ID token subject",
130                    &[],
131                );
132                return Ok(None);
133            }
134        }
135        let Some(user) = provider.decode_profile(profile.clone())? else {
136            return Ok(None);
137        };
138        let mut response = OAuthUserInfoResponse {
139            user,
140            data: profile,
141        };
142        if let Some(mapper) = &provider.map_profile_to_user {
143            let mapped = mapper.map_profile(&response.data).await?;
144            apply_mapped_profile(&mut response, mapped);
145        }
146        let _ = response.user.email()?;
147        let _ = response.user.email_verified()?;
148        Ok(Some(response))
149    }
150    .await;
151    match result {
152        Ok(response) => Ok(response),
153        Err(error) => {
154            logger.error(
155                "Failed to fetch user info from PayPal:",
156                &[better_auth_core::observability::LogArgument::Error(&error)],
157            );
158            Ok(None)
159        }
160    }
161}
162
163pub(in crate::plugins::oauth) fn token_result(
164    result: AuthResult<super::OAuthTokenSet>,
165    grant: crate::plugins::oauth::token::TokenGrantType,
166) -> AuthResult<super::OAuthTokenSet> {
167    use crate::plugins::oauth::token::TokenGrantType;
168    result.map_err(|error| {
169        let (message, code) = match grant {
170            TokenGrantType::AuthorizationCode => (
171                "PayPal token exchange failed:",
172                "FAILED_TO_GET_ACCESS_TOKEN",
173            ),
174            TokenGrantType::RefreshToken => (
175                "PayPal token refresh failed:",
176                "FAILED_TO_REFRESH_ACCESS_TOKEN",
177            ),
178        };
179        better_auth_core::observability::logger::current().error(
180            message,
181            &[better_auth_core::observability::LogArgument::Error(&error)],
182        );
183        AuthError::internal(code)
184    })
185}