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}