use super::decoding::decode_json;
use reqwest::header::ACCEPT;
use secrecy::{ExposeSecret, SecretString};
use serde::{Deserialize, Serialize};
use time::{OffsetDateTime, format_description::well_known::Rfc3339};
use super::{ClientError, WekanClient};
#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize)]
#[serde(rename_all = "snake_case")]
pub enum AuthEndpoint {
Login,
Register,
Logout,
}
impl AuthEndpoint {
pub const fn path(self) -> &'static str {
match self {
Self::Login => "users/login",
Self::Register => "users/register",
Self::Logout => "users/logout",
}
}
}
#[derive(Debug)]
pub struct RegisterRequest {
pub username: Option<String>,
pub email: Option<String>,
pub password: SecretString,
}
#[derive(Debug)]
pub struct LoginRequest {
pub username: Option<String>,
pub email: Option<String>,
pub password: SecretString,
pub code: Option<SecretString>,
}
#[derive(Debug, Serialize)]
pub struct LogoutRequest {
pub all: bool,
}
#[derive(Debug)]
pub struct AuthSession {
user_id: String,
token: SecretString,
token_expires: OffsetDateTime,
}
impl AuthSession {
pub fn user_id(&self) -> &str {
&self.user_id
}
pub fn token(&self) -> &SecretString {
&self.token
}
pub const fn token_expires(&self) -> OffsetDateTime {
self.token_expires
}
pub fn into_parts(self) -> (String, SecretString, OffsetDateTime) {
(self.user_id, self.token, self.token_expires)
}
}
#[derive(Serialize)]
#[serde(rename_all = "camelCase")]
struct RegisterRequestBody<'a> {
#[serde(skip_serializing_if = "Option::is_none")]
username: Option<&'a str>,
#[serde(skip_serializing_if = "Option::is_none")]
email: Option<&'a str>,
password: &'a str,
}
#[derive(Serialize)]
struct LoginRequestBody<'a> {
#[serde(skip_serializing_if = "Option::is_none")]
username: Option<&'a str>,
#[serde(skip_serializing_if = "Option::is_none")]
email: Option<&'a str>,
password: &'a str,
#[serde(skip_serializing_if = "Option::is_none")]
code: Option<&'a str>,
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields, rename_all = "camelCase")]
struct AuthTokenResponse {
id: String,
token: String,
token_expires: String,
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct LogoutResponse {
message: String,
}
impl WekanClient {
pub async fn auth_preflight(&self, endpoint: AuthEndpoint) -> Result<(), ClientError> {
let endpoint = self
.server()
.join(endpoint.path())
.map_err(|_| ClientError::Protocol {
diagnostic: None,
message: "could not build the authentication preflight endpoint".into(),
success_status_received: false,
})?;
let body = self
.execute_success_request(self.http.request(reqwest::Method::OPTIONS, endpoint))
.await?;
if !body.is_empty() {
return Err(ClientError::Protocol {
diagnostic: None,
message: "the authentication preflight response must have an empty body".into(),
success_status_received: true,
});
}
Ok(())
}
pub async fn register(&self, request: &RegisterRequest) -> Result<AuthSession, ClientError> {
let body = RegisterRequestBody {
username: request.username.as_deref(),
email: request.email.as_deref(),
password: request.password.expose_secret(),
};
self.authenticate("users/register", "registration", &body)
.await
}
pub async fn login(&self, request: &LoginRequest) -> Result<AuthSession, ClientError> {
let body = LoginRequestBody {
username: request.username.as_deref(),
email: request.email.as_deref(),
password: request.password.expose_secret(),
code: request.code.as_ref().map(ExposeSecret::expose_secret),
};
self.authenticate("users/login", "login", &body).await
}
pub async fn logout(
&self,
request: &LogoutRequest,
token: &SecretString,
) -> Result<(), ClientError> {
let endpoint =
self.server()
.join("users/logout")
.map_err(|error| ClientError::Protocol {
diagnostic: None,
message: format!("could not build the logout endpoint: {error}"),
success_status_received: false,
})?;
let response_body = self
.execute_success_request(
self.http
.post(endpoint)
.header(ACCEPT, "application/json")
.bearer_auth(token.expose_secret())
.json(request),
)
.await?;
let response: LogoutResponse = decode_json(&response_body, "logout")?;
let _ = response.message;
Ok(())
}
async fn authenticate(
&self,
path: &str,
operation: &str,
body: &impl Serialize,
) -> Result<AuthSession, ClientError> {
let endpoint = self
.server()
.join(path)
.map_err(|error| ClientError::Protocol {
diagnostic: None,
message: format!("could not build the {operation} endpoint: {error}"),
success_status_received: false,
})?;
let response_body = self
.execute_success_request(
self.http
.post(endpoint)
.header(ACCEPT, "application/json")
.json(&body),
)
.await?;
let response: AuthTokenResponse = decode_json(&response_body, operation)?;
if response.id.is_empty() {
return Err(ClientError::Protocol {
diagnostic: None,
message: "the user id was empty".to_owned(),
success_status_received: true,
});
}
if response.token.is_empty() {
return Err(ClientError::Protocol {
diagnostic: None,
message: "the login token was empty".to_owned(),
success_status_received: true,
});
}
let token_expires =
OffsetDateTime::parse(&response.token_expires, &Rfc3339).map_err(|error| {
ClientError::Protocol {
diagnostic: None,
message: format!("tokenExpires was not RFC 3339: {error}"),
success_status_received: true,
}
})?;
Ok(AuthSession {
user_id: response.id,
token: SecretString::from(response.token),
token_expires,
})
}
}
#[cfg(test)]
mod strict_response_tests {
use serde_json::json;
use super::{AuthTokenResponse, LogoutResponse};
#[test]
fn authentication_responses_reject_unmapped_fields() {
assert!(
serde_json::from_value::<AuthTokenResponse>(json!({
"id": "user-1",
"token": "token",
"tokenExpires": "2030-01-02T03:04:05Z",
"unexpected": true
}))
.is_err()
);
assert!(
serde_json::from_value::<LogoutResponse>(json!({
"message": "logged out",
"unexpected": true
}))
.is_err()
);
}
}