use std::time::Duration;
use serde::{Deserialize, Serialize};
use thiserror::Error;
use crate::config::ConfigSet;
use crate::credentials::{Credential, CredentialProvider};
use crate::error::{Error, RemoteUnreadableReason, Result};
use crate::github_auth::PollClock;
use crate::transport::http::HttpClient;
pub const IDENTITY_PATH: &str = ".grit/user";
pub const LOGIN_PATH: &str = ".grit/login";
pub const LOGIN_POLL_PATH: &str = ".grit/login/poll";
pub const AUTHORIZE_PATH: &str = ".grit/authorize";
const MIN_POLL_INTERVAL: Duration = Duration::from_secs(1);
const JSON_CONTENT_TYPE: &str = "application/json";
const VERIFY_USERNAME: &str = "grit";
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ServerIdentity {
pub email: String,
pub admin: bool,
}
#[derive(Debug, Error)]
#[non_exhaustive]
pub enum ServerAuthError {
#[error("not a server URL: {0}")]
InvalidUrl(String),
#[error("unsupported URL scheme {0:?}; use http:// or https://")]
UnsupportedScheme(String),
#[error("the server rejected the token")]
TokenRejected,
#[error(transparent)]
Http(Error),
#[error("{0} did not answer like a grit server")]
NotGritServer(String),
#[error("the sign-in was declined")]
LoginDenied,
#[error("the sign-in code expired")]
LoginExpired,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct LoginStart {
pub device_code: String,
pub user_code: String,
pub interval: u64,
pub expires_in: u64,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct LoginPollRequest {
pub device_code: String,
}
#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct LoginGrant {
pub email: String,
pub token: String,
}
impl std::fmt::Debug for LoginGrant {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("LoginGrant")
.field("email", &self.email)
.field("token", &"<redacted>")
.finish()
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "status", rename_all = "snake_case")]
pub enum LoginPoll {
Pending,
Authorized(LoginGrant),
Denied,
Expired,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ServerUrl {
protocol: String,
host: String,
}
impl ServerUrl {
pub fn parse(url: &str) -> std::result::Result<Self, ServerAuthError> {
let trimmed = url.trim();
let parsed = url::Url::parse(&with_default_scheme(trimmed))
.map_err(|_| ServerAuthError::InvalidUrl(trimmed.to_owned()))?;
let protocol = parsed.scheme().to_owned();
if protocol != "http" && protocol != "https" {
return Err(ServerAuthError::UnsupportedScheme(protocol));
}
let host = parsed
.host_str()
.filter(|h| !h.is_empty())
.ok_or_else(|| ServerAuthError::InvalidUrl(trimmed.to_owned()))?;
let host = match parsed.port() {
Some(port) => format!("{host}:{port}"),
None => host.to_owned(),
};
Ok(Self { protocol, host })
}
#[must_use]
pub fn protocol(&self) -> &str {
&self.protocol
}
#[must_use]
pub fn host(&self) -> &str {
&self.host
}
#[must_use]
pub fn root_url(&self) -> String {
format!("{}://{}/", self.protocol, self.host)
}
#[must_use]
pub fn identity_url(&self) -> String {
format!("{}{IDENTITY_PATH}", self.root_url())
}
#[must_use]
pub fn authorize_url(&self, user_code: &str) -> String {
let code: String = url::form_urlencoded::byte_serialize(user_code.as_bytes()).collect();
format!("{}{AUTHORIZE_PATH}?code={code}", self.root_url())
}
#[must_use]
pub fn credential(&self, username: Option<&str>, token: Option<&str>) -> Credential {
Credential {
protocol: Some(self.protocol.clone()),
host: Some(self.host.clone()),
username: username.map(str::to_owned),
password: token.map(str::to_owned),
..Default::default()
}
}
}
fn with_default_scheme(url: &str) -> String {
if url.contains("://") {
url.to_owned()
} else {
format!("https://{url}")
}
}
pub fn credential_for_url(
url: &str,
config: &ConfigSet,
username: &str,
token: &str,
) -> std::result::Result<Credential, ServerAuthError> {
ServerUrl::parse(url)?;
let trimmed = url.trim();
let mut credential = Credential::from_url(&with_default_scheme(trimmed), config)
.ok_or_else(|| ServerAuthError::InvalidUrl(trimmed.to_owned()))?;
credential.username = Some(username.to_owned());
credential.password = Some(token.to_owned());
Ok(credential)
}
#[derive(Clone)]
pub struct TokenCredentialProvider {
token: String,
}
impl TokenCredentialProvider {
#[must_use]
pub fn new(token: impl Into<String>) -> Self {
Self {
token: token.into(),
}
}
}
impl std::fmt::Debug for TokenCredentialProvider {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("TokenCredentialProvider")
.field("token", &"<redacted>")
.finish()
}
}
impl CredentialProvider for TokenCredentialProvider {
fn fill(&self, input: &Credential) -> Result<Credential> {
let mut filled = input.clone();
filled.username = Some(VERIFY_USERNAME.to_owned());
filled.password = Some(self.token.clone());
Ok(filled)
}
fn approve(&self, _cred: &Credential) -> Result<()> {
Ok(())
}
fn reject(&self, _cred: &Credential) -> Result<()> {
Ok(())
}
}
pub fn fetch_identity(
http: &dyn HttpClient,
server: &ServerUrl,
) -> std::result::Result<ServerIdentity, ServerAuthError> {
let url = server.identity_url();
let body = http.get(&url, None).map_err(|e| match e {
Error::Auth { .. } => ServerAuthError::TokenRejected,
other => not_grit_server_or_http(server, other),
})?;
serde_json::from_slice(&body).map_err(|_| ServerAuthError::NotGritServer(server.root_url()))
}
fn not_grit_server_or_http(server: &ServerUrl, err: Error) -> ServerAuthError {
match err {
Error::RemoteUnreadable {
reason: RemoteUnreadableReason::HttpStatus { status },
..
} if (400..500).contains(&status) => ServerAuthError::NotGritServer(server.root_url()),
other => ServerAuthError::Http(other),
}
}
pub struct LoginFlow<'a> {
http: &'a dyn HttpClient,
server: &'a ServerUrl,
}
impl<'a> LoginFlow<'a> {
#[must_use]
pub fn new(http: &'a dyn HttpClient, server: &'a ServerUrl) -> Self {
Self { http, server }
}
pub fn start(&self) -> std::result::Result<LoginStart, ServerAuthError> {
let url = format!("{}{LOGIN_PATH}", self.server.root_url());
let body = self
.http
.post(&url, JSON_CONTENT_TYPE, JSON_CONTENT_TYPE, b"{}", None)
.map_err(|e| match e {
Error::Auth { .. } => ServerAuthError::NotGritServer(self.server.root_url()),
other => not_grit_server_or_http(self.server, other),
})?;
serde_json::from_slice(&body)
.map_err(|_| ServerAuthError::NotGritServer(self.server.root_url()))
}
pub fn poll(&self, login: &LoginStart) -> std::result::Result<LoginPoll, ServerAuthError> {
let url = format!("{}{LOGIN_POLL_PATH}", self.server.root_url());
let request = serde_json::to_vec(&LoginPollRequest {
device_code: login.device_code.clone(),
})
.map_err(|_| ServerAuthError::NotGritServer(self.server.root_url()))?;
let body = self
.http
.post(&url, JSON_CONTENT_TYPE, JSON_CONTENT_TYPE, &request, None)
.map_err(ServerAuthError::Http)?;
serde_json::from_slice(&body)
.map_err(|_| ServerAuthError::NotGritServer(self.server.root_url()))
}
pub fn wait(
&self,
login: &LoginStart,
clock: &mut dyn PollClock,
) -> std::result::Result<LoginGrant, ServerAuthError> {
let deadline = clock.now() + Duration::from_secs(login.expires_in);
let interval = Duration::from_secs(login.interval).max(MIN_POLL_INTERVAL);
loop {
clock.sleep(interval);
if clock.now() >= deadline {
return Err(ServerAuthError::LoginExpired);
}
match self.poll(login)? {
LoginPoll::Pending => {}
LoginPoll::Authorized(grant) => return Ok(grant),
LoginPoll::Denied => return Err(ServerAuthError::LoginDenied),
LoginPoll::Expired => return Err(ServerAuthError::LoginExpired),
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::Mutex;
#[test]
fn urls_reduce_to_scheme_and_authority() {
let server = ServerUrl::parse("http://alice@127.0.0.1:8080/app.git?x=1").unwrap();
assert_eq!(server.protocol(), "http");
assert_eq!(server.host(), "127.0.0.1:8080");
assert_eq!(server.root_url(), "http://127.0.0.1:8080/");
assert_eq!(server.identity_url(), "http://127.0.0.1:8080/.grit/user");
let default_port = ServerUrl::parse("https://git.example.com:443/").unwrap();
assert_eq!(default_port.host(), "git.example.com");
}
#[test]
fn a_bare_host_means_https() {
let server = ServerUrl::parse("git.example.com:9000").unwrap();
assert_eq!(server.protocol(), "https");
assert_eq!(server.host(), "git.example.com:9000");
}
#[test]
fn other_schemes_and_hostless_urls_are_refused() {
assert!(matches!(
ServerUrl::parse("ssh://git.example.com/app.git"),
Err(ServerAuthError::UnsupportedScheme(s)) if s == "ssh"
));
assert!(matches!(
ServerUrl::parse("http://"),
Err(ServerAuthError::InvalidUrl(_))
));
}
#[test]
fn credentials_key_on_protocol_and_host() {
let server = ServerUrl::parse("http://127.0.0.1:8080/app.git").unwrap();
let stored = server.credential(Some("bob"), Some("grit_abc"));
assert_eq!(stored.protocol.as_deref(), Some("http"));
assert_eq!(stored.host.as_deref(), Some("127.0.0.1:8080"));
assert_eq!(stored.username.as_deref(), Some("bob"));
assert_eq!(stored.password.as_deref(), Some("grit_abc"));
assert_eq!(stored.path, None);
let lookup = server.credential(None, None);
assert_eq!(lookup.username, None);
assert_eq!(lookup.password, None);
}
#[test]
fn the_token_provider_fills_the_password_and_redacts_debug() {
let provider = TokenCredentialProvider::new("grit_secret");
let filled = provider.fill(&Credential::default()).unwrap();
assert_eq!(filled.password.as_deref(), Some("grit_secret"));
assert!(filled.username.is_some());
assert!(provider.approve(&filled).is_ok());
assert!(provider.reject(&filled).is_ok());
assert!(!format!("{provider:?}").contains("grit_secret"));
}
#[test]
fn credentials_for_a_url_follow_use_http_path() {
let host_level = credential_for_url(
"http://bob:old@127.0.0.1:8080/org/app.git",
&ConfigSet::new(),
"alice",
"t0k",
)
.unwrap();
assert_eq!(host_level.protocol.as_deref(), Some("http"));
assert_eq!(host_level.host.as_deref(), Some("127.0.0.1:8080"));
assert_eq!(host_level.path, None);
assert_eq!(host_level.username.as_deref(), Some("alice"));
assert_eq!(host_level.password.as_deref(), Some("t0k"));
assert_eq!(
host_level.url.as_deref(),
Some("http://127.0.0.1:8080/org/app.git")
);
let mut config = ConfigSet::new();
config
.add_command_override("credential.useHttpPath", "true")
.unwrap();
let per_repo =
credential_for_url("example.com/org/app.git", &config, "alice", "t0k").unwrap();
assert_eq!(per_repo.protocol.as_deref(), Some("https"));
assert_eq!(per_repo.path.as_deref(), Some("org/app.git"));
assert!(matches!(
credential_for_url("ssh://example.com/app.git", &config, "a", "b"),
Err(ServerAuthError::UnsupportedScheme(_))
));
}
struct Canned {
answer: Mutex<Option<Result<Vec<u8>>>>,
seen: Mutex<Vec<String>>,
}
impl Canned {
fn new(answer: Result<Vec<u8>>) -> Self {
Self {
answer: Mutex::new(Some(answer)),
seen: Mutex::new(Vec::new()),
}
}
}
impl HttpClient for Canned {
fn get(&self, url: &str, _git_protocol: Option<&str>) -> Result<Vec<u8>> {
self.seen.lock().unwrap().push(url.to_owned());
self.answer.lock().unwrap().take().unwrap()
}
fn post(
&self,
_url: &str,
_content_type: &str,
_accept: &str,
_body: &[u8],
_git_protocol: Option<&str>,
) -> Result<Vec<u8>> {
unreachable!("identity lookups only GET")
}
}
#[test]
fn identity_is_read_from_the_identity_endpoint() {
let http = Canned::new(Ok(br#"{"email":"bob@example.com","admin":false}"#.to_vec()));
let server = ServerUrl::parse("http://127.0.0.1:8080").unwrap();
let identity = fetch_identity(&http, &server).unwrap();
assert_eq!(
identity,
ServerIdentity {
email: "bob@example.com".to_owned(),
admin: false
}
);
assert_eq!(
http.seen.lock().unwrap().as_slice(),
["http://127.0.0.1:8080/.grit/user"]
);
}
#[test]
fn identity_failures_are_typed() {
let server = ServerUrl::parse("http://127.0.0.1:8080").unwrap();
let rejected = Canned::new(Err(Error::Auth {
url: "http://127.0.0.1:8080".to_owned(),
reason: crate::error::AuthFailure::Rejected,
}));
assert!(matches!(
fetch_identity(&rejected, &server),
Err(ServerAuthError::TokenRejected)
));
let down = Canned::new(Err(Error::Message("HTTP 404".to_owned())));
assert!(matches!(
fetch_identity(&down, &server),
Err(ServerAuthError::Http(_))
));
let html = Canned::new(Ok(b"<html>hello</html>".to_vec()));
assert!(matches!(
fetch_identity(&html, &server),
Err(ServerAuthError::NotGritServer(_))
));
}
struct Scripted {
answers: Mutex<Vec<Result<Vec<u8>>>>,
seen: Mutex<Vec<(String, Vec<u8>)>>,
}
impl Scripted {
fn new(answers: Vec<Result<Vec<u8>>>) -> Self {
Self {
answers: Mutex::new(answers.into_iter().rev().collect()),
seen: Mutex::new(Vec::new()),
}
}
}
impl HttpClient for Scripted {
fn get(&self, _url: &str, _git_protocol: Option<&str>) -> Result<Vec<u8>> {
unreachable!("the login flow only POSTs")
}
fn post(
&self,
url: &str,
content_type: &str,
_accept: &str,
body: &[u8],
_git_protocol: Option<&str>,
) -> Result<Vec<u8>> {
assert_eq!(content_type, "application/json");
self.seen
.lock()
.unwrap()
.push((url.to_owned(), body.to_vec()));
self.answers.lock().unwrap().pop().unwrap()
}
}
struct FakeClock {
start: std::time::Instant,
elapsed: Duration,
sleeps: usize,
}
impl FakeClock {
fn new() -> Self {
Self {
start: std::time::Instant::now(),
elapsed: Duration::ZERO,
sleeps: 0,
}
}
}
impl PollClock for FakeClock {
fn now(&self) -> std::time::Instant {
self.start + self.elapsed
}
fn sleep(&mut self, duration: Duration) {
self.elapsed += duration;
self.sleeps += 1;
}
}
fn login() -> LoginStart {
LoginStart {
device_code: "dev123".to_owned(),
user_code: "ABCD-2345".to_owned(),
interval: 0,
expires_in: 60,
}
}
#[test]
fn the_authorize_url_is_built_from_the_client_url() {
let server = ServerUrl::parse("http://127.0.0.1:8080/app.git").unwrap();
assert_eq!(
server.authorize_url("ABCD-2345"),
"http://127.0.0.1:8080/.grit/authorize?code=ABCD-2345"
);
assert_eq!(
server.authorize_url("a&b"),
"http://127.0.0.1:8080/.grit/authorize?code=a%26b"
);
}
#[test]
fn login_starts_then_polls_until_authorized() {
let server = ServerUrl::parse("http://127.0.0.1:8080").unwrap();
let start = serde_json::to_vec(&login()).unwrap();
let http = Scripted::new(vec![
Ok(start),
Ok(br#"{"status":"pending"}"#.to_vec()),
Ok(br#"{"status":"authorized","email":"bob@example.com","token":"grit_t"}"#.to_vec()),
]);
let flow = LoginFlow::new(&http, &server);
let started = flow.start().unwrap();
assert_eq!(started, login());
let mut clock = FakeClock::new();
let grant = flow.wait(&started, &mut clock).unwrap();
assert_eq!(grant.email, "bob@example.com");
assert_eq!(grant.token, "grit_t");
assert!(!format!("{grant:?}").contains("grit_t"));
assert_eq!(clock.sleeps, 2, "one wait before each poll");
assert!(
clock.elapsed >= Duration::from_secs(2),
"polls at least a second apart"
);
let seen = http.seen.lock().unwrap();
assert_eq!(seen[0].0, "http://127.0.0.1:8080/.grit/login");
assert_eq!(seen[1].0, "http://127.0.0.1:8080/.grit/login/poll");
let request: LoginPollRequest = serde_json::from_slice(&seen[1].1).unwrap();
assert_eq!(request.device_code, "dev123");
}
#[test]
fn login_failures_are_typed() {
let server = ServerUrl::parse("http://127.0.0.1:8080").unwrap();
let wait = |answer: &[u8]| {
let http = Scripted::new(vec![Ok(answer.to_vec())]);
LoginFlow::new(&http, &server).wait(&login(), &mut FakeClock::new())
};
assert!(matches!(
wait(br#"{"status":"denied"}"#),
Err(ServerAuthError::LoginDenied)
));
assert!(matches!(
wait(br#"{"status":"expired"}"#),
Err(ServerAuthError::LoginExpired)
));
assert!(matches!(
wait(b"<html>"),
Err(ServerAuthError::NotGritServer(_))
));
let http = Scripted::new(vec![Err(Error::Message("HTTP 404".to_owned()))]);
assert!(matches!(
LoginFlow::new(&http, &server).start(),
Err(ServerAuthError::Http(_))
));
}
fn status(code: u16) -> Error {
Error::remote_unreadable(
"http://127.0.0.1:8080/.grit/login".to_owned(),
RemoteUnreadableReason::HttpStatus { status: code },
)
}
#[test]
fn client_errors_mean_the_host_is_not_a_grit_server() {
let server = ServerUrl::parse("https://gitlab.example.com/team/app.git").unwrap();
for code in [404, 405, 422] {
let http = Scripted::new(vec![Err(status(code))]);
assert!(
matches!(
LoginFlow::new(&http, &server).start(),
Err(ServerAuthError::NotGritServer(root)) if root == "https://gitlab.example.com/"
),
"{code}"
);
let http = Canned::new(Err(status(code)));
assert!(
matches!(
fetch_identity(&http, &server),
Err(ServerAuthError::NotGritServer(_))
),
"{code}"
);
}
let challenged = Scripted::new(vec![Err(Error::Auth {
url: "https://gitlab.example.com".to_owned(),
reason: crate::error::AuthFailure::NoCredentialProvider,
})]);
assert!(matches!(
LoginFlow::new(&challenged, &server).start(),
Err(ServerAuthError::NotGritServer(_))
));
let failing = Scripted::new(vec![Err(status(503))]);
assert!(matches!(
LoginFlow::new(&failing, &server).start(),
Err(ServerAuthError::Http(_))
));
let failing = Canned::new(Err(status(500)));
assert!(matches!(
fetch_identity(&failing, &server),
Err(ServerAuthError::Http(_))
));
}
#[test]
fn login_gives_up_when_the_code_expires_locally() {
let server = ServerUrl::parse("http://127.0.0.1:8080").unwrap();
let http = Scripted::new(
(0..3)
.map(|_| Ok(br#"{"status":"pending"}"#.to_vec()))
.collect(),
);
let mut short = login();
short.expires_in = 3;
let result = LoginFlow::new(&http, &server).wait(&short, &mut FakeClock::new());
assert!(matches!(result, Err(ServerAuthError::LoginExpired)));
assert_eq!(http.seen.lock().unwrap().len(), 2);
}
}