querypie-cli 0.1.0

Query QueryPie databases from the terminal with webview authentication.
use anyhow::{anyhow, bail, Result};

use super::{webview, AuthSession};
use crate::lockfile::HostLock;
use crate::paths;
use crate::qpapi::{Client, GrpcError};

use super::status::AuthCheck;

#[derive(Debug, Clone)]
pub struct AuthService {
    host: String,
}

impl AuthService {
    pub fn new(host: impl AsRef<str>) -> Result<Self> {
        let host = paths::normalize_host(host.as_ref());
        if host.trim().is_empty() {
            bail!("auth: host is required");
        }
        Ok(Self { host })
    }

    pub fn login(&self) -> Result<AuthSession> {
        let _lock = HostLock::acquire(&self.host)?;
        webview::clear_profile(&self.host)?;
        webview::login(&self.host)
    }

    pub fn logout(&self) -> Result<()> {
        webview::clear_profile(&self.host)
    }

    fn read_cookie_via_child(&self) -> Result<Option<String>> {
        let output = std::process::Command::new(std::env::current_exe()?)
            .arg("--host")
            .arg(&self.host)
            .arg("auth")
            .arg("read-cookie")
            .stderr(std::process::Stdio::null())
            .output()?;
        cookie_from_stdout(output.stdout)
    }

    pub(crate) fn read_cookie_in_process(&self) -> Result<Option<String>> {
        webview::read_cookies(&self.host)
    }

    pub fn read_cookie_or_error(&self) -> Result<String> {
        self.read_cookie_via_child()?.ok_or_else(|| {
            anyhow!(
                "not logged in to {}; run `querypie --host {} auth login` first",
                self.host,
                self.host
            )
        })
    }

    pub fn check(&self) -> Result<AuthCheck> {
        let Some(cookies) = self.read_cookie_via_child()? else {
            return Ok(AuthCheck::missing(self.host.clone()));
        };
        if let Some(check) = self.check_cookies(&cookies)? {
            return Ok(check);
        }

        let Some(cookies) = self.refresh_cookie_via_child()? else {
            return Ok(AuthCheck::expired(self.host.clone()));
        };
        Ok(self
            .check_cookies(&cookies)?
            .unwrap_or_else(|| AuthCheck::expired(self.host.clone())))
    }

    pub fn refresh_cookie_via_child(&self) -> Result<Option<String>> {
        let output = std::process::Command::new(std::env::current_exe()?)
            .arg("--host")
            .arg(&self.host)
            .arg("auth")
            .arg("refresh-cookie")
            .stderr(std::process::Stdio::null())
            .output()?;
        let cookies = cookie_from_stdout(output.stdout)?;
        if cookies.is_some() || output.status.success() {
            return Ok(cookies);
        }
        Ok(None)
    }

    pub(crate) fn refresh_cookie_in_process(&self) -> Result<Option<String>> {
        let _lock = HostLock::acquire(&self.host)?;
        let host = self.host.clone();
        webview::refresh_cookies_if_needed(&self.host, move |cookies| {
            match Client::new(&host, cookies, new_window_id())?.connections() {
                Ok(_) => Ok(false),
                Err(err) if is_auth_expired(&err) => Ok(true),
                Err(err) => Err(err),
            }
        })
    }

    fn validate_cookie(&self, cookies: &str) -> Result<()> {
        Client::new(&self.host, cookies, new_window_id())?
            .connections()
            .map(|_| ())
    }

    fn check_cookies(&self, cookies: &str) -> Result<Option<AuthCheck>> {
        match self.validate_cookie(cookies) {
            Ok(()) => Ok(Some(AuthCheck::valid(self.host.clone()))),
            Err(err) if is_auth_expired(&err) => Ok(None),
            Err(err) => Err(err),
        }
    }
}

pub fn is_auth_expired(err: &anyhow::Error) -> bool {
    err.downcast_ref::<GrpcError>()
        .map(|e| e.is_auth_expired())
        .unwrap_or(false)
}

fn new_window_id() -> String {
    uuid::Uuid::new_v4().simple().to_string()
}

fn cookie_from_stdout(stdout: Vec<u8>) -> Result<Option<String>> {
    let cookies = String::from_utf8(stdout)?.trim().to_string();
    Ok((!cookies.is_empty()).then_some(cookies))
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn detects_auth_expired_grpc_error() {
        // given
        let err = GrpcError {
            code: "16".to_string(),
            app_code: 0,
            message: "Access token expired".to_string(),
            domain: "ENGINE".to_string(),
        };
        let err = anyhow::Error::new(err);

        // when / then
        assert!(is_auth_expired(&err));
    }
}