From 4e9b43b26d6e934d654904bc01af40d2a4c4f92e Mon Sep 17 00:00:00 2001 From: mdecimus Date: Sat, 29 Jun 2024 13:11:41 +0200 Subject: [PATCH] Authentication flow improvements --- crates/common/src/lib.rs | 21 +- .../directory/src/backend/internal/manage.rs | 30 +-- crates/directory/src/backend/internal/mod.rs | 28 +++ crates/directory/src/core/secret.rs | 74 ++++-- crates/imap/src/op/authenticate.rs | 12 +- crates/jmap/src/api/management/mod.rs | 13 +- crates/jmap/src/api/management/principal.rs | 227 ++++++++++++++---- crates/jmap/src/auth/authenticate.rs | 20 +- crates/managesieve/src/op/authenticate.rs | 10 +- crates/pop3/src/op/authenticate.rs | 10 +- crates/smtp/src/inbound/auth.rs | 8 +- crates/utils/src/map/ttl_dashmap.rs | 2 +- tests/src/jmap/crypto.rs | 4 +- 13 files changed, 334 insertions(+), 125 deletions(-) diff --git a/crates/common/src/lib.rs b/crates/common/src/lib.rs index c19bccda..f5f193e1 100644 --- a/crates/common/src/lib.rs +++ b/crates/common/src/lib.rs @@ -94,9 +94,14 @@ pub struct Enterprise { pub enum AuthResult { Success(T), - Failure, - Banned, + Failure(AuthFailureReason), +} + +pub enum AuthFailureReason { + InvalidCredentials, MissingTotp, + Banned, + InternalError(DirectoryError), } #[derive(Debug)] @@ -270,7 +275,9 @@ impl Core { return Ok(AuthResult::Success(principal)); } Ok(None) => Ok(()), - Err(DirectoryError::MissingTotpCode) => return Ok(AuthResult::MissingTotp), + Err(DirectoryError::MissingTotpCode) => { + return Ok(AuthResult::Failure(AuthFailureReason::MissingTotp)) + } Err(err) => Err(err), }; @@ -345,7 +352,7 @@ impl Core { .await; } - AuthResult::Failure + AuthResult::Failure(AuthFailureReason::InvalidCredentials) }, ); } @@ -392,7 +399,7 @@ impl Core { .await; } - Ok(AuthResult::Banned) + Ok(AuthResult::Failure(AuthFailureReason::Banned)) } else { // Send webhook event if self.has_webhook_subscribers(WebhookType::AuthFailure) { @@ -409,7 +416,7 @@ impl Core { .await; } - Ok(AuthResult::Failure) + Ok(AuthResult::Failure(AuthFailureReason::InvalidCredentials)) } } else { // Send webhook event @@ -426,7 +433,7 @@ impl Core { ) .await; } - Ok(AuthResult::Failure) + Ok(AuthResult::Failure(AuthFailureReason::InvalidCredentials)) } } } diff --git a/crates/directory/src/backend/internal/manage.rs b/crates/directory/src/backend/internal/manage.rs index 80d8a28f..85eb4408 100644 --- a/crates/directory/src/backend/internal/manage.rs +++ b/crates/directory/src/backend/internal/manage.rs @@ -17,7 +17,7 @@ use crate::{DirectoryError, ManagementError, Principal, QueryBy, Type}; use super::{ lookup::DirectoryStore, PrincipalAction, PrincipalField, PrincipalIdType, PrincipalUpdate, - PrincipalValue, + PrincipalValue, SpecialSecrets, }; #[allow(async_fn_in_trait)] @@ -419,29 +419,25 @@ impl ManageDirectory for Store { PrincipalField::Secrets, PrincipalValue::String(secret), ) => { - let mut do_add = true; - let mut new_secrets = Vec::with_capacity(principal.inner.secrets.len() + 1); - for prev_secret in principal.inner.secrets { - if prev_secret == secret { - do_add = false; - } else if prev_secret.starts_with("otpauth://") - || prev_secret == "$disabled$" - || prev_secret.starts_with("$app$") - { - new_secrets.push(prev_secret); - } + if !principal.inner.secrets.contains(&secret) { + principal.inner.secrets.push(secret); } - if do_add { - new_secrets.push(secret); - } - principal.inner.secrets = new_secrets; } ( PrincipalAction::RemoveItem, PrincipalField::Secrets, PrincipalValue::String(secret), ) => { - principal.inner.secrets.retain(|v| *v != secret); + if secret.is_app_password() || secret.is_otp_auth() { + principal + .inner + .secrets + .retain(|v| *v != secret && !v.starts_with(&secret)); + } else if !secret.is_empty() { + principal.inner.secrets.retain(|v| *v != secret); + } else { + principal.inner.secrets.retain(|v| !v.is_password()); + } } ( PrincipalAction::Set, diff --git a/crates/directory/src/backend/internal/mod.rs b/crates/directory/src/backend/internal/mod.rs index b708a1b5..656f7243 100644 --- a/crates/directory/src/backend/internal/mod.rs +++ b/crates/directory/src/backend/internal/mod.rs @@ -260,3 +260,31 @@ impl FromStr for Type { Type::parse(s).ok_or(()) } } + +pub trait SpecialSecrets { + fn is_disabled(&self) -> bool; + fn is_otp_auth(&self) -> bool; + fn is_app_password(&self) -> bool; + fn is_password(&self) -> bool; +} + +impl SpecialSecrets for T +where + T: AsRef, +{ + fn is_disabled(&self) -> bool { + self.as_ref() == "$disabled$" + } + + fn is_otp_auth(&self) -> bool { + self.as_ref().starts_with("otpauth://") + } + + fn is_app_password(&self) -> bool { + self.as_ref().starts_with("$app$") + } + + fn is_password(&self) -> bool { + !self.is_disabled() && !self.is_otp_auth() && !self.is_app_password() + } +} diff --git a/crates/directory/src/core/secret.rs b/crates/directory/src/core/secret.rs index 783e69ab..39fe487a 100644 --- a/crates/directory/src/core/secret.rs +++ b/crates/directory/src/core/secret.rs @@ -18,49 +18,83 @@ use sha2::Sha512; use tokio::sync::oneshot; use totp_rs::TOTP; +use crate::backend::internal::SpecialSecrets; use crate::DirectoryError; use crate::Principal; impl Principal { pub async fn verify_secret(&self, mut code: &str) -> crate::Result { let mut totp_token = None; + let mut is_totp_token_missing = false; + let mut is_totp_required = false; + let mut is_totp_verified = false; + let mut is_authenticated = false; + let mut is_app_authenticated = false; for secret in &self.secrets { - let mut secret = secret.as_str(); + if secret.is_disabled() { + // Account is disabled, no need to check further - if secret == "$disabled$" { return Ok(false); - } else if secret.starts_with("otpauth://") && totp_token.is_none() { + } else if secret.is_otp_auth() && !is_totp_verified && !is_totp_token_missing { + is_totp_required = true; + let totp_token = if let Some(totp_token) = totp_token { totp_token - } else { - let (_code, _totp_token) = code - .rsplit_once('$') - .filter(|(c, t)| !c.is_empty() && !t.is_empty()) - .ok_or(DirectoryError::MissingTotpCode)?; + } else if let Some((_code, _totp_token)) = code + .rsplit_once('$') + .filter(|(c, t)| !c.is_empty() && !t.is_empty()) + { totp_token = Some(_totp_token); code = _code; _totp_token + } else { + is_totp_token_missing = true; + continue; }; - if !TOTP::from_url(secret) + + // Token needs to validate with at least one of the TOPT secrets + is_totp_verified = TOTP::from_url(secret) .map_err(DirectoryError::InvalidTotpUrl)? .check_current(totp_token) - .unwrap_or(false) - { - return Ok(false); - } - } else if let Some((_, app_secret)) = - secret.strip_prefix("$app$").and_then(|s| s.split_once('$')) - { - secret = app_secret; + .unwrap_or(false); } - if verify_secret_hash(secret, code).await { - return Ok(true); + if is_app_authenticated || is_authenticated { + continue; + } + + if let Some((_, app_secret)) = + secret.strip_prefix("$app$").and_then(|s| s.split_once('$')) + { + is_app_authenticated = verify_secret_hash(app_secret, code).await; + } else { + is_authenticated = verify_secret_hash(secret, code).await; } } - Ok(false) + if is_authenticated { + if !is_totp_required { + // Authenticated without TOTP enabled + + Ok(true) + } else if is_totp_token_missing { + // Only let the client know if the TOTP code is missing + // if the password is correct + + Err(DirectoryError::MissingTotpCode) + } else { + // Return the TOTP verification status + + Ok(is_totp_verified) + } + } else if is_app_authenticated { + // App passwords do not require TOTP + + Ok(true) + } else { + Ok(false) + } } } diff --git a/crates/imap/src/op/authenticate.rs b/crates/imap/src/op/authenticate.rs index d1bed741..5846bb44 100644 --- a/crates/imap/src/op/authenticate.rs +++ b/crates/imap/src/op/authenticate.rs @@ -4,7 +4,9 @@ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL */ -use common::{config::server::ServerProtocol, listener::SessionStream, AuthResult}; +use common::{ + config::server::ServerProtocol, listener::SessionStream, AuthFailureReason, AuthResult, +}; use imap_proto::{ protocol::{authenticate::Mechanism, capability::Capability}, receiver::{self, Request}, @@ -110,12 +112,14 @@ impl Session { .await { AuthResult::Success(token) => Some(token), - AuthResult::Failure => None, - AuthResult::MissingTotp => { + AuthResult::Failure( + AuthFailureReason::InvalidCredentials | AuthFailureReason::InternalError(_), + ) => None, + AuthResult::Failure(AuthFailureReason::MissingTotp) => { is_totp_error = true; None } - AuthResult::Banned => return Err(()), + AuthResult::Failure(AuthFailureReason::Banned) => return Err(()), } } Credentials::OAuthBearer { token } => { diff --git a/crates/jmap/src/api/management/mod.rs b/crates/jmap/src/api/management/mod.rs index 64e3f5aa..fffcad91 100644 --- a/crates/jmap/src/api/management/mod.rs +++ b/crates/jmap/src/api/management/mod.rs @@ -81,14 +81,15 @@ impl JMAP { .into_http_response() } "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, + "account" => match (path.get(1).copied().unwrap_or_default(), req.method()) { + ("crypto", &Method::POST) => self.handle_crypto_post(access_token, body).await, + ("crypto", &Method::GET) => self.handle_crypto_get(access_token).await, + ("auth", &Method::GET) => self.handle_account_auth_get(access_token).await, + ("auth", &Method::POST) => { + self.handle_account_auth_post(req, access_token, body).await + } _ => RequestError::not_found().into_http_response(), }, - "password" if req.method() == Method::POST => { - self.handle_change_password(req, access_token, body).await - } _ => RequestError::not_found().into_http_response(), } } diff --git a/crates/jmap/src/api/management/principal.rs b/crates/jmap/src/api/management/principal.rs index 03969bc2..096fa366 100644 --- a/crates/jmap/src/api/management/principal.rs +++ b/crates/jmap/src/api/management/principal.rs @@ -8,8 +8,8 @@ use std::sync::Arc; use directory::{ backend::internal::{ - lookup::DirectoryStore, manage::ManageDirectory, PrincipalField, PrincipalUpdate, - PrincipalValue, + lookup::DirectoryStore, manage::ManageDirectory, PrincipalAction, PrincipalField, + PrincipalUpdate, PrincipalValue, SpecialSecrets, }, DirectoryError, DirectoryInner, ManagementError, Principal, QueryBy, Type, }; @@ -53,6 +53,27 @@ pub struct PrincipalResponse { pub description: Option, } +#[derive(Debug, serde::Serialize, serde::Deserialize)] +#[serde(tag = "type")] +#[serde(rename_all = "camelCase")] +pub enum AccountAuthRequest { + SetPassword { password: String }, + EnableOtpAuth { url: String }, + DisableOtpAuth { url: Option }, + AddAppPassword { name: String }, + RemoveAppPassword { name: String }, +} + +#[derive(Debug, serde::Serialize, serde::Deserialize)] +pub struct AccountAuthResponse { + #[serde(rename = "otpEnabled")] + pub otp_auth: bool, + #[serde(rename = "isAdministrator")] + pub is_admin: bool, + #[serde(rename = "appPasswords")] + pub app_passwords: Vec, +} + impl JMAP { pub async fn handle_manage_principal( &self, @@ -223,10 +244,15 @@ impl JMAP { .delete_account(QueryBy::Id(account_id)) .await { - Ok(_) => JsonResponse::new(json!({ - "data": (), - })) - .into_http_response(), + Ok(_) => { + // Remove entries from cache + self.inner.sessions.retain(|_, id| id.item != account_id); + + JsonResponse::new(json!({ + "data": (), + })) + .into_http_response() + } Err(err) => err.into_http_response(), } } @@ -246,6 +272,9 @@ impl JMAP { return response; } } + let is_password_change = changes + .iter() + .any(|change| matches!(change.field, PrincipalField::Secrets)); match self .core @@ -254,10 +283,19 @@ impl JMAP { .update_account(QueryBy::Id(account_id), changes) .await { - Ok(_) => JsonResponse::new(json!({ - "data": (), - })) - .into_http_response(), + Ok(_) => { + if is_password_change { + // Remove entries from cache + self.inner + .sessions + .retain(|_, id| id.item != account_id); + } + + JsonResponse::new(json!({ + "data": (), + })) + .into_http_response() + } Err(err) => err.into_http_response(), } } @@ -272,14 +310,71 @@ impl JMAP { } } - pub async fn handle_change_password( + pub async fn handle_account_auth_get(&self, access_token: Arc) -> HttpResponse { + let mut response = AccountAuthResponse { + otp_auth: false, + is_admin: access_token.is_super_user(), + app_passwords: Vec::new(), + }; + + if access_token.primary_id() != u32::MAX { + match self + .core + .storage + .directory + .query(QueryBy::Id(access_token.primary_id()), false) + .await + { + Ok(Some(principal)) => { + for secret in principal.secrets { + if secret.is_otp_auth() { + response.otp_auth = true; + } else if let Some((app_name, _)) = + secret.strip_prefix("$app$").and_then(|s| s.split_once('$')) + { + response.app_passwords.push(app_name.to_string()); + } + } + } + Ok(None) => { + return RequestError::not_found().into_http_response(); + } + Err(err) => return err.into_http_response(), + } + } + + JsonResponse::new(json!({ + "data": response, + })) + .into_http_response() + } + + pub async fn handle_account_auth_post( &self, req: &HttpRequest, access_token: Arc, body: Option>, ) -> HttpResponse { + // Parse request + let requests = match serde_json::from_slice::>( + body.as_deref().unwrap_or_default(), + ) { + Ok(request) => request, + Err(err) => return err.into_http_response(), + }; + if requests.is_empty() { + return RequestError::invalid_parameters().into_http_response(); + } + // Make sure the user authenticated using Basic auth - if req + if requests.iter().any(|r| { + matches!( + r, + AccountAuthRequest::DisableOtpAuth { .. } + | AccountAuthRequest::EnableOtpAuth { .. } + | AccountAuthRequest::SetPassword { .. } + ) + }) && req .headers() .get(header::AUTHORIZATION) .and_then(|h| h.to_str().ok()) @@ -291,32 +386,38 @@ impl JMAP { .into_http_response(); } - // Obtain new password - let new_password = match String::from_utf8(body.unwrap_or_default()) { - Ok(new_password) if !new_password.is_empty() => new_password, - _ => { - return ManagementApiError::Other { - details: "Invalid change password request".into(), - } - .into_http_response() - } - }; - // Handle Fallback admin password changes if access_token.is_super_user() && access_token.primary_id() == u32::MAX { - return match self - .core - .storage - .config - .set([("authentication.fallback-admin.secret", new_password)]) - .await - { - Ok(_) => JsonResponse::new(json!({ - "data": (), - })) - .into_http_response(), - Err(err) => err.into_http_response(), - }; + match requests.into_iter().next().unwrap() { + AccountAuthRequest::SetPassword { password } => { + return match self + .core + .storage + .config + .set([("authentication.fallback-admin.secret", password)]) + .await + { + Ok(_) => { + // Remove entries from cache + self.inner.sessions.retain(|_, id| id.item != u32::MAX); + + JsonResponse::new(json!({ + "data": (), + })) + .into_http_response() + } + Err(err) => err.into_http_response(), + }; + } + _ => { + return ManagementApiError::Other { + details: + "Fallback administrator accounts do not support 2FA or AppPasswords" + .into(), + } + .into_http_response() + } + } } // Make sure the current directory supports updates @@ -324,24 +425,56 @@ impl JMAP { return response; } + // Build actions + let mut actions = Vec::with_capacity(requests.len()); + for request in requests { + let (action, secret) = match request { + AccountAuthRequest::SetPassword { password } => { + actions.push(PrincipalUpdate { + action: PrincipalAction::RemoveItem, + field: PrincipalField::Secrets, + value: PrincipalValue::String(String::new()), + }); + + (PrincipalAction::AddItem, password) + } + AccountAuthRequest::EnableOtpAuth { url } => (PrincipalAction::AddItem, url), + AccountAuthRequest::DisableOtpAuth { url } => ( + PrincipalAction::RemoveItem, + url.unwrap_or_else(|| "otpauth://".to_string()), + ), + AccountAuthRequest::AddAppPassword { name } => (PrincipalAction::AddItem, name), + AccountAuthRequest::RemoveAppPassword { name } => { + (PrincipalAction::RemoveItem, name) + } + }; + + actions.push(PrincipalUpdate { + action, + field: PrincipalField::Secrets, + value: PrincipalValue::String(secret), + }); + } + // Update password match self .core .storage .data - .update_account( - QueryBy::Id(access_token.primary_id()), - vec![PrincipalUpdate::add_item( - PrincipalField::Secrets, - PrincipalValue::String(new_password), - )], - ) + .update_account(QueryBy::Id(access_token.primary_id()), actions) .await { - Ok(_) => JsonResponse::new(json!({ - "data": (), - })) - .into_http_response(), + Ok(_) => { + // Remove entries from cache + self.inner + .sessions + .retain(|_, id| id.item != access_token.primary_id()); + + JsonResponse::new(json!({ + "data": (), + })) + .into_http_response() + } Err(err) => err.into_http_response(), } } diff --git a/crates/jmap/src/auth/authenticate.rs b/crates/jmap/src/auth/authenticate.rs index 7a2d99d0..e615661d 100644 --- a/crates/jmap/src/auth/authenticate.rs +++ b/crates/jmap/src/auth/authenticate.rs @@ -6,7 +6,9 @@ use std::{net::IpAddr, sync::Arc, time::Instant}; -use common::{config::server::ServerProtocol, listener::limiter::InFlight, AuthResult}; +use common::{ + config::server::ServerProtocol, listener::limiter::InFlight, AuthFailureReason, AuthResult, +}; use directory::{Principal, QueryBy}; use hyper::header; use jmap_proto::error::request::RequestError; @@ -51,9 +53,9 @@ impl JMAP { .await { AuthResult::Success(access_token) => Some(access_token), - AuthResult::MissingTotp => { + AuthResult::Failure(AuthFailureReason::MissingTotp) => { return Err(RequestError::blank( - 401, + 403, "TOTP code required", concat!( "A TOTP code is required to authenticate this account. ", @@ -165,13 +167,13 @@ impl JMAP { .await { Ok(AuthResult::Success(principal)) => AuthResult::Success(AccessToken::new(principal)), - Ok(AuthResult::Failure) => { - let _ = self.is_auth_allowed_hard(&remote_ip).await; - AuthResult::Failure + Ok(AuthResult::Failure(reason)) => { + if !matches!(reason, AuthFailureReason::MissingTotp) { + let _ = self.is_auth_allowed_hard(&remote_ip).await; + } + AuthResult::Failure(reason) } - Ok(AuthResult::Banned) => AuthResult::Banned, - Ok(AuthResult::MissingTotp) => AuthResult::MissingTotp, - Err(_) => AuthResult::Failure, + Err(err) => AuthResult::Failure(AuthFailureReason::InternalError(err)), } } diff --git a/crates/managesieve/src/op/authenticate.rs b/crates/managesieve/src/op/authenticate.rs index c44f4892..a65bafcd 100644 --- a/crates/managesieve/src/op/authenticate.rs +++ b/crates/managesieve/src/op/authenticate.rs @@ -7,7 +7,7 @@ use common::{ config::server::ServerProtocol, listener::{limiter::ConcurrencyLimiter, SessionStream}, - AuthResult, + AuthFailureReason, AuthResult, }; use imap::op::authenticate::{decode_challenge_oauth, decode_challenge_plain}; use imap_proto::{ @@ -93,12 +93,14 @@ impl Session { .await { AuthResult::Success(token) => Some(token), - AuthResult::Failure => None, - AuthResult::MissingTotp => { + AuthResult::Failure( + AuthFailureReason::InvalidCredentials | AuthFailureReason::InternalError(_), + ) => None, + AuthResult::Failure(AuthFailureReason::MissingTotp) => { is_totp_error = true; None } - AuthResult::Banned => { + AuthResult::Failure(AuthFailureReason::Banned) => { return Err(StatusResponse::bye( "Too many authentication requests from this IP address.", )) diff --git a/crates/pop3/src/op/authenticate.rs b/crates/pop3/src/op/authenticate.rs index d302e9a1..123f0dd9 100644 --- a/crates/pop3/src/op/authenticate.rs +++ b/crates/pop3/src/op/authenticate.rs @@ -7,7 +7,7 @@ use common::{ config::server::ServerProtocol, listener::{limiter::ConcurrencyLimiter, SessionStream}, - AuthResult, + AuthFailureReason, AuthResult, }; use imap::op::authenticate::{decode_challenge_oauth, decode_challenge_plain}; use jmap::auth::rate_limit::ConcurrencyLimiters; @@ -92,12 +92,14 @@ impl Session { .await { AuthResult::Success(token) => Some(token), - AuthResult::Failure => None, - AuthResult::MissingTotp => { + AuthResult::Failure( + AuthFailureReason::InvalidCredentials | AuthFailureReason::InternalError(_), + ) => None, + AuthResult::Failure(AuthFailureReason::MissingTotp) => { is_totp_error = true; None } - AuthResult::Banned => { + AuthResult::Failure(AuthFailureReason::Banned) => { self.write_err("Too many authentication requests from this IP address.") .await?; return Err(()); diff --git a/crates/smtp/src/inbound/auth.rs b/crates/smtp/src/inbound/auth.rs index 15ef30f3..4eeb0913 100644 --- a/crates/smtp/src/inbound/auth.rs +++ b/crates/smtp/src/inbound/auth.rs @@ -4,7 +4,7 @@ * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL */ -use common::{listener::SessionStream, AuthResult}; +use common::{listener::SessionStream, AuthFailureReason, AuthResult}; use mail_parser::decoders::base64::base64_decode; use mail_send::Credentials; use smtp_proto::{IntoString, AUTH_LOGIN, AUTH_OAUTHBEARER, AUTH_PLAIN, AUTH_XOAUTH2}; @@ -195,7 +195,7 @@ impl Session { .await?; return Ok(false); } - Ok(AuthResult::Failure) => { + Ok(AuthResult::Failure(AuthFailureReason::InvalidCredentials)) => { tracing::debug!( parent: &self.span, context = "auth", @@ -207,7 +207,7 @@ impl Session { .auth_error(b"535 5.7.8 Authentication credentials invalid.\r\n") .await; } - Ok(AuthResult::Banned) => { + Ok(AuthResult::Failure(AuthFailureReason::Banned)) => { tracing::debug!( parent: &self.span, context = "auth", @@ -217,7 +217,7 @@ impl Session { return Err(()); } - Ok(AuthResult::MissingTotp) => { + Ok(AuthResult::Failure(AuthFailureReason::MissingTotp)) => { tracing::debug!( parent: &self.span, context = "auth", diff --git a/crates/utils/src/map/ttl_dashmap.rs b/crates/utils/src/map/ttl_dashmap.rs index 7cdec783..5d351aa7 100644 --- a/crates/utils/src/map/ttl_dashmap.rs +++ b/crates/utils/src/map/ttl_dashmap.rs @@ -12,7 +12,7 @@ pub type TtlDashMap = DashMap, ahash::RandomState>; #[derive(Debug, Clone)] pub struct LruItem { - item: V, + pub item: V, valid_until: Instant, } diff --git a/tests/src/jmap/crypto.rs b/tests/src/jmap/crypto.rs index 8e435b03..6d90d35b 100644 --- a/tests/src/jmap/crypto.rs +++ b/tests/src/jmap/crypto.rs @@ -67,7 +67,7 @@ pub async fn test(params: &mut JMAPTest) { }; assert_eq!( - api.post::("/api/crypto", &request) + api.post::("/api/account/crypto", &request) .await .unwrap() .unwrap_data(), @@ -118,7 +118,7 @@ pub async fn test(params: &mut JMAPTest) { // Disable encryption assert_eq!( - api.post::>("/api/crypto", &EncryptionType::Disabled) + api.post::>("/api/account/crypto", &EncryptionType::Disabled) .await .unwrap() .unwrap_data(),