Skip to main content

xui_rs/
auth.rs

1//! Cookie-session authentication, CSRF, and two-factor login operations.
2
3use std::fmt;
4
5use reqwest::Method;
6use secrecy::{ExposeSecret, SecretString};
7use serde::Serialize;
8
9use crate::{Client, Error, Result, client::AuthenticationScope};
10
11/// Credentials accepted by the 3x-ui login endpoint.
12#[derive(Clone)]
13#[must_use]
14pub struct LoginRequest {
15    username: String,
16    password: SecretString,
17    two_factor_code: Option<SecretString>,
18}
19
20impl LoginRequest {
21    /// Creates credentials for a regular username/password login.
22    pub fn new(username: impl Into<String>, password: impl Into<String>) -> Self {
23        Self {
24            username: username.into(),
25            password: SecretString::from(password.into()),
26            two_factor_code: None,
27        }
28    }
29
30    /// Adds the one-time code required by panels with 2FA enabled.
31    pub fn with_two_factor_code(mut self, code: impl Into<String>) -> Self {
32        self.two_factor_code = Some(SecretString::from(code.into()));
33        self
34    }
35
36    /// Returns the username associated with the request.
37    pub fn username(&self) -> &str {
38        &self.username
39    }
40}
41
42impl fmt::Debug for LoginRequest {
43    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
44        formatter
45            .debug_struct("LoginRequest")
46            .field("username", &self.username)
47            .field("password", &"[REDACTED]")
48            .field(
49                "two_factor_code",
50                &self.two_factor_code.as_ref().map(|_| "[REDACTED]"),
51            )
52            .finish()
53    }
54}
55
56#[derive(Serialize)]
57#[serde(rename_all = "camelCase")]
58struct LoginPayload<'a> {
59    username: &'a str,
60    password: &'a str,
61    #[serde(skip_serializing_if = "Option::is_none")]
62    two_factor_code: Option<&'a str>,
63}
64
65/// A CSRF token bound to a cookie session.
66#[derive(Clone)]
67pub struct CsrfToken(SecretString);
68
69impl CsrfToken {
70    /// Exposes the token for integrations that need to build a raw request.
71    pub fn expose(&self) -> &str {
72        self.0.expose_secret()
73    }
74}
75
76impl fmt::Debug for CsrfToken {
77    fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
78        formatter.write_str("CsrfToken([REDACTED])")
79    }
80}
81
82/// Authentication endpoints for a [`Client`].
83#[derive(Clone, Copy, Debug)]
84pub struct AuthApi<'client> {
85    client: &'client Client,
86}
87
88impl<'client> AuthApi<'client> {
89    pub(crate) const fn new(client: &'client Client) -> Self {
90        Self { client }
91    }
92
93    /// Returns the session's CSRF token, creating a pre-auth session if needed.
94    ///
95    /// # Errors
96    ///
97    /// Returns an error when the panel cannot be reached or returns an invalid
98    /// CSRF response.
99    pub async fn csrf_token(self) -> Result<CsrfToken> {
100        self.client.ensure_csrf_token().await.map(CsrfToken)
101    }
102
103    /// Reports whether two-factor authentication is enabled on the panel.
104    ///
105    /// # Errors
106    ///
107    /// Returns an error when the request fails or the response omits its
108    /// documented boolean value.
109    pub async fn is_two_factor_enabled(self) -> Result<bool> {
110        let envelope = self
111            .client
112            .execute::<bool, ()>(
113                Method::POST,
114                "getTwoFactorEnable",
115                None,
116                AuthenticationScope::Session,
117            )
118            .await?;
119        let url = self.client.endpoint("getTwoFactorEnable")?;
120        envelope.obj.ok_or_else(|| Error::MissingObject {
121            method: Method::POST,
122            url: Box::new(url),
123        })
124    }
125
126    /// Authenticates a cookie session.
127    ///
128    /// The SDK obtains a CSRF token before login and lets the HTTP cookie jar
129    /// manage the signed `3x-ui` cookie. Credentials are never retained, and
130    /// expired sessions are not silently re-authenticated.
131    ///
132    /// # Errors
133    ///
134    /// Returns an error when CSRF initialization, transport, authentication,
135    /// or response decoding fails.
136    pub async fn login(self, credentials: LoginRequest) -> Result<()> {
137        let payload = LoginPayload {
138            username: credentials.username(),
139            password: credentials.password.expose_secret(),
140            two_factor_code: credentials
141                .two_factor_code
142                .as_ref()
143                .map(ExposeSecret::expose_secret),
144        };
145        self.client
146            .execute::<serde_json::Value, _>(
147                Method::POST,
148                "login",
149                Some(&payload),
150                AuthenticationScope::Session,
151            )
152            .await?;
153        Ok(())
154    }
155
156    /// Clears the current cookie session.
157    ///
158    /// This does not revoke or remove a bearer token configured on the client.
159    ///
160    /// # Errors
161    ///
162    /// Returns an error when the panel cannot clear the session.
163    pub async fn logout(self) -> Result<()> {
164        self.client
165            .execute::<serde_json::Value, ()>(
166                Method::POST,
167                "logout",
168                None,
169                AuthenticationScope::Session,
170            )
171            .await?;
172        self.client.clear_csrf_token().await;
173        Ok(())
174    }
175}
176
177#[cfg(test)]
178mod tests {
179    use super::*;
180
181    #[test]
182    fn login_request_debug_redacts_all_secrets() {
183        let request =
184            LoginRequest::new("admin", "password-secret").with_two_factor_code("otp-secret");
185        let debug = format!("{request:?}");
186        assert!(debug.contains("admin"));
187        assert!(!debug.contains("password-secret"));
188        assert!(!debug.contains("otp-secret"));
189    }
190
191    #[test]
192    fn csrf_debug_is_redacted() {
193        let token = CsrfToken(SecretString::from("csrf-secret".to_owned()));
194        assert!(!format!("{token:?}").contains("csrf-secret"));
195        assert_eq!(token.expose(), "csrf-secret");
196    }
197}