Skip to main content

better_auth_api/plugins/oauth/
providers.rs

1use async_trait::async_trait;
2use chrono::{DateTime, Utc};
3use serde::Deserialize;
4use serde::de::DeserializeOwned;
5use serde_json::Value;
6use std::collections::HashMap;
7use std::sync::Arc;
8
9/// Configuration for the OAuth plugin, containing all registered providers.
10#[derive(Clone, Default)]
11pub struct OAuthConfig {
12    pub providers: HashMap<String, OAuthProvider>,
13}
14
15#[derive(Debug, Clone, Default)]
16pub struct OAuthTokenSet {
17    pub token_type: Option<String>,
18    pub access_token: Option<String>,
19    pub refresh_token: Option<String>,
20    pub access_token_expires_at: Option<DateTime<Utc>>,
21    pub refresh_token_expires_at: Option<DateTime<Utc>>,
22    pub scopes: Vec<String>,
23    pub id_token: Option<String>,
24    pub raw: Option<Value>,
25}
26
27/// User information extracted from an OAuth provider's user info endpoint.
28#[derive(Debug, Clone)]
29pub struct OAuthUserInfo {
30    pub id: String,
31    pub email: String,
32    pub name: Option<String>,
33    pub image: Option<String>,
34    pub email_verified: bool,
35}
36
37#[derive(Debug, Clone, Default)]
38pub struct OAuthCallbackUserPayload {
39    pub name: Option<OAuthCallbackUserName>,
40    pub email: Option<String>,
41}
42
43#[derive(Debug, Clone, Default)]
44pub struct OAuthCallbackUserName {
45    pub first_name: Option<String>,
46    pub last_name: Option<String>,
47}
48
49#[derive(Debug, Clone, Default)]
50pub struct OAuthUserInfoRequest {
51    pub token_type: Option<String>,
52    pub access_token: Option<String>,
53    pub refresh_token: Option<String>,
54    pub access_token_expires_at: Option<DateTime<Utc>>,
55    pub refresh_token_expires_at: Option<DateTime<Utc>>,
56    pub scopes: Vec<String>,
57    pub id_token: Option<String>,
58    pub raw: Option<Value>,
59    pub user: Option<OAuthCallbackUserPayload>,
60}
61
62#[derive(Debug, Clone)]
63pub struct OAuthUserInfoResponse {
64    pub user: OAuthUserInfo,
65    pub data: Value,
66}
67
68#[async_trait]
69pub trait OAuthUserInfoHandler: Send + Sync {
70    async fn get_user_info(
71        &self,
72        request: OAuthUserInfoRequest,
73    ) -> Result<OAuthUserInfoResponse, String>;
74}
75
76#[async_trait]
77pub trait OAuthRefreshTokenHandler: Send + Sync {
78    async fn refresh_access_token(&self, refresh_token: &str) -> Result<OAuthTokenSet, String>;
79}
80
81#[async_trait]
82pub trait OAuthIdTokenVerifier: Send + Sync {
83    async fn verify_id_token(&self, token: &str, nonce: Option<&str>) -> Result<bool, String>;
84}
85
86#[derive(Debug, Deserialize)]
87struct GitHubEmailAddress {
88    email: String,
89    #[serde(default)]
90    primary: bool,
91    #[serde(default)]
92    verified: bool,
93}
94
95#[derive(Clone)]
96struct GitHubUserInfoHandler {
97    user_url: String,
98    emails_url: String,
99}
100
101impl GitHubUserInfoHandler {
102    fn new(user_url: String, emails_url: String) -> Self {
103        Self {
104            user_url,
105            emails_url,
106        }
107    }
108
109    async fn fetch_json<T: DeserializeOwned>(
110        &self,
111        client: &reqwest::Client,
112        url: &str,
113        access_token: &str,
114    ) -> Result<T, String> {
115        let response = client
116            .get(url)
117            .bearer_auth(access_token)
118            .header("Accept", "application/json")
119            .header("User-Agent", "better-auth")
120            .send()
121            .await
122            .map_err(|error| format!("Failed to fetch GitHub user info: {error}"))?;
123
124        if !response.status().is_success() {
125            let body = response
126                .text()
127                .await
128                .unwrap_or_else(|_| "Unknown error".to_string());
129            return Err(format!("GitHub user info request failed: {body}"));
130        }
131
132        response
133            .json()
134            .await
135            .map_err(|error| format!("Failed to parse GitHub user info: {error}"))
136    }
137}
138
139#[async_trait]
140impl OAuthUserInfoHandler for GitHubUserInfoHandler {
141    async fn get_user_info(
142        &self,
143        request: OAuthUserInfoRequest,
144    ) -> Result<OAuthUserInfoResponse, String> {
145        let access_token = request
146            .access_token
147            .as_deref()
148            .ok_or("Missing access token for user-info lookup")?;
149
150        let client = reqwest::Client::new();
151        let mut profile: Value = self
152            .fetch_json(&client, &self.user_url, access_token)
153            .await?;
154        let emails = self
155            .fetch_json::<Vec<GitHubEmailAddress>>(&client, &self.emails_url, access_token)
156            .await
157            .unwrap_or_default();
158
159        let resolved_email = profile
160            .get("email")
161            .and_then(Value::as_str)
162            .map(String::from)
163            .or_else(|| {
164                emails
165                    .iter()
166                    .find(|record| record.primary)
167                    .or_else(|| emails.first())
168                    .map(|record| record.email.clone())
169            })
170            .unwrap_or_default();
171
172        if let Some(profile_object) = profile.as_object_mut()
173            && profile_object
174                .get("email")
175                .and_then(Value::as_str)
176                .is_none()
177            && !resolved_email.is_empty()
178        {
179            let _ =
180                profile_object.insert("email".to_string(), Value::String(resolved_email.clone()));
181        }
182
183        let email_verified = emails
184            .iter()
185            .find(|record| record.email == resolved_email)
186            .map(|record| record.verified)
187            .unwrap_or(false);
188
189        let id = profile
190            .get("id")
191            .and_then(|value| value.as_i64().map(|value| value.to_string()))
192            .or_else(|| profile.get("id").and_then(Value::as_str).map(String::from))
193            .ok_or("missing id")?;
194
195        let login = profile
196            .get("login")
197            .and_then(Value::as_str)
198            .map(String::from);
199
200        Ok(OAuthUserInfoResponse {
201            user: OAuthUserInfo {
202                id,
203                email: resolved_email,
204                name: profile
205                    .get("name")
206                    .and_then(Value::as_str)
207                    .map(String::from)
208                    .or(login),
209                image: profile
210                    .get("avatar_url")
211                    .and_then(Value::as_str)
212                    .map(String::from),
213                email_verified,
214            },
215            data: profile,
216        })
217    }
218}
219
220/// Configuration for a single OAuth provider.
221#[derive(Clone)]
222pub struct OAuthProvider {
223    pub client_id: String,
224    pub client_secret: String,
225    pub auth_url: String,
226    pub token_url: String,
227    pub user_info_url: Option<String>,
228    pub scopes: Vec<String>,
229    pub authorization_params: Vec<(String, String)>,
230    pub map_user_info: Option<fn(Value) -> Result<OAuthUserInfo, String>>,
231    pub get_user_info: Option<Arc<dyn OAuthUserInfoHandler>>,
232    pub refresh_access_token: Option<Arc<dyn OAuthRefreshTokenHandler>>,
233    pub verify_id_token: Option<Arc<dyn OAuthIdTokenVerifier>>,
234    pub disable_implicit_sign_up: bool,
235    pub disable_sign_up: bool,
236    pub override_user_info_on_sign_in: bool,
237}
238
239impl OAuthProvider {
240    pub fn google(client_id: &str, client_secret: &str) -> Self {
241        Self {
242            client_id: client_id.to_string(),
243            client_secret: client_secret.to_string(),
244            auth_url: "https://accounts.google.com/o/oauth2/v2/auth".to_string(),
245            token_url: "https://oauth2.googleapis.com/token".to_string(),
246            user_info_url: Some("https://www.googleapis.com/oauth2/v3/userinfo".to_string()),
247            scopes: vec![
248                "email".to_string(),
249                "profile".to_string(),
250                "openid".to_string(),
251            ],
252            authorization_params: vec![("include_granted_scopes".to_string(), "true".to_string())],
253            map_user_info: Some(|v| {
254                Ok(OAuthUserInfo {
255                    id: v
256                        .get("sub")
257                        .and_then(|v| v.as_str())
258                        .ok_or("missing sub")?
259                        .to_string(),
260                    email: v
261                        .get("email")
262                        .and_then(|v| v.as_str())
263                        .ok_or("missing email")?
264                        .to_string(),
265                    name: v.get("name").and_then(|v| v.as_str()).map(String::from),
266                    image: v.get("picture").and_then(|v| v.as_str()).map(String::from),
267                    email_verified: v
268                        .get("email_verified")
269                        .and_then(|v| v.as_bool())
270                        .unwrap_or(false),
271                })
272            }),
273            get_user_info: None,
274            refresh_access_token: None,
275            verify_id_token: None,
276            disable_implicit_sign_up: false,
277            disable_sign_up: false,
278            override_user_info_on_sign_in: false,
279        }
280    }
281
282    pub fn github(client_id: &str, client_secret: &str) -> Self {
283        Self::github_with_endpoints(
284            client_id,
285            client_secret,
286            "https://github.com/login/oauth/authorize",
287            "https://github.com/login/oauth/access_token",
288            "https://api.github.com/user",
289            "https://api.github.com/user/emails",
290        )
291    }
292
293    /// Construct a GitHub provider using custom endpoints.
294    ///
295    /// This keeps the built-in GitHub semantics while allowing local test
296    /// harnesses or GitHub Enterprise-style deployments to override the URLs.
297    pub fn github_with_endpoints(
298        client_id: &str,
299        client_secret: &str,
300        auth_url: &str,
301        token_url: &str,
302        user_info_url: &str,
303        user_emails_url: &str,
304    ) -> Self {
305        Self {
306            client_id: client_id.to_string(),
307            client_secret: client_secret.to_string(),
308            auth_url: auth_url.to_string(),
309            token_url: token_url.to_string(),
310            user_info_url: Some(user_info_url.to_string()),
311            scopes: vec!["read:user".to_string(), "user:email".to_string()],
312            authorization_params: Vec::new(),
313            map_user_info: None,
314            get_user_info: Some(Arc::new(GitHubUserInfoHandler::new(
315                user_info_url.to_string(),
316                user_emails_url.to_string(),
317            ))),
318            refresh_access_token: None,
319            verify_id_token: None,
320            disable_implicit_sign_up: false,
321            disable_sign_up: false,
322            override_user_info_on_sign_in: false,
323        }
324    }
325
326    pub fn discord(client_id: &str, client_secret: &str) -> Self {
327        Self {
328            client_id: client_id.to_string(),
329            client_secret: client_secret.to_string(),
330            auth_url: "https://discord.com/api/oauth2/authorize".to_string(),
331            token_url: "https://discord.com/api/oauth2/token".to_string(),
332            user_info_url: Some("https://discord.com/api/users/@me".to_string()),
333            scopes: vec!["identify".to_string(), "email".to_string()],
334            authorization_params: Vec::new(),
335            map_user_info: Some(|v| {
336                Ok(OAuthUserInfo {
337                    id: v
338                        .get("id")
339                        .and_then(|v| v.as_str())
340                        .ok_or("missing id")?
341                        .to_string(),
342                    email: v
343                        .get("email")
344                        .and_then(|v| v.as_str())
345                        .ok_or("missing email")?
346                        .to_string(),
347                    name: v.get("username").and_then(|v| v.as_str()).map(String::from),
348                    image: v.get("avatar").and_then(|v| v.as_str()).map(|a| {
349                        format!(
350                            "https://cdn.discordapp.com/avatars/{}/{}.png",
351                            v.get("id").and_then(|v| v.as_str()).unwrap_or(""),
352                            a
353                        )
354                    }),
355                    email_verified: v.get("verified").and_then(|v| v.as_bool()).unwrap_or(false),
356                })
357            }),
358            get_user_info: None,
359            refresh_access_token: None,
360            verify_id_token: None,
361            disable_implicit_sign_up: false,
362            disable_sign_up: false,
363            override_user_info_on_sign_in: false,
364        }
365    }
366}
367
368#[cfg(test)]
369mod tests {
370    use super::*;
371
372    use std::sync::{Arc, Once};
373
374    use tokio::io::{AsyncReadExt, AsyncWriteExt};
375    use tokio::sync::Mutex;
376
377    static LOCAL_PROXY_BYPASS: Once = Once::new();
378
379    fn ensure_local_proxy_bypass() {
380        LOCAL_PROXY_BYPASS.call_once(|| {
381            // SAFETY: Test code in this module only needs localhost proxy bypass
382            // values, and they are set once before issuing local HTTP requests.
383            unsafe { std::env::set_var("NO_PROXY", "localhost,127.0.0.1") };
384            // SAFETY: Test code in this module only needs localhost proxy bypass
385            // values, and they are set once before issuing local HTTP requests.
386            unsafe { std::env::set_var("no_proxy", "localhost,127.0.0.1") };
387        });
388    }
389
390    async fn start_github_mock_server(
391        profile: Value,
392        emails: Value,
393    ) -> (String, String, Arc<Mutex<Vec<String>>>) {
394        ensure_local_proxy_bypass();
395        let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
396        let addr = listener.local_addr().unwrap();
397        let requests = Arc::new(Mutex::new(Vec::new()));
398        let captured_requests = requests.clone();
399
400        tokio::spawn(async move {
401            loop {
402                let Ok((mut stream, _)) = listener.accept().await else {
403                    break;
404                };
405                let profile = profile.clone();
406                let emails = emails.clone();
407                let requests = captured_requests.clone();
408                tokio::spawn(async move {
409                    let mut buffer = vec![0u8; 4096];
410                    let read = stream.read(&mut buffer).await.unwrap_or(0);
411                    let request = String::from_utf8_lossy(&buffer[..read]).to_string();
412                    requests.lock().await.push(request.clone());
413
414                    let (status, body) = if request.contains("/user/emails") {
415                        ("200 OK", emails.to_string())
416                    } else if request.contains("/user") {
417                        ("200 OK", profile.to_string())
418                    } else {
419                        (
420                            "404 Not Found",
421                            serde_json::json!({ "error": "not found" }).to_string(),
422                        )
423                    };
424
425                    let response = format!(
426                        "HTTP/1.1 {status}\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
427                        body.len(),
428                    );
429
430                    let _ = stream.write_all(response.as_bytes()).await;
431                    let _ = stream.flush().await;
432                });
433            }
434        });
435
436        let base_url = format!("http://127.0.0.1:{}", addr.port());
437        tokio::time::sleep(std::time::Duration::from_millis(25)).await;
438        (
439            format!("{base_url}/user"),
440            format!("{base_url}/user/emails"),
441            requests,
442        )
443    }
444
445    // Upstream source: packages/core/src/social-providers/github.ts :: github().createAuthorizationURL default scope list.
446    #[test]
447    fn github_provider_uses_ts_default_scopes() {
448        let provider = OAuthProvider::github("github-client-id", "github-client-secret");
449
450        assert_eq!(
451            provider.scopes,
452            vec!["read:user".to_string(), "user:email".to_string()]
453        );
454        assert!(provider.get_user_info.is_some());
455        assert!(provider.map_user_info.is_none());
456    }
457
458    // Upstream source: packages/core/src/social-providers/github.ts :: github().getUserInfo fallback from profile.email to /user/emails, login fallback for name, and request headers.
459    #[tokio::test]
460    async fn github_provider_get_user_info_uses_email_fallback_and_login_name() {
461        let (user_url, emails_url, requests) = start_github_mock_server(
462            serde_json::json!({
463                "id": 42,
464                "login": "octocat",
465                "name": null,
466                "email": null,
467                "avatar_url": "https://avatars.githubusercontent.com/u/42?v=4",
468            }),
469            serde_json::json!([
470                {
471                    "email": "octocat@example.com",
472                    "primary": true,
473                    "verified": true,
474                    "visibility": "private"
475                },
476                {
477                    "email": "secondary@example.com",
478                    "primary": false,
479                    "verified": false,
480                    "visibility": "private"
481                }
482            ]),
483        )
484        .await;
485
486        let provider = OAuthProvider::github_with_endpoints(
487            "github-client-id",
488            "github-client-secret",
489            "https://github.com/login/oauth/authorize",
490            "https://github.com/login/oauth/access_token",
491            &user_url,
492            &emails_url,
493        );
494        let handler = provider.get_user_info.as_ref().unwrap();
495
496        let response = handler
497            .get_user_info(OAuthUserInfoRequest {
498                access_token: Some("github-access-token".to_string()),
499                ..Default::default()
500            })
501            .await
502            .unwrap();
503
504        assert_eq!(response.user.id, "42");
505        assert_eq!(response.user.email, "octocat@example.com");
506        assert_eq!(response.user.name.as_deref(), Some("octocat"));
507        assert_eq!(
508            response.user.image.as_deref(),
509            Some("https://avatars.githubusercontent.com/u/42?v=4")
510        );
511        assert!(response.user.email_verified);
512        assert_eq!(
513            response.data["email"],
514            serde_json::json!("octocat@example.com")
515        );
516
517        let requests = requests.lock().await;
518        assert_eq!(requests.len(), 2);
519        for request in requests.iter() {
520            let lowered = request.to_ascii_lowercase();
521            assert!(lowered.contains("authorization: bearer github-access-token"));
522            assert!(lowered.contains("user-agent: better-auth"));
523        }
524    }
525
526    // Upstream source: packages/core/src/social-providers/github.ts :: github().getUserInfo keeps profile.email when present and resolves verified status from the matching email record.
527    #[tokio::test]
528    async fn github_provider_get_user_info_keeps_inline_email() {
529        let (user_url, emails_url, _) = start_github_mock_server(
530            serde_json::json!({
531                "id": "github-inline-email",
532                "login": "octocat",
533                "name": "Octo Cat",
534                "email": "public@example.com",
535                "avatar_url": null,
536            }),
537            serde_json::json!([
538                {
539                    "email": "primary@example.com",
540                    "primary": true,
541                    "verified": true,
542                    "visibility": "private"
543                },
544                {
545                    "email": "public@example.com",
546                    "primary": false,
547                    "verified": false,
548                    "visibility": "public"
549                }
550            ]),
551        )
552        .await;
553
554        let provider = OAuthProvider::github_with_endpoints(
555            "github-client-id",
556            "github-client-secret",
557            "https://github.com/login/oauth/authorize",
558            "https://github.com/login/oauth/access_token",
559            &user_url,
560            &emails_url,
561        );
562        let handler = provider.get_user_info.as_ref().unwrap();
563
564        let response = handler
565            .get_user_info(OAuthUserInfoRequest {
566                access_token: Some("github-access-token".to_string()),
567                ..Default::default()
568            })
569            .await
570            .unwrap();
571
572        assert_eq!(response.user.email, "public@example.com");
573        assert_eq!(response.user.name.as_deref(), Some("Octo Cat"));
574        assert!(!response.user.email_verified);
575    }
576}