diff options
Diffstat (limited to 'crates/jmap/src/auth')
-rw-r--r-- | crates/jmap/src/auth/authenticate.rs | 54 | ||||
-rw-r--r-- | crates/jmap/src/auth/oauth/auth.rs | 8 | ||||
-rw-r--r-- | crates/jmap/src/auth/oauth/mod.rs | 1 | ||||
-rw-r--r-- | crates/jmap/src/auth/oauth/registration.rs | 156 | ||||
-rw-r--r-- | crates/jmap/src/auth/oauth/token.rs | 75 |
5 files changed, 250 insertions, 44 deletions
diff --git a/crates/jmap/src/auth/authenticate.rs b/crates/jmap/src/auth/authenticate.rs index d2f46b09..4e04d9ab 100644 --- a/crates/jmap/src/auth/authenticate.rs +++ b/crates/jmap/src/auth/authenticate.rs @@ -24,6 +24,7 @@ pub trait Authenticator: Sync + Send { &self, req: &HttpRequest, session: &HttpSessionData, + allow_api_access: bool, ) -> impl Future<Output = trc::Result<(InFlight, Arc<AccessToken>)>> + Send; } @@ -32,6 +33,7 @@ impl Authenticator for Server { &self, req: &HttpRequest, session: &HttpSessionData, + allow_api_access: bool, ) -> trc::Result<(InFlight, Arc<AccessToken>)> { if let Some((mechanism, token)) = req.authorization() { let access_token = @@ -43,29 +45,24 @@ impl Authenticator for Server { self.is_auth_allowed_soft(&session.remote_ip).await?; // Decode the base64 encoded credentials - if let Some((username, secret)) = base64_decode(token.as_bytes()) - .and_then(|token| String::from_utf8(token).ok()) - .and_then(|token| { - token.split_once(':').map(|(login, secret)| { - (login.trim().to_lowercase(), secret.to_string()) - }) - }) - { - Credentials::Plain { username, secret } - } else { - return Err(trc::AuthEvent::Error + decode_plain_auth(token).ok_or_else(|| { + trc::AuthEvent::Error .into_err() .details("Failed to decode Basic auth request.") .id(token.to_string()) - .caused_by(trc::location!())); - } + .caused_by(trc::location!()) + })? } else if mechanism.eq_ignore_ascii_case("bearer") { // Enforce anonymous rate limit self.is_anonymous_allowed(&session.remote_ip).await?; - Credentials::OAuthBearer { - token: token.to_string(), - } + decode_bearer_token(token, allow_api_access).ok_or_else(|| { + trc::AuthEvent::Error + .into_err() + .details("Failed to decode Bearer token.") + .id(token.to_string()) + .caused_by(trc::location!()) + })? } else { // Enforce anonymous rate limit self.is_anonymous_allowed(&session.remote_ip).await?; @@ -139,3 +136,28 @@ impl HttpHeaders for HttpRequest { }) } } + +fn decode_plain_auth(token: &str) -> Option<Credentials<String>> { + base64_decode(token.as_bytes()) + .and_then(|token| String::from_utf8(token).ok()) + .and_then(|token| { + token + .split_once(':') + .map(|(login, secret)| Credentials::Plain { + username: login.trim().to_lowercase(), + secret: secret.to_string(), + }) + }) +} + +fn decode_bearer_token(token: &str, allow_api_access: bool) -> Option<Credentials<String>> { + if allow_api_access { + if let Some(token) = token.strip_prefix("api_") { + return decode_plain_auth(token); + } + } + + Some(Credentials::OAuthBearer { + token: token.to_string(), + }) +} diff --git a/crates/jmap/src/auth/oauth/auth.rs b/crates/jmap/src/auth/oauth/auth.rs index 44179cff..a669b60b 100644 --- a/crates/jmap/src/auth/oauth/auth.rs +++ b/crates/jmap/src/auth/oauth/auth.rs @@ -39,6 +39,7 @@ pub struct OAuthMetadata { pub token_endpoint: String, pub authorization_endpoint: String, pub device_authorization_endpoint: String, + pub registration_endpoint: String, pub introspection_endpoint: String, pub grant_types_supported: Vec<String>, pub response_types_supported: Vec<String>, @@ -191,7 +192,7 @@ impl OAuthApiHandler for Server { let client_id = FormData::from_request(req, MAX_POST_LEN, session.session_id) .await? .remove("client_id") - .filter(|client_id| client_id.len() < CLIENT_ID_MAX_LEN) + .filter(|client_id| client_id.len() <= CLIENT_ID_MAX_LEN) .ok_or_else(|| { trc::ResourceEvent::BadParameters .into_err() @@ -277,12 +278,14 @@ impl OAuthApiHandler for Server { Ok(JsonResponse::new(OAuthMetadata { authorization_endpoint: format!("{base_url}/authorize/code",), token_endpoint: format!("{base_url}/auth/token"), + device_authorization_endpoint: format!("{base_url}/auth/device"), + introspection_endpoint: format!("{base_url}/auth/introspect"), + registration_endpoint: format!("{base_url}/auth/register"), grant_types_supported: vec![ "authorization_code".to_string(), "implicit".to_string(), "urn:ietf:params:oauth:grant-type:device_code".to_string(), ], - device_authorization_endpoint: format!("{base_url}/auth/device"), response_types_supported: vec![ "code".to_string(), "id_token".to_string(), @@ -290,7 +293,6 @@ impl OAuthApiHandler for Server { "id_token token".to_string(), ], scopes_supported: vec!["openid".to_string(), "offline_access".to_string()], - introspection_endpoint: format!("{base_url}/auth/introspect"), issuer: base_url, }) .into_http_response()) diff --git a/crates/jmap/src/auth/oauth/mod.rs b/crates/jmap/src/auth/oauth/mod.rs index 14d1caa9..738ac989 100644 --- a/crates/jmap/src/auth/oauth/mod.rs +++ b/crates/jmap/src/auth/oauth/mod.rs @@ -12,6 +12,7 @@ use crate::api::{http::fetch_body, HttpRequest}; pub mod auth; pub mod openid; +pub mod registration; pub mod token; #[derive(Copy, Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] diff --git a/crates/jmap/src/auth/oauth/registration.rs b/crates/jmap/src/auth/oauth/registration.rs new file mode 100644 index 00000000..7c151fcf --- /dev/null +++ b/crates/jmap/src/auth/oauth/registration.rs @@ -0,0 +1,156 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs Ltd <hello@stalw.art> + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use std::future::Future; + +use common::{ + auth::oauth::registration::{ClientRegistrationRequest, ClientRegistrationResponse}, + Server, +}; +use directory::{ + backend::internal::{lookup::DirectoryStore, manage::ManageDirectory, PrincipalField}, + Permission, Principal, QueryBy, Type, +}; +use rand::{distributions::Alphanumeric, thread_rng, Rng}; +use trc::{AddContext, AuthEvent}; + +use crate::{ + api::{ + http::{fetch_body, HttpSessionData, ToHttpResponse}, + HttpRequest, HttpResponse, JsonResponse, + }, + auth::{authenticate::Authenticator, rate_limit::RateLimiter}, +}; + +use super::ErrorType; + +pub trait ClientRegistrationHandler: Sync + Send { + fn handle_oauth_registration_request( + &self, + req: &mut HttpRequest, + session: HttpSessionData, + ) -> impl Future<Output = trc::Result<HttpResponse>> + Send; + + fn validate_client_registration( + &self, + client_id: &str, + redirect_uri: Option<&str>, + account_id: u32, + ) -> impl Future<Output = trc::Result<Option<ErrorType>>> + Send; +} +impl ClientRegistrationHandler for Server { + async fn handle_oauth_registration_request( + &self, + req: &mut HttpRequest, + session: HttpSessionData, + ) -> trc::Result<HttpResponse> { + if !self.core.oauth.allow_anonymous_client_registration { + // Authenticate request + let (_, access_token) = self.authenticate_headers(req, &session, true).await?; + + // Validate permissions + access_token.assert_has_permission(Permission::OauthClientRegistration)?; + } else { + self.is_anonymous_allowed(&session.remote_ip).await?; + } + + // Parse request + let body = fetch_body(req, 20 * 1024, session.session_id).await; + let request = serde_json::from_slice::<ClientRegistrationRequest>( + body.as_deref().unwrap_or_default(), + ) + .map_err(|err| { + trc::EventType::Resource(trc::ResourceEvent::BadParameters).from_json_error(err) + })?; + + // Generate client ID + let client_id = thread_rng() + .sample_iter(Alphanumeric) + .take(20) + .map(|ch| char::from(ch.to_ascii_lowercase())) + .collect::<String>(); + self.store() + .create_principal( + Principal::new(u32::MAX, Type::OauthClient) + .with_field(PrincipalField::Name, client_id.clone()) + .with_field(PrincipalField::Urls, request.redirect_uris.clone()) + .with_opt_field(PrincipalField::Description, request.client_name.clone()) + .with_field(PrincipalField::Emails, request.contacts.clone()) + .with_opt_field(PrincipalField::Picture, request.logo_uri.clone()), + None, + ) + .await + .caused_by(trc::location!())?; + + trc::event!( + Auth(AuthEvent::ClientRegistration), + Id = client_id.to_string(), + RemoteIp = session.remote_ip + ); + + Ok(JsonResponse::new(ClientRegistrationResponse { + client_id, + request, + ..Default::default() + }) + .into_http_response()) + } + + async fn validate_client_registration( + &self, + client_id: &str, + redirect_uri: Option<&str>, + account_id: u32, + ) -> trc::Result<Option<ErrorType>> { + if !self.core.oauth.require_client_authentication { + return Ok(None); + } + + // Fetch client registration + let found_registration = if let Some(client) = self + .store() + .query(QueryBy::Name(client_id), false) + .await + .caused_by(trc::location!())? + .filter(|p| p.typ() == Type::OauthClient) + { + if let Some(redirect_uri) = redirect_uri { + if client + .get_str_array(PrincipalField::Urls) + .unwrap_or_default() + .iter() + .any(|uri| uri == redirect_uri) + { + return Ok(None); + } + } else { + // Device flow does not require a redirect URI + + return Ok(None); + } + + true + } else { + false + }; + + // Check if the account is allowed to override client registration + if self + .get_cached_access_token(account_id) + .await + .caused_by(trc::location!())? + .has_permission(Permission::OauthClientOverride) + { + return Ok(None); + } + + Ok(Some(if found_registration { + ErrorType::InvalidClient + } else { + ErrorType::InvalidRequest + })) + } +} diff --git a/crates/jmap/src/auth/oauth/token.rs b/crates/jmap/src/auth/oauth/token.rs index 677028b9..73e15829 100644 --- a/crates/jmap/src/auth/oauth/token.rs +++ b/crates/jmap/src/auth/oauth/token.rs @@ -18,7 +18,8 @@ use crate::api::{ }; use super::{ - ErrorType, FormData, OAuthCode, OAuthResponse, OAuthStatus, TokenResponse, MAX_POST_LEN, + registration::ClientRegistrationHandler, ErrorType, FormData, OAuthCode, OAuthResponse, + OAuthStatus, TokenResponse, MAX_POST_LEN, }; pub trait TokenHandler: Sync + Send { @@ -80,23 +81,35 @@ impl TokenHandler for Server { 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 - self.core - .storage - .lookup - .key_delete(format!("oauth:{code}").into_bytes()) - .await?; + // Validate client id + if let Some(error) = self + .validate_client_registration( + client_id, + redirect_uri.into(), + oauth.account_id, + ) + .await? + { + TokenResponse::error(error) + } else { + // Mark this token as issued + self.core + .storage + .lookup + .key_delete(format!("oauth:{code}").into_bytes()) + .await?; - // Issue token - self.issue_token(oauth.account_id, &oauth.client_id, issuer, true) - .await - .map(TokenResponse::Granted) - .map_err(|err| { - trc::AuthEvent::Error - .into_err() - .details(err) - .caused_by(trc::location!()) - })? + // Issue token + self.issue_token(oauth.account_id, &oauth.client_id, issuer, true) + .await + .map(TokenResponse::Granted) + .map_err(|err| { + trc::AuthEvent::Error + .into_err() + .details(err) + .caused_by(trc::location!()) + })? + } } else { TokenResponse::error(ErrorType::InvalidGrant) } @@ -126,15 +139,26 @@ impl TokenHandler for Server { } else { match oauth.status { OAuthStatus::Authorized => { - // Mark this token as issued - self.core - .storage - .lookup - .key_delete(format!("oauth:{device_code}").into_bytes()) - .await?; + if let Some(error) = self + .validate_client_registration(client_id, None, oauth.account_id) + .await? + { + TokenResponse::error(error) + } else { + // Mark this token as issued + self.core + .storage + .lookup + .key_delete(format!("oauth:{device_code}").into_bytes()) + .await?; - // Issue token - self.issue_token(oauth.account_id, &oauth.client_id, issuer, true) + // Issue token + self.issue_token( + oauth.account_id, + &oauth.client_id, + issuer, + true, + ) .await .map(TokenResponse::Granted) .map_err(|err| { @@ -143,6 +167,7 @@ impl TokenHandler for Server { .details(err) .caused_by(trc::location!()) })? + } } OAuthStatus::Pending => { TokenResponse::error(ErrorType::AuthorizationPending) |