1use santui_core::auth::{AuthHandle, User};
2use serde::{Deserialize, Serialize};
3use std::collections::HashMap;
4use std::io::{BufRead, BufReader, Write};
5use std::net::TcpListener;
6use std::path::PathBuf;
7use std::sync::Arc;
8use std::sync::Mutex;
9use std::thread;
10use std::time::Duration;
11use url::Url;
12
13#[derive(Debug, Clone, Serialize, Deserialize)]
14struct StoredToken {
15 id: String,
16 email: String,
17 name: String,
18 avatar_url: Option<String>,
19 provider: String,
20 access_token: String,
21 refresh_token: Option<String>,
22}
23
24#[derive(Debug, Clone)]
25pub struct AuthConfig {
26 pub client_id: String,
27 pub client_secret: Option<String>,
28 pub auth_uri: String,
29 pub token_uri: String,
30 pub scopes: Vec<String>,
31 pub redirect_port: u16,
32}
33
34impl AuthConfig {
35 pub fn google(client_id: String, client_secret: Option<String>) -> Self {
36 AuthConfig {
37 client_id,
38 client_secret,
39 auth_uri: "https://accounts.google.com/o/oauth2/v2/auth".into(),
40 token_uri: "https://oauth2.googleapis.com/token".into(),
41 scopes: vec!["openid".into(), "email".into(), "profile".into()],
42 redirect_port: 9842,
43 }
44 }
45
46 pub fn github(client_id: String) -> Self {
47 AuthConfig {
48 client_id,
49 client_secret: None,
50 auth_uri: String::new(),
51 token_uri: "https://github.com/login/oauth/access_token".into(),
52 scopes: vec!["read:user".into(), "user:email".into()],
53 redirect_port: 0,
54 }
55 }
56}
57
58#[cfg(target_os = "windows")]
59fn open_browser(url: &str) {
60 let _ = std::process::Command::new("cmd")
61 .args(["/c", "start", &url.replace('&', "^&")])
62 .spawn();
63}
64
65#[cfg(target_os = "linux")]
66fn open_browser(url: &str) {
67 let _ = std::process::Command::new("xdg-open").arg(url).spawn();
68}
69
70#[cfg(target_os = "macos")]
71fn open_browser(url: &str) {
72 let _ = std::process::Command::new("open").arg(url).spawn();
73}
74
75#[cfg(not(any(target_os = "windows", target_os = "linux", target_os = "macos")))]
76fn open_browser(url: &str) {
77 let _ = std::process::Command::new("xdg-open").arg(url).spawn();
78}
79
80fn bind_with_fallback() -> Result<(TcpListener, u16), Box<dyn std::error::Error>> {
81 for port in 9842..9850 {
82 if let Ok(listener) = TcpListener::bind(("127.0.0.1", port)) {
83 return Ok((listener, port));
84 }
85 }
86 let listener = TcpListener::bind(("127.0.0.1", 0))?;
87 let port = listener.local_addr()?.port();
88 Ok((listener, port))
89}
90
91fn handle_redirect(
92 listener: TcpListener,
93) -> Result<HashMap<String, String>, Box<dyn std::error::Error>> {
94 let (stream, _) = listener.accept()?;
95 stream.set_read_timeout(Some(Duration::from_secs(120)))?;
96 let mut reader = BufReader::new(&stream);
97 let mut request_line = String::new();
98 reader.read_line(&mut request_line)?;
99
100 let params = request_line
101 .split_whitespace()
102 .nth(1)
103 .and_then(|path| {
104 let full_url = format!("http://localhost{path}");
105 Url::parse(&full_url).ok().map(|u| {
106 u.query_pairs()
107 .map(|(k, v)| (k.into_owned(), v.into_owned()))
108 .collect::<HashMap<String, String>>()
109 })
110 })
111 .ok_or_else(|| "No query parameters in redirect".to_string())?;
112
113 let response = "HTTP/1.1 200 OK\r\nContent-Type: text/html\r\n\r\n<!DOCTYPE html><html lang=\"en\"><head><meta charset=\"UTF-8\"><script src=\"https://cdn.tailwindcss.com\"></script><title>Santui — Signed In</title></head><body class=\"bg-gradient-to-br from-gray-900 via-slate-800 to-gray-900 min-h-screen flex items-center justify-center font-sans\"><div class=\"bg-white/10 backdrop-blur-lg rounded-lg shadow-2xl border border-white/20 p-8 max-w-md w-full mx-4 text-center\"><div class=\"text-emerald-400 mb-4\"><svg class=\"w-16 h-16 mx-auto mb-4\" fill=\"none\" stroke=\"currentColor\" viewBox=\"0 0 24 24\"><path stroke-linecap=\"round\" stroke-linejoin=\"round\" stroke-width=\"1.5\" d=\"M9 12.75L11.25 15 15 9.75M21 12a9 9 0 11-18 0 9 9 0 0118 0z\"/></svg><h1 class=\"text-2xl font-bold mb-1\">Signed In!</h1><p class=\"text-gray-400 text-sm\">You can close this window.</p></div></div></body></html>";
114 let mut stream = stream;
115 let _ = stream.write_all(response.as_bytes());
116
117 if let Some(err) = params.get("error") {
118 return Err(format!("OAuth error from server: {err}").into());
119 }
120
121 Ok(params)
122}
123
124#[derive(Deserialize)]
125struct DeviceCodeResponse {
126 device_code: String,
127 user_code: String,
128 #[allow(dead_code)]
129 verification_uri: String,
130 interval: Option<u64>,
131}
132
133#[derive(Deserialize)]
134struct DeviceTokenResponse {
135 access_token: Option<String>,
136 error: Option<String>,
137}
138
139fn request_device_code(
140 config: &AuthConfig,
141) -> Result<DeviceCodeResponse, Box<dyn std::error::Error>> {
142 let scope = config.scopes.join(" ");
143 let mut resp = ureq::post("https://github.com/login/device/code")
144 .header("Accept", "application/json")
145 .send_form([
146 ("client_id", config.client_id.as_str()),
147 ("scope", scope.as_str()),
148 ])?;
149 let text = resp.body_mut().read_to_string()?;
150 Ok(serde_json::from_str(&text)?)
151}
152
153fn poll_device_token(
154 config: &AuthConfig,
155 device_code: &str,
156 interval: u64,
157) -> Result<String, Box<dyn std::error::Error>> {
158 loop {
159 std::thread::sleep(std::time::Duration::from_secs(interval));
160 let mut resp = ureq::post(&config.token_uri)
161 .header("Accept", "application/json")
162 .send_form([
163 ("client_id", config.client_id.as_str()),
164 ("device_code", device_code),
165 ("grant_type", "urn:ietf:params:oauth:grant-type:device_code"),
166 ])?;
167 let text = resp.body_mut().read_to_string()?;
168 let body: DeviceTokenResponse = serde_json::from_str(&text)?;
169 if let Some(token) = body.access_token {
170 return Ok(token);
171 }
172 match body.error.as_deref() {
173 Some("authorization_pending") => continue,
174 Some("slow_down") => continue,
175 Some(err) => return Err(format!("device flow error: {err}").into()),
176 None => return Err("unexpected device flow response".into()),
177 }
178 }
179}
180
181fn user_from_token(provider: &str, access_token: &str) -> Result<User, Box<dyn std::error::Error>> {
182 match provider {
183 "github" => {
184 let mut resp = ureq::get("https://api.github.com/user")
185 .header("Authorization", &format!("Bearer {access_token}"))
186 .header("Accept", "application/vnd.github.v3+json")
187 .call()?;
188 let body: serde_json::Value = serde_json::from_str(&resp.body_mut().read_to_string()?)?;
189 Ok(User {
190 id: body["id"].to_string(),
191 email: body["email"].as_str().unwrap_or("").into(),
192 name: body["login"].as_str().unwrap_or("").into(),
193 avatar_url: body["avatar_url"].as_str().map(|s| s.into()),
194 provider: provider.into(),
195 })
196 }
197 _ => Err("unsupported provider".into()),
198 }
199}
200
201struct FlowCtx {
202 token_path: PathBuf,
203 user: Arc<Mutex<Option<User>>>,
204 pending: Arc<Mutex<Option<Result<User, String>>>>,
205 auth_msg: Arc<Mutex<Option<String>>>,
206}
207
208impl FlowCtx {
209 fn set_msg(&self, msg: &str) {
210 *self.auth_msg.lock().unwrap_or_else(|e| e.into_inner()) = Some(msg.into());
211 }
212
213 fn fail(&self, err: String) {
214 *self.pending.lock().unwrap_or_else(|e| e.into_inner()) = Some(Err(err));
215 *self.auth_msg.lock().unwrap_or_else(|e| e.into_inner()) = None;
216 }
217
218 fn succeed(&self, user: User, access_token: String) {
219 let stored = StoredToken {
220 id: user.id.clone(),
221 email: user.email.clone(),
222 name: user.name.clone(),
223 avatar_url: user.avatar_url.clone(),
224 provider: user.provider.clone(),
225 access_token,
226 refresh_token: None,
227 };
228 save_tokens_to_path(&self.token_path, &stored);
229 *self.user.lock().unwrap_or_else(|e| e.into_inner()) = Some(user.clone());
230 *self.auth_msg.lock().unwrap_or_else(|e| e.into_inner()) = None;
231 *self.pending.lock().unwrap_or_else(|e| e.into_inner()) = Some(Ok(user));
232 }
233}
234
235pub struct AuthClient {
236 providers: HashMap<String, AuthConfig>,
237 user: Arc<Mutex<Option<User>>>,
238 pending_sign_in: Arc<Mutex<Option<Result<User, String>>>>,
239 auth_msg: Arc<Mutex<Option<String>>>,
240 token_path: PathBuf,
241 vercel_url: String,
242}
243
244impl AuthClient {
245 pub fn new(providers: Vec<(String, AuthConfig)>) -> Self {
246 let token_path = dirs::data_dir()
247 .unwrap_or_else(|| PathBuf::from("."))
248 .join("santui")
249 .join("auth-tokens.json");
250 let user = Self::load_tokens(&token_path);
251 AuthClient {
252 providers: providers.into_iter().collect(),
253 user: Arc::new(Mutex::new(user)),
254 pending_sign_in: Arc::new(Mutex::new(None)),
255 auth_msg: Arc::new(Mutex::new(None)),
256 token_path,
257 vercel_url: String::new(),
258 }
259 }
260
261 pub fn with_vercel(mut self, url: String) -> Self {
262 self.vercel_url = url;
263 self
264 }
265
266 fn flow_ctx(&self) -> FlowCtx {
267 FlowCtx {
268 token_path: self.token_path.clone(),
269 user: Arc::clone(&self.user),
270 pending: Arc::clone(&self.pending_sign_in),
271 auth_msg: Arc::clone(&self.auth_msg),
272 }
273 }
274
275 fn wait_for_pending(&self) -> Result<User, Box<dyn std::error::Error>> {
276 loop {
277 if let Some(result) = self
278 .pending_sign_in
279 .lock()
280 .unwrap_or_else(|e| e.into_inner())
281 .take()
282 {
283 *self.auth_msg.lock().unwrap_or_else(|e| e.into_inner()) = None;
284 return result.map_err(|e| e.into());
285 }
286 thread::sleep(Duration::from_millis(100));
287 }
288 }
289
290 fn load_tokens(path: &PathBuf) -> Option<User> {
291 let data = std::fs::read_to_string(path).ok()?;
292 let stored: StoredToken = serde_json::from_str(&data).ok()?;
293 Some(User {
294 id: stored.id,
295 email: stored.email,
296 name: stored.name,
297 avatar_url: stored.avatar_url,
298 provider: stored.provider,
299 })
300 }
301
302 fn clear_tokens(&self) {
303 let _ = std::fs::remove_file(&self.token_path);
304 }
305
306 fn run_google_redirect_flow(vercel_url: &str, ctx: &FlowCtx) {
307 let vercel = if vercel_url.is_empty() {
308 "https://santuiapp.vercel.app".to_string()
309 } else {
310 vercel_url.to_string()
311 };
312
313 let (listener, port) = match bind_with_fallback() {
314 Ok(v) => v,
315 Err(e) => {
316 ctx.fail(e.to_string());
317 return;
318 }
319 };
320 let auth_url = format!("{vercel}/api/auth/google?port={port}");
321 ctx.set_msg("Google: waiting for browser…");
322 open_browser(&auth_url);
323
324 let params = match handle_redirect(listener) {
325 Ok(p) => p,
326 Err(e) => {
327 ctx.fail(e.to_string());
328 return;
329 }
330 };
331
332 let access_token = match params.get("access_token") {
333 Some(t) => t.clone(),
334 None => {
335 ctx.fail("No access_token in redirect".into());
336 return;
337 }
338 };
339
340 let user = User {
341 id: params.get("id").cloned().unwrap_or_default(),
342 email: params.get("email").cloned().unwrap_or_default(),
343 name: params.get("name").cloned().unwrap_or_default(),
344 avatar_url: params.get("avatar_url").cloned(),
345 provider: "google".into(),
346 };
347
348 ctx.succeed(user, access_token);
349 }
350
351 fn sign_in_google(&self) -> Result<User, Box<dyn std::error::Error>> {
352 let vercel_url = self.vercel_url.clone();
353 Self::run_google_redirect_flow(&vercel_url, &self.flow_ctx());
354 self.wait_for_pending()
355 }
356
357 fn start_sign_in_google(&self) -> Result<(), Box<dyn std::error::Error>> {
358 let vercel_url = self.vercel_url.clone();
359 let ctx = self.flow_ctx();
360 thread::spawn(move || Self::run_google_redirect_flow(&vercel_url, &ctx));
361 Ok(())
362 }
363
364 fn run_github_device_flow(config: &AuthConfig, ctx: &FlowCtx) {
365 let device = match request_device_code(config) {
366 Ok(d) => d,
367 Err(e) => {
368 ctx.fail(e.to_string());
369 return;
370 }
371 };
372 let user_code = device.user_code.clone();
373 let interval = device.interval.unwrap_or(5);
374 let activation_url = format!("https://github.com/login/device?user_code={user_code}");
375 ctx.set_msg(&format!(
376 "GitHub: enter code {user_code} at github.com/login/device"
377 ));
378 open_browser(&activation_url);
379
380 let access_token = match poll_device_token(config, &device.device_code, interval) {
381 Ok(t) => t,
382 Err(e) => {
383 ctx.fail(e.to_string());
384 return;
385 }
386 };
387
388 let user = match user_from_token("github", &access_token) {
389 Ok(u) => u,
390 Err(e) => {
391 ctx.fail(e.to_string());
392 return;
393 }
394 };
395
396 ctx.succeed(user, access_token);
397 }
398
399 fn sign_in_github(&self) -> Result<User, Box<dyn std::error::Error>> {
400 let config = self
401 .providers
402 .get("github")
403 .ok_or_else(|| "GitHub auth not configured".to_string())?
404 .clone();
405 Self::run_github_device_flow(&config, &self.flow_ctx());
406 self.wait_for_pending()
407 }
408
409 fn start_sign_in_github(&self) -> Result<(), Box<dyn std::error::Error>> {
410 let config = self
411 .providers
412 .get("github")
413 .ok_or_else(|| "GitHub auth not configured".to_string())?
414 .clone();
415 let ctx = self.flow_ctx();
416 thread::spawn(move || Self::run_github_device_flow(&config, &ctx));
417 Ok(())
418 }
419}
420
421fn save_tokens_to_path(token_path: &PathBuf, stored: &StoredToken) {
422 if let Some(parent) = token_path.parent() {
423 let _ = std::fs::create_dir_all(parent);
424 }
425 if let Ok(data) = serde_json::to_string_pretty(stored) {
426 let _ = std::fs::write(token_path, data);
427 }
428}
429
430impl AuthHandle for AuthClient {
431 fn current_user(&self) -> Option<User> {
432 self.user.lock().unwrap_or_else(|e| e.into_inner()).clone()
433 }
434
435 fn bearer_token(&self) -> Option<String> {
436 let data = std::fs::read_to_string(&self.token_path).ok()?;
437 let stored: StoredToken = serde_json::from_str(&data).ok()?;
438 Some(stored.access_token)
439 }
440
441 fn sign_in(&self, provider: &str) -> Result<User, Box<dyn std::error::Error>> {
442 match provider {
443 "google" => self.sign_in_google(),
444 "github" => self.sign_in_github(),
445 _ => Err("unsupported provider".into()),
446 }
447 }
448
449 fn start_sign_in(&self, provider: &str) -> Result<(), Box<dyn std::error::Error>> {
450 match provider {
451 "github" => self.start_sign_in_github(),
452 "google" => self.start_sign_in_google(),
453 _ => Err("unsupported provider".into()),
454 }
455 }
456
457 fn drain_pending_sign_in(&self) -> Option<Result<User, Box<dyn std::error::Error>>> {
458 let mut guard = self
459 .pending_sign_in
460 .lock()
461 .unwrap_or_else(|e| e.into_inner());
462 guard.take().map(|r| r.map_err(|e| e.into()))
463 }
464
465 fn auth_message(&self) -> Option<String> {
466 self.auth_msg
467 .lock()
468 .unwrap_or_else(|e| e.into_inner())
469 .clone()
470 }
471
472 fn sign_out(&self) {
473 self.clear_tokens();
474 *self.auth_msg.lock().unwrap_or_else(|e| e.into_inner()) = None;
475 *self.user.lock().unwrap_or_else(|e| e.into_inner()) = None;
476 }
477}
478
479#[cfg(test)]
480mod tests {
481 use super::*;
482 use std::io::Read;
483
484 #[test]
485 fn auth_config_google() {
486 let cfg = AuthConfig::google("cid".into(), Some("secret".into()));
487 assert_eq!(cfg.client_id, "cid");
488 assert_eq!(cfg.client_secret, Some("secret".into()));
489 assert_eq!(cfg.auth_uri, "https://accounts.google.com/o/oauth2/v2/auth");
490 assert_eq!(cfg.scopes, vec!["openid", "email", "profile"]);
491 assert_eq!(cfg.redirect_port, 9842);
492 }
493
494 #[test]
495 fn auth_config_google_without_secret() {
496 let cfg = AuthConfig::google("cid".into(), None);
497 assert_eq!(cfg.client_secret, None);
498 }
499
500 #[test]
501 fn auth_config_github() {
502 let cfg = AuthConfig::github("cid".into());
503 assert_eq!(cfg.client_id, "cid");
504 assert!(cfg.client_secret.is_none());
505 assert_eq!(cfg.auth_uri, "");
506 assert_eq!(cfg.token_uri, "https://github.com/login/oauth/access_token");
507 assert_eq!(cfg.scopes, vec!["read:user", "user:email"]);
508 assert_eq!(cfg.redirect_port, 0);
509 }
510
511 #[test]
512 fn flow_ctx_set_msg() {
513 let ctx = FlowCtx {
514 token_path: PathBuf::new(),
515 user: Arc::new(Mutex::new(None)),
516 pending: Arc::new(Mutex::new(None)),
517 auth_msg: Arc::new(Mutex::new(None)),
518 };
519 ctx.set_msg("hello");
520 assert_eq!(*ctx.auth_msg.lock().unwrap(), Some("hello".into()));
521 }
522
523 #[test]
524 fn flow_ctx_fail_clears_msg_and_sets_error() {
525 let ctx = FlowCtx {
526 token_path: PathBuf::new(),
527 user: Arc::new(Mutex::new(None)),
528 pending: Arc::new(Mutex::new(None)),
529 auth_msg: Arc::new(Mutex::new(Some("waiting".into()))),
530 };
531 ctx.fail("broken".into());
532 assert_eq!(*ctx.auth_msg.lock().unwrap(), None);
533 assert!(matches!(
534 *ctx.pending.lock().unwrap(),
535 Some(Err(ref e)) if e == "broken"
536 ));
537 }
538
539 #[test]
540 fn flow_ctx_succeed_sets_user_writes_token_and_clears_msg() {
541 let dir = std::env::temp_dir().join("santui-auth-test-succeed");
542 let _ = std::fs::create_dir_all(&dir);
543 let token_path = dir.join("token.json");
544
545 let user = User {
546 id: "42".into(),
547 email: "a@b.com".into(),
548 name: "Alice".into(),
549 avatar_url: None,
550 provider: "google".into(),
551 };
552
553 let ctx = FlowCtx {
554 token_path: token_path.clone(),
555 user: Arc::new(Mutex::new(None)),
556 pending: Arc::new(Mutex::new(None)),
557 auth_msg: Arc::new(Mutex::new(Some("waiting".into()))),
558 };
559
560 ctx.succeed(user.clone(), "tok123".into());
561
562 assert_eq!(*ctx.auth_msg.lock().unwrap(), None);
563 let stored_user = ctx.user.lock().unwrap().clone().unwrap();
564 assert_eq!(stored_user.id, "42");
565 assert_eq!(stored_user.email, "a@b.com");
566 assert!(token_path.exists());
567
568 let _ = std::fs::remove_file(&token_path);
569 let _ = std::fs::remove_dir(&dir);
570 }
571
572 #[test]
573 fn handle_redirect_parses_query_params() {
574 let listener = TcpListener::bind("127.0.0.1:0").unwrap();
575 let port = listener.local_addr().unwrap().port();
576
577 let handle = thread::spawn(move || {
578 let mut stream = std::net::TcpStream::connect(("127.0.0.1", port)).unwrap();
579 let req = "GET /callback?code=abc&state=123 HTTP/1.1\r\nHost: localhost\r\n\r\n";
580 let _ = stream.write_all(req.as_bytes());
581 let mut buf = [0u8; 4096];
582 let _ = stream.read(&mut buf);
583 });
584
585 let params = handle_redirect(listener).unwrap();
586 assert_eq!(params.get("code"), Some(&"abc".into()));
587 assert_eq!(params.get("state"), Some(&"123".into()));
588
589 handle.join().unwrap();
590 }
591
592 #[test]
593 fn handle_redirect_returns_error_when_server_sends_error() {
594 let listener = TcpListener::bind("127.0.0.1:0").unwrap();
595 let port = listener.local_addr().unwrap().port();
596
597 let handle = thread::spawn(move || {
598 let mut stream = std::net::TcpStream::connect(("127.0.0.1", port)).unwrap();
599 let req = "GET /callback?error=access_denied HTTP/1.1\r\nHost: localhost\r\n\r\n";
600 let _ = stream.write_all(req.as_bytes());
601 let mut buf = [0u8; 4096];
602 let _ = stream.read(&mut buf);
603 });
604
605 let err = handle_redirect(listener).unwrap_err();
606 assert!(err.to_string().contains("access_denied"));
607
608 handle.join().unwrap();
609 }
610
611 #[test]
612 fn stored_token_serde_roundtrip() {
613 let token = StoredToken {
614 id: "1".into(),
615 email: "a@b.com".into(),
616 name: "Alice".into(),
617 avatar_url: Some("https://example.com/av.png".into()),
618 provider: "github".into(),
619 access_token: "gho_xxx".into(),
620 refresh_token: Some("refresh".into()),
621 };
622 let json = serde_json::to_string(&token).unwrap();
623 let back: StoredToken = serde_json::from_str(&json).unwrap();
624 assert_eq!(back.id, token.id);
625 assert_eq!(back.email, token.email);
626 assert_eq!(back.name, token.name);
627 assert_eq!(back.avatar_url, token.avatar_url);
628 assert_eq!(back.provider, token.provider);
629 assert_eq!(back.access_token, token.access_token);
630 assert_eq!(back.refresh_token, token.refresh_token);
631 }
632
633 #[test]
634 fn save_and_load_tokens_roundtrip() {
635 let dir = std::env::temp_dir().join("santui-auth-test-tokens");
636 let _ = std::fs::create_dir_all(&dir);
637 let path = dir.join("tokens.json");
638
639 let stored = StoredToken {
640 id: "2".into(),
641 email: "b@c.com".into(),
642 name: "Bob".into(),
643 avatar_url: None,
644 provider: "github".into(),
645 access_token: "gho_yyy".into(),
646 refresh_token: None,
647 };
648
649 save_tokens_to_path(&path, &stored);
650 assert!(path.exists());
651
652 let loaded = AuthClient::load_tokens(&path).unwrap();
653 assert_eq!(loaded.id, stored.id);
654 assert_eq!(loaded.email, stored.email);
655 assert_eq!(loaded.name, stored.name);
656 assert_eq!(loaded.provider, stored.provider);
657
658 let _ = std::fs::remove_file(&path);
659 let _ = std::fs::remove_dir(&dir);
660 }
661
662 #[test]
663 fn load_tokens_returns_none_when_file_missing() {
664 let r = AuthClient::load_tokens(&PathBuf::from("C:\\nonexistent\\path\\file.json"));
665 assert!(r.is_none());
666 }
667
668 #[test]
669 fn bind_with_fallback_returns_bound_port() {
670 let (listener, port) = bind_with_fallback().unwrap();
671 assert!(port >= 9842);
672 drop(listener);
673 }
674
675 #[test]
676 fn auth_client_new_has_no_pending_state() {
677 let c = AuthClient::new(vec![]);
678 assert!(c.auth_message().is_none());
679 assert!(c.drain_pending_sign_in().is_none());
680 }
681
682 #[test]
683 fn auth_client_sign_in_unsupported_provider() {
684 let c = AuthClient::new(vec![]);
685 assert!(c.sign_in("unsupported").is_err());
686 }
687
688 #[test]
689 fn auth_client_start_sign_in_unsupported_provider() {
690 let c = AuthClient::new(vec![]);
691 assert!(c.start_sign_in("unsupported").is_err());
692 }
693
694 #[test]
695 fn auth_client_sign_in_github_not_configured() {
696 let c = AuthClient::new(vec![]);
697 let r = c.sign_in("github");
698 assert!(r.is_err());
699 assert!(r.unwrap_err().to_string().contains("not configured"));
700 }
701}