1use 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
38pub const IDENTITY_PATH: &str = ".grit/user";
42
43pub const LOGIN_PATH: &str = ".grit/login";
46
47pub const LOGIN_POLL_PATH: &str = ".grit/login/poll";
50
51pub const AUTHORIZE_PATH: &str = ".grit/authorize";
54
55const MIN_POLL_INTERVAL: Duration = Duration::from_secs(1);
57
58const JSON_CONTENT_TYPE: &str = "application/json";
59
60const VERIFY_USERNAME: &str = "grit";
62
63#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
65pub struct ServerIdentity {
66 pub email: String,
68 pub admin: bool,
70}
71
72#[derive(Debug, Error)]
74#[non_exhaustive]
75pub enum ServerAuthError {
76 #[error("not a server URL: {0}")]
78 InvalidUrl(String),
79 #[error("unsupported URL scheme {0:?}; use http:// or https://")]
81 UnsupportedScheme(String),
82 #[error("the server rejected the token")]
84 TokenRejected,
85 #[error(transparent)]
87 Http(Error),
88 #[error("{0} did not answer like a grit server")]
91 NotGritServer(String),
92 #[error("the sign-in was declined")]
94 LoginDenied,
95 #[error("the sign-in code expired")]
97 LoginExpired,
98}
99
100#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
102pub struct LoginStart {
103 pub device_code: String,
105 pub user_code: String,
107 pub interval: u64,
109 pub expires_in: u64,
111}
112
113#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
115pub struct LoginPollRequest {
116 pub device_code: String,
118}
119
120#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
122pub struct LoginGrant {
123 pub email: String,
125 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#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
141#[serde(tag = "status", rename_all = "snake_case")]
142pub enum LoginPoll {
143 Pending,
145 Authorized(LoginGrant),
147 Denied,
149 Expired,
151}
152
153#[derive(Debug, Clone, PartialEq, Eq)]
155pub struct ServerUrl {
156 protocol: String,
157 host: String,
158}
159
160impl ServerUrl {
161 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 #[must_use]
194 pub fn protocol(&self) -> &str {
195 &self.protocol
196 }
197
198 #[must_use]
200 pub fn host(&self) -> &str {
201 &self.host
202 }
203
204 #[must_use]
206 pub fn root_url(&self) -> String {
207 format!("{}://{}/", self.protocol, self.host)
208 }
209
210 #[must_use]
212 pub fn identity_url(&self) -> String {
213 format!("{}{IDENTITY_PATH}", self.root_url())
214 }
215
216 #[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 #[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
244fn with_default_scheme(url: &str) -> String {
246 if url.contains("://") {
247 url.to_owned()
248 } else {
249 format!("https://{url}")
250 }
251}
252
253pub 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#[derive(Clone)]
287pub struct TokenCredentialProvider {
288 token: String,
289}
290
291impl TokenCredentialProvider {
292 #[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
326pub 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
349fn 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
362pub struct LoginFlow<'a> {
364 http: &'a dyn HttpClient,
365 server: &'a ServerUrl,
366}
367
368impl<'a> LoginFlow<'a> {
369 #[must_use]
372 pub fn new(http: &'a dyn HttpClient, server: &'a ServerUrl) -> Self {
373 Self { http, server }
374 }
375
376 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 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 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 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 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}