Skip to main content

wekan_cli/client/
auth.rs

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