1use std::fmt;
4
5use reqwest::Method;
6use secrecy::{ExposeSecret, SecretString};
7use serde::Serialize;
8
9use crate::{Client, Error, Result, client::AuthenticationScope};
10
11#[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 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 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 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#[derive(Clone)]
67pub struct CsrfToken(SecretString);
68
69impl CsrfToken {
70 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#[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 pub async fn csrf_token(self) -> Result<CsrfToken> {
100 self.client.ensure_csrf_token().await.map(CsrfToken)
101 }
102
103 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 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 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}