diff --git a/CHANGELOG.md b/CHANGELOG.md index bc0c0e3f..9a66cbe6 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,8 +7,10 @@ All notable changes to this project will be documented in this file. This projec If you are upgrading from v0.16.x, replace the binary (or run `docker pull`). If you are upgrading from v0.15.x and below, please read the [upgrading documentation](https://github.com/stalwartlabs/stalwart/blob/main/UPGRADING/v0_16.md) for more information on how to upgrade from previous versions. ## Added -- OAuth Profile for Open Public Clients ([draft-ietf-mailmaint-oauth-public](https://datatracker.ietf.org/doc/draft-ietf-mailmaint-oauth-public/)) -- OAuth client secret verification for confidential clients. +- OAuth: + - OAuth Profile for Open Public Clients ([draft-ietf-mailmaint-oauth-public](https://datatracker.ietf.org/doc/draft-ietf-mailmaint-oauth-public/)) + - Client secret verification for confidential clients. +- HTTP: Add `redirectRoot` option to `Http` object to allow redirecting requests to the root path to a different path (e.g. `/account`). ## Changed diff --git a/crates/common/src/config/network.rs b/crates/common/src/config/network.rs index b945f0c5..4d741d80 100644 --- a/crates/common/src/config/network.rs +++ b/crates/common/src/config/network.rs @@ -62,6 +62,7 @@ pub struct Http { pub allowed_endpoint: IfBlock, pub response_headers: Vec<(hyper::header::HeaderName, hyper::header::HeaderValue)>, pub use_forwarded: bool, + pub redirect_root: Option, } #[derive(Clone)] @@ -448,6 +449,7 @@ impl Http { rate_anonymous: http.rate_limit_anonymous, response_headers: http_headers, use_forwarded: http.use_x_forwarded, + redirect_root: http.redirect_root, } } } diff --git a/crates/http/src/auth/oauth/auth.rs b/crates/http/src/auth/oauth/auth.rs index 41c54d95..b84d26aa 100644 --- a/crates/http/src/auth/oauth/auth.rs +++ b/crates/http/src/auth/oauth/auth.rs @@ -233,24 +233,22 @@ impl OAuthApiHandler for Server { // Parse and validate PKCE challenge (RFC 7636). let pkce_challenge = match code_challenge { - Some(challenge) => { - match code_challenge_method.as_deref().unwrap_or("plain") { - "S256" => PkceCodeChallenge::S256(challenge), - "plain" if stateless_client.is_none() => { - PkceCodeChallenge::Plain(challenge) - } - _ => { - return Err(trc::AuthEvent::Error - .into_err() - .details("Unsupported PKCE code_challenge_method.")); - } + Some(challenge) => match code_challenge_method.as_deref().unwrap_or("plain") { + "S256" => PkceCodeChallenge::S256(challenge), + "plain" if stateless_client.is_none() => { + PkceCodeChallenge::Plain(challenge) } - } - None => { - if stateless_client.is_some() { + _ => { return Err(trc::AuthEvent::Error .into_err() - .details("A PKCE code_challenge with the S256 method is required.")); + .details("Unsupported PKCE code_challenge_method.")); + } + }, + None => { + if stateless_client.is_some() { + return Err(trc::AuthEvent::Error.into_err().details( + "A PKCE code_challenge with the S256 method is required.", + )); } PkceCodeChallenge::None } diff --git a/crates/http/src/request.rs b/crates/http/src/request.rs index 0d14d913..6b0cfc55 100644 --- a/crates/http/src/request.rs +++ b/crates/http/src/request.rs @@ -611,6 +611,8 @@ impl ParseHttp for Server { if path.next().is_none() { if !external.is_empty() { return Ok(HttpResponse::redirect(format!("/{external}/"))); + } else if let Some(url) = &self.core.network.http.redirect_root { + return Ok(HttpResponse::redirect(url.clone())); } } else if let Some(resource) = self .inner diff --git a/crates/registry/src/schema/properties.rs b/crates/registry/src/schema/properties.rs index 96927a98..a12e6c67 100644 --- a/crates/registry/src/schema/properties.rs +++ b/crates/registry/src/schema/properties.rs @@ -948,6 +948,7 @@ pub enum Property { Recipients = 484, Records = 256, RecurrenceId = 805, + RedirectRoot = 911, RedirectUris = 605, Refresh = 419, RefreshTokenExpiry = 617, diff --git a/crates/registry/src/schema/properties_impl.rs b/crates/registry/src/schema/properties_impl.rs index 0cf0c6f1..afe9d578 100644 --- a/crates/registry/src/schema/properties_impl.rs +++ b/crates/registry/src/schema/properties_impl.rs @@ -1101,6 +1101,7 @@ impl EnumImpl for Property { b"recipients" => Property::Recipients, b"records" => Property::Records, b"recurrenceId" => Property::RecurrenceId, + b"redirectRoot" => Property::RedirectRoot, b"redirectUris" => Property::RedirectUris, b"refresh" => Property::Refresh, b"refreshTokenExpiry" => Property::RefreshTokenExpiry, @@ -2017,6 +2018,7 @@ impl EnumImpl for Property { Property::Recipients => "recipients", Property::Records => "records", Property::RecurrenceId => "recurrenceId", + Property::RedirectRoot => "redirectRoot", Property::RedirectUris => "redirectUris", Property::Refresh => "refresh", Property::RefreshTokenExpiry => "refreshTokenExpiry", @@ -2937,6 +2939,7 @@ impl EnumImpl for Property { 484 => Some(Property::Recipients), 256 => Some(Property::Records), 805 => Some(Property::RecurrenceId), + 911 => Some(Property::RedirectRoot), 605 => Some(Property::RedirectUris), 419 => Some(Property::Refresh), 617 => Some(Property::RefreshTokenExpiry), @@ -3158,7 +3161,7 @@ impl EnumImpl for Property { } } - const COUNT: usize = 911; + const COUNT: usize = 912; } impl serde::Serialize for Property { diff --git a/crates/registry/src/schema/structs.rs b/crates/registry/src/schema/structs.rs index 94720d0c..c5fb832f 100644 --- a/crates/registry/src/schema/structs.rs +++ b/crates/registry/src/schema/structs.rs @@ -2930,6 +2930,8 @@ pub struct Http { pub response_headers: VecMap, #[serde(rename = "useXForwarded")] pub use_x_forwarded: bool, + #[serde(rename = "redirectRoot")] + pub redirect_root: Option, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] diff --git a/crates/registry/src/schema/structs_impl.rs b/crates/registry/src/schema/structs_impl.rs index 4aa7887b..4816267c 100644 --- a/crates/registry/src/schema/structs_impl.rs +++ b/crates/registry/src/schema/structs_impl.rs @@ -21203,7 +21203,7 @@ impl RegistryJsonPropertyPatch for GroupAccount { impl ObjectImpl for Http { const FLAGS: u64 = OBJ_SINGLETON; - const VERSION: u8 = 0; + const VERSION: u8 = 1; const OBJECT: ObjectType = ObjectType::Http; fn validate(&self, errors: &mut Vec) -> bool { @@ -21222,6 +21222,11 @@ impl ObjectImpl for Http { errors.push(ValidationError::required(Property::ResponseHeaders)); } } + if let Some(value) = &self.redirect_root { + if value.is_empty() { + errors.push(ValidationError::required(Property::RedirectRoot)); + } + } errors.len() == neb } @@ -21256,6 +21261,7 @@ impl Pickle for Http { self.use_permissive_cors.pickle(out); self.response_headers.pickle(out); self.use_x_forwarded.pickle(out); + self.redirect_root.pickle(out); } fn unpickle(stream: &mut crate::pickle::PickledStream<'_>) -> Option { @@ -21267,6 +21273,9 @@ impl Pickle for Http { this.use_permissive_cors = Pickle::unpickle(stream)?; this.response_headers = Pickle::unpickle(stream)?; this.use_x_forwarded = Pickle::unpickle(stream)?; + if stream.version() >= 1 { + this.redirect_root = Pickle::unpickle(stream)?; + } Some(this) } } @@ -21290,13 +21299,14 @@ impl Default for Http { use_permissive_cors: false, response_headers: Default::default(), use_x_forwarded: false, + redirect_root: Some("/account".to_string()), } } } impl IntoValue for Http { fn into_value(self) -> JmapValue<'static> { - let mut map = jmap_tools::Map::with_capacity(9); + let mut map = jmap_tools::Map::with_capacity(10); map.insert_unchecked( Property::RateLimitAuthenticated, self.rate_limit_authenticated.into_value(), @@ -21319,6 +21329,7 @@ impl IntoValue for Http { self.response_headers.into_value(), ); map.insert_unchecked(Property::UseXForwarded, self.use_x_forwarded.into_value()); + map.insert_unchecked(Property::RedirectRoot, self.redirect_root.into_value()); JmapValue::Object(map) } } @@ -21341,6 +21352,9 @@ impl RegistryJsonPropertyPatch for Http { .response_headers .patch(pointer.with_validators(&[StringValidator::Trim]), value), Some(Property::UseXForwarded) => self.use_x_forwarded.patch(pointer, value), + Some(Property::RedirectRoot) => self + .redirect_root + .patch(pointer.with_validators(&[StringValidator::Trim]), value), Some(Property::Type) => Ok(MaybeUnpatched::Unpatched { property: Property::Type, value, diff --git a/resources/schema/schema.json.gz b/resources/schema/schema.json.gz index 473a2fa5..2cf95620 100644 Binary files a/resources/schema/schema.json.gz and b/resources/schema/schema.json.gz differ diff --git a/resources/schema/schema.json.sha256 b/resources/schema/schema.json.sha256 index 0d3b6f98..2ec3207b 100644 --- a/resources/schema/schema.json.sha256 +++ b/resources/schema/schema.json.sha256 @@ -1 +1 @@ -fleWj8pl8amEMbzM8N8ku4pLHlm4yYln_xEcXSCADBw \ No newline at end of file +qtv0KUvdELIzxfAvonxvf3PW10RnWMB9iADw0FtHMF4 \ No newline at end of file