guacamole-client 0.5.0

Rust client library for the Guacamole REST API
Documentation
use std::fmt;

use serde::{Deserialize, Serialize};

use crate::client::GuacamoleClient;
use crate::error::Result;
use crate::validation::{validate_data_source, validate_token};

/// Response from the Guacamole authentication endpoint.
#[derive(Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "camelCase")]
#[non_exhaustive]
pub struct AuthResponse {
    /// The session authentication token.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub auth_token: Option<String>,

    /// The authenticated username.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub username: Option<String>,

    /// The default data source for this session.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub data_source: Option<String>,

    /// All data sources available to this user.
    #[serde(skip_serializing_if = "Option::is_none")]
    pub available_data_sources: Option<Vec<String>>,
}

impl fmt::Debug for AuthResponse {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        f.debug_struct("AuthResponse")
            .field("auth_token", &"<redacted>")
            .field("username", &self.username)
            .field("data_source", &self.data_source)
            .field("available_data_sources", &self.available_data_sources)
            .finish()
    }
}

impl GuacamoleClient {
    /// Authenticates with the Guacamole server and stores the session token.
    ///
    /// On success the client stores the token and default data source for
    /// subsequent API calls.
    pub async fn login(&mut self, username: &str, password: &str) -> Result<AuthResponse> {
        let url = self.url_unauth("/api/tokens");
        let response = self
            .http
            .post(&url)
            .form(&[("username", username), ("password", password)])
            .send()
            .await?;

        let auth: AuthResponse = Self::parse_response(response, "authentication").await?;

        if let Some(ref token) = auth.auth_token {
            validate_token(token)?;
        }

        if let Some(ref ds) = auth.data_source {
            validate_data_source(ds)?;
        }

        self.auth_token.clone_from(&auth.auth_token);
        self.data_source.clone_from(&auth.data_source);

        Ok(auth)
    }

    /// Logs out of the Guacamole server and clears the stored session.
    ///
    /// # Security note
    ///
    /// The Guacamole REST API requires the auth token in the URL path
    /// (`DELETE /api/tokens/{token}`). This means the token may appear in
    /// HTTP server access logs, reverse-proxy logs, and browser history.
    /// There is no alternative endpoint that accepts the token in a header.
    pub async fn logout(&mut self) -> Result<()> {
        let token = self.require_token()?.to_owned();
        let url = self.url_unauth(&format!("/api/tokens/{token}"));
        let response = self.http.delete(&url).send().await?;
        Self::handle_error(response, "logout").await?;

        self.auth_token = None;
        self.data_source = None;

        Ok(())
    }
}

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

    #[test]
    fn auth_response_serde_roundtrip() {
        let auth = AuthResponse {
            auth_token: Some("ABCDEF".to_string()),
            username: Some("guacadmin".to_string()),
            data_source: Some("mysql".to_string()),
            available_data_sources: Some(vec!["mysql".to_string(), "postgresql".to_string()]),
        };
        let json = serde_json::to_string(&auth).unwrap();
        let deserialized: AuthResponse = serde_json::from_str(&json).unwrap();
        assert_eq!(auth, deserialized);
    }

    #[test]
    fn auth_response_camel_case_keys() {
        let auth = AuthResponse {
            auth_token: Some("tok".to_string()),
            data_source: Some("mysql".to_string()),
            available_data_sources: Some(vec!["mysql".to_string()]),
            ..Default::default()
        };
        let json = serde_json::to_value(&auth).unwrap();
        assert!(json.get("authToken").is_some());
        assert!(json.get("dataSource").is_some());
        assert!(json.get("availableDataSources").is_some());
    }

    #[test]
    fn auth_response_debug_redacts_token() {
        let auth = AuthResponse {
            auth_token: Some("super-secret-token".to_string()),
            username: Some("guacadmin".to_string()),
            ..Default::default()
        };
        let debug_output = format!("{auth:?}");
        assert!(
            !debug_output.contains("super-secret-token"),
            "Debug must not contain the auth token"
        );
        assert!(debug_output.contains("<redacted>"));
        assert!(debug_output.contains("guacadmin"));
    }

    #[test]
    fn auth_response_skip_none_fields() {
        let auth = AuthResponse::default();
        let json = serde_json::to_value(&auth).unwrap();
        let obj = json.as_object().unwrap();
        assert!(obj.is_empty());
    }

    #[test]
    fn deserialize_from_api_json() {
        let json = r#"{
            "authToken": "168F8D0A2D68247F30B7E2E01187AEE2CF82186D",
            "username": "guacadmin",
            "dataSource": "mysql",
            "availableDataSources": ["mysql", "mysql-shared"]
        }"#;
        let auth: AuthResponse = serde_json::from_str(json).unwrap();
        assert_eq!(
            auth.auth_token.as_deref(),
            Some("168F8D0A2D68247F30B7E2E01187AEE2CF82186D")
        );
        assert_eq!(auth.username.as_deref(), Some("guacadmin"));
        assert_eq!(auth.data_source.as_deref(), Some("mysql"));
        assert_eq!(
            auth.available_data_sources,
            Some(vec!["mysql".to_string(), "mysql-shared".to_string()])
        );
    }

    #[test]
    fn auth_response_unknown_fields_ignored() {
        let json = r#"{"authToken": "tok", "unknownField": 42}"#;
        let auth: AuthResponse = serde_json::from_str(json).unwrap();
        assert_eq!(auth.auth_token.as_deref(), Some("tok"));
    }
}