Skip to main content

better_auth_api/plugins/oauth/
generic_presets.rs

1use std::sync::Arc;
2
3use async_trait::async_trait;
4use better_auth_core::{AuthError, AuthResult, SchemaValue};
5use serde_json::{Map, Value};
6
7use super::{
8    GenericOAuthConfig, GenericOAuthUserInfoHandler, OAuthAccountSubject, OAuthUserInfoRequest,
9    TokenEndpointSecretAuthentication,
10};
11use crate::plugins::json_body::is_truthy;
12
13impl GenericOAuthConfig {
14    /// Configure Gumroad with its `view_profile` scope and user profile endpoint.
15    pub fn gumroad(client_id: &str, client_secret: &str) -> Self {
16        Preset::Gumroad.config(client_id, client_secret)
17    }
18
19    /// Configure HubSpot with its `oauth` scope and access-token profile lookup.
20    pub fn hubspot(client_id: &str, client_secret: &str) -> Self {
21        Preset::HubSpot.config(client_id, client_secret)
22    }
23
24    /// Configure Patreon with its `identity[email]` scope and identity fields.
25    pub fn patreon(client_id: &str, client_secret: &str) -> Self {
26        Preset::Patreon.config(client_id, client_secret)
27    }
28
29    /// Configure Slack with its OpenID scopes and userinfo profile mapping.
30    pub fn slack(client_id: &str, client_secret: &str) -> Self {
31        Preset::Slack.config(client_id, client_secret)
32    }
33
34    /// Configure Yandex with its info, email, and avatar scopes.
35    pub fn yandex(client_id: &str, client_secret: &str) -> Self {
36        Preset::Yandex.config(client_id, client_secret)
37    }
38}
39
40#[derive(Clone, Copy)]
41enum Preset {
42    Gumroad,
43    HubSpot,
44    Patreon,
45    Slack,
46    Yandex,
47}
48
49impl Preset {
50    fn config(self, client_id: &str, client_secret: &str) -> GenericOAuthConfig {
51        let (authorization, token, scopes): (_, _, &[&str]) = match self {
52            Self::Gumroad => (
53                "https://gumroad.com/oauth/authorize",
54                "https://api.gumroad.com/oauth/token",
55                &["view_profile"],
56            ),
57            Self::HubSpot => (
58                "https://app.hubspot.com/oauth/authorize",
59                "https://api.hubapi.com/oauth/v1/token",
60                &["oauth"],
61            ),
62            Self::Patreon => (
63                "https://www.patreon.com/oauth2/authorize",
64                "https://www.patreon.com/api/oauth2/token",
65                &["identity[email]"],
66            ),
67            Self::Slack => (
68                "https://slack.com/openid/connect/authorize",
69                "https://slack.com/api/openid.connect.token",
70                &["openid", "profile", "email"],
71            ),
72            Self::Yandex => (
73                "https://oauth.yandex.com/authorize",
74                "https://oauth.yandex.com/token",
75                &["login:info", "login:email", "login:avatar"],
76            ),
77        };
78        GenericOAuthConfig {
79            client_id: client_id.into(),
80            client_secret: Some(client_secret.into()),
81            authorization_url: Some(authorization.into()),
82            token_url: Some(token.into()),
83            user_info_url: matches!(self, Self::Slack).then(|| self.profile_endpoint().into()),
84            scopes: scopes.iter().map(|scope| (*scope).into()).collect(),
85            authentication: matches!(self, Self::HubSpot)
86                .then_some(TokenEndpointSecretAuthentication::Post),
87            get_user_info: Some(Arc::new(PresetProfile {
88                preset: self,
89                endpoint: self.profile_endpoint().into(),
90            })),
91            account_subject: Some(Arc::new(self)),
92            ..Default::default()
93        }
94    }
95
96    fn profile_endpoint(self) -> &'static str {
97        match self {
98            Self::Gumroad => "https://api.gumroad.com/v2/user",
99            Self::HubSpot => "https://api.hubapi.com/oauth/v1/access-tokens/",
100            Self::Patreon => "https://www.patreon.com/api/oauth2/v2/identity",
101            Self::Slack => "https://slack.com/api/openid.connect.userInfo",
102            Self::Yandex => "https://login.yandex.ru/info?format=json",
103        }
104    }
105
106    fn profile(self, response: Value) -> AuthResult<Value> {
107        if !is_truthy(&response) {
108            return Ok(Value::Null);
109        }
110        let mut profile = Map::new();
111        match self {
112            Self::Gumroad => {
113                let Some(user) = response.get("user").filter(|value| is_truthy(value)) else {
114                    return Ok(Value::Null);
115                };
116                if !response.get("success").is_some_and(is_truthy) {
117                    return Ok(Value::Null);
118                }
119                copy_fields(
120                    &mut profile,
121                    user,
122                    &[
123                        ("user_id", "id"),
124                        ("name", "name"),
125                        ("email", "email"),
126                        ("profile_url", "image"),
127                    ],
128                );
129                let _ = profile.insert("emailVerified".into(), Value::Bool(false));
130            }
131            Self::HubSpot => {
132                let id = response
133                    .get("user_id")
134                    .filter(|value| !value.is_null())
135                    .or_else(|| response.pointer("/signed_access_token/userId"));
136                let Some(id) = id.filter(|value| is_truthy(value)) else {
137                    return Ok(Value::Null);
138                };
139                let _ = profile.insert("id".into(), id.clone());
140                copy_fields(
141                    &mut profile,
142                    &response,
143                    &[("user", "name"), ("user", "email")],
144                );
145                let _ = profile.insert("emailVerified".into(), Value::Bool(false));
146            }
147            Self::Patreon => {
148                let data = response
149                    .get("data")
150                    .filter(|value| !value.is_null())
151                    .ok_or_else(|| AuthError::internal("Missing Patreon profile data"))?;
152                let attributes = data
153                    .get("attributes")
154                    .filter(|value| !value.is_null())
155                    .ok_or_else(|| AuthError::internal("Missing Patreon profile attributes"))?;
156                copy_fields(&mut profile, data, &[("id", "id")]);
157                copy_fields(
158                    &mut profile,
159                    attributes,
160                    &[
161                        ("full_name", "name"),
162                        ("email", "email"),
163                        ("image_url", "image"),
164                        ("is_email_verified", "emailVerified"),
165                    ],
166                );
167            }
168            Self::Slack => {
169                copy_fields(
170                    &mut profile,
171                    &response,
172                    &[("sub", "sub"), ("name", "name"), ("email", "email")],
173                );
174                if let Some(image) = response
175                    .get("picture")
176                    .filter(|value| !value.is_null())
177                    .or_else(|| response.get("https://slack.com/user_image_512"))
178                {
179                    let _ = profile.insert("image".into(), image.clone());
180                }
181                let verified = response
182                    .get("email_verified")
183                    .filter(|value| !value.is_null())
184                    .cloned()
185                    .unwrap_or(Value::Bool(false));
186                let _ = profile.insert("emailVerified".into(), verified);
187            }
188            Self::Yandex => {
189                let email = response
190                    .get("default_email")
191                    .filter(|value| !value.is_null())
192                    .or_else(|| response.pointer("/emails/0"));
193                let Some(email) = email.filter(|value| is_truthy(value)) else {
194                    return Ok(Value::Null);
195                };
196                copy_fields(&mut profile, &response, &[("id", "id")]);
197                if let Some(name) = ["display_name", "real_name", "first_name"]
198                    .into_iter()
199                    .find_map(|key| response.get(key).filter(|value| !value.is_null()))
200                    .or_else(|| response.get("login"))
201                {
202                    let _ = profile.insert("name".into(), name.clone());
203                }
204                let _ = profile.insert("email".into(), email.clone());
205                let _ = profile.insert("emailVerified".into(), Value::Bool(false));
206                if !response.get("is_avatar_empty").is_some_and(is_truthy)
207                    && let Some(avatar) = response
208                        .get("default_avatar_id")
209                        .filter(|value| is_truthy(value))
210                {
211                    let avatar =
212                        SchemaValue::<String>::from_json(Some(avatar.clone()))?.display_string()?;
213                    let _ = profile.insert(
214                        "image".into(),
215                        Value::String(format!(
216                            "https://avatars.yandex.net/get-yapic/{avatar}/islands-200"
217                        )),
218                    );
219                }
220            }
221        }
222        Ok(Value::Object(profile))
223    }
224}
225
226fn copy_fields(target: &mut Map<String, Value>, source: &Value, fields: &[(&str, &str)]) {
227    target.extend(
228        fields
229            .iter()
230            .filter_map(|(from, to)| source.get(*from).map(|value| ((*to).into(), value.clone()))),
231    );
232}
233
234struct PresetProfile {
235    preset: Preset,
236    endpoint: String,
237}
238
239impl PresetProfile {
240    fn request(&self, tokens: &OAuthUserInfoRequest) -> reqwest::RequestBuilder {
241        let access_token = tokens.access_token.as_deref().unwrap_or("undefined");
242        let client = reqwest::Client::new();
243        match self.preset {
244            Preset::HubSpot => client
245                .get(format!("{}{access_token}", self.endpoint))
246                .header("content-type", "application/json"),
247            Preset::Yandex => client
248                .get(&self.endpoint)
249                .header("authorization", format!("OAuth {access_token}")),
250            Preset::Patreon => client
251                .get(&self.endpoint)
252                .query(&[(
253                    "fields[user]",
254                    "email,full_name,image_url,is_email_verified",
255                )])
256                .bearer_auth(access_token),
257            _ => client.get(&self.endpoint).bearer_auth(access_token),
258        }
259    }
260}
261
262#[async_trait]
263impl GenericOAuthUserInfoHandler for PresetProfile {
264    async fn get_user_info(&self, tokens: &OAuthUserInfoRequest) -> AuthResult<Option<Value>> {
265        match super::social_profile::fetch_http_profile(self.request(tokens)).await? {
266            Some(profile) => self
267                .preset
268                .profile(profile)
269                .map(|profile| (!profile.is_null()).then_some(profile)),
270            None => Ok(None),
271        }
272    }
273}
274
275#[async_trait]
276impl OAuthAccountSubject for Preset {
277    async fn resolve_subject(
278        &self,
279        _: &OAuthUserInfoRequest,
280        profile: &Value,
281    ) -> AuthResult<String> {
282        let field = if matches!(self, Self::Slack) {
283            "sub"
284        } else {
285            "id"
286        };
287        SchemaValue::<String>::from_json(Some(
288            profile
289                .get(field)
290                .filter(|value| !value.is_null())
291                .cloned()
292                .unwrap_or_else(|| Value::String(String::new())),
293        ))?
294        .display_string()
295    }
296}
297
298#[cfg(test)]
299#[path = "generic_presets_tests.rs"]
300mod tests;