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}