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