From 93a2f691ea0d1eb21b354999cf5ac0130e2dde67 Mon Sep 17 00:00:00 2001 From: mdecimus Date: Tue, 2 Apr 2024 17:50:37 +0200 Subject: [PATCH] OAuth and cryto management APIs + Store OAuth codes in lookup stores rather than memory --- crates/jmap/src/api/http.rs | 30 --- crates/jmap/src/api/management/mod.rs | 59 +---- crates/jmap/src/auth/oauth/auth.rs | 270 ++++++++++++++++++++++ crates/jmap/src/auth/oauth/device_auth.rs | 244 ------------------- crates/jmap/src/auth/oauth/mod.rs | 52 ++--- crates/jmap/src/auth/oauth/token.rs | 163 +++++++------ crates/jmap/src/auth/oauth/user_code.rs | 247 -------------------- crates/jmap/src/email/crypto.rs | 3 +- crates/jmap/src/lib.rs | 4 +- crates/jmap/src/services/housekeeper.rs | 1 - crates/store/src/write/mod.rs | 9 +- resources/htx/error.htx | 1 - resources/htx/footer.htx | 1 - resources/htx/header.htx | 1 - resources/htx/login.htx | 1 - resources/htx/login_code.htx | 1 - resources/htx/login_code_hidden.htx | 1 - resources/htx/login_hdr_client.htx | 1 - resources/htx/login_hdr_device.htx | 1 - resources/htx/login_hdr_failed.htx | 1 - resources/htx/login_success.htx | 1 - resources/htx/oauth.htx | 125 ---------- tests/src/jmap/auth_oauth.rs | 189 +++++---------- tests/src/jmap/crypto.rs | 117 +++------- tests/src/jmap/mod.rs | 142 +++++++++++- tests/src/smtp/management/mod.rs | 77 ------ tests/src/smtp/management/queue.rs | 100 ++++---- tests/src/smtp/management/report.rs | 54 +++-- 28 files changed, 723 insertions(+), 1173 deletions(-) create mode 100644 crates/jmap/src/auth/oauth/auth.rs delete mode 100644 crates/jmap/src/auth/oauth/device_auth.rs delete mode 100644 crates/jmap/src/auth/oauth/user_code.rs delete mode 100644 resources/htx/error.htx delete mode 100644 resources/htx/footer.htx delete mode 100644 resources/htx/header.htx delete mode 100644 resources/htx/login.htx delete mode 100644 resources/htx/login_code.htx delete mode 100644 resources/htx/login_code_hidden.htx delete mode 100644 resources/htx/login_hdr_client.htx delete mode 100644 resources/htx/login_hdr_device.htx delete mode 100644 resources/htx/login_hdr_failed.htx delete mode 100644 resources/htx/login_success.htx delete mode 100644 resources/htx/oauth.htx diff --git a/crates/jmap/src/api/http.rs b/crates/jmap/src/api/http.rs index fe0c956d..cce8bc2c 100644 --- a/crates/jmap/src/api/http.rs +++ b/crates/jmap/src/api/http.rs @@ -233,36 +233,6 @@ impl JMAP { _ => (), }, "auth" => match (path.next().unwrap_or(""), req.method()) { - ("", &Method::GET) => { - return match self.is_anonymous_allowed(&session.remote_ip).await { - Ok(_) => self.handle_user_device_auth(&mut req).await, - Err(err) => err.into_http_response(), - } - } - ("", &Method::POST) => { - return match self.is_auth_allowed_soft(&session.remote_ip).await { - Ok(_) => { - self.handle_user_device_auth_post(&mut req, session.remote_ip) - .await - } - Err(err) => err.into_http_response(), - } - } - ("code", &Method::GET) => { - return match self.is_anonymous_allowed(&session.remote_ip).await { - Ok(_) => self.handle_user_code_auth(&mut req).await, - Err(err) => err.into_http_response(), - } - } - ("code", &Method::POST) => { - return match self.is_auth_allowed_soft(&session.remote_ip).await { - Ok(_) => { - self.handle_user_code_auth_post(&mut req, session.remote_ip) - .await - } - Err(err) => err.into_http_response(), - } - } ("device", &Method::POST) => { return match self.is_anonymous_allowed(&session.remote_ip).await { Ok(_) => { diff --git a/crates/jmap/src/api/management/mod.rs b/crates/jmap/src/api/management/mod.rs index c1e609d8..cedbb45e 100644 --- a/crates/jmap/src/api/management/mod.rs +++ b/crates/jmap/src/api/management/mod.rs @@ -35,9 +35,8 @@ use http_body_util::combinators::BoxBody; use hyper::{body::Bytes, Method}; use jmap_proto::error::request::RequestError; use serde::Serialize; -use serde_json::json; -use crate::{auth::{oauth::OAuthCodeRequest, AccessToken}, JMAP}; +use crate::{auth::AccessToken, JMAP}; use super::{http::ToHttpResponse, HttpRequest, JsonResponse}; @@ -77,49 +76,14 @@ impl JMAP { let is_superuser = access_token.is_super_user(); match path.first().copied().unwrap_or_default() { - "principal" if is_superuser => { - self.handle_manage_principal(req, path, body) - .await - } - "domain" if is_superuser => { - self.handle_manage_domain(req, path) - .await - } - "store" if is_superuser => { - self.handle_manage_store(req, path,) - .await - } - "reload" if is_superuser => { - self.handle_manage_reload(req, path) - .await - } - "settings" if is_superuser => { - self.handle_manage_settings(req, path, body) - .await - } - "queue" if is_superuser => { - self.handle_manage_queue(req, path) - .await - } - "reports" if is_superuser => { - self.handle_manage_reports(req, path) - .await - } - "oauth" => { - match serde_json::from_slice::(body.as_deref().unwrap_or_default()) { - Ok(request) => { - JsonResponse::new(json!({ - "data": { - "code": self.issue_client_code(&access_token, request.client_id, request.redirect_uri), - "is_admin": access_token.is_super_user(), - }, - })) - .into_http_response() - - }, - Err(err) => err.into_http_response(), - } - } + "principal" if is_superuser => self.handle_manage_principal(req, path, body).await, + "domain" if is_superuser => self.handle_manage_domain(req, path).await, + "store" if is_superuser => self.handle_manage_store(req, path).await, + "reload" if is_superuser => self.handle_manage_reload(req, path).await, + "settings" if is_superuser => self.handle_manage_settings(req, path, body).await, + "queue" if is_superuser => self.handle_manage_queue(req, path).await, + "reports" if is_superuser => self.handle_manage_reports(req, path).await, + "oauth" => self.handle_oauth_api_request(access_token, body).await, "crypto" => match *req.method() { Method::POST => self.handle_crypto_post(access_token, body).await, Method::GET => self.handle_crypto_get(access_token).await, @@ -134,7 +98,6 @@ impl JMAP { } } - impl ToHttpResponse for ManagementApiError { fn into_http_response(self) -> super::HttpResponse { JsonResponse::new(self).into_http_response() @@ -149,6 +112,8 @@ impl From> for ManagementApiError { impl From for ManagementApiError { fn from(details: String) -> Self { - ManagementApiError::Other { details: details.into() } + ManagementApiError::Other { + details: details.into(), + } } } diff --git a/crates/jmap/src/auth/oauth/auth.rs b/crates/jmap/src/auth/oauth/auth.rs new file mode 100644 index 00000000..ff760699 --- /dev/null +++ b/crates/jmap/src/auth/oauth/auth.rs @@ -0,0 +1,270 @@ +/* + * Copyright (c) 2023 Stalwart Labs Ltd. + * + * This file is part of Stalwart Mail Server. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as + * published by the Free Software Foundation, either version 3 of + * the License, or (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * in the LICENSE file at the top-level directory of this distribution. + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + * + * You can be released from the requirements of the AGPLv3 license by + * purchasing a commercial license. Please contact licensing@stalw.art + * for more details. +*/ + +use std::sync::Arc; + +use hyper::StatusCode; +use rand::distributions::Standard; +use serde_json::json; +use store::{ + rand::{distributions::Alphanumeric, thread_rng, Rng}, + write::Bincode, + Serialize, +}; + +use crate::{ + api::{ + http::ToHttpResponse, management::ManagementApiError, HtmlResponse, HttpRequest, + HttpResponse, JsonResponse, + }, + auth::{oauth::OAuthStatus, AccessToken}, + JMAP, +}; + +use super::{ + DeviceAuthResponse, FormData, OAuthCode, OAuthCodeRequest, CLIENT_ID_MAX_LEN, DEVICE_CODE_LEN, + MAX_POST_LEN, USER_CODE_ALPHABET, USER_CODE_LEN, +}; + +impl JMAP { + pub async fn handle_oauth_api_request( + &self, + access_token: Arc, + body: Option>, + ) -> HttpResponse { + match serde_json::from_slice::(body.as_deref().unwrap_or_default()) { + Ok(request) => { + let response = match request { + OAuthCodeRequest::Code { + client_id, + redirect_uri, + } => { + // Validate clientId + if client_id.len() > CLIENT_ID_MAX_LEN { + return ManagementApiError::Other { + details: "Client ID is invalid.".into(), + } + .into_http_response(); + } else if redirect_uri + .as_ref() + .map_or(false, |uri| !uri.starts_with("https://")) + { + return ManagementApiError::Other { + details: "Redirect URI must be HTTPS.".into(), + } + .into_http_response(); + } + + // Generate client code + let client_code = thread_rng() + .sample_iter(Alphanumeric) + .take(DEVICE_CODE_LEN) + .map(char::from) + .collect::(); + + // Serialize OAuth code + let value = Bincode::new(OAuthCode { + status: OAuthStatus::Authorized, + account_id: access_token.primary_id(), + client_id, + params: redirect_uri.unwrap_or_default(), + }) + .serialize(); + + // Insert client code + if let Err(err) = self + .core + .storage + .lookup + .key_set( + format!("oauth:{client_code}").into_bytes(), + value, + self.core.jmap.oauth_expiry_auth_code.into(), + ) + .await + { + return err.into_http_response(); + } + + json!({ + "data": { + "code": client_code, + "is_admin": access_token.is_super_user(), + }, + }) + } + OAuthCodeRequest::Device { code } => { + let mut success = false; + + // Obtain code + match self + .core + .storage + .lookup + .key_get::>(format!("oauth:{code}").into_bytes()) + .await + { + Ok(Some(mut auth_code)) + if auth_code.inner.status == OAuthStatus::Pending => + { + auth_code.inner.status = OAuthStatus::Authorized; + auth_code.inner.account_id = access_token.primary_id(); + let device_code = std::mem::take(&mut auth_code.inner.params); + success = true; + + // Delete issued user code + if let Err(err) = self + .core + .storage + .lookup + .key_delete(format!("oauth:{code}").into_bytes()) + .await + { + return err.into_http_response(); + } + + // Update device code status + if let Err(err) = self + .core + .storage + .lookup + .key_set( + format!("oauth:{device_code}").into_bytes(), + auth_code.serialize(), + self.core.jmap.oauth_expiry_auth_code.into(), + ) + .await + { + return err.into_http_response(); + } + } + Err(err) => return err.into_http_response(), + _ => (), + } + + json!({ + "data": success, + }) + } + }; + + JsonResponse::new(response).into_http_response() + } + Err(err) => err.into_http_response(), + } + } + + pub async fn handle_device_auth( + &self, + req: &mut HttpRequest, + base_url: impl AsRef, + ) -> HttpResponse { + // Parse form + let client_id = match FormData::from_request(req, MAX_POST_LEN) + .await + .map(|mut p| p.remove("client_id")) + { + Ok(Some(client_id)) if client_id.len() < CLIENT_ID_MAX_LEN => client_id, + Err(err) => return err, + _ => { + return HtmlResponse::with_status( + StatusCode::BAD_REQUEST, + "Client ID is invalid.".to_string(), + ) + .into_http_response(); + } + }; + + // Generate device code + let device_code = thread_rng() + .sample_iter(Alphanumeric) + .take(DEVICE_CODE_LEN) + .map(char::from) + .collect::(); + + // Generate user code + let mut user_code = String::with_capacity(USER_CODE_LEN + 1); + for (pos, ch) in thread_rng() + .sample_iter::(Standard) + .take(USER_CODE_LEN) + .map(|v| char::from(USER_CODE_ALPHABET[v % USER_CODE_ALPHABET.len()])) + .enumerate() + { + if pos == USER_CODE_LEN / 2 { + user_code.push('-'); + } + user_code.push(ch); + } + + // Add OAuth status + let oauth_code = Bincode::new(OAuthCode { + status: OAuthStatus::Pending, + account_id: u32::MAX, + client_id, + params: device_code.clone(), + }) + .serialize(); + + // Insert device code + if let Err(err) = self + .core + .storage + .lookup + .key_set( + format!("oauth:{device_code}").into_bytes(), + oauth_code.clone(), + self.core.jmap.oauth_expiry_user_code.into(), + ) + .await + { + return err.into_http_response(); + } + + // Insert user code + if let Err(err) = self + .core + .storage + .lookup + .key_set( + format!("oauth:{user_code}").into_bytes(), + oauth_code, + self.core.jmap.oauth_expiry_user_code.into(), + ) + .await + { + return err.into_http_response(); + } + + // Build response + let base_url = base_url.as_ref(); + JsonResponse::new(DeviceAuthResponse { + verification_uri: format!("{}/authorize", base_url), + verification_uri_complete: format!("{}/authorize/?code={}", base_url, user_code), + device_code, + user_code, + expires_in: self.core.jmap.oauth_expiry_user_code, + interval: 5, + }) + .into_http_response() + } +} diff --git a/crates/jmap/src/auth/oauth/device_auth.rs b/crates/jmap/src/auth/oauth/device_auth.rs deleted file mode 100644 index 4fb3a796..00000000 --- a/crates/jmap/src/auth/oauth/device_auth.rs +++ /dev/null @@ -1,244 +0,0 @@ -/* - * Copyright (c) 2023 Stalwart Labs Ltd. - * - * This file is part of Stalwart Mail Server. - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of - * the License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * in the LICENSE file at the top-level directory of this distribution. - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see . - * - * You can be released from the requirements of the AGPLv3 license by - * purchasing a commercial license. Please contact licensing@stalw.art - * for more details. -*/ - -use std::{ - net::IpAddr, - sync::{atomic, Arc}, - time::{Duration, Instant}, -}; - -use common::AuthResult; -use hyper::StatusCode; -use store::rand::{ - distributions::{Alphanumeric, Standard}, - thread_rng, Rng, -}; -use utils::map::ttl_dashmap::TtlMap; - -use crate::{ - api::{http::ToHttpResponse, HtmlResponse, HttpRequest, HttpResponse, JsonResponse}, - auth::oauth::{ - MAX_POST_LEN, OAUTH_HTML_ERROR, OAUTH_HTML_LOGIN_HEADER_FAILED, OAUTH_HTML_LOGIN_SUCCESS, - STATUS_AUTHORIZED, - }, - JMAP, -}; - -use super::{ - DeviceAuthResponse, FormData, OAuthCode, CLIENT_ID_MAX_LEN, DEVICE_CODE_LEN, OAUTH_HTML_FOOTER, - OAUTH_HTML_HEADER, OAUTH_HTML_LOGIN_CODE, OAUTH_HTML_LOGIN_FORM, - OAUTH_HTML_LOGIN_HEADER_DEVICE, STATUS_PENDING, USER_CODE_ALPHABET, USER_CODE_LEN, -}; - -// Device authorization endpoint -impl JMAP { - pub async fn handle_device_auth( - &self, - req: &mut HttpRequest, - base_url: impl AsRef, - ) -> HttpResponse { - // Parse form - let client_id = match FormData::from_request(req, MAX_POST_LEN) - .await - .map(|mut p| p.remove("client_id")) - { - Ok(Some(client_id)) if client_id.len() < CLIENT_ID_MAX_LEN => client_id, - Err(err) => return err, - _ => { - return HtmlResponse::with_status( - StatusCode::BAD_REQUEST, - "Client ID is invalid.".to_string(), - ) - .into_http_response(); - } - }; - - // Generate device code - let device_code = thread_rng() - .sample_iter(Alphanumeric) - .take(DEVICE_CODE_LEN) - .map(char::from) - .collect::(); - - // Generate user code - let mut user_code = String::with_capacity(USER_CODE_LEN + 1); - for (pos, ch) in thread_rng() - .sample_iter::(Standard) - .take(USER_CODE_LEN) - .map(|v| char::from(USER_CODE_ALPHABET[v % USER_CODE_ALPHABET.len()])) - .enumerate() - { - if pos == USER_CODE_LEN / 2 { - user_code.push('-'); - } - user_code.push(ch); - } - - // Add OAuth status - let oauth_code = Arc::new(OAuthCode { - status: STATUS_PENDING.into(), - account_id: u32::MAX.into(), - client_id, - redirect_uri: None, - }); - let expiry = Instant::now() + Duration::from_secs(self.core.jmap.oauth_expiry_user_code); - self.inner - .oauth_codes - .insert_with_ttl(device_code.clone(), oauth_code.clone(), expiry); - self.inner - .oauth_codes - .insert_with_ttl(user_code.clone(), oauth_code, expiry); - - // Build response - let base_url = base_url.as_ref(); - JsonResponse::new(DeviceAuthResponse { - verification_uri: format!("{}/auth", base_url), - verification_uri_complete: format!("{}/auth/?code={}", base_url, user_code), - device_code, - user_code, - expires_in: self.core.jmap.oauth_expiry_user_code, - interval: 5, - }) - .into_http_response() - } - - // Device authorization flow, renders the authorization page - pub async fn handle_user_device_auth(&self, req: &mut HttpRequest) -> HttpResponse { - let code = req - .uri() - .query() - .and_then(|q| { - form_urlencoded::parse(q.as_bytes()) - .find(|(k, _)| k == "code") - .map(|(_, v)| v.into_owned()) - }) - .unwrap_or_default(); - let mut response = String::with_capacity( - OAUTH_HTML_HEADER.len() - + OAUTH_HTML_LOGIN_HEADER_DEVICE.len() - + OAUTH_HTML_LOGIN_CODE.len() - + OAUTH_HTML_LOGIN_FORM.len() - + OAUTH_HTML_FOOTER.len() - + code.len() - + 16, - ); - - response.push_str(&OAUTH_HTML_HEADER.replace("@@@", "/auth")); - response.push_str(OAUTH_HTML_LOGIN_HEADER_DEVICE); - response.push_str(&OAUTH_HTML_LOGIN_CODE.replace("@@@", &code)); - response.push_str(&OAUTH_HTML_LOGIN_FORM.replace("@@@", "about:blank")); - response.push_str(OAUTH_HTML_FOOTER); - - HtmlResponse::new(response).into_http_response() - } - - // Handles POST request from the device authorization form - pub async fn handle_user_device_auth_post( - &self, - req: &mut HttpRequest, - remote_addr: IpAddr, - ) -> HttpResponse { - // Parse form - let fields = match FormData::from_request(req, MAX_POST_LEN).await { - Ok(fields) => fields, - Err(err) => return err, - }; - - enum Response { - Success, - Failed, - InvalidCode, - } - - let code = if let Some(oauth) = fields - .get("code") - .and_then(|code| self.inner.oauth_codes.get_with_ttl(code)) - { - if (STATUS_PENDING..STATUS_PENDING + self.core.jmap.oauth_max_auth_attempts) - .contains(&oauth.status.load(atomic::Ordering::Relaxed)) - { - if let (Some(email), Some(password)) = (fields.get("email"), fields.get("password")) - { - if let AuthResult::Success(id) = - self.authenticate_plain(email, password, remote_addr).await - { - oauth - .account_id - .store(id.primary_id(), atomic::Ordering::Relaxed); - oauth - .status - .store(STATUS_AUTHORIZED, atomic::Ordering::Relaxed); - Response::Success - } else { - oauth.status.fetch_add(1, atomic::Ordering::Relaxed); - Response::Failed - } - } else { - Response::Failed - } - } else { - Response::InvalidCode - } - } else { - Response::InvalidCode - }; - - let mut response = String::with_capacity( - OAUTH_HTML_HEADER.len() - + OAUTH_HTML_LOGIN_HEADER_DEVICE.len() - + OAUTH_HTML_LOGIN_CODE.len() - + OAUTH_HTML_LOGIN_FORM.len() - + OAUTH_HTML_FOOTER.len() - + USER_CODE_LEN - + 17, - ); - response.push_str(&OAUTH_HTML_HEADER.replace("@@@", "/auth")); - - match code { - Response::Success => { - response.push_str(OAUTH_HTML_LOGIN_SUCCESS); - } - Response::Failed => { - response.push_str(OAUTH_HTML_LOGIN_HEADER_FAILED); - response.push_str( - &OAUTH_HTML_LOGIN_CODE.replace("@@@", fields.get("code").unwrap_or_default()), - ); - response.push_str(&OAUTH_HTML_LOGIN_FORM.replace("@@@", "about:blank")); - } - Response::InvalidCode => { - response.push_str( - &OAUTH_HTML_ERROR.replace("@@@", "Invalid or expired authentication code."), - ); - } /*Response::Error => { - response.push_str(&OAUTH_HTML_ERROR.replace( - "@@@", - "There was a problem processing your request, please try again later.", - )); - }*/ - } - - response.push_str(OAUTH_HTML_FOOTER); - - HtmlResponse::new(response).into_http_response() - } -} diff --git a/crates/jmap/src/auth/oauth/mod.rs b/crates/jmap/src/auth/oauth/mod.rs index 962cb6b9..4ff8f066 100644 --- a/crates/jmap/src/auth/oauth/mod.rs +++ b/crates/jmap/src/auth/oauth/mod.rs @@ -21,7 +21,7 @@ * for more details. */ -use std::{collections::HashMap, sync::atomic::AtomicU32}; +use std::collections::HashMap; use http_body_util::BodyExt; use hyper::{header::CONTENT_TYPE, StatusCode}; @@ -29,29 +29,15 @@ use serde::{Deserialize, Serialize}; use crate::api::{http::ToHttpResponse, HtmlResponse, HttpRequest, HttpResponse}; -pub mod device_auth; +pub mod auth; pub mod token; -pub mod user_code; -const OAUTH_HTML_HEADER: &str = include_str!("../../../../../resources/htx/header.htx"); -const OAUTH_HTML_FOOTER: &str = include_str!("../../../../../resources/htx/footer.htx"); -const OAUTH_HTML_LOGIN_HEADER_CLIENT: &str = - include_str!("../../../../../resources/htx/login_hdr_client.htx"); -const OAUTH_HTML_LOGIN_HEADER_DEVICE: &str = - include_str!("../../../../../resources/htx/login_hdr_device.htx"); -const OAUTH_HTML_LOGIN_HEADER_FAILED: &str = - include_str!("../../../../../resources/htx/login_hdr_failed.htx"); -const OAUTH_HTML_LOGIN_FORM: &str = include_str!("../../../../../resources/htx/login.htx"); -const OAUTH_HTML_LOGIN_CODE: &str = include_str!("../../../../../resources/htx/login_code.htx"); -const OAUTH_HTML_LOGIN_CODE_HIDDEN: &str = - include_str!("../../../../../resources/htx/login_code_hidden.htx"); -const OAUTH_HTML_LOGIN_SUCCESS: &str = - include_str!("../../../../../resources/htx/login_success.htx"); -const OAUTH_HTML_ERROR: &str = include_str!("../../../../../resources/htx/error.htx"); - -const STATUS_AUTHORIZED: u32 = 0; -const STATUS_TOKEN_ISSUED: u32 = 1; -const STATUS_PENDING: u32 = 2; +#[derive(Copy, Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +pub enum OAuthStatus { + Authorized, + TokenIssued, + Pending, +} const DEVICE_CODE_LEN: usize = 40; const USER_CODE_LEN: usize = 8; @@ -73,12 +59,12 @@ pub struct OAuth { pub metadata: String, } -#[derive(Debug)] +#[derive(Debug, Serialize, Deserialize)] pub struct OAuthCode { - pub status: AtomicU32, - pub account_id: AtomicU32, + pub status: OAuthStatus, + pub account_id: u32, pub client_id: String, - pub redirect_uri: Option, + pub params: String, } #[derive(Debug, Serialize, Deserialize)] @@ -192,7 +178,7 @@ impl OAuthMetadata { let base_url = base_url.as_ref(); OAuthMetadata { issuer: base_url.into(), - authorization_endpoint: format!("{}/auth/code", base_url), + authorization_endpoint: format!("{}/authorize/code", base_url), token_endpoint: format!("{}/auth/token", base_url), grant_types_supported: vec![ "authorization_code".to_string(), @@ -207,9 +193,15 @@ impl OAuthMetadata { } #[derive(Debug, Serialize, Deserialize)] -pub struct OAuthCodeRequest { - pub client_id: String, - pub redirect_uri: Option, +#[serde(tag = "type")] +pub enum OAuthCodeRequest { + Code { + client_id: String, + redirect_uri: Option, + }, + Device { + code: String, + }, } impl TokenResponse { diff --git a/crates/jmap/src/auth/oauth/token.rs b/crates/jmap/src/auth/oauth/token.rs index 0e93cf2f..4728c18f 100644 --- a/crates/jmap/src/auth/oauth/token.rs +++ b/crates/jmap/src/auth/oauth/token.rs @@ -21,7 +21,7 @@ * for more details. */ -use std::{sync::atomic, time::SystemTime}; +use std::time::SystemTime; use directory::QueryBy; use hyper::StatusCode; @@ -30,11 +30,9 @@ use mail_parser::decoders::base64::base64_decode; use store::{ blake3, rand::{thread_rng, Rng}, + write::Bincode, }; -use utils::{ - codec::leb128::{Leb128Iterator, Leb128Vec}, - map::ttl_dashmap::TtlMap, -}; +use utils::codec::leb128::{Leb128Iterator, Leb128Vec}; use crate::{ api::{http::ToHttpResponse, HttpRequest, HttpResponse, JsonResponse}, @@ -43,8 +41,8 @@ use crate::{ }; use super::{ - ErrorType, FormData, OAuthResponse, TokenResponse, CLIENT_ID_MAX_LEN, MAX_POST_LEN, - RANDOM_CODE_LEN, STATUS_AUTHORIZED, STATUS_PENDING, STATUS_TOKEN_ISSUED, + ErrorType, FormData, OAuthCode, OAuthResponse, OAuthStatus, TokenResponse, CLIENT_ID_MAX_LEN, + MAX_POST_LEN, RANDOM_CODE_LEN, }; impl JMAP { @@ -65,34 +63,44 @@ impl JMAP { params.get("client_id"), params.get("redirect_uri"), ) { - if let Some(oauth) = self.inner.oauth_codes.get_with_ttl(code) { - if client_id != oauth.client_id - || redirect_uri != oauth.redirect_uri.as_deref().unwrap_or("") - { - TokenResponse::error(ErrorType::InvalidClient) - } else if oauth.status.load(atomic::Ordering::Relaxed) == STATUS_AUTHORIZED { - // Mark this token as issued - oauth - .status - .store(STATUS_TOKEN_ISSUED, atomic::Ordering::Relaxed); + // Obtain code + match self + .core + .storage + .lookup + .key_get::>(format!("oauth:{code}").into_bytes()) + .await + { + Ok(Some(auth_code)) => { + let oauth = auth_code.inner; + if client_id != oauth.client_id || redirect_uri != oauth.params { + TokenResponse::error(ErrorType::InvalidClient) + } else if oauth.status == OAuthStatus::Authorized { + // Mark this token as issued + if let Err(err) = self + .core + .storage + .lookup + .key_delete(format!("oauth:{code}").into_bytes()) + .await + { + return err.into_http_response(); + } - // Issue token - self.issue_token( - oauth.account_id.load(atomic::Ordering::Relaxed), - &oauth.client_id, - true, - ) - .await - .map(TokenResponse::Granted) - .unwrap_or_else(|err| { - tracing::error!("Failed to generate OAuth token: {}", err); - TokenResponse::error(ErrorType::InvalidRequest) - }) - } else { - TokenResponse::error(ErrorType::InvalidGrant) + // Issue token + self.issue_token(oauth.account_id, &oauth.client_id, true) + .await + .map(TokenResponse::Granted) + .unwrap_or_else(|err| { + tracing::error!("Failed to generate OAuth token: {}", err); + TokenResponse::error(ErrorType::InvalidRequest) + }) + } else { + TokenResponse::error(ErrorType::InvalidGrant) + } } - } else { - TokenResponse::error(ErrorType::AccessDenied) + Ok(None) => TokenResponse::error(ErrorType::AccessDenied), + Err(err) => return err.into_http_response(), } } else { TokenResponse::error(ErrorType::InvalidClient) @@ -100,46 +108,59 @@ impl JMAP { } else if grant_type.eq_ignore_ascii_case("urn:ietf:params:oauth:grant-type:device_code") { response = TokenResponse::error(ErrorType::ExpiredToken); - if let (Some(oauth), Some(client_id)) = ( - params - .get("device_code") - .and_then(|dc| self.inner.oauth_codes.get_with_ttl(dc)), - params.get("client_id"), - ) { - response = if oauth.client_id != client_id { - TokenResponse::error(ErrorType::InvalidClient) - } else { - match oauth.status.load(atomic::Ordering::Relaxed) { - STATUS_AUTHORIZED => { - // Mark this token as issued - oauth - .status - .store(STATUS_TOKEN_ISSUED, atomic::Ordering::Relaxed); + if let (Some(device_code), Some(client_id)) = + (params.get("device_code"), params.get("client_id")) + { + // Obtain code + match self + .core + .storage + .lookup + .key_get::>(format!("oauth:{device_code}").into_bytes()) + .await + { + Ok(Some(auth_code)) => { + let oauth = auth_code.inner; + response = if oauth.client_id != client_id { + TokenResponse::error(ErrorType::InvalidClient) + } else { + match oauth.status { + OAuthStatus::Authorized => { + // Mark this token as issued + if let Err(err) = self + .core + .storage + .lookup + .key_delete(format!("oauth:{device_code}").into_bytes()) + .await + { + return err.into_http_response(); + } - // Issue token - self.issue_token( - oauth.account_id.load(atomic::Ordering::Relaxed), - &oauth.client_id, - true, - ) - .await - .map(TokenResponse::Granted) - .unwrap_or_else(|err| { - tracing::error!("Failed to generate OAuth token: {}", err); - TokenResponse::error(ErrorType::InvalidRequest) - }) - } - status - if (STATUS_PENDING - ..STATUS_PENDING + self.core.jmap.oauth_max_auth_attempts) - .contains(&status) => - { - TokenResponse::error(ErrorType::AuthorizationPending) - } - STATUS_TOKEN_ISSUED => TokenResponse::error(ErrorType::ExpiredToken), - _ => TokenResponse::error(ErrorType::AccessDenied), + // Issue token + self.issue_token(oauth.account_id, &oauth.client_id, true) + .await + .map(TokenResponse::Granted) + .unwrap_or_else(|err| { + tracing::error!( + "Failed to generate OAuth token: {}", + err + ); + TokenResponse::error(ErrorType::InvalidRequest) + }) + } + OAuthStatus::Pending => { + TokenResponse::error(ErrorType::AuthorizationPending) + } + OAuthStatus::TokenIssued => { + TokenResponse::error(ErrorType::ExpiredToken) + } + } + }; } - }; + Ok(None) => (), + Err(err) => return err.into_http_response(), + } } } else if grant_type.eq_ignore_ascii_case("refresh_token") { if let Some(refresh_token) = params.get("refresh_token") { diff --git a/crates/jmap/src/auth/oauth/user_code.rs b/crates/jmap/src/auth/oauth/user_code.rs deleted file mode 100644 index d94fdc54..00000000 --- a/crates/jmap/src/auth/oauth/user_code.rs +++ /dev/null @@ -1,247 +0,0 @@ -/* - * Copyright (c) 2023 Stalwart Labs Ltd. - * - * This file is part of Stalwart Mail Server. - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU Affero General Public License as - * published by the Free Software Foundation, either version 3 of - * the License, or (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU Affero General Public License for more details. - * in the LICENSE file at the top-level directory of this distribution. - * You should have received a copy of the GNU Affero General Public License - * along with this program. If not, see . - * - * You can be released from the requirements of the AGPLv3 license by - * purchasing a commercial license. Please contact licensing@stalw.art - * for more details. -*/ - -use std::{ - collections::HashMap, - net::IpAddr, - sync::Arc, - time::{Duration, Instant}, -}; - -use common::AuthResult; -use http_body_util::{BodyExt, Full}; -use hyper::{body::Bytes, header, StatusCode}; -use mail_builder::encoders::base64::base64_encode; -use mail_parser::decoders::base64::base64_decode; -use std::fmt::Write; -use store::rand::{distributions::Alphanumeric, thread_rng, Rng}; -use utils::map::ttl_dashmap::TtlMap; - -use crate::{ - api::{http::ToHttpResponse, HtmlResponse, HttpRequest, HttpResponse}, - auth::AccessToken, - JMAP, -}; - -use super::{ - FormData, OAuthCode, CLIENT_ID_MAX_LEN, DEVICE_CODE_LEN, MAX_POST_LEN, OAUTH_HTML_FOOTER, - OAUTH_HTML_HEADER, OAUTH_HTML_LOGIN_CODE_HIDDEN, OAUTH_HTML_LOGIN_FORM, - OAUTH_HTML_LOGIN_HEADER_CLIENT, OAUTH_HTML_LOGIN_HEADER_FAILED, STATUS_AUTHORIZED, -}; - -impl JMAP { - // Code authorization flow, handles an authorization request - pub async fn handle_user_code_auth(&self, req: &mut HttpRequest) -> HttpResponse { - let params = form_urlencoded::parse(req.uri().query().unwrap_or_default().as_bytes()) - .into_owned() - .collect::>(); - let client_id = params - .get("client_id") - .map(|s| s.as_str()) - .unwrap_or_default(); - let redirect_uri = params - .get("redirect_uri") - .map(|s| s.as_str()) - .unwrap_or_default(); - - // Validate clientId - if client_id.len() > CLIENT_ID_MAX_LEN { - return HtmlResponse::with_status( - StatusCode::BAD_REQUEST, - "Client ID is invalid.".to_string(), - ) - .into_http_response(); - } else if !redirect_uri.starts_with("https://") { - return HtmlResponse::with_status( - StatusCode::BAD_REQUEST, - "Redirect URI must be HTTPS".to_string(), - ) - .into_http_response(); - } - - let mut cancel_link = format!("{}?error=access_denied", redirect_uri); - if let Some(state) = params.get("state") { - let _ = write!(cancel_link, "&state={}", state); - } - let code = String::from_utf8( - base64_encode(&bincode::serialize(&(1u32, params)).unwrap_or_default()) - .unwrap_or_default(), - ) - .unwrap(); - - let mut response = String::with_capacity( - OAUTH_HTML_HEADER.len() - + OAUTH_HTML_LOGIN_HEADER_CLIENT.len() - + OAUTH_HTML_LOGIN_CODE_HIDDEN.len() - + OAUTH_HTML_LOGIN_FORM.len() - + OAUTH_HTML_FOOTER.len() - + code.len() - + cancel_link.len() - + 10, - ); - - response.push_str(&OAUTH_HTML_HEADER.replace("@@@", "/auth/code")); - response.push_str(OAUTH_HTML_LOGIN_HEADER_CLIENT); - response.push_str(&OAUTH_HTML_LOGIN_CODE_HIDDEN.replace("@@@", &code)); - response.push_str(&OAUTH_HTML_LOGIN_FORM.replace("@@@", &cancel_link)); - response.push_str(OAUTH_HTML_FOOTER); - - HtmlResponse::new(response).into_http_response() - } - - pub fn issue_client_code( - &self, - access_token: &AccessToken, - client_id: String, - redirect_uri: Option, - ) -> String { - // Generate client code - let client_code = thread_rng() - .sample_iter(Alphanumeric) - .take(DEVICE_CODE_LEN) - .map(char::from) - .collect::(); - - // Add client code - self.inner.oauth_codes.insert_with_ttl( - client_code.clone(), - Arc::new(OAuthCode { - status: STATUS_AUTHORIZED.into(), - account_id: access_token.primary_id().into(), - client_id, - redirect_uri, - }), - Instant::now() + Duration::from_secs(self.core.jmap.oauth_expiry_auth_code), - ); - client_code - } - - // Handles POST request from the code authorization form - pub async fn handle_user_code_auth_post( - &self, - req: &mut HttpRequest, - remote_addr: IpAddr, - ) -> HttpResponse { - // Parse form - let params = match FormData::from_request(req, MAX_POST_LEN).await { - Ok(params) => params, - Err(err) => return err, - }; - - let mut auth_code = None; - let (auth_attempts, code_req) = match params - .get_bytes("code") - .and_then(base64_decode) - .and_then(|bytes| bincode::deserialize::<(u32, HashMap)>(&bytes).ok()) - { - Some(code) => code, - None => { - return HtmlResponse::with_status( - StatusCode::BAD_REQUEST, - "Failed to deserialize code.".to_string(), - ) - .into_http_response(); - } - }; - - // Authenticate user - if let (Some(email), Some(password)) = (params.get("email"), params.get("password")) { - if let AuthResult::Success(access_token) = - self.authenticate_plain(email, password, remote_addr).await - { - auth_code = self - .issue_client_code( - &access_token, - code_req - .get("client_id") - .map(|s| s.as_str()) - .unwrap_or_default() - .to_string(), - code_req.get("redirect_uri").cloned(), - ) - .into(); - } - } - - // Build redirect link - let mut redirect_link = if let Some(auth_code) = &auth_code { - format!( - "{}?code={}", - code_req - .get("redirect_uri") - .map(|s| s.as_str()) - .unwrap_or_default(), - auth_code - ) - } else { - format!( - "{}?error=access_denied", - code_req - .get("redirect_uri") - .map(|s| s.as_str()) - .unwrap_or_default() - ) - }; - if let Some(state) = &code_req.get("state") { - let _ = write!(redirect_link, "&state={}", state); - } - - if auth_code.is_none() && (auth_attempts < self.core.jmap.oauth_max_auth_attempts) { - let code = String::from_utf8( - base64_encode( - &bincode::serialize(&(auth_attempts + 1, code_req)).unwrap_or_default(), - ) - .unwrap_or_default(), - ) - .unwrap(); - - let mut response = String::with_capacity( - OAUTH_HTML_HEADER.len() - + OAUTH_HTML_LOGIN_HEADER_CLIENT.len() - + OAUTH_HTML_LOGIN_CODE_HIDDEN.len() - + OAUTH_HTML_LOGIN_FORM.len() - + OAUTH_HTML_FOOTER.len() - + code.len() - + redirect_link.len() - + 10, - ); - response.push_str(&OAUTH_HTML_HEADER.replace("@@@", "/auth/code")); - response.push_str(OAUTH_HTML_LOGIN_HEADER_FAILED); - response.push_str(&OAUTH_HTML_LOGIN_CODE_HIDDEN.replace("@@@", &code)); - response.push_str(&OAUTH_HTML_LOGIN_FORM.replace("@@@", &redirect_link)); - response.push_str(OAUTH_HTML_FOOTER); - - HtmlResponse::new(response).into_http_response() - } else { - hyper::Response::builder() - .status(StatusCode::MOVED_PERMANENTLY) - .header(header::LOCATION, redirect_link) - .body( - Full::new(Bytes::from(Vec::::new())) - .map_err(|never| match never {}) - .boxed(), - ) - .unwrap() - } - } -} diff --git a/crates/jmap/src/email/crypto.rs b/crates/jmap/src/email/crypto.rs index 219efa30..848a4736 100644 --- a/crates/jmap/src/email/crypto.rs +++ b/crates/jmap/src/email/crypto.rs @@ -762,6 +762,7 @@ impl JMAP { } // Save encryption params + let num_certs = params.certs.len(); let mut batch = BatchBuilder::new(); batch .with_account_id(access_token.primary_id()) @@ -770,7 +771,7 @@ impl JMAP { .value(Property::Parameters, ¶ms, F_VALUE); match self.core.storage.data.write(batch.build()).await { Ok(_) => JsonResponse::new(json!({ - "data": (), + "data": num_certs, })) .into_http_response(), Err(err) => err.into_http_response(), diff --git a/crates/jmap/src/lib.rs b/crates/jmap/src/lib.rs index b164dcf1..f650d29f 100644 --- a/crates/jmap/src/lib.rs +++ b/crates/jmap/src/lib.rs @@ -23,7 +23,7 @@ use std::{collections::hash_map::RandomState, fmt::Display, sync::Arc, time::Duration}; -use auth::{oauth::OAuthCode, rate_limit::ConcurrencyLimiters, AccessToken}; +use auth::{rate_limit::ConcurrencyLimiters, AccessToken}; use common::{Core, DeliveryEvent, SharedCore}; use dashmap::DashMap; use directory::QueryBy; @@ -100,7 +100,6 @@ pub struct Inner { pub snowflake_id: SnowflakeIdGenerator, pub concurrency_limiter: DashMap>, - pub oauth_codes: TtlDashMap>, pub state_tx: mpsc::Sender, pub housekeeper_tx: mpsc::Sender, @@ -143,7 +142,6 @@ impl JMAP { RandomState::default(), shard_amount, ), - oauth_codes: TtlDashMap::with_capacity(capacity, shard_amount), state_tx, housekeeper_tx, cache_threads: LruCache::with_capacity( diff --git a/crates/jmap/src/services/housekeeper.rs b/crates/jmap/src/services/housekeeper.rs index 4edd3f96..00e591d0 100644 --- a/crates/jmap/src/services/housekeeper.rs +++ b/crates/jmap/src/services/housekeeper.rs @@ -284,7 +284,6 @@ impl Inner { pub fn purge(&self) { self.sessions.cleanup(); self.access_tokens.cleanup(); - self.oauth_codes.cleanup(); self.concurrency_limiter .retain(|_, limiter| limiter.is_active()); } diff --git a/crates/store/src/write/mod.rs b/crates/store/src/write/mod.rs index 53c3d42d..cf6225c4 100644 --- a/crates/store/src/write/mod.rs +++ b/crates/store/src/write/mod.rs @@ -34,7 +34,7 @@ use utils::{ BlobHash, }; -use crate::{backend::MAX_TOKEN_LENGTH, BlobClass, Deserialize, Serialize}; +use crate::{backend::MAX_TOKEN_LENGTH, BlobClass, Deserialize, Serialize, Value}; use self::assert::AssertValue; @@ -608,6 +608,7 @@ impl BlobClass { } } +#[derive(Debug)] pub struct Bincode { pub inner: T, } @@ -618,6 +619,12 @@ impl Bincode { } } +impl From> for Bincode { + fn from(_: Value<'static>) -> Self { + unreachable!("From Value called on Bincode") + } +} + impl Serialize for &Bincode { fn serialize(self) -> Vec { lz4_flex::compress_prepend_size(&bincode::serialize(&self.inner).unwrap_or_default()) diff --git a/resources/htx/error.htx b/resources/htx/error.htx deleted file mode 100644 index 4fded2e0..00000000 --- a/resources/htx/error.htx +++ /dev/null @@ -1 +0,0 @@ -

@@@

diff --git a/resources/htx/footer.htx b/resources/htx/footer.htx deleted file mode 100644 index 4182b881..00000000 --- a/resources/htx/footer.htx +++ /dev/null @@ -1 +0,0 @@ - diff --git a/resources/htx/header.htx b/resources/htx/header.htx deleted file mode 100644 index ea76175b..00000000 --- a/resources/htx/header.htx +++ /dev/null @@ -1 +0,0 @@ -Stalwart Mail Server - Authorization - - - - - \ No newline at end of file diff --git a/tests/src/jmap/auth_oauth.rs b/tests/src/jmap/auth_oauth.rs index 4647a830..337c99d6 100644 --- a/tests/src/jmap/auth_oauth.rs +++ b/tests/src/jmap/auth_oauth.rs @@ -25,20 +25,28 @@ use std::time::{Duration, Instant}; use bytes::Bytes; use directory::backend::internal::manage::ManageDirectory; -use jmap::auth::oauth::{DeviceAuthResponse, ErrorType, OAuthMetadata, TokenResponse}; +use jmap::auth::oauth::{ + DeviceAuthResponse, ErrorType, OAuthCodeRequest, OAuthMetadata, TokenResponse, +}; use jmap_client::{ client::{Client, Credentials}, mailbox::query::Filter, }; use jmap_proto::types::id::Id; -use reqwest::{header, redirect::Policy}; use serde::de::DeserializeOwned; use store::ahash::AHashMap; -use crate::jmap::{assert_is_empty, mailbox::destroy_all_mailboxes}; +use crate::jmap::{assert_is_empty, mailbox::destroy_all_mailboxes, ManagementApi}; use super::JMAPTest; +#[derive(serde::Deserialize)] +#[allow(dead_code)] +struct OAuthCodeResponse { + code: String, + is_admin: bool, +} + pub async fn test(params: &mut JMAPTest) { println!("Running OAuth tests..."); @@ -59,6 +67,9 @@ pub async fn test(params: &mut JMAPTest) { ) .to_string(); + // Build API + let api = ManagementApi::new(8899, "jdoe@example.com", "12345"); + // Obtain OAuth metadata let metadata: OAuthMetadata = get("https://127.0.0.1:8899/.well-known/oauth-authorization-server").await; @@ -68,43 +79,25 @@ pub async fn test(params: &mut JMAPTest) { // Authorization code flow // ------------------------ - // Build authorization request - let auth_endpoint = format!( - "{}?response_type=token&client_id=OAuthyMcOAuthFace&state=xyz&redirect_uri=https://localhost", - metadata.authorization_endpoint - ); - let mut auth_request = AHashMap::from_iter([ - ("email".to_string(), "jdoe@example.com".to_string()), - ("password".to_string(), "wrong_pass".to_string()), - ( - "code".to_string(), - parse_code_input(get_bytes(&auth_endpoint).await), - ), - ]); - - // Exceeding the max failed attempts should redirect with an access_denied code - assert_eq!( - post_expect_redirect(&metadata.authorization_endpoint, &auth_request).await, - "https://localhost?error=access_denied&state=xyz" - ); - // Authenticate with the correct password - auth_request.insert("password".to_string(), "12345".to_string()); - auth_request.insert( - "code".to_string(), - parse_code_input(get_bytes(&auth_endpoint).await), - ); - let code = parse_code_redirect( - post_expect_redirect(&metadata.authorization_endpoint, &auth_request).await, - "xyz", - ); + let response = api + .post::( + "/api/oauth", + &OAuthCodeRequest::Code { + client_id: "OAuthyMcOAuthFace".to_string(), + redirect_uri: "https://localhost".to_string().into(), + }, + ) + .await + .unwrap() + .unwrap_data(); // Both client_id and redirect_uri have to match let mut token_params = AHashMap::from_iter([ ("client_id".to_string(), "invalid_client".to_string()), ("redirect_uri".to_string(), "https://localhost".to_string()), ("grant_type".to_string(), "authorization_code".to_string()), - ("code".to_string(), code), + ("code".to_string(), response.code), ]); assert_eq!( post::(&metadata.token_endpoint, &token_params).await, @@ -172,45 +165,20 @@ pub async fn test(params: &mut JMAPTest) { } ); - // Invalidate the code by having too many unsuccessful attempts - assert_client_auth( - "jdoe@example.com", - "wrongpass", - &device_response, - "Incorrect", - ) - .await; - assert_client_auth( - "jdoe@example.com", - "wrongpass", - &device_response, - "Invalid or expired authentication code.", - ) - .await; - assert_eq!( - post::(&metadata.token_endpoint, &token_params).await, - TokenResponse::Error { - error: ErrorType::AccessDenied - } - ); - - // Request a new device code - let device_response: DeviceAuthResponse = - post(&metadata.device_authorization_endpoint, &device_code_params).await; - token_params.insert( - "device_code".to_string(), - device_response.device_code.to_string(), - ); - // Let the code expire and make sure it's invalidated tokio::time::sleep(Duration::from_secs(1)).await; - assert_client_auth( - "jdoe@example.com", - "12345", - &device_response, - "Invalid or expired authentication code.", - ) - .await; + assert!( + !api.post::( + "/api/oauth", + &OAuthCodeRequest::Device { + code: device_response.user_code.clone(), + }, + ) + .await + .unwrap() + .unwrap_data(), + "Code should be expired" + ); assert_eq!( post::(&metadata.token_endpoint, &token_params).await, TokenResponse::Error { @@ -225,7 +193,18 @@ pub async fn test(params: &mut JMAPTest) { "device_code".to_string(), device_response.device_code.to_string(), ); - assert_client_auth("jdoe@example.com", "12345", &device_response, "successful").await; + assert!( + api.post::( + "/api/oauth", + &OAuthCodeRequest::Device { + code: device_response.user_code.clone(), + }, + ) + .await + .unwrap() + .unwrap_data(), + "Code is invalid" + ); // Obtain token let time_first_token = Instant::now(); @@ -314,6 +293,13 @@ pub async fn test(params: &mut JMAPTest) { ); // Destroy test accounts + server + .core + .storage + .lookup + .purge_lookup_store() + .await + .unwrap(); params.client.set_default_account_id(john_id); destroy_all_mailboxes(params).await; assert_is_empty(server).await; @@ -339,27 +325,6 @@ async fn post(url: &str, params: &AHashMap) serde_json::from_slice(&post_bytes(url, params).await).unwrap() } -async fn post_expect_redirect(url: &str, params: &AHashMap) -> String { - let response = reqwest::Client::builder() - .timeout(Duration::from_millis(500)) - .danger_accept_invalid_certs(true) - .redirect(Policy::none()) - .build() - .unwrap_or_default() - .post(url) - .form(params) - .send() - .await - .unwrap(); - response - .headers() - .get(header::LOCATION) - .expect("no Location header found in response") - .to_str() - .unwrap() - .to_string() -} - async fn get_bytes(url: &str) -> Bytes { reqwest::Client::builder() .timeout(Duration::from_millis(500)) @@ -379,27 +344,6 @@ async fn get(url: &str) -> T { serde_json::from_slice(&get_bytes(url).await).unwrap() } -async fn assert_client_auth( - email: &str, - pass: &str, - device_response: &DeviceAuthResponse, - expect: &str, -) { - let html_response = String::from_utf8_lossy( - &post_bytes( - &device_response.verification_uri, - &AHashMap::from_iter([ - ("email".to_string(), email.to_string()), - ("password".to_string(), pass.to_string()), - ("code".to_string(), device_response.user_code.to_string()), - ]), - ) - .await, - ) - .into_owned(); - assert!(html_response.contains(expect), "{:#?}", html_response); -} - async fn assert_unauthorized(base_url: &str, token: &str) { match Client::new() .credentials(Credentials::bearer(token)) @@ -415,25 +359,6 @@ async fn assert_unauthorized(base_url: &str, token: &str) { } } -fn parse_code_input(bytes: Bytes) -> String { - let html = String::from_utf8_lossy(&bytes).into_owned(); - if let Some((_, code)) = html.split_once("name=\"code\" value=\"") { - if let Some((code, _)) = code.split_once('\"') { - return code.to_string(); - } - } - panic!("Could not parse code input: {}", html); -} - -fn parse_code_redirect(uri: String, state: &str) -> String { - if let Some(code) = uri.strip_prefix("https://localhost?code=") { - if let Some(code) = code.strip_suffix(&format!("&state={}", state)) { - return code.to_string(); - } - } - panic!("Invalid redirect URI: {}", uri); -} - fn unwrap_token_response(response: TokenResponse) -> (String, Option, u64) { match response { TokenResponse::Granted(granted) => { diff --git a/tests/src/jmap/crypto.rs b/tests/src/jmap/crypto.rs index deb2c04e..52b972ea 100644 --- a/tests/src/jmap/crypto.rs +++ b/tests/src/jmap/crypto.rs @@ -21,17 +21,16 @@ * for more details. */ -use std::{path::PathBuf, time::Duration}; +use std::path::PathBuf; -use ahash::AHashMap; use directory::backend::internal::manage::ManageDirectory; use jmap::email::crypto::{ - try_parse_certs, Algorithm, EncryptMessage, EncryptionMethod, EncryptionParams, + try_parse_certs, Algorithm, EncryptMessage, EncryptionMethod, EncryptionParams, EncryptionType, }; use jmap_proto::types::id::Id; use mail_parser::{MessageParser, MimeHeaders}; -use crate::jmap::delivery::SmtpConnection; +use crate::jmap::{delivery::SmtpConnection, ManagementApi}; use super::JMAPTest; @@ -56,51 +55,41 @@ pub async fn test(params: &mut JMAPTest) { ) .to_string(); - // Update - let mut params = AHashMap::from_iter([ - ("email".to_string(), b"jdoe@example.com".to_vec()), - ("password".to_string(), b"12345".to_vec()), - ]); + // Build API + let api = ManagementApi::new(8899, "jdoe@example.com", "12345"); // Try importing using multiple methods and symmetric algos for (file_name, method, num_certs) in [ ("cert_smime.pem", EncryptionMethod::SMIME, 3), ("cert_pgp.pem", EncryptionMethod::PGP, 1), ] { - params.insert( - "certificate".to_string(), - std::fs::read( - PathBuf::from(env!("CARGO_MANIFEST_DIR")) - .join("resources") - .join("crypto") - .join(file_name), - ) - .unwrap(), - ); + let certs = std::fs::read_to_string( + PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .join("resources") + .join("crypto") + .join(file_name), + ) + .unwrap(); for algo in [Algorithm::Aes128, Algorithm::Aes256] { - let encryption = format!( - "{}-{}", - match method { - EncryptionMethod::PGP => "pgp", - EncryptionMethod::SMIME => "smime", + let request = match method { + EncryptionMethod::PGP => EncryptionType::PGP { + algo, + certs: certs.clone(), }, - match algo { - Algorithm::Aes128 => "128", - Algorithm::Aes256 => "256", - } + EncryptionMethod::SMIME => EncryptionType::SMIME { + algo, + certs: certs.clone(), + }, + }; + + assert_eq!( + api.post::("/api/crypto", &request) + .await + .unwrap() + .unwrap_data(), + num_certs ); - params.insert("encryption".to_string(), encryption.as_bytes().to_vec()); - let response = post(¶ms).await; - assert!( - response.contains(&format!("{num_certs} certificate")), - "got response {response}, expected {num_certs} certs" - ); - assert!( - response.contains(&format!("{method} ({algo})")), - "got response {response}, expected {encryption} algo" - ); - //println!("response = {response}"); } } @@ -145,12 +134,12 @@ pub async fn test(params: &mut JMAPTest) { .await; // Disable encryption - params.remove("certificate"); - params.insert("encryption".to_string(), "disable".as_bytes().to_vec()); - let response = post(¶ms).await; - assert!( - response.contains("Encryption at rest disabled"), - "got response {response}, expected 'Encryption at rest disabled'" + assert_eq!( + api.post::>("/api/crypto", &EncryptionType::Disabled) + .await + .unwrap() + .unwrap_data(), + None ); // Send a new message, which should NOT be encrypted @@ -280,41 +269,3 @@ pub fn check_is_encrypted() { ); } } - -async fn post(params: &AHashMap>) -> String { - let mut form = reqwest::multipart::Form::new(); - for (key, value) in params { - form = if key != "certificate" { - form.text( - key.to_string(), - std::str::from_utf8(value).unwrap().to_string(), - ) - } else { - form.part( - key.to_string(), - reqwest::multipart::Part::bytes(value.to_vec()) - .file_name("certificate") - .mime_str("application/octet-stream") - .unwrap(), - ) - } - } - - String::from_utf8( - reqwest::Client::builder() - .timeout(Duration::from_millis(500)) - .danger_accept_invalid_certs(true) - .build() - .unwrap_or_default() - .post("https://127.0.0.1:8899/crypto") - .multipart(form) - .send() - .await - .unwrap() - .bytes() - .await - .unwrap() - .to_vec(), - ) - .unwrap() -} diff --git a/tests/src/jmap/mod.rs b/tests/src/jmap/mod.rs index f189d5a8..9d1b3b2e 100644 --- a/tests/src/jmap/mod.rs +++ b/tests/src/jmap/mod.rs @@ -23,11 +23,15 @@ use std::{sync::Arc, time::Duration}; -use base64::{engine::general_purpose, Engine}; +use base64::{ + engine::general_purpose::{self, STANDARD}, + Engine, +}; use common::{ config::server::{ServerProtocol, Servers}, Core, }; +use hyper::{header::AUTHORIZATION, Method}; use imap::core::{ImapSessionManager, IMAP}; use jmap::{ api::JmapSessionManager, @@ -35,9 +39,10 @@ use jmap::{ JMAP, }; use jmap_client::client::{Client, Credentials}; -use jmap_proto::types::id::Id; +use jmap_proto::{error::request::RequestError, types::id::Id}; use managesieve::core::ManageSieveSessionManager; use reqwest::header; +use serde::{de::DeserializeOwned, Deserialize, Serialize}; use smtp::core::{SmtpSessionManager, SMTP}; use store::Stores; @@ -612,3 +617,136 @@ pub async fn test_account_login(login: &str, secret: &str) -> Client { .await .unwrap() } + +#[derive(Deserialize)] +#[serde(untagged)] +pub enum Response { + RequestError(RequestError), + Error { error: String, details: String }, + Data { data: T }, +} + +pub struct ManagementApi { + pub port: u16, + pub username: String, + pub password: String, +} + +impl Default for ManagementApi { + fn default() -> Self { + Self { + port: 9980, + username: "admin".to_string(), + password: "secret".to_string(), + } + } +} + +impl ManagementApi { + pub fn new(port: u16, username: &str, password: &str) -> Self { + Self { + port, + username: username.to_string(), + password: password.to_string(), + } + } + + pub async fn post( + &self, + query: &str, + body: &impl Serialize, + ) -> Result, String> { + self.request_raw( + Method::POST, + query, + Some(serde_json::to_string(body).unwrap()), + ) + .await + .map(|result| { + serde_json::from_str::>(&result) + .unwrap_or_else(|err| panic!("{err}: {result}")) + }) + } + + pub async fn request( + &self, + method: Method, + query: &str, + ) -> Result, String> { + self.request_raw(method, query, None).await.map(|result| { + serde_json::from_str::>(&result) + .unwrap_or_else(|err| panic!("{err}: {result}")) + }) + } + + async fn request_raw( + &self, + method: Method, + query: &str, + body: Option, + ) -> Result { + let mut request = reqwest::Client::builder() + .timeout(Duration::from_millis(500)) + .danger_accept_invalid_certs(true) + .build() + .unwrap() + .request(method, format!("https://127.0.0.1:{}{query}", self.port)); + + if let Some(body) = body { + request = request.body(body); + } + + request + .header( + AUTHORIZATION, + format!( + "Basic {}", + STANDARD.encode(format!("{}:{}", self.username, self.password).as_bytes()) + ), + ) + .send() + .await + .map_err(|err| err.to_string())? + .bytes() + .await + .map(|bytes| String::from_utf8(bytes.to_vec()).unwrap()) + .map_err(|err| err.to_string()) + } +} + +impl Response { + pub fn unwrap_data(self) -> T { + match self { + Response::Data { data } => data, + Response::Error { error, details } => { + panic!("Expected data, found error {error:?}: {details:?}") + } + Response::RequestError(err) => { + panic!("Expected data, found error {err:?}") + } + } + } + + pub fn try_unwrap_data(self) -> Option { + match self { + Response::Data { data } => Some(data), + Response::RequestError(error) if error.status == 404 => None, + Response::Error { error, details } => { + panic!("Expected data, found error {error:?}: {details:?}") + } + Response::RequestError(err) => { + panic!("Expected data, found error {err:?}") + } + } + } + + pub fn unwrap_error(self) -> (String, String) { + match self { + Response::Error { error, details } => (error, details), + Response::Data { .. } => panic!("Expected error, found data."), + Response::RequestError(err) => { + panic!("Expected error, found request error {err:?}") + } + } + } +} diff --git a/tests/src/smtp/management/mod.rs b/tests/src/smtp/management/mod.rs index dd04bd39..3be5d6c7 100644 --- a/tests/src/smtp/management/mod.rs +++ b/tests/src/smtp/management/mod.rs @@ -21,82 +21,5 @@ * for more details. */ -use std::time::Duration; - -use jmap_proto::error::request::RequestError; -use reqwest::{header::AUTHORIZATION, Method}; -use serde::{de::DeserializeOwned, Deserialize}; - pub mod queue; pub mod report; - -#[derive(Deserialize)] -#[serde(untagged)] -pub enum Response { - RequestError(RequestError), - Error { error: String, details: String }, - Data { data: T }, -} - -pub async fn send_manage_request( - method: Method, - query: &str, -) -> Result, String> { - send_manage_request_raw(method, query).await.map(|result| { - serde_json::from_str::>(&result).unwrap_or_else(|err| panic!("{err}: {result}")) - }) -} - -pub async fn send_manage_request_raw(method: Method, query: &str) -> Result { - reqwest::Client::builder() - .timeout(Duration::from_millis(500)) - .danger_accept_invalid_certs(true) - .build() - .unwrap() - .request(method, format!("https://127.0.0.1:9980{query}")) - .header(AUTHORIZATION, "Basic YWRtaW46c2VjcmV0") - .send() - .await - .map_err(|err| err.to_string())? - .bytes() - .await - .map(|bytes| String::from_utf8(bytes.to_vec()).unwrap()) - .map_err(|err| err.to_string()) -} - -impl Response { - pub fn unwrap_data(self) -> T { - match self { - Response::Data { data } => data, - Response::Error { error, details } => { - panic!("Expected data, found error {error:?}: {details:?}") - } - Response::RequestError(err) => { - panic!("Expected data, found error {err:?}") - } - } - } - - pub fn try_unwrap_data(self) -> Option { - match self { - Response::Data { data } => Some(data), - Response::RequestError(error) if error.status == 404 => None, - Response::Error { error, details } => { - panic!("Expected data, found error {error:?}: {details:?}") - } - Response::RequestError(err) => { - panic!("Expected data, found error {err:?}") - } - } - } - - pub fn unwrap_error(self) -> (String, String) { - match self { - Response::Error { error, details } => (error, details), - Response::Data { .. } => panic!("Expected error, found data."), - Response::RequestError(err) => { - panic!("Expected error, found request error {err:?}") - } - } - } -} diff --git a/tests/src/smtp/management/queue.rs b/tests/src/smtp/management/queue.rs index ea11b2f0..fdcc35e3 100644 --- a/tests/src/smtp/management/queue.rs +++ b/tests/src/smtp/management/queue.rs @@ -31,7 +31,10 @@ use mail_auth::MX; use mail_parser::DateTime; use reqwest::{header::AUTHORIZATION, Method, StatusCode}; -use crate::smtp::{management::send_manage_request, outbound::TestServer, session::TestSession}; +use crate::{ + jmap::ManagementApi, + smtp::{outbound::TestServer, session::TestSession}, +}; use smtp::queue::{manager::SpawnQueue, QueueId, Status}; const LOCAL: &str = r#" @@ -189,7 +192,9 @@ async fn manage_queue() { ); // Fetch and validate messages - let ids = send_manage_request::>(Method::GET, "/api/queue/messages") + let api = ManagementApi::default(); + let ids = api + .request::>(Method::GET, "/api/queue/messages") .await .unwrap() .unwrap_data() @@ -198,7 +203,7 @@ async fn manage_queue() { let mut id_map = AHashMap::new(); let mut id_map_rev = AHashMap::new(); let mut test_search = String::new(); - for (message, id) in get_messages(&ids).await.into_iter().zip(ids) { + for (message, id) in api.get_messages(&ids).await.into_iter().zip(ids) { let message = message.unwrap(); let env_id = message.env_id.as_ref().unwrap().clone(); @@ -295,7 +300,8 @@ async fn manage_queue() { ), ] { let expected_ids = HashSet::from_iter(expected_ids.into_iter().map(|s| s.to_string())); - let ids = send_manage_request::>(Method::GET, &query) + let ids = api + .request::>(Method::GET, &query) .await .unwrap() .unwrap_data() @@ -308,23 +314,23 @@ async fn manage_queue() { // Retry delivery for id in [id_map.get("e").unwrap(), id_map.get("f").unwrap()] { - assert!( - send_manage_request::(Method::PATCH, &format!("/api/queue/messages/{id}",)) - .await - .unwrap() - .unwrap_data(), - ); + assert!(api + .request::(Method::PATCH, &format!("/api/queue/messages/{id}",)) + .await + .unwrap() + .unwrap_data(),); } - assert!(send_manage_request::( - Method::PATCH, - &format!( - "/api/queue/messages/{}?filter=example1.org&at=2200-01-01T00:00:00Z", - id_map.get("a").unwrap(), + assert!(api + .request::( + Method::PATCH, + &format!( + "/api/queue/messages/{}?filter=example1.org&at=2200-01-01T00:00:00Z", + id_map.get("a").unwrap(), + ) ) - ) - .await - .unwrap() - .unwrap_data()); + .await + .unwrap() + .unwrap_data()); // Expect delivery to john@foobar.org tokio::time::sleep(Duration::from_millis(100)).await; @@ -342,13 +348,14 @@ async fn manage_queue() { // Message 'e' should be gone, 'f' should have retry_num == 2 // while 'a' should have a retry time of 2200-01-01T00:00:00Z for example1.org - let mut messages = get_messages(&[ - *id_map.get("e").unwrap(), - *id_map.get("f").unwrap(), - *id_map.get("a").unwrap(), - ]) - .await - .into_iter(); + let mut messages = api + .get_messages(&[ + *id_map.get("e").unwrap(), + *id_map.get("f").unwrap(), + *id_map.get("a").unwrap(), + ]) + .await + .into_iter(); assert_eq!(messages.next().unwrap(), None); assert_eq!( messages @@ -380,7 +387,7 @@ async fn manage_queue() { ("d", ""), ] { assert!( - send_manage_request::( + api.request::( Method::DELETE, &format!( "/api/queue/messages/{}{}{}", @@ -396,7 +403,7 @@ async fn manage_queue() { ); } assert_eq!( - send_manage_request::>(Method::GET, "/api/queue/messages") + api.request::>(Method::GET, "/api/queue/messages") .await .unwrap() .unwrap_data() @@ -404,15 +411,16 @@ async fn manage_queue() { .len(), 3 ); - for (message, id) in get_messages(&[ - *id_map.get("a").unwrap(), - *id_map.get("b").unwrap(), - *id_map.get("c").unwrap(), - *id_map.get("d").unwrap(), - ]) - .await - .into_iter() - .zip(["a", "b", "c", "d"]) + for (message, id) in api + .get_messages(&[ + *id_map.get("a").unwrap(), + *id_map.get("b").unwrap(), + *id_map.get("c").unwrap(), + *id_map.get("d").unwrap(), + ]) + .await + .into_iter() + .zip(["a", "b", "c", "d"]) { if ["b", "d"].contains(&id) { assert_eq!(message, None); @@ -481,17 +489,19 @@ fn assert_timestamp(timestamp: &DateTime, expected: i64, ctx: &str, message: &Me } } -async fn get_messages(ids: &[QueueId]) -> Vec> { - let mut results = Vec::with_capacity(ids.len()); +impl ManagementApi { + async fn get_messages(&self, ids: &[QueueId]) -> Vec> { + let mut results = Vec::with_capacity(ids.len()); - for id in ids { - let message = - send_manage_request::(Method::GET, &format!("/api/queue/messages/{id}",)) + for id in ids { + let message = self + .request::(Method::GET, &format!("/api/queue/messages/{id}",)) .await .unwrap() .try_unwrap_data(); - results.push(message); - } + results.push(message); + } - results + results + } } diff --git a/tests/src/smtp/management/report.rs b/tests/src/smtp/management/report.rs index 49c32721..68ae38bf 100644 --- a/tests/src/smtp/management/report.rs +++ b/tests/src/smtp/management/report.rs @@ -38,9 +38,9 @@ use mail_auth::{ }; use reqwest::Method; -use crate::smtp::{ - management::{queue::List, send_manage_request}, - outbound::TestServer, +use crate::{ + jmap::ManagementApi, + smtp::{management::queue::List, outbound::TestServer}, }; use smtp::reporting::{scheduler::SpawnReport, DmarcEvent, TlsEvent}; @@ -135,7 +135,9 @@ async fn manage_reports() { .await; // List reports - let ids = send_manage_request::>(Method::GET, "/api/queue/reports") + let api = ManagementApi::default(); + let ids = api + .request::>(Method::GET, "/api/queue/reports") .await .unwrap() .unwrap_data() @@ -143,7 +145,7 @@ async fn manage_reports() { assert_eq!(ids.len(), 4); let mut id_map = AHashMap::new(); let mut id_map_rev = AHashMap::new(); - for (report, id) in get_reports(&ids).await.into_iter().zip(ids) { + for (report, id) in api.get_reports(&ids).await.into_iter().zip(ids) { let mut parts = id.split('!'); let report = report.unwrap(); let mut id_num = if parts.next().unwrap() == "t" { @@ -189,7 +191,8 @@ async fn manage_reports() { ("/api/queue/reports?domain=foobar.net&type=tls", vec!["d"]), ] { let expected_ids = HashSet::from_iter(expected_ids.into_iter().map(|s| s.to_string())); - let ids = send_manage_request::>(Method::GET, query) + let ids = api + .request::>(Method::GET, query) .await .unwrap() .unwrap_data() @@ -203,7 +206,7 @@ async fn manage_reports() { // Cancel reports for id in ["a", "b"] { assert!( - send_manage_request::( + api.request::( Method::DELETE, &format!("/api/queue/reports/{}", id_map.get(id).unwrap(),) ) @@ -214,7 +217,7 @@ async fn manage_reports() { ); } assert_eq!( - send_manage_request::>(Method::GET, "/api/queue/reports") + api.request::>(Method::GET, "/api/queue/reports") .await .unwrap() .unwrap_data() @@ -222,31 +225,34 @@ async fn manage_reports() { .len(), 2 ); - let mut ids = get_reports(&[ - id_map.get("a").unwrap().clone(), - id_map.get("b").unwrap().clone(), - id_map.get("c").unwrap().clone(), - id_map.get("d").unwrap().clone(), - ]) - .await - .into_iter(); + let mut ids = api + .get_reports(&[ + id_map.get("a").unwrap().clone(), + id_map.get("b").unwrap().clone(), + id_map.get("c").unwrap().clone(), + id_map.get("d").unwrap().clone(), + ]) + .await + .into_iter(); assert!(ids.next().unwrap().is_none()); assert!(ids.next().unwrap().is_none()); assert!(ids.next().unwrap().is_some()); assert!(ids.next().unwrap().is_some()); } -async fn get_reports(ids: &[String]) -> Vec> { - let mut results = Vec::with_capacity(ids.len()); +impl ManagementApi { + async fn get_reports(&self, ids: &[String]) -> Vec> { + let mut results = Vec::with_capacity(ids.len()); - for id in ids { - let report = - send_manage_request::(Method::GET, &format!("/api/queue/reports/{id}",)) + for id in ids { + let report = self + .request::(Method::GET, &format!("/api/queue/reports/{id}",)) .await .unwrap() .try_unwrap_data(); - results.push(report); - } + results.push(report); + } - results + results + } }