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"].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}