Skip to main content

wekan_cli/client/
auth.rs

1use super::decoding::decode_json;
2use reqwest::header::ACCEPT;
3use secrecy::{ExposeSecret, SecretString};
4use serde::{Deserialize, Serialize};
5use time::{OffsetDateTime, format_description::well_known::Rfc3339};
6
7use super::{ClientError, WekanClient};
8
9#[derive(Debug)]
10pub struct RegisterRequest {
11    pub username: Option<String>,
12    pub email: Option<String>,
13    pub password: SecretString,
14}
15
16#[derive(Debug)]
17pub struct LoginRequest {
18    pub username: Option<String>,
19    pub email: Option<String>,
20    pub password: SecretString,
21    pub code: Option<SecretString>,
22}
23
24#[derive(Debug, Serialize)]
25pub struct LogoutRequest {
26    pub all: bool,
27}
28
29#[derive(Debug)]
30pub struct AuthSession {
31    user_id: String,
32    token: SecretString,
33    token_expires: OffsetDateTime,
34}
35
36impl AuthSession {
37    pub fn user_id(&self) -> &str {
38        &self.user_id
39    }
40
41    pub fn token(&self) -> &SecretString {
42        &self.token
43    }
44
45    pub const fn token_expires(&self) -> OffsetDateTime {
46        self.token_expires
47    }
48
49    pub fn into_parts(self) -> (String, SecretString, OffsetDateTime) {
50        (self.user_id, self.token, self.token_expires)
51    }
52}
53
54#[derive(Serialize)]
55#[serde(rename_all = "camelCase")]
56struct RegisterRequestBody<'a> {
57    #[serde(skip_serializing_if = "Option::is_none")]
58    username: Option<&'a str>,
59    #[serde(skip_serializing_if = "Option::is_none")]
60    email: Option<&'a str>,
61    password: &'a str,
62}
63
64#[derive(Serialize)]
65struct LoginRequestBody<'a> {
66    #[serde(skip_serializing_if = "Option::is_none")]
67    username: Option<&'a str>,
68    #[serde(skip_serializing_if = "Option::is_none")]
69    email: Option<&'a str>,
70    password: &'a str,
71    #[serde(skip_serializing_if = "Option::is_none")]
72    code: Option<&'a str>,
73}
74
75#[derive(Deserialize)]
76#[serde(deny_unknown_fields, rename_all = "camelCase")]
77struct AuthTokenResponse {
78    id: String,
79    token: String,
80    token_expires: String,
81}
82
83#[derive(Deserialize)]
84#[serde(deny_unknown_fields)]
85struct LogoutResponse {
86    message: String,
87}
88
89impl WekanClient {
90    pub async fn register(&self, request: &RegisterRequest) -> Result<AuthSession, ClientError> {
91        let body = RegisterRequestBody {
92            username: request.username.as_deref(),
93            email: request.email.as_deref(),
94            password: request.password.expose_secret(),
95        };
96
97        self.authenticate("users/register", "registration", &body)
98            .await
99    }
100
101    pub async fn login(&self, request: &LoginRequest) -> Result<AuthSession, ClientError> {
102        let body = LoginRequestBody {
103            username: request.username.as_deref(),
104            email: request.email.as_deref(),
105            password: request.password.expose_secret(),
106            code: request.code.as_ref().map(ExposeSecret::expose_secret),
107        };
108
109        self.authenticate("users/login", "login", &body).await
110    }
111
112    pub async fn logout(
113        &self,
114        request: &LogoutRequest,
115        token: &SecretString,
116    ) -> Result<(), ClientError> {
117        let endpoint =
118            self.server()
119                .join("users/logout")
120                .map_err(|error| ClientError::Protocol {
121                    diagnostic: None,
122                    message: format!("could not build the logout endpoint: {error}"),
123                    success_status_received: false,
124                })?;
125
126        let response_body = self
127            .execute_success_request(
128                self.http
129                    .post(endpoint)
130                    .header(ACCEPT, "application/json")
131                    .bearer_auth(token.expose_secret())
132                    .json(request),
133            )
134            .await?;
135
136        let response: LogoutResponse = decode_json(&response_body, "logout")?;
137        let _ = response.message;
138        Ok(())
139    }
140
141    async fn authenticate(
142        &self,
143        path: &str,
144        operation: &str,
145        body: &impl Serialize,
146    ) -> Result<AuthSession, ClientError> {
147        let endpoint = self
148            .server()
149            .join(path)
150            .map_err(|error| ClientError::Protocol {
151                diagnostic: None,
152                message: format!("could not build the {operation} endpoint: {error}"),
153                success_status_received: false,
154            })?;
155
156        let response_body = self
157            .execute_success_request(
158                self.http
159                    .post(endpoint)
160                    .header(ACCEPT, "application/json")
161                    .json(&body),
162            )
163            .await?;
164
165        let response: AuthTokenResponse = decode_json(&response_body, operation)?;
166
167        if response.id.is_empty() {
168            return Err(ClientError::Protocol {
169                diagnostic: None,
170                message: "the user id was empty".to_owned(),
171                success_status_received: true,
172            });
173        }
174        if response.token.is_empty() {
175            return Err(ClientError::Protocol {
176                diagnostic: None,
177                message: "the login token was empty".to_owned(),
178                success_status_received: true,
179            });
180        }
181        let token_expires =
182            OffsetDateTime::parse(&response.token_expires, &Rfc3339).map_err(|error| {
183                ClientError::Protocol {
184                    diagnostic: None,
185                    message: format!("tokenExpires was not RFC 3339: {error}"),
186                    success_status_received: true,
187                }
188            })?;
189
190        Ok(AuthSession {
191            user_id: response.id,
192            token: SecretString::from(response.token),
193            token_expires,
194        })
195    }
196}
197
198#[cfg(test)]
199mod strict_response_tests {
200    use serde_json::json;
201
202    use super::{AuthTokenResponse, LogoutResponse};
203
204    #[test]
205    fn authentication_responses_reject_unmapped_fields() {
206        assert!(
207            serde_json::from_value::<AuthTokenResponse>(json!({
208                "id": "user-1",
209                "token": "token",
210                "tokenExpires": "2030-01-02T03:04:05Z",
211                "unexpected": true
212            }))
213            .is_err()
214        );
215        assert!(
216            serde_json::from_value::<LogoutResponse>(json!({
217                "message": "logged out",
218                "unexpected": true
219            }))
220            .is_err()
221        );
222    }
223}