Skip to main content

santui_auth/
lib.rs

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}