better_auth_api/plugins/oauth/
generic_presets.rs1use 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 pub fn gumroad(client_id: &str, client_secret: &str) -> Self {
16 Preset::Gumroad.config(client_id, client_secret)
17 }
18
19 pub fn hubspot(client_id: &str, client_secret: &str) -> Self {
21 Preset::HubSpot.config(client_id, client_secret)
22 }
23
24 pub fn patreon(client_id: &str, client_secret: &str) -> Self {
26 Preset::Patreon.config(client_id, client_secret)
27 }
28
29 pub fn slack(client_id: &str, client_secret: &str) -> Self {
31 Preset::Slack.config(client_id, client_secret)
32 }
33
34 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;