Skip to main content

grit_lib/
grit_server_auth.rs

1//! Sign-in to a `grit serve` host.
2//!
3//! A `grit serve` server authenticates Git requests with access tokens. A
4//! client gets one by signing in through the server's web page, much like
5//! GitHub's device flow, and stores it with the user's credential helper, so
6//! later HTTP fetches and pushes send it as the basic-auth password without the
7//! token ever appearing in a remote URL:
8//!
9//! 1. [`ServerUrl::parse`] reduces whatever URL the user typed (a server root
10//!    or a repository URL) to the scheme and `host[:port]` that credential
11//!    helpers key on.
12//! 2. [`LoginFlow::start`] asks the server for a login code ([`LoginStart`]).
13//!    The user opens [`ServerUrl::authorize_url`] in a browser, signs in with
14//!    their email address and password, and approves the code.
15//! 3. [`LoginFlow::wait`] polls the server until the user approves, then
16//!    returns the new token ([`LoginGrant`]).
17//! 4. [`ServerUrl::credential`] builds the [`Credential`] a
18//!    [`CredentialProvider`] should `approve` (store) or `reject` (erase).
19//!
20//! A token from elsewhere (an admin's `grit serve token`) can instead be
21//! checked with [`fetch_identity`], which asks the server who it belongs to.
22//!
23//! The JSON bodies ([`ServerIdentity`], [`LoginStart`], [`LoginPollRequest`],
24//! [`LoginPoll`]) are serialized by the server from the same types, so client
25//! and server share one contract. Nothing here prompts or prints.
26
27use std::time::Duration;
28
29use serde::{Deserialize, Serialize};
30use thiserror::Error;
31
32use crate::config::ConfigSet;
33use crate::credentials::{Credential, CredentialProvider};
34use crate::error::{Error, RemoteUnreadableReason, Result};
35use crate::github_auth::PollClock;
36use crate::transport::http::HttpClient;
37
38/// Path, relative to the server root, of the endpoint that names the user a
39/// token belongs to. It starts with `.`, which no served repository name may,
40/// so it never shadows a repository.
41pub const IDENTITY_PATH: &str = ".grit/user";
42
43/// Path of the endpoint that starts a sign-in: `POST` with an empty body
44/// answers a [`LoginStart`].
45pub const LOGIN_PATH: &str = ".grit/login";
46
47/// Path of the endpoint a client polls while the user signs in: `POST` a
48/// [`LoginPollRequest`], get a [`LoginPoll`].
49pub const LOGIN_POLL_PATH: &str = ".grit/login/poll";
50
51/// Path of the web page where the user approves a login code
52/// (`?code=<user code>`).
53pub const AUTHORIZE_PATH: &str = ".grit/authorize";
54
55/// Shortest wait between polls, whatever the server asks for.
56const MIN_POLL_INTERVAL: Duration = Duration::from_secs(1);
57
58const JSON_CONTENT_TYPE: &str = "application/json";
59
60/// User name sent with the token while verifying it; the server ignores it.
61const VERIFY_USERNAME: &str = "grit";
62
63/// The user a token authenticates as: the JSON body of [`IDENTITY_PATH`].
64#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
65pub struct ServerIdentity {
66    /// The user's email address, which is their name on the server.
67    pub email: String,
68    /// Whether the user is an admin (read and write on every repository).
69    pub admin: bool,
70}
71
72/// Why a server URL could not be used or a token could not be verified.
73#[derive(Debug, Error)]
74#[non_exhaustive]
75pub enum ServerAuthError {
76    /// The URL could not be parsed, or has no host.
77    #[error("not a server URL: {0}")]
78    InvalidUrl(String),
79    /// The URL's scheme is not `http` or `https`.
80    #[error("unsupported URL scheme {0:?}; use http:// or https://")]
81    UnsupportedScheme(String),
82    /// The server refused the token (`401`).
83    #[error("the server rejected the token")]
84    TokenRejected,
85    /// The request failed (connection, TLS, or an HTTP error status).
86    #[error(transparent)]
87    Http(Error),
88    /// The server answered, but not like a `grit serve` host: a client error
89    /// status from a `.grit/` endpoint, or a body that is not grit's JSON.
90    #[error("{0} did not answer like a grit server")]
91    NotGritServer(String),
92    /// The user declined the login code in the browser.
93    #[error("the sign-in was declined")]
94    LoginDenied,
95    /// The login code expired before the user approved it.
96    #[error("the sign-in code expired")]
97    LoginExpired,
98}
99
100/// A started sign-in: the JSON body [`LOGIN_PATH`] answers.
101#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
102pub struct LoginStart {
103    /// Secret the client polls with; never shown to the user.
104    pub device_code: String,
105    /// Short code the user checks in the browser, like `ABCD-2345`.
106    pub user_code: String,
107    /// Seconds to wait between polls.
108    pub interval: u64,
109    /// Seconds until the codes expire.
110    pub expires_in: u64,
111}
112
113/// The JSON body a client sends to [`LOGIN_POLL_PATH`].
114#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
115pub struct LoginPollRequest {
116    /// [`LoginStart::device_code`].
117    pub device_code: String,
118}
119
120/// The token a finished sign-in hands the client. `Debug` hides the token.
121#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
122pub struct LoginGrant {
123    /// The email address of the user who approved the sign-in.
124    pub email: String,
125    /// A new access token for that user.
126    pub token: String,
127}
128
129impl std::fmt::Debug for LoginGrant {
130    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
131        f.debug_struct("LoginGrant")
132            .field("email", &self.email)
133            .field("token", &"<redacted>")
134            .finish()
135    }
136}
137
138/// Where a sign-in stands: the JSON body [`LOGIN_POLL_PATH`] answers,
139/// tagged by `status`.
140#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
141#[serde(tag = "status", rename_all = "snake_case")]
142pub enum LoginPoll {
143    /// The user has not approved the code yet.
144    Pending,
145    /// The user approved; the token is handed out once.
146    Authorized(LoginGrant),
147    /// The user declined the code.
148    Denied,
149    /// The code expired, was already used, or never existed.
150    Expired,
151}
152
153/// A server's scheme and authority: what credentials for it are stored under.
154#[derive(Debug, Clone, PartialEq, Eq)]
155pub struct ServerUrl {
156    protocol: String,
157    host: String,
158}
159
160impl ServerUrl {
161    /// Parse a server root or repository URL, keeping only its scheme and
162    /// `host[:port]`.
163    ///
164    /// A URL without a scheme is taken as `https://`. User info, path, query
165    /// and fragment are dropped, so `http://alice@host:8080/app.git` and
166    /// `http://host:8080` name the same server.
167    ///
168    /// # Errors
169    ///
170    /// [`ServerAuthError::InvalidUrl`] when the URL does not parse or has no
171    /// host, and [`ServerAuthError::UnsupportedScheme`] for anything but
172    /// `http` and `https`.
173    pub fn parse(url: &str) -> std::result::Result<Self, ServerAuthError> {
174        let trimmed = url.trim();
175        let parsed = url::Url::parse(&with_default_scheme(trimmed))
176            .map_err(|_| ServerAuthError::InvalidUrl(trimmed.to_owned()))?;
177        let protocol = parsed.scheme().to_owned();
178        if protocol != "http" && protocol != "https" {
179            return Err(ServerAuthError::UnsupportedScheme(protocol));
180        }
181        let host = parsed
182            .host_str()
183            .filter(|h| !h.is_empty())
184            .ok_or_else(|| ServerAuthError::InvalidUrl(trimmed.to_owned()))?;
185        let host = match parsed.port() {
186            Some(port) => format!("{host}:{port}"),
187            None => host.to_owned(),
188        };
189        Ok(Self { protocol, host })
190    }
191
192    /// `http` or `https`.
193    #[must_use]
194    pub fn protocol(&self) -> &str {
195        &self.protocol
196    }
197
198    /// The host, with `:port` when the URL named one.
199    #[must_use]
200    pub fn host(&self) -> &str {
201        &self.host
202    }
203
204    /// The server root, `<protocol>://<host>/`.
205    #[must_use]
206    pub fn root_url(&self) -> String {
207        format!("{}://{}/", self.protocol, self.host)
208    }
209
210    /// The identity endpoint's URL.
211    #[must_use]
212    pub fn identity_url(&self) -> String {
213        format!("{}{IDENTITY_PATH}", self.root_url())
214    }
215
216    /// The web page where the user approves `user_code`.
217    ///
218    /// Built from the URL the client used rather than one the server names, so
219    /// it works behind proxies and never sends the user somewhere unexpected.
220    #[must_use]
221    pub fn authorize_url(&self, user_code: &str) -> String {
222        let code: String = url::form_urlencoded::byte_serialize(user_code.as_bytes()).collect();
223        format!("{}{AUTHORIZE_PATH}?code={code}", self.root_url())
224    }
225
226    /// The credential a token for this server is stored under.
227    ///
228    /// Pass the user name and `Some(token)` to build the credential to
229    /// [`approve`](CredentialProvider::approve); pass `None` for both to build
230    /// the lookup to [`reject`](CredentialProvider::reject), which erases
231    /// whatever is stored for the host.
232    #[must_use]
233    pub fn credential(&self, username: Option<&str>, token: Option<&str>) -> Credential {
234        Credential {
235            protocol: Some(self.protocol.clone()),
236            host: Some(self.host.clone()),
237            username: username.map(str::to_owned),
238            password: token.map(str::to_owned),
239            ..Default::default()
240        }
241    }
242}
243
244/// `url` with `https://` in front when it names no scheme.
245fn with_default_scheme(url: &str) -> String {
246    if url.contains("://") {
247        url.to_owned()
248    } else {
249        format!("https://{url}")
250    }
251}
252
253/// The credential to [`approve`](CredentialProvider::approve) so that `username`
254/// and `token` answer later requests to the repository or server `url`.
255///
256/// Built with [`Credential::from_url`] and `config`, so it is keyed exactly as an
257/// HTTP fetch or push to `url` looks it up: by scheme and host, plus the
258/// repository path when `credential.useHttpPath` applies, and it keeps `url` for
259/// choosing `credential.<url>.helper` entries. A URL without a scheme is taken as
260/// `https://`; user info in `url` is replaced by `username` and `token`.
261///
262/// # Errors
263///
264/// [`ServerAuthError::InvalidUrl`] when the URL does not parse or has no host,
265/// and [`ServerAuthError::UnsupportedScheme`] for anything but `http` and
266/// `https`.
267pub fn credential_for_url(
268    url: &str,
269    config: &ConfigSet,
270    username: &str,
271    token: &str,
272) -> std::result::Result<Credential, ServerAuthError> {
273    ServerUrl::parse(url)?;
274    let trimmed = url.trim();
275    let mut credential = Credential::from_url(&with_default_scheme(trimmed), config)
276        .ok_or_else(|| ServerAuthError::InvalidUrl(trimmed.to_owned()))?;
277    credential.username = Some(username.to_owned());
278    credential.password = Some(token.to_owned());
279    Ok(credential)
280}
281
282/// A [`CredentialProvider`] that answers every request with one token.
283///
284/// Give it to an HTTP client to check a token before it is stored: it never
285/// runs a helper, and `approve` / `reject` do nothing.
286#[derive(Clone)]
287pub struct TokenCredentialProvider {
288    token: String,
289}
290
291impl TokenCredentialProvider {
292    /// A provider that sends `token` as the basic-auth password.
293    #[must_use]
294    pub fn new(token: impl Into<String>) -> Self {
295        Self {
296            token: token.into(),
297        }
298    }
299}
300
301impl std::fmt::Debug for TokenCredentialProvider {
302    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
303        f.debug_struct("TokenCredentialProvider")
304            .field("token", &"<redacted>")
305            .finish()
306    }
307}
308
309impl CredentialProvider for TokenCredentialProvider {
310    fn fill(&self, input: &Credential) -> Result<Credential> {
311        let mut filled = input.clone();
312        filled.username = Some(VERIFY_USERNAME.to_owned());
313        filled.password = Some(self.token.clone());
314        Ok(filled)
315    }
316
317    fn approve(&self, _cred: &Credential) -> Result<()> {
318        Ok(())
319    }
320
321    fn reject(&self, _cred: &Credential) -> Result<()> {
322        Ok(())
323    }
324}
325
326/// Ask `server` who the token in `http`'s credential provider belongs to.
327///
328/// `http` should authenticate with the token being checked, for example a
329/// client built with a [`TokenCredentialProvider`].
330///
331/// # Errors
332///
333/// [`ServerAuthError::TokenRejected`] when the server answers `401` to the
334/// token, [`ServerAuthError::NotGritServer`] when it answers another client
335/// error (such as `404`) or a body that is not a [`ServerIdentity`], and
336/// [`ServerAuthError::Http`] when the request fails otherwise.
337pub fn fetch_identity(
338    http: &dyn HttpClient,
339    server: &ServerUrl,
340) -> std::result::Result<ServerIdentity, ServerAuthError> {
341    let url = server.identity_url();
342    let body = http.get(&url, None).map_err(|e| match e {
343        Error::Auth { .. } => ServerAuthError::TokenRejected,
344        other => not_grit_server_or_http(server, other),
345    })?;
346    serde_json::from_slice(&body).map_err(|_| ServerAuthError::NotGritServer(server.root_url()))
347}
348
349/// [`ServerAuthError::NotGritServer`] when `err` is a client-error status (`404`,
350/// `405`, `422`, …) from one of the `.grit/` endpoints, which every `grit serve`
351/// host answers; otherwise [`ServerAuthError::Http`].
352fn not_grit_server_or_http(server: &ServerUrl, err: Error) -> ServerAuthError {
353    match err {
354        Error::RemoteUnreadable {
355            reason: RemoteUnreadableReason::HttpStatus { status },
356            ..
357        } if (400..500).contains(&status) => ServerAuthError::NotGritServer(server.root_url()),
358        other => ServerAuthError::Http(other),
359    }
360}
361
362/// The browser sign-in to one `grit serve` host.
363pub struct LoginFlow<'a> {
364    http: &'a dyn HttpClient,
365    server: &'a ServerUrl,
366}
367
368impl<'a> LoginFlow<'a> {
369    /// A sign-in to `server`, sending requests through `http` (which needs no
370    /// credentials: these endpoints are open).
371    #[must_use]
372    pub fn new(http: &'a dyn HttpClient, server: &'a ServerUrl) -> Self {
373        Self { http, server }
374    }
375
376    /// Ask the server for a login code.
377    ///
378    /// # Errors
379    ///
380    /// [`ServerAuthError::NotGritServer`] when the server asks for credentials
381    /// or answers another client error (GitLab, for one, answers `422`), or
382    /// when the answer is not a [`LoginStart`]; [`ServerAuthError::Http`] when
383    /// the request fails otherwise.
384    pub fn start(&self) -> std::result::Result<LoginStart, ServerAuthError> {
385        let url = format!("{}{LOGIN_PATH}", self.server.root_url());
386        let body = self
387            .http
388            .post(&url, JSON_CONTENT_TYPE, JSON_CONTENT_TYPE, b"{}", None)
389            .map_err(|e| match e {
390                Error::Auth { .. } => ServerAuthError::NotGritServer(self.server.root_url()),
391                other => not_grit_server_or_http(self.server, other),
392            })?;
393        serde_json::from_slice(&body)
394            .map_err(|_| ServerAuthError::NotGritServer(self.server.root_url()))
395    }
396
397    /// Ask once whether the user has approved `login`.
398    ///
399    /// # Errors
400    ///
401    /// [`ServerAuthError::Http`] when the request fails, and
402    /// [`ServerAuthError::NotGritServer`] when the answer is not a
403    /// [`LoginPoll`].
404    pub fn poll(&self, login: &LoginStart) -> std::result::Result<LoginPoll, ServerAuthError> {
405        let url = format!("{}{LOGIN_POLL_PATH}", self.server.root_url());
406        let request = serde_json::to_vec(&LoginPollRequest {
407            device_code: login.device_code.clone(),
408        })
409        .map_err(|_| ServerAuthError::NotGritServer(self.server.root_url()))?;
410        let body = self
411            .http
412            .post(&url, JSON_CONTENT_TYPE, JSON_CONTENT_TYPE, &request, None)
413            .map_err(ServerAuthError::Http)?;
414        serde_json::from_slice(&body)
415            .map_err(|_| ServerAuthError::NotGritServer(self.server.root_url()))
416    }
417
418    /// Poll until the user approves `login`, and return the token.
419    ///
420    /// Waits `login.interval` seconds (at least one) before every poll and
421    /// gives up once `login.expires_in` seconds have passed.
422    ///
423    /// # Errors
424    ///
425    /// [`ServerAuthError::LoginDenied`] when the user declines,
426    /// [`ServerAuthError::LoginExpired`] when the code expires first, and the
427    /// errors of [`poll`](Self::poll).
428    pub fn wait(
429        &self,
430        login: &LoginStart,
431        clock: &mut dyn PollClock,
432    ) -> std::result::Result<LoginGrant, ServerAuthError> {
433        let deadline = clock.now() + Duration::from_secs(login.expires_in);
434        let interval = Duration::from_secs(login.interval).max(MIN_POLL_INTERVAL);
435        loop {
436            clock.sleep(interval);
437            if clock.now() >= deadline {
438                return Err(ServerAuthError::LoginExpired);
439            }
440            match self.poll(login)? {
441                LoginPoll::Pending => {}
442                LoginPoll::Authorized(grant) => return Ok(grant),
443                LoginPoll::Denied => return Err(ServerAuthError::LoginDenied),
444                LoginPoll::Expired => return Err(ServerAuthError::LoginExpired),
445            }
446        }
447    }
448}
449
450#[cfg(test)]
451mod tests {
452    use super::*;
453    use std::sync::Mutex;
454
455    #[test]
456    fn urls_reduce_to_scheme_and_authority() {
457        let server = ServerUrl::parse("http://alice@127.0.0.1:8080/app.git?x=1").unwrap();
458        assert_eq!(server.protocol(), "http");
459        assert_eq!(server.host(), "127.0.0.1:8080");
460        assert_eq!(server.root_url(), "http://127.0.0.1:8080/");
461        assert_eq!(server.identity_url(), "http://127.0.0.1:8080/.grit/user");
462
463        let default_port = ServerUrl::parse("https://git.example.com:443/").unwrap();
464        assert_eq!(default_port.host(), "git.example.com");
465    }
466
467    #[test]
468    fn a_bare_host_means_https() {
469        let server = ServerUrl::parse("git.example.com:9000").unwrap();
470        assert_eq!(server.protocol(), "https");
471        assert_eq!(server.host(), "git.example.com:9000");
472    }
473
474    #[test]
475    fn other_schemes_and_hostless_urls_are_refused() {
476        assert!(matches!(
477            ServerUrl::parse("ssh://git.example.com/app.git"),
478            Err(ServerAuthError::UnsupportedScheme(s)) if s == "ssh"
479        ));
480        assert!(matches!(
481            ServerUrl::parse("http://"),
482            Err(ServerAuthError::InvalidUrl(_))
483        ));
484    }
485
486    #[test]
487    fn credentials_key_on_protocol_and_host() {
488        let server = ServerUrl::parse("http://127.0.0.1:8080/app.git").unwrap();
489        let stored = server.credential(Some("bob"), Some("grit_abc"));
490        assert_eq!(stored.protocol.as_deref(), Some("http"));
491        assert_eq!(stored.host.as_deref(), Some("127.0.0.1:8080"));
492        assert_eq!(stored.username.as_deref(), Some("bob"));
493        assert_eq!(stored.password.as_deref(), Some("grit_abc"));
494        assert_eq!(stored.path, None);
495
496        let lookup = server.credential(None, None);
497        assert_eq!(lookup.username, None);
498        assert_eq!(lookup.password, None);
499    }
500
501    #[test]
502    fn the_token_provider_fills_the_password_and_redacts_debug() {
503        let provider = TokenCredentialProvider::new("grit_secret");
504        let filled = provider.fill(&Credential::default()).unwrap();
505        assert_eq!(filled.password.as_deref(), Some("grit_secret"));
506        assert!(filled.username.is_some());
507        assert!(provider.approve(&filled).is_ok());
508        assert!(provider.reject(&filled).is_ok());
509        assert!(!format!("{provider:?}").contains("grit_secret"));
510    }
511
512    #[test]
513    fn credentials_for_a_url_follow_use_http_path() {
514        let host_level = credential_for_url(
515            "http://bob:old@127.0.0.1:8080/org/app.git",
516            &ConfigSet::new(),
517            "alice",
518            "t0k",
519        )
520        .unwrap();
521        assert_eq!(host_level.protocol.as_deref(), Some("http"));
522        assert_eq!(host_level.host.as_deref(), Some("127.0.0.1:8080"));
523        assert_eq!(host_level.path, None);
524        assert_eq!(host_level.username.as_deref(), Some("alice"));
525        assert_eq!(host_level.password.as_deref(), Some("t0k"));
526        assert_eq!(
527            host_level.url.as_deref(),
528            Some("http://127.0.0.1:8080/org/app.git")
529        );
530
531        let mut config = ConfigSet::new();
532        config
533            .add_command_override("credential.useHttpPath", "true")
534            .unwrap();
535        let per_repo =
536            credential_for_url("example.com/org/app.git", &config, "alice", "t0k").unwrap();
537        assert_eq!(per_repo.protocol.as_deref(), Some("https"));
538        assert_eq!(per_repo.path.as_deref(), Some("org/app.git"));
539
540        assert!(matches!(
541            credential_for_url("ssh://example.com/app.git", &config, "a", "b"),
542            Err(ServerAuthError::UnsupportedScheme(_))
543        ));
544    }
545
546    /// Answers every GET with one canned result and records the URL.
547    struct Canned {
548        answer: Mutex<Option<Result<Vec<u8>>>>,
549        seen: Mutex<Vec<String>>,
550    }
551
552    impl Canned {
553        fn new(answer: Result<Vec<u8>>) -> Self {
554            Self {
555                answer: Mutex::new(Some(answer)),
556                seen: Mutex::new(Vec::new()),
557            }
558        }
559    }
560
561    impl HttpClient for Canned {
562        fn get(&self, url: &str, _git_protocol: Option<&str>) -> Result<Vec<u8>> {
563            self.seen.lock().unwrap().push(url.to_owned());
564            self.answer.lock().unwrap().take().unwrap()
565        }
566
567        fn post(
568            &self,
569            _url: &str,
570            _content_type: &str,
571            _accept: &str,
572            _body: &[u8],
573            _git_protocol: Option<&str>,
574        ) -> Result<Vec<u8>> {
575            unreachable!("identity lookups only GET")
576        }
577    }
578
579    #[test]
580    fn identity_is_read_from_the_identity_endpoint() {
581        let http = Canned::new(Ok(br#"{"email":"bob@example.com","admin":false}"#.to_vec()));
582        let server = ServerUrl::parse("http://127.0.0.1:8080").unwrap();
583        let identity = fetch_identity(&http, &server).unwrap();
584        assert_eq!(
585            identity,
586            ServerIdentity {
587                email: "bob@example.com".to_owned(),
588                admin: false
589            }
590        );
591        assert_eq!(
592            http.seen.lock().unwrap().as_slice(),
593            ["http://127.0.0.1:8080/.grit/user"]
594        );
595    }
596
597    #[test]
598    fn identity_failures_are_typed() {
599        let server = ServerUrl::parse("http://127.0.0.1:8080").unwrap();
600
601        let rejected = Canned::new(Err(Error::Auth {
602            url: "http://127.0.0.1:8080".to_owned(),
603            reason: crate::error::AuthFailure::Rejected,
604        }));
605        assert!(matches!(
606            fetch_identity(&rejected, &server),
607            Err(ServerAuthError::TokenRejected)
608        ));
609
610        let down = Canned::new(Err(Error::Message("HTTP 404".to_owned())));
611        assert!(matches!(
612            fetch_identity(&down, &server),
613            Err(ServerAuthError::Http(_))
614        ));
615
616        let html = Canned::new(Ok(b"<html>hello</html>".to_vec()));
617        assert!(matches!(
618            fetch_identity(&html, &server),
619            Err(ServerAuthError::NotGritServer(_))
620        ));
621    }
622
623    /// Answers POSTs from a script, in order, and records each URL and body.
624    struct Scripted {
625        answers: Mutex<Vec<Result<Vec<u8>>>>,
626        seen: Mutex<Vec<(String, Vec<u8>)>>,
627    }
628
629    impl Scripted {
630        fn new(answers: Vec<Result<Vec<u8>>>) -> Self {
631            Self {
632                answers: Mutex::new(answers.into_iter().rev().collect()),
633                seen: Mutex::new(Vec::new()),
634            }
635        }
636    }
637
638    impl HttpClient for Scripted {
639        fn get(&self, _url: &str, _git_protocol: Option<&str>) -> Result<Vec<u8>> {
640            unreachable!("the login flow only POSTs")
641        }
642
643        fn post(
644            &self,
645            url: &str,
646            content_type: &str,
647            _accept: &str,
648            body: &[u8],
649            _git_protocol: Option<&str>,
650        ) -> Result<Vec<u8>> {
651            assert_eq!(content_type, "application/json");
652            self.seen
653                .lock()
654                .unwrap()
655                .push((url.to_owned(), body.to_vec()));
656            self.answers.lock().unwrap().pop().unwrap()
657        }
658    }
659
660    struct FakeClock {
661        start: std::time::Instant,
662        elapsed: Duration,
663        sleeps: usize,
664    }
665
666    impl FakeClock {
667        fn new() -> Self {
668            Self {
669                start: std::time::Instant::now(),
670                elapsed: Duration::ZERO,
671                sleeps: 0,
672            }
673        }
674    }
675
676    impl PollClock for FakeClock {
677        fn now(&self) -> std::time::Instant {
678            self.start + self.elapsed
679        }
680
681        fn sleep(&mut self, duration: Duration) {
682            self.elapsed += duration;
683            self.sleeps += 1;
684        }
685    }
686
687    fn login() -> LoginStart {
688        LoginStart {
689            device_code: "dev123".to_owned(),
690            user_code: "ABCD-2345".to_owned(),
691            interval: 0,
692            expires_in: 60,
693        }
694    }
695
696    #[test]
697    fn the_authorize_url_is_built_from_the_client_url() {
698        let server = ServerUrl::parse("http://127.0.0.1:8080/app.git").unwrap();
699        assert_eq!(
700            server.authorize_url("ABCD-2345"),
701            "http://127.0.0.1:8080/.grit/authorize?code=ABCD-2345"
702        );
703        assert_eq!(
704            server.authorize_url("a&b"),
705            "http://127.0.0.1:8080/.grit/authorize?code=a%26b"
706        );
707    }
708
709    #[test]
710    fn login_starts_then_polls_until_authorized() {
711        let server = ServerUrl::parse("http://127.0.0.1:8080").unwrap();
712        let start = serde_json::to_vec(&login()).unwrap();
713        let http = Scripted::new(vec![
714            Ok(start),
715            Ok(br#"{"status":"pending"}"#.to_vec()),
716            Ok(br#"{"status":"authorized","email":"bob@example.com","token":"grit_t"}"#.to_vec()),
717        ]);
718        let flow = LoginFlow::new(&http, &server);
719        let started = flow.start().unwrap();
720        assert_eq!(started, login());
721        let mut clock = FakeClock::new();
722        let grant = flow.wait(&started, &mut clock).unwrap();
723        assert_eq!(grant.email, "bob@example.com");
724        assert_eq!(grant.token, "grit_t");
725        assert!(!format!("{grant:?}").contains("grit_t"));
726        assert_eq!(clock.sleeps, 2, "one wait before each poll");
727        assert!(
728            clock.elapsed >= Duration::from_secs(2),
729            "polls at least a second apart"
730        );
731
732        let seen = http.seen.lock().unwrap();
733        assert_eq!(seen[0].0, "http://127.0.0.1:8080/.grit/login");
734        assert_eq!(seen[1].0, "http://127.0.0.1:8080/.grit/login/poll");
735        let request: LoginPollRequest = serde_json::from_slice(&seen[1].1).unwrap();
736        assert_eq!(request.device_code, "dev123");
737    }
738
739    #[test]
740    fn login_failures_are_typed() {
741        let server = ServerUrl::parse("http://127.0.0.1:8080").unwrap();
742        let wait = |answer: &[u8]| {
743            let http = Scripted::new(vec![Ok(answer.to_vec())]);
744            LoginFlow::new(&http, &server).wait(&login(), &mut FakeClock::new())
745        };
746        assert!(matches!(
747            wait(br#"{"status":"denied"}"#),
748            Err(ServerAuthError::LoginDenied)
749        ));
750        assert!(matches!(
751            wait(br#"{"status":"expired"}"#),
752            Err(ServerAuthError::LoginExpired)
753        ));
754        assert!(matches!(
755            wait(b"<html>"),
756            Err(ServerAuthError::NotGritServer(_))
757        ));
758
759        let http = Scripted::new(vec![Err(Error::Message("HTTP 404".to_owned()))]);
760        assert!(matches!(
761            LoginFlow::new(&http, &server).start(),
762            Err(ServerAuthError::Http(_))
763        ));
764    }
765
766    fn status(code: u16) -> Error {
767        Error::remote_unreadable(
768            "http://127.0.0.1:8080/.grit/login".to_owned(),
769            RemoteUnreadableReason::HttpStatus { status: code },
770        )
771    }
772
773    #[test]
774    fn client_errors_mean_the_host_is_not_a_grit_server() {
775        let server = ServerUrl::parse("https://gitlab.example.com/team/app.git").unwrap();
776        for code in [404, 405, 422] {
777            let http = Scripted::new(vec![Err(status(code))]);
778            assert!(
779                matches!(
780                    LoginFlow::new(&http, &server).start(),
781                    Err(ServerAuthError::NotGritServer(root)) if root == "https://gitlab.example.com/"
782                ),
783                "{code}"
784            );
785            let http = Canned::new(Err(status(code)));
786            assert!(
787                matches!(
788                    fetch_identity(&http, &server),
789                    Err(ServerAuthError::NotGritServer(_))
790                ),
791                "{code}"
792            );
793        }
794
795        let challenged = Scripted::new(vec![Err(Error::Auth {
796            url: "https://gitlab.example.com".to_owned(),
797            reason: crate::error::AuthFailure::NoCredentialProvider,
798        })]);
799        assert!(matches!(
800            LoginFlow::new(&challenged, &server).start(),
801            Err(ServerAuthError::NotGritServer(_))
802        ));
803
804        let failing = Scripted::new(vec![Err(status(503))]);
805        assert!(matches!(
806            LoginFlow::new(&failing, &server).start(),
807            Err(ServerAuthError::Http(_))
808        ));
809        let failing = Canned::new(Err(status(500)));
810        assert!(matches!(
811            fetch_identity(&failing, &server),
812            Err(ServerAuthError::Http(_))
813        ));
814    }
815
816    #[test]
817    fn login_gives_up_when_the_code_expires_locally() {
818        let server = ServerUrl::parse("http://127.0.0.1:8080").unwrap();
819        let http = Scripted::new(
820            (0..3)
821                .map(|_| Ok(br#"{"status":"pending"}"#.to_vec()))
822                .collect(),
823        );
824        let mut short = login();
825        short.expires_in = 3;
826        let result = LoginFlow::new(&http, &server).wait(&short, &mut FakeClock::new());
827        assert!(matches!(result, Err(ServerAuthError::LoginExpired)));
828        assert_eq!(http.seen.lock().unwrap().len(), 2);
829    }
830}