Skip to main content

guacamole_client/
auth.rs

1use std::fmt;
2
3use serde::{Deserialize, Serialize};
4
5use crate::client::GuacamoleClient;
6use crate::error::Result;
7use crate::validation::{validate_data_source, validate_token};
8
9/// Response from the Guacamole authentication endpoint.
10#[derive(Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
11#[serde(rename_all = "camelCase")]
12#[non_exhaustive]
13pub struct AuthResponse {
14    /// The session authentication token.
15    #[serde(skip_serializing_if = "Option::is_none")]
16    pub auth_token: Option<String>,
17
18    /// The authenticated username.
19    #[serde(skip_serializing_if = "Option::is_none")]
20    pub username: Option<String>,
21
22    /// The default data source for this session.
23    #[serde(skip_serializing_if = "Option::is_none")]
24    pub data_source: Option<String>,
25
26    /// All data sources available to this user.
27    #[serde(skip_serializing_if = "Option::is_none")]
28    pub available_data_sources: Option<Vec<String>>,
29}
30
31impl fmt::Debug for AuthResponse {
32    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
33        f.debug_struct("AuthResponse")
34            .field("auth_token", &"<redacted>")
35            .field("username", &self.username)
36            .field("data_source", &self.data_source)
37            .field("available_data_sources", &self.available_data_sources)
38            .finish()
39    }
40}
41
42impl GuacamoleClient {
43    /// Authenticates with the Guacamole server and stores the session token.
44    ///
45    /// On success the client stores the token and default data source for
46    /// subsequent API calls.
47    pub async fn login(&mut self, username: &str, password: &str) -> Result<AuthResponse> {
48        let url = self.url_unauth("/api/tokens");
49        let response = self
50            .http
51            .post(&url)
52            .form(&[("username", username), ("password", password)])
53            .send()
54            .await?;
55
56        let auth: AuthResponse = Self::parse_response(response, "authentication").await?;
57
58        if let Some(ref token) = auth.auth_token {
59            validate_token(token)?;
60        }
61
62        if let Some(ref ds) = auth.data_source {
63            validate_data_source(ds)?;
64        }
65
66        self.auth_token.clone_from(&auth.auth_token);
67        self.data_source.clone_from(&auth.data_source);
68
69        Ok(auth)
70    }
71
72    /// Logs out of the Guacamole server and clears the stored session.
73    ///
74    /// # Security note
75    ///
76    /// The Guacamole REST API requires the auth token in the URL path
77    /// (`DELETE /api/tokens/{token}`). This means the token may appear in
78    /// HTTP server access logs, reverse-proxy logs, and browser history.
79    /// There is no alternative endpoint that accepts the token in a header.
80    pub async fn logout(&mut self) -> Result<()> {
81        let token = self.require_token()?.to_owned();
82        let url = self.url_unauth(&format!("/api/tokens/{token}"));
83        let response = self.http.delete(&url).send().await?;
84        Self::handle_error(response, "logout").await?;
85
86        self.auth_token = None;
87        self.data_source = None;
88
89        Ok(())
90    }
91}
92
93#[cfg(test)]
94mod tests {
95    use super::*;
96
97    #[test]
98    fn auth_response_serde_roundtrip() {
99        let auth = AuthResponse {
100            auth_token: Some("ABCDEF".to_string()),
101            username: Some("guacadmin".to_string()),
102            data_source: Some("mysql".to_string()),
103            available_data_sources: Some(vec!["mysql".to_string(), "postgresql".to_string()]),
104        };
105        let json = serde_json::to_string(&auth).unwrap();
106        let deserialized: AuthResponse = serde_json::from_str(&json).unwrap();
107        assert_eq!(auth, deserialized);
108    }
109
110    #[test]
111    fn auth_response_camel_case_keys() {
112        let auth = AuthResponse {
113            auth_token: Some("tok".to_string()),
114            data_source: Some("mysql".to_string()),
115            available_data_sources: Some(vec!["mysql".to_string()]),
116            ..Default::default()
117        };
118        let json = serde_json::to_value(&auth).unwrap();
119        assert!(json.get("authToken").is_some());
120        assert!(json.get("dataSource").is_some());
121        assert!(json.get("availableDataSources").is_some());
122    }
123
124    #[test]
125    fn auth_response_debug_redacts_token() {
126        let auth = AuthResponse {
127            auth_token: Some("super-secret-token".to_string()),
128            username: Some("guacadmin".to_string()),
129            ..Default::default()
130        };
131        let debug_output = format!("{auth:?}");
132        assert!(
133            !debug_output.contains("super-secret-token"),
134            "Debug must not contain the auth token"
135        );
136        assert!(debug_output.contains("<redacted>"));
137        assert!(debug_output.contains("guacadmin"));
138    }
139
140    #[test]
141    fn auth_response_skip_none_fields() {
142        let auth = AuthResponse::default();
143        let json = serde_json::to_value(&auth).unwrap();
144        let obj = json.as_object().unwrap();
145        assert!(obj.is_empty());
146    }
147
148    #[test]
149    fn deserialize_from_api_json() {
150        let json = r#"{
151            "authToken": "168F8D0A2D68247F30B7E2E01187AEE2CF82186D",
152            "username": "guacadmin",
153            "dataSource": "mysql",
154            "availableDataSources": ["mysql", "mysql-shared"]
155        }"#;
156        let auth: AuthResponse = serde_json::from_str(json).unwrap();
157        assert_eq!(
158            auth.auth_token.as_deref(),
159            Some("168F8D0A2D68247F30B7E2E01187AEE2CF82186D")
160        );
161        assert_eq!(auth.username.as_deref(), Some("guacadmin"));
162        assert_eq!(auth.data_source.as_deref(), Some("mysql"));
163        assert_eq!(
164            auth.available_data_sources,
165            Some(vec!["mysql".to_string(), "mysql-shared".to_string()])
166        );
167    }
168
169    #[test]
170    fn auth_response_unknown_fields_ignored() {
171        let json = r#"{"authToken": "tok", "unknownField": 42}"#;
172        let auth: AuthResponse = serde_json::from_str(json).unwrap();
173        assert_eq!(auth.auth_token.as_deref(), Some("tok"));
174    }
175}