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"]
191 .as_u64()
192 .map(|n| n.to_string())
193 .unwrap_or_default(),
194 email: body["email"].as_str().unwrap_or("").into(),
195 name: body["login"].as_str().unwrap_or("").into(),
196 avatar_url: body["avatar_url"].as_str().map(|s| s.into()),
197 provider: provider.into(),
198 })
199 }
200 _ => Err("unsupported provider".into()),
201 }
202}
203
204struct FlowCtx {
205 token_path: PathBuf,
206 user: Arc<Mutex<Option<User>>>,
207 pending: Arc<Mutex<Option<Result<User, String>>>>,
208 auth_msg: Arc<Mutex<Option<String>>>,
209}
210
211impl FlowCtx {
212 fn set_msg(&self, msg: &str) {
213 *self.auth_msg.lock().unwrap_or_else(|e| e.into_inner()) = Some(msg.into());
214 }
215
216 fn fail(&self, err: String) {
217 *self.pending.lock().unwrap_or_else(|e| e.into_inner()) = Some(Err(err));
218 *self.auth_msg.lock().unwrap_or_else(|e| e.into_inner()) = None;
219 }
220
221 fn succeed(&self, user: User, access_token: String) {
222 let stored = StoredToken {
223 id: user.id.clone(),
224 email: user.email.clone(),
225 name: user.name.clone(),
226 avatar_url: user.avatar_url.clone(),
227 provider: user.provider.clone(),
228 access_token,
229 refresh_token: None,
230 };
231 save_tokens_to_path(&self.token_path, &stored);
232 *self.user.lock().unwrap_or_else(|e| e.into_inner()) = Some(user.clone());
233 *self.auth_msg.lock().unwrap_or_else(|e| e.into_inner()) = None;
234 *self.pending.lock().unwrap_or_else(|e| e.into_inner()) = Some(Ok(user));
235 }
236}
237
238pub struct AuthClient {
239 providers: HashMap<String, AuthConfig>,
240 user: Arc<Mutex<Option<User>>>,
241 pending_sign_in: Arc<Mutex<Option<Result<User, String>>>>,
242 auth_msg: Arc<Mutex<Option<String>>>,
243 token_path: PathBuf,
244 vercel_url: String,
245}
246
247impl AuthClient {
248 pub fn new(providers: Vec<(String, AuthConfig)>) -> Self {
249 let token_path = dirs::data_dir()
250 .unwrap_or_else(|| PathBuf::from("."))
251 .join("santui")
252 .join("auth-tokens.json");
253 let user = Self::load_tokens(&token_path);
254 AuthClient {
255 providers: providers.into_iter().collect(),
256 user: Arc::new(Mutex::new(user)),
257 pending_sign_in: Arc::new(Mutex::new(None)),
258 auth_msg: Arc::new(Mutex::new(None)),
259 token_path,
260 vercel_url: String::new(),
261 }
262 }
263
264 pub fn with_vercel(mut self, url: String) -> Self {
265 self.vercel_url = url;
266 self
267 }
268
269 fn flow_ctx(&self) -> FlowCtx {
270 FlowCtx {
271 token_path: self.token_path.clone(),
272 user: Arc::clone(&self.user),
273 pending: Arc::clone(&self.pending_sign_in),
274 auth_msg: Arc::clone(&self.auth_msg),
275 }
276 }
277
278 fn wait_for_pending(&self) -> Result<User, Box<dyn std::error::Error>> {
279 loop {
280 if let Some(result) = self
281 .pending_sign_in
282 .lock()
283 .unwrap_or_else(|e| e.into_inner())
284 .take()
285 {
286 *self.auth_msg.lock().unwrap_or_else(|e| e.into_inner()) = None;
287 return result.map_err(|e| e.into());
288 }
289 thread::sleep(Duration::from_millis(100));
290 }
291 }
292
293 fn load_tokens(path: &PathBuf) -> Option<User> {
294 let data = std::fs::read_to_string(path).ok()?;
295 let stored: StoredToken = serde_json::from_str(&data).ok()?;
296 Some(User {
297 id: stored.id,
298 email: stored.email,
299 name: stored.name,
300 avatar_url: stored.avatar_url,
301 provider: stored.provider,
302 })
303 }
304
305 fn clear_tokens(&self) {
306 let _ = std::fs::remove_file(&self.token_path);
307 }
308
309 fn run_google_redirect_flow(vercel_url: &str, ctx: &FlowCtx) {
310 let vercel = if vercel_url.is_empty() {
311 "https://santuiapp.vercel.app".to_string()
312 } else {
313 vercel_url.to_string()
314 };
315
316 let (listener, port) = match bind_with_fallback() {
317 Ok(v) => v,
318 Err(e) => {
319 ctx.fail(e.to_string());
320 return;
321 }
322 };
323 let auth_url = format!("{vercel}/api/auth/google?port={port}");
324 ctx.set_msg("Google: waiting for browser…");
325 open_browser(&auth_url);
326
327 let params = match handle_redirect(listener) {
328 Ok(p) => p,
329 Err(e) => {
330 ctx.fail(e.to_string());
331 return;
332 }
333 };
334
335 let access_token = match params.get("access_token") {
336 Some(t) => t.clone(),
337 None => {
338 ctx.fail("No access_token in redirect".into());
339 return;
340 }
341 };
342
343 let user = User {
344 id: params.get("id").cloned().unwrap_or_default(),
345 email: params.get("email").cloned().unwrap_or_default(),
346 name: params.get("name").cloned().unwrap_or_default(),
347 avatar_url: params.get("avatar_url").cloned(),
348 provider: "google".into(),
349 };
350
351 ctx.succeed(user, access_token);
352 }
353
354 fn sign_in_google(&self) -> Result<User, Box<dyn std::error::Error>> {
355 let vercel_url = self.vercel_url.clone();
356 Self::run_google_redirect_flow(&vercel_url, &self.flow_ctx());
357 self.wait_for_pending()
358 }
359
360 fn start_sign_in_google(&self) -> Result<(), Box<dyn std::error::Error>> {
361 let vercel_url = self.vercel_url.clone();
362 let ctx = self.flow_ctx();
363 thread::spawn(move || Self::run_google_redirect_flow(&vercel_url, &ctx));
364 Ok(())
365 }
366
367 fn run_github_device_flow(config: &AuthConfig, ctx: &FlowCtx) {
368 let device = match request_device_code(config) {
369 Ok(d) => d,
370 Err(e) => {
371 ctx.fail(e.to_string());
372 return;
373 }
374 };
375 let user_code = device.user_code.clone();
376 let interval = device.interval.unwrap_or(5);
377 let activation_url = format!("https://github.com/login/device?user_code={user_code}");
378 ctx.set_msg(&format!(
379 "GitHub: enter code {user_code} at github.com/login/device"
380 ));
381 open_browser(&activation_url);
382
383 let access_token = match poll_device_token(config, &device.device_code, interval) {
384 Ok(t) => t,
385 Err(e) => {
386 ctx.fail(e.to_string());
387 return;
388 }
389 };
390
391 let user = match user_from_token("github", &access_token) {
392 Ok(u) => u,
393 Err(e) => {
394 ctx.fail(e.to_string());
395 return;
396 }
397 };
398
399 ctx.succeed(user, access_token);
400 }
401
402 fn sign_in_github(&self) -> Result<User, Box<dyn std::error::Error>> {
403 let config = self
404 .providers
405 .get("github")
406 .ok_or_else(|| "GitHub auth not configured".to_string())?
407 .clone();
408 Self::run_github_device_flow(&config, &self.flow_ctx());
409 self.wait_for_pending()
410 }
411
412 fn start_sign_in_github(&self) -> Result<(), Box<dyn std::error::Error>> {
413 let config = self
414 .providers
415 .get("github")
416 .ok_or_else(|| "GitHub auth not configured".to_string())?
417 .clone();
418 let ctx = self.flow_ctx();
419 thread::spawn(move || Self::run_github_device_flow(&config, &ctx));
420 Ok(())
421 }
422}
423
424fn save_tokens_to_path(token_path: &PathBuf, stored: &StoredToken) {
425 if let Some(parent) = token_path.parent() {
426 let _ = std::fs::create_dir_all(parent);
427 }
428 if let Ok(data) = serde_json::to_string_pretty(stored) {
429 let _ = std::fs::write(token_path, data);
430 }
431}
432
433impl AuthHandle for AuthClient {
434 fn current_user(&self) -> Option<User> {
435 self.user.lock().unwrap_or_else(|e| e.into_inner()).clone()
436 }
437
438 fn bearer_token(&self) -> Option<String> {
439 let data = std::fs::read_to_string(&self.token_path).ok()?;
440 let stored: StoredToken = serde_json::from_str(&data).ok()?;
441 Some(stored.access_token)
442 }
443
444 fn sign_in(&self, provider: &str) -> Result<User, Box<dyn std::error::Error>> {
445 match provider {
446 "google" => self.sign_in_google(),
447 "github" => self.sign_in_github(),
448 _ => Err("unsupported provider".into()),
449 }
450 }
451
452 fn start_sign_in(&self, provider: &str) -> Result<(), Box<dyn std::error::Error>> {
453 match provider {
454 "github" => self.start_sign_in_github(),
455 "google" => self.start_sign_in_google(),
456 _ => Err("unsupported provider".into()),
457 }
458 }
459
460 fn drain_pending_sign_in(&self) -> Option<Result<User, Box<dyn std::error::Error>>> {
461 let mut guard = self
462 .pending_sign_in
463 .lock()
464 .unwrap_or_else(|e| e.into_inner());
465 guard.take().map(|r| r.map_err(|e| e.into()))
466 }
467
468 fn auth_message(&self) -> Option<String> {
469 self.auth_msg
470 .lock()
471 .unwrap_or_else(|e| e.into_inner())
472 .clone()
473 }
474
475 fn sign_out(&self) {
476 self.clear_tokens();
477 *self.auth_msg.lock().unwrap_or_else(|e| e.into_inner()) = None;
478 *self.user.lock().unwrap_or_else(|e| e.into_inner()) = None;
479 }
480}
481
482#[cfg(test)]
483mod tests {
484 use super::*;
485 use std::io::Read;
486
487 #[test]
488 fn auth_config_google() {
489 let cfg = AuthConfig::google("cid".into(), Some("secret".into()));
490 assert_eq!(cfg.client_id, "cid");
491 assert_eq!(cfg.client_secret, Some("secret".into()));
492 assert_eq!(cfg.auth_uri, "https://accounts.google.com/o/oauth2/v2/auth");
493 assert_eq!(cfg.scopes, vec!["openid", "email", "profile"]);
494 assert_eq!(cfg.redirect_port, 9842);
495 }
496
497 #[test]
498 fn auth_config_google_without_secret() {
499 let cfg = AuthConfig::google("cid".into(), None);
500 assert_eq!(cfg.client_secret, None);
501 }
502
503 #[test]
504 fn auth_config_github() {
505 let cfg = AuthConfig::github("cid".into());
506 assert_eq!(cfg.client_id, "cid");
507 assert!(cfg.client_secret.is_none());
508 assert_eq!(cfg.auth_uri, "");
509 assert_eq!(cfg.token_uri, "https://github.com/login/oauth/access_token");
510 assert_eq!(cfg.scopes, vec!["read:user", "user:email"]);
511 assert_eq!(cfg.redirect_port, 0);
512 }
513
514 #[test]
515 fn flow_ctx_set_msg() {
516 let ctx = FlowCtx {
517 token_path: PathBuf::new(),
518 user: Arc::new(Mutex::new(None)),
519 pending: Arc::new(Mutex::new(None)),
520 auth_msg: Arc::new(Mutex::new(None)),
521 };
522 ctx.set_msg("hello");
523 assert_eq!(*ctx.auth_msg.lock().unwrap(), Some("hello".into()));
524 }
525
526 #[test]
527 fn flow_ctx_fail_clears_msg_and_sets_error() {
528 let ctx = FlowCtx {
529 token_path: PathBuf::new(),
530 user: Arc::new(Mutex::new(None)),
531 pending: Arc::new(Mutex::new(None)),
532 auth_msg: Arc::new(Mutex::new(Some("waiting".into()))),
533 };
534 ctx.fail("broken".into());
535 assert_eq!(*ctx.auth_msg.lock().unwrap(), None);
536 assert!(matches!(
537 *ctx.pending.lock().unwrap(),
538 Some(Err(ref e)) if e == "broken"
539 ));
540 }
541
542 #[test]
543 fn flow_ctx_succeed_sets_user_writes_token_and_clears_msg() {
544 let dir = std::env::temp_dir().join("santui-auth-test-succeed");
545 let _ = std::fs::create_dir_all(&dir);
546 let token_path = dir.join("token.json");
547
548 let user = User {
549 id: "42".into(),
550 email: "a@b.com".into(),
551 name: "Alice".into(),
552 avatar_url: None,
553 provider: "google".into(),
554 };
555
556 let ctx = FlowCtx {
557 token_path: token_path.clone(),
558 user: Arc::new(Mutex::new(None)),
559 pending: Arc::new(Mutex::new(None)),
560 auth_msg: Arc::new(Mutex::new(Some("waiting".into()))),
561 };
562
563 ctx.succeed(user.clone(), "tok123".into());
564
565 assert_eq!(*ctx.auth_msg.lock().unwrap(), None);
566 let stored_user = ctx.user.lock().unwrap().clone().unwrap();
567 assert_eq!(stored_user.id, "42");
568 assert_eq!(stored_user.email, "a@b.com");
569 assert!(token_path.exists());
570
571 let _ = std::fs::remove_file(&token_path);
572 let _ = std::fs::remove_dir(&dir);
573 }
574
575 #[test]
576 fn handle_redirect_parses_query_params() {
577 let listener = TcpListener::bind("127.0.0.1:0").unwrap();
578 let port = listener.local_addr().unwrap().port();
579
580 let handle = thread::spawn(move || {
581 let mut stream = std::net::TcpStream::connect(("127.0.0.1", port)).unwrap();
582 let req = "GET /callback?code=abc&state=123 HTTP/1.1\r\nHost: localhost\r\n\r\n";
583 let _ = stream.write_all(req.as_bytes());
584 let mut buf = [0u8; 4096];
585 let _ = stream.read(&mut buf);
586 });
587
588 let params = handle_redirect(listener).unwrap();
589 assert_eq!(params.get("code"), Some(&"abc".into()));
590 assert_eq!(params.get("state"), Some(&"123".into()));
591
592 handle.join().unwrap();
593 }
594
595 #[test]
596 fn handle_redirect_returns_error_when_server_sends_error() {
597 let listener = TcpListener::bind("127.0.0.1:0").unwrap();
598 let port = listener.local_addr().unwrap().port();
599
600 let handle = thread::spawn(move || {
601 let mut stream = std::net::TcpStream::connect(("127.0.0.1", port)).unwrap();
602 let req = "GET /callback?error=access_denied HTTP/1.1\r\nHost: localhost\r\n\r\n";
603 let _ = stream.write_all(req.as_bytes());
604 let mut buf = [0u8; 4096];
605 let _ = stream.read(&mut buf);
606 });
607
608 let err = handle_redirect(listener).unwrap_err();
609 assert!(err.to_string().contains("access_denied"));
610
611 handle.join().unwrap();
612 }
613
614 #[test]
615 fn stored_token_serde_roundtrip() {
616 let token = StoredToken {
617 id: "1".into(),
618 email: "a@b.com".into(),
619 name: "Alice".into(),
620 avatar_url: Some("https://example.com/av.png".into()),
621 provider: "github".into(),
622 access_token: "gho_xxx".into(),
623 refresh_token: Some("refresh".into()),
624 };
625 let json = serde_json::to_string(&token).unwrap();
626 let back: StoredToken = serde_json::from_str(&json).unwrap();
627 assert_eq!(back.id, token.id);
628 assert_eq!(back.email, token.email);
629 assert_eq!(back.name, token.name);
630 assert_eq!(back.avatar_url, token.avatar_url);
631 assert_eq!(back.provider, token.provider);
632 assert_eq!(back.access_token, token.access_token);
633 assert_eq!(back.refresh_token, token.refresh_token);
634 }
635
636 #[test]
637 fn save_and_load_tokens_roundtrip() {
638 let dir = std::env::temp_dir().join("santui-auth-test-tokens");
639 let _ = std::fs::create_dir_all(&dir);
640 let path = dir.join("tokens.json");
641
642 let stored = StoredToken {
643 id: "2".into(),
644 email: "b@c.com".into(),
645 name: "Bob".into(),
646 avatar_url: None,
647 provider: "github".into(),
648 access_token: "gho_yyy".into(),
649 refresh_token: None,
650 };
651
652 save_tokens_to_path(&path, &stored);
653 assert!(path.exists());
654
655 let loaded = AuthClient::load_tokens(&path).unwrap();
656 assert_eq!(loaded.id, stored.id);
657 assert_eq!(loaded.email, stored.email);
658 assert_eq!(loaded.name, stored.name);
659 assert_eq!(loaded.provider, stored.provider);
660
661 let _ = std::fs::remove_file(&path);
662 let _ = std::fs::remove_dir(&dir);
663 }
664
665 #[test]
666 fn load_tokens_returns_none_when_file_missing() {
667 let r = AuthClient::load_tokens(&PathBuf::from("C:\\nonexistent\\path\\file.json"));
668 assert!(r.is_none());
669 }
670
671 #[test]
672 fn bind_with_fallback_returns_bound_port() {
673 let (listener, port) = bind_with_fallback().unwrap();
674 assert!(port >= 9842);
675 drop(listener);
676 }
677
678 #[test]
679 fn auth_client_new_has_no_pending_state() {
680 let c = AuthClient::new(vec![]);
681 assert!(c.auth_message().is_none());
682 assert!(c.drain_pending_sign_in().is_none());
683 }
684
685 #[test]
686 fn auth_client_sign_in_unsupported_provider() {
687 let c = AuthClient::new(vec![]);
688 assert!(c.sign_in("unsupported").is_err());
689 }
690
691 #[test]
692 fn auth_client_start_sign_in_unsupported_provider() {
693 let c = AuthClient::new(vec![]);
694 assert!(c.start_sign_in("unsupported").is_err());
695 }
696
697 #[test]
698 fn auth_client_sign_in_github_not_configured() {
699 let c = AuthClient::new(vec![]);
700 let r = c.sign_in("github");
701 assert!(r.is_err());
702 assert!(r.unwrap_err().to_string().contains("not configured"));
703 }
704}