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#[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#[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#[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 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 unsafe { std::env::set_var("NO_PROXY", "localhost,127.0.0.1") };
384 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 #[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 #[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 #[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}