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}