diff --git a/crates/common/src/auth/oauth/config.rs b/crates/common/src/auth/oauth/config.rs index a283d458..79db96f7 100644 --- a/crates/common/src/auth/oauth/config.rs +++ b/crates/common/src/auth/oauth/config.rs @@ -36,6 +36,9 @@ pub struct OAuthConfig { pub oauth_expiry_refresh_token_renew: u64, pub oauth_max_auth_attempts: u32, + pub allow_anonymous_client_registration: bool, + pub require_client_authentication: bool, + pub oidc_expiry_id_token: u64, pub oidc_signing_secret: Secret, pub oidc_signature_algorithm: SignatureAlgorithm, @@ -179,6 +182,12 @@ impl OAuthConfig { .property_or_default::("oauth.oidc.expiry.id-token", "15m") .unwrap_or_else(|| Duration::from_secs(15 * 60)) .as_secs(), + allow_anonymous_client_registration: config + .property_or_default("oauth.client-registration.anonymous", "false") + .unwrap_or(false), + require_client_authentication: config + .property_or_default("oauth.client-registration.required", "false") + .unwrap_or(true), oidc_signing_secret, oidc_signature_algorithm, oidc_jwks, @@ -197,6 +206,8 @@ impl Default for OAuthConfig { oauth_expiry_refresh_token_renew: Default::default(), oauth_max_auth_attempts: Default::default(), oidc_expiry_id_token: Default::default(), + allow_anonymous_client_registration: Default::default(), + require_client_authentication: Default::default(), oidc_signing_secret: Secret::Bytes("secret".to_string().into_bytes()), oidc_signature_algorithm: SignatureAlgorithm::HS256, oidc_jwks: Resource { diff --git a/crates/common/src/auth/oauth/mod.rs b/crates/common/src/auth/oauth/mod.rs index 680b6e68..575b7591 100644 --- a/crates/common/src/auth/oauth/mod.rs +++ b/crates/common/src/auth/oauth/mod.rs @@ -8,6 +8,7 @@ pub mod config; pub mod crypto; pub mod introspect; pub mod oidc; +pub mod registration; pub mod token; pub const DEVICE_CODE_LEN: usize = 40; diff --git a/crates/common/src/auth/oauth/registration.rs b/crates/common/src/auth/oauth/registration.rs new file mode 100644 index 00000000..2408aba5 --- /dev/null +++ b/crates/common/src/auth/oauth/registration.rs @@ -0,0 +1,181 @@ +/* + * SPDX-FileCopyrightText: 2020 Stalwart Labs Ltd + * + * SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL + */ + +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; + +#[derive(Serialize, Deserialize, Debug, Default)] +#[serde(rename_all = "snake_case")] +pub struct ClientRegistrationRequest { + pub redirect_uris: Vec, + + #[serde(default)] + #[serde(skip_serializing_if = "Vec::is_empty")] + pub response_types: Vec, + + #[serde(default)] + #[serde(skip_serializing_if = "Vec::is_empty")] + pub grant_types: Vec, + + #[serde(default)] + #[serde(skip_serializing_if = "Option::is_none")] + pub application_type: Option, + + #[serde(default)] + #[serde(skip_serializing_if = "Vec::is_empty")] + pub contacts: Vec, + + #[serde(default)] + #[serde(skip_serializing_if = "Option::is_none")] + pub client_name: Option, + + #[serde(default)] + #[serde(skip_serializing_if = "Option::is_none")] + pub logo_uri: Option, + + #[serde(default)] + #[serde(skip_serializing_if = "Option::is_none")] + pub client_uri: Option, + + #[serde(default)] + #[serde(skip_serializing_if = "Option::is_none")] + pub policy_uri: Option, + + #[serde(default)] + #[serde(skip_serializing_if = "Option::is_none")] + pub tos_uri: Option, + + #[serde(default)] + #[serde(skip_serializing_if = "Option::is_none")] + pub jwks_uri: Option, + + #[serde(default)] + #[serde(skip_serializing_if = "Option::is_none")] + pub jwks: Option, // Using serde_json::Value for flexibility + + #[serde(default)] + #[serde(skip_serializing_if = "Option::is_none")] + pub sector_identifier_uri: Option, + + #[serde(default)] + #[serde(skip_serializing_if = "Option::is_none")] + pub subject_type: Option, + + #[serde(default)] + #[serde(skip_serializing_if = "Option::is_none")] + pub id_token_signed_response_alg: Option, + + #[serde(default)] + #[serde(skip_serializing_if = "Option::is_none")] + pub id_token_encrypted_response_alg: Option, + + #[serde(default)] + #[serde(skip_serializing_if = "Option::is_none")] + pub id_token_encrypted_response_enc: Option, + + #[serde(default)] + #[serde(skip_serializing_if = "Option::is_none")] + pub userinfo_signed_response_alg: Option, + + #[serde(default)] + #[serde(skip_serializing_if = "Option::is_none")] + pub userinfo_encrypted_response_alg: Option, + + #[serde(default)] + #[serde(skip_serializing_if = "Option::is_none")] + pub userinfo_encrypted_response_enc: Option, + + #[serde(default)] + #[serde(skip_serializing_if = "Option::is_none")] + pub request_object_signing_alg: Option, + + #[serde(default)] + #[serde(skip_serializing_if = "Option::is_none")] + pub request_object_encryption_alg: Option, + + #[serde(default)] + #[serde(skip_serializing_if = "Option::is_none")] + pub request_object_encryption_enc: Option, + + #[serde(default)] + #[serde(skip_serializing_if = "Option::is_none")] + pub token_endpoint_auth_method: Option, + + #[serde(default)] + #[serde(skip_serializing_if = "Option::is_none")] + pub token_endpoint_auth_signing_alg: Option, + + #[serde(default)] + #[serde(skip_serializing_if = "Option::is_none")] + pub default_max_age: Option, + + #[serde(default)] + #[serde(skip_serializing_if = "Option::is_none")] + pub require_auth_time: Option, + + #[serde(default)] + #[serde(skip_serializing_if = "Vec::is_empty")] + pub default_acr_values: Vec, + + #[serde(default)] + #[serde(skip_serializing_if = "Option::is_none")] + pub initiate_login_uri: Option, + + #[serde(default)] + #[serde(skip_serializing_if = "Vec::is_empty")] + pub request_uris: Vec, + + #[serde(flatten)] + #[serde(skip_serializing_if = "HashMap::is_empty")] + pub additional_fields: HashMap, +} + +#[derive(Serialize, Deserialize, Debug, Default)] +#[serde(rename_all = "snake_case")] +pub struct ClientRegistrationResponse { + // Required fields + pub client_id: String, + + // Optional fields specific to the response + #[serde(skip_serializing_if = "Option::is_none")] + pub client_secret: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub registration_access_token: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub registration_client_uri: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub client_id_issued_at: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub client_secret_expires_at: Option, + + // Echo back the request + #[serde(flatten)] + pub request: ClientRegistrationRequest, +} + +#[derive(Serialize, Deserialize, Debug)] +#[serde(rename_all = "lowercase")] +pub enum ApplicationType { + Web, + Native, +} + +#[derive(Serialize, Deserialize, Debug)] +#[serde(rename_all = "lowercase")] +pub enum SubjectType { + Pairwise, + Public, +} + +#[derive(Serialize, Deserialize, Debug)] +#[serde(rename_all = "snake_case")] +pub enum TokenEndpointAuthMethod { + ClientSecretPost, + ClientSecretBasic, + ClientSecretJwt, + PrivateKeyJwt, + None, +} diff --git a/crates/directory/src/backend/internal/manage.rs b/crates/directory/src/backend/internal/manage.rs index 7ee269bc..d672af4d 100644 --- a/crates/directory/src/backend/internal/manage.rs +++ b/crates/directory/src/backend/internal/manage.rs @@ -15,7 +15,9 @@ use store::{ }; use trc::AddContext; -use crate::{Permission, Principal, QueryBy, Type, ROLE_ADMIN, ROLE_TENANT_ADMIN, ROLE_USER}; +use crate::{ + Permission, Principal, QueryBy, Type, MAX_TYPE_ID, ROLE_ADMIN, ROLE_TENANT_ADMIN, ROLE_USER, +}; use super::{ lookup::DirectoryStore, PrincipalAction, PrincipalField, PrincipalInfo, PrincipalUpdate, @@ -271,16 +273,7 @@ impl ManageDirectory for Store { principal.set(PrincipalField::Tenant, tenant_id); - if matches!( - principal.typ, - Type::Individual - | Type::Group - | Type::List - | Type::Role - | Type::Location - | Type::Resource - | Type::Other - ) { + if !matches!(principal.typ, Type::Tenant | Type::Domain) { if let Some(domain) = name.split('@').nth(1) { if self .get_principal_info(domain) @@ -513,6 +506,7 @@ impl ManageDirectory for Store { Type::Other, Type::Location, Type::Domain, + Type::ApiKey, ], &[PrincipalField::Name], 0, @@ -771,7 +765,12 @@ impl ManageDirectory for Store { Type::Other, ][..], Type::List => &[Type::Individual, Type::Group][..], - Type::Other | Type::Domain | Type::Tenant | Type::Individual => &[][..], + Type::Other + | Type::Domain + | Type::Tenant + | Type::Individual + | Type::ApiKey + | Type::OauthClient => &[][..], Type::Role => &[Type::Role][..], }; let mut valid_domains = AHashSet::new(); @@ -784,16 +783,7 @@ impl ManageDirectory for Store { let new_name = new_name.to_lowercase(); if principal.inner.name() != new_name { if tenant_id.is_some() - && matches!( - principal.inner.typ, - Type::Individual - | Type::Group - | Type::List - | Type::Role - | Type::Location - | Type::Resource - | Type::Other - ) + && !matches!(principal.inner.typ, Type::Tenant | Type::Domain) { if let Some(domain) = new_name.split('@').nth(1) { if self @@ -978,7 +968,7 @@ impl ManageDirectory for Store { PrincipalField::Quota, PrincipalValue::IntegerList(quotas), ) if matches!(principal.inner.typ, Type::Tenant) - && quotas.len() <= (Type::Role as usize + 2) => + && quotas.len() <= (MAX_TYPE_ID + 2) => { principal.inner.set(PrincipalField::Quota, quotas); } diff --git a/crates/directory/src/backend/internal/mod.rs b/crates/directory/src/backend/internal/mod.rs index 9806d184..4268f09c 100644 --- a/crates/directory/src/backend/internal/mod.rs +++ b/crates/directory/src/backend/internal/mod.rs @@ -408,6 +408,7 @@ pub enum PrincipalField { EnabledPermissions, DisabledPermissions, Picture, + Urls, } #[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] @@ -486,6 +487,7 @@ impl PrincipalField { PrincipalField::DisabledPermissions => 12, PrincipalField::UsedQuota => 13, PrincipalField::Picture => 14, + PrincipalField::Urls => 15, } } @@ -506,6 +508,7 @@ impl PrincipalField { 12 => Some(PrincipalField::DisabledPermissions), 13 => Some(PrincipalField::UsedQuota), 14 => Some(PrincipalField::Picture), + 15 => Some(PrincipalField::Urls), _ => None, } } @@ -527,6 +530,7 @@ impl PrincipalField { PrincipalField::EnabledPermissions => "enabledPermissions", PrincipalField::DisabledPermissions => "disabledPermissions", PrincipalField::Picture => "picture", + PrincipalField::Urls => "urls", } } @@ -547,6 +551,7 @@ impl PrincipalField { "enabledPermissions" => Some(PrincipalField::EnabledPermissions), "disabledPermissions" => Some(PrincipalField::DisabledPermissions), "picture" => Some(PrincipalField::Picture), + "urls" => Some(PrincipalField::Urls), _ => None, } } diff --git a/crates/directory/src/core/mod.rs b/crates/directory/src/core/mod.rs index 11bc7f5c..a91852a2 100644 --- a/crates/directory/src/core/mod.rs +++ b/crates/directory/src/core/mod.rs @@ -183,6 +183,18 @@ impl Permission { Permission::SieveRenameScript => "Rename Sieve scripts", Permission::SieveCheckScript => "Validate Sieve scripts", Permission::SieveHaveSpace => "Check available space for Sieve scripts", + Permission::OauthClientRegistration => "Register OAuth clients", + Permission::OauthClientOverride => "Override OAuth client settings", + Permission::ApiKeyList => "View API keys", + Permission::ApiKeyGet => "Retrieve specific API keys", + Permission::ApiKeyCreate => "Create new API keys", + Permission::ApiKeyUpdate => "Modify API keys", + Permission::ApiKeyDelete => "Remove API keys", + Permission::OauthClientList => "View OAuth clients", + Permission::OauthClientGet => "Retrieve specific OAuth clients", + Permission::OauthClientCreate => "Create new OAuth clients", + Permission::OauthClientUpdate => "Modify OAuth clients", + Permission::OauthClientDelete => "Remove OAuth clients", } } } diff --git a/crates/directory/src/core/principal.rs b/crates/directory/src/core/principal.rs index ef126329..4eba98e9 100644 --- a/crates/directory/src/core/principal.rs +++ b/crates/directory/src/core/principal.rs @@ -591,6 +591,8 @@ impl Type { Self::Tenant => "tenant", Self::Role => "role", Self::Domain => "domain", + Self::ApiKey => "api-key", + Self::OauthClient => "oauth-client", } } @@ -605,6 +607,8 @@ impl Type { Self::Other => "Other", Self::Role => "Role", Self::Domain => "Domain", + Self::ApiKey => "API Key", + Self::OauthClient => "OAuth Client", } } @@ -619,6 +623,8 @@ impl Type { "superuser" => Some(Type::Individual), // legacy "role" => Some(Type::Role), "domain" => Some(Type::Domain), + "api-key" => Some(Type::ApiKey), + "oauth-client" => Some(Type::OauthClient), _ => None, } } @@ -635,6 +641,8 @@ impl Type { 7 => Type::Domain, 8 => Type::Tenant, 9 => Type::Role, + 10 => Type::ApiKey, + 11 => Type::OauthClient, _ => Type::Other, } } @@ -835,18 +843,17 @@ impl<'de> serde::Deserialize<'de> for Principal { | PrincipalField::Roles | PrincipalField::Lists | PrincipalField::EnabledPermissions - | PrincipalField::DisabledPermissions => { - match map.next_value::()? { - StringOrMany::One(v) => PrincipalValue::StringList(vec![v]), - StringOrMany::Many(v) => { - if !v.is_empty() { - PrincipalValue::StringList(v) - } else { - continue; - } + | PrincipalField::DisabledPermissions + | PrincipalField::Urls => match map.next_value::()? { + StringOrMany::One(v) => PrincipalValue::StringList(vec![v]), + StringOrMany::Many(v) => { + if !v.is_empty() { + PrincipalValue::StringList(v) + } else { + continue; } } - } + }, PrincipalField::UsedQuota => { // consume and ignore map.next_value::()?; diff --git a/crates/directory/src/lib.rs b/crates/directory/src/lib.rs index fc5474c0..2f5ae217 100644 --- a/crates/directory/src/lib.rs +++ b/crates/directory/src/lib.rs @@ -52,8 +52,12 @@ pub enum Type { Domain = 7, Tenant = 8, Role = 9, + ApiKey = 10, + OauthClient = 11, } +pub const MAX_TYPE_ID: usize = 11; + #[derive( Debug, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize, EnumMethods, )] @@ -240,6 +244,24 @@ pub enum Permission { SieveRenameScript, SieveCheckScript, SieveHaveSpace, + + // API keys + ApiKeyList, + ApiKeyGet, + ApiKeyCreate, + ApiKeyUpdate, + ApiKeyDelete, + + // OAuth clients + OauthClientList, + OauthClientGet, + OauthClientCreate, + OauthClientUpdate, + OauthClientDelete, + + // OAuth client registration + OauthClientRegistration, + OauthClientOverride, // WARNING: add new ids at the end (TODO: use static ids) } diff --git a/crates/jmap/src/api/http.rs b/crates/jmap/src/api/http.rs index 8a147a79..6be2bb2d 100644 --- a/crates/jmap/src/api/http.rs +++ b/crates/jmap/src/api/http.rs @@ -37,7 +37,10 @@ use crate::{ api::management::enterprise::telemetry::TelemetryApi, auth::{ authenticate::{Authenticator, HttpHeaders}, - oauth::{auth::OAuthApiHandler, openid::OpenIdHandler, token::TokenHandler, FormData}, + oauth::{ + auth::OAuthApiHandler, openid::OpenIdHandler, registration::ClientRegistrationHandler, + token::TokenHandler, FormData, + }, rate_limit::RateLimiter, }, blob::{download::BlobDownload, upload::BlobUpload, DownloadResponse, UploadResponse}, @@ -99,7 +102,7 @@ impl ParseHttp for Server { ("", &Method::POST) => { // Authenticate request let (_in_flight, access_token) = - self.authenticate_headers(&req, &session).await?; + self.authenticate_headers(&req, &session, false).await?; let request = fetch_body( &mut req, @@ -128,7 +131,7 @@ impl ParseHttp for Server { ("download", &Method::GET) => { // Authenticate request let (_in_flight, access_token) = - self.authenticate_headers(&req, &session).await?; + self.authenticate_headers(&req, &session, false).await?; if let (Some(_), Some(blob_id), Some(name)) = ( path.next().and_then(|p| Id::from_bytes(p.as_bytes())), @@ -157,7 +160,7 @@ impl ParseHttp for Server { ("upload", &Method::POST) => { // Authenticate request let (_in_flight, access_token) = - self.authenticate_headers(&req, &session).await?; + self.authenticate_headers(&req, &session, false).await?; if let Some(account_id) = path.next().and_then(|p| Id::from_bytes(p.as_bytes())) @@ -192,14 +195,14 @@ impl ParseHttp for Server { ("eventsource", &Method::GET) => { // Authenticate request let (_in_flight, access_token) = - self.authenticate_headers(&req, &session).await?; + self.authenticate_headers(&req, &session, false).await?; return self.handle_event_source(req, access_token).await; } ("ws", &Method::GET) => { // Authenticate request let (_in_flight, access_token) = - self.authenticate_headers(&req, &session).await?; + self.authenticate_headers(&req, &session, false).await?; return self .upgrade_websocket_connection(req, access_token, session) @@ -215,7 +218,7 @@ impl ParseHttp for Server { ("jmap", &Method::GET) => { // Authenticate request let (_in_flight, access_token) = - self.authenticate_headers(&req, &session).await?; + self.authenticate_headers(&req, &session, false).await?; return self .handle_session_resource(ctx.resolve_response_url(self).await, access_token) @@ -286,7 +289,7 @@ impl ParseHttp for Server { ("introspect", &Method::POST) => { // Authenticate request let (_in_flight, access_token) = - self.authenticate_headers(&req, &session).await?; + self.authenticate_headers(&req, &session, false).await?; return self .handle_token_introspect(&mut req, &access_token, session.session_id) @@ -295,10 +298,15 @@ impl ParseHttp for Server { ("userinfo", &Method::GET) => { // Authenticate request let (_in_flight, access_token) = - self.authenticate_headers(&req, &session).await?; + self.authenticate_headers(&req, &session, false).await?; return self.handle_userinfo_request(&access_token).await; } + ("register", &Method::POST) => { + return self + .handle_oauth_registration_request(&mut req, session) + .await; + } ("jwks.json", &Method::GET) => { // Limit anonymous requests self.is_anonymous_allowed(&session.remote_ip).await?; @@ -317,11 +325,10 @@ impl ParseHttp for Server { } // Authenticate user - match self.authenticate_headers(&req, &session).await { + match self.authenticate_headers(&req, &session, true).await { Ok((_, access_token)) => { - let body = fetch_body(&mut req, 1024 * 1024, session.session_id).await; return self - .handle_api_manage_request(&req, body, access_token, &session) + .handle_api_manage_request(&mut req, access_token, &session) .await; } Err(err) => { diff --git a/crates/jmap/src/api/management/mod.rs b/crates/jmap/src/api/management/mod.rs index d7a0b704..a61ef970 100644 --- a/crates/jmap/src/api/management/mod.rs +++ b/crates/jmap/src/api/management/mod.rs @@ -39,7 +39,10 @@ use stores::ManageStore; use crate::{auth::oauth::auth::OAuthApiHandler, email::crypto::CryptoHandler}; -use super::{http::HttpSessionData, HttpRequest, HttpResponse}; +use super::{ + http::{fetch_body, HttpSessionData}, + HttpRequest, HttpResponse, +}; use std::future::Future; #[derive(Serialize)] @@ -69,8 +72,7 @@ pub enum ManagementApiError<'x> { pub trait ManagementApi: Sync + Send { fn handle_api_manage_request( &self, - req: &HttpRequest, - body: Option>, + req: &mut HttpRequest, access_token: Arc, session: &HttpSessionData, ) -> impl Future> + Send; @@ -80,11 +82,11 @@ impl ManagementApi for Server { #[allow(unused_variables)] async fn handle_api_manage_request( &self, - req: &HttpRequest, - body: Option>, + req: &mut HttpRequest, access_token: Arc, session: &HttpSessionData, ) -> trc::Result { + let body = fetch_body(req, 1024 * 1024, session.session_id).await; let path = req.uri().path().split('/').skip(2).collect::>(); match path.first().copied().unwrap_or_default() { diff --git a/crates/jmap/src/api/management/principal.rs b/crates/jmap/src/api/management/principal.rs index 764414de..46458954 100644 --- a/crates/jmap/src/api/management/principal.rs +++ b/crates/jmap/src/api/management/principal.rs @@ -95,6 +95,8 @@ impl PrincipalManager for Server { Type::Domain => Permission::DomainCreate, Type::Tenant => Permission::TenantCreate, Type::Role => Permission::RoleCreate, + Type::ApiKey => Permission::ApiKeyCreate, + Type::OauthClient => Permission::OauthClientCreate, Type::Resource | Type::Location | Type::Other => Permission::PrincipalCreate, })?; @@ -175,6 +177,8 @@ impl PrincipalManager for Server { Type::Tenant, Type::Role, Type::Other, + Type::ApiKey, + Type::OauthClient, ] }; for typ in validate_types { @@ -185,6 +189,8 @@ impl PrincipalManager for Server { Type::Domain => Permission::DomainList, Type::Tenant => Permission::TenantList, Type::Role => Permission::RoleList, + Type::ApiKey => Permission::ApiKeyList, + Type::OauthClient => Permission::OauthClientList, Type::Resource | Type::Location | Type::Other => Permission::PrincipalList, })?; } @@ -266,6 +272,8 @@ impl PrincipalManager for Server { Type::Domain => Permission::DomainGet, Type::Tenant => Permission::TenantGet, Type::Role => Permission::RoleGet, + Type::ApiKey => Permission::ApiKeyGet, + Type::OauthClient => Permission::OauthClientGet, Type::Resource | Type::Location | Type::Other => { Permission::PrincipalGet } @@ -301,6 +309,8 @@ impl PrincipalManager for Server { Type::Domain => Permission::DomainDelete, Type::Tenant => Permission::TenantDelete, Type::Role => Permission::RoleDelete, + Type::ApiKey => Permission::ApiKeyDelete, + Type::OauthClient => Permission::OauthClientDelete, Type::Resource | Type::Location | Type::Other => { Permission::PrincipalDelete } @@ -347,6 +357,8 @@ impl PrincipalManager for Server { Type::Domain => Permission::DomainUpdate, Type::Tenant => Permission::TenantUpdate, Type::Role => Permission::RoleUpdate, + Type::ApiKey => Permission::ApiKeyUpdate, + Type::OauthClient => Permission::OauthClientUpdate, Type::Resource | Type::Location | Type::Other => { Permission::PrincipalUpdate } @@ -382,7 +394,8 @@ impl PrincipalManager for Server { | PrincipalField::Picture | PrincipalField::MemberOf | PrincipalField::Members - | PrincipalField::Lists => (), + | PrincipalField::Lists + | PrincipalField::Urls => (), PrincipalField::Tenant => { // Tenants are not allowed to change their tenantId if access_token.tenant.is_some() { 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)>> + Send; } @@ -32,6 +33,7 @@ impl Authenticator for Server { &self, req: &HttpRequest, session: &HttpSessionData, + allow_api_access: bool, ) -> trc::Result<(InFlight, Arc)> { 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> { + 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> { + 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, pub response_types_supported: Vec, @@ -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 + * + * 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> + Send; + + fn validate_client_registration( + &self, + client_id: &str, + redirect_uri: Option<&str>, + account_id: u32, + ) -> impl Future>> + Send; +} +impl ClientRegistrationHandler for Server { + async fn handle_oauth_registration_request( + &self, + req: &mut HttpRequest, + session: HttpSessionData, + ) -> trc::Result { + 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::( + 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::(); + 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> { + 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) diff --git a/crates/trc/src/event/description.rs b/crates/trc/src/event/description.rs index 2e05eee3..c475abae 100644 --- a/crates/trc/src/event/description.rs +++ b/crates/trc/src/event/description.rs @@ -1742,6 +1742,7 @@ impl AuthEvent { AuthEvent::TooManyAttempts => "Too many authentication attempts", AuthEvent::Error => "Authentication error", AuthEvent::TokenExpired => "OAuth token expired", + AuthEvent::ClientRegistration => "OAuth Client registration", } } @@ -1753,6 +1754,7 @@ impl AuthEvent { AuthEvent::TooManyAttempts => "Too many authentication attempts have been made", AuthEvent::Error => "An error occurred with authentication", AuthEvent::TokenExpired => "OAuth authentication token has expired", + AuthEvent::ClientRegistration => "OAuth client successfully registered", } } } diff --git a/crates/trc/src/event/level.rs b/crates/trc/src/event/level.rs index 9156adea..097fb1e5 100644 --- a/crates/trc/src/event/level.rs +++ b/crates/trc/src/event/level.rs @@ -229,7 +229,7 @@ impl EventType { AuthEvent::MissingTotp => Level::Trace, AuthEvent::TooManyAttempts => Level::Warn, AuthEvent::Error => Level::Error, - AuthEvent::Success => Level::Info, + AuthEvent::Success | AuthEvent::ClientRegistration => Level::Info, }, EventType::Config(cause) => match cause { ConfigEvent::ParseError diff --git a/crates/trc/src/lib.rs b/crates/trc/src/lib.rs index 07e6c58f..28271d91 100644 --- a/crates/trc/src/lib.rs +++ b/crates/trc/src/lib.rs @@ -926,6 +926,7 @@ pub enum AuthEvent { TokenExpired, MissingTotp, TooManyAttempts, + ClientRegistration, Error, } diff --git a/crates/trc/src/serializers/binary.rs b/crates/trc/src/serializers/binary.rs index c29d93ce..3f37f895 100644 --- a/crates/trc/src/serializers/binary.rs +++ b/crates/trc/src/serializers/binary.rs @@ -860,6 +860,7 @@ impl EventType { EventType::Security(SecurityEvent::Unauthorized) => 552, EventType::Limit(LimitEvent::TenantQuota) => 553, EventType::Auth(AuthEvent::TokenExpired) => 554, + EventType::Auth(AuthEvent::ClientRegistration) => 555, } } @@ -1460,6 +1461,7 @@ impl EventType { 552 => Some(EventType::Security(SecurityEvent::Unauthorized)), 553 => Some(EventType::Limit(LimitEvent::TenantQuota)), 554 => Some(EventType::Auth(AuthEvent::TokenExpired)), + 555 => Some(EventType::Auth(AuthEvent::ClientRegistration)), _ => None, } } diff --git a/tests/src/jmap/auth_oauth.rs b/tests/src/jmap/auth_oauth.rs index dafe5e4e..7535b373 100644 --- a/tests/src/jmap/auth_oauth.rs +++ b/tests/src/jmap/auth_oauth.rs @@ -9,7 +9,10 @@ use std::time::{Duration, Instant}; use base64::{engine::general_purpose, Engine}; use biscuit::{jwk::JWKSet, SingleOrMultiple, JWT}; use bytes::Bytes; -use common::auth::oauth::introspect::OAuthIntrospect; +use common::auth::oauth::{ + introspect::OAuthIntrospect, + registration::{ClientRegistrationRequest, ClientRegistrationResponse}, +}; use imap_proto::ResponseType; use jmap::auth::oauth::{ auth::OAuthMetadata, openid::OpenIdMetadata, DeviceAuthResponse, ErrorType, OAuthCodeRequest, @@ -20,7 +23,7 @@ use jmap_client::{ mailbox::query::Filter, }; use jmap_proto::types::id::Id; -use serde::de::DeserializeOwned; +use serde::{de::DeserializeOwned, Serialize}; use store::ahash::AHashMap; use crate::{ @@ -72,6 +75,18 @@ pub async fn test(params: &mut JMAPTest) { get("https://127.0.0.1:8899/.well-known/openid-configuration").await; let jwk_set: JWKSet<()> = get(&oidc_metadata.jwks_uri).await; + // Register client + let registration: ClientRegistrationResponse = post_json( + &metadata.registration_endpoint, + None, + &ClientRegistrationRequest { + redirect_uris: vec!["https://localhost".to_string()], + ..Default::default() + }, + ) + .await; + let client_id = registration.client_id; + /*println!("OAuth metadata: {:#?}", metadata); println!("OpenID metadata: {:#?}", oidc_metadata); println!("JWKSet: {:#?}", jwk_set);*/ @@ -85,7 +100,7 @@ pub async fn test(params: &mut JMAPTest) { .post::( "/api/oauth", &OAuthCodeRequest::Code { - client_id: "OAuthyMcOAuthFace".to_string(), + client_id: client_id.to_string(), redirect_uri: "https://localhost".to_string().into(), }, ) @@ -106,7 +121,7 @@ pub async fn test(params: &mut JMAPTest) { error: ErrorType::InvalidClient } ); - token_params.insert("client_id".to_string(), "OAuthyMcOAuthFace".to_string()); + token_params.insert("client_id".to_string(), client_id.to_string()); token_params.insert( "redirect_uri".to_string(), "https://some-other.url".to_string(), @@ -147,7 +162,7 @@ pub async fn test(params: &mut JMAPTest) { assert_eq!(claims.subject, Some(john_int_id.to_string())); assert_eq!( claims.audience, - Some(SingleOrMultiple::Single("OAuthyMcOAuthFace".to_string())) + Some(SingleOrMultiple::Single(client_id.to_string())) ); // Introspect token @@ -159,7 +174,7 @@ pub async fn test(params: &mut JMAPTest) { .await; assert_eq!(access_introspect.username.unwrap(), "jdoe@example.com"); assert_eq!(access_introspect.token_type.unwrap(), "bearer"); - assert_eq!(access_introspect.client_id.unwrap(), "OAuthyMcOAuthFace"); + assert_eq!(access_introspect.client_id.unwrap(), client_id); assert!(access_introspect.active); let refresh_introspect = post_with_auth::( &metadata.introspection_endpoint, @@ -168,7 +183,7 @@ pub async fn test(params: &mut JMAPTest) { ) .await; assert_eq!(refresh_introspect.username.unwrap(), "jdoe@example.com"); - assert_eq!(refresh_introspect.client_id.unwrap(), "OAuthyMcOAuthFace"); + assert_eq!(refresh_introspect.client_id.unwrap(), client_id); assert!(refresh_introspect.active); assert_eq!( refresh_introspect.iat.unwrap(), @@ -211,14 +226,15 @@ pub async fn test(params: &mut JMAPTest) { // ------------------------ // Request a device code - let device_code_params = AHashMap::from_iter([("client_id".to_string(), "1234".to_string())]); + let device_code_params = + AHashMap::from_iter([("client_id".to_string(), client_id.to_string())]); let device_response: DeviceAuthResponse = post(&metadata.device_authorization_endpoint, &device_code_params).await; //println!("Device response: {:#?}", device_response); // Status should be pending let mut token_params = AHashMap::from_iter([ - ("client_id".to_string(), "1234".to_string()), + ("client_id".to_string(), client_id.to_string()), ( "grant_type".to_string(), "urn:ietf:params:oauth:grant-type:device_code".to_string(), @@ -313,7 +329,7 @@ pub async fn test(params: &mut JMAPTest) { post::( &metadata.token_endpoint, &AHashMap::from_iter([ - ("client_id".to_string(), "1234".to_string()), + ("client_id".to_string(), client_id.to_string()), ("grant_type".to_string(), "refresh_token".to_string()), ("refresh_token".to_string(), token), ]), @@ -326,7 +342,7 @@ pub async fn test(params: &mut JMAPTest) { // Refreshing the access token before expiration should not include a new refresh token let refresh_params = AHashMap::from_iter([ - ("client_id".to_string(), "1234".to_string()), + ("client_id".to_string(), client_id.to_string()), ("grant_type".to_string(), "refresh_token".to_string()), ("refresh_token".to_string(), refresh_token), ]); @@ -401,6 +417,35 @@ async fn post_bytes( .unwrap() } +async fn post_json( + url: &str, + auth_token: Option<&str>, + body: &impl Serialize, +) -> D { + let mut client = reqwest::Client::builder() + .timeout(Duration::from_millis(500)) + .danger_accept_invalid_certs(true) + .build() + .unwrap_or_default() + .post(url); + + if let Some(auth_token) = auth_token { + client = client.bearer_auth(auth_token); + } + + serde_json::from_slice( + &client + .body(serde_json::to_string(body).unwrap().into_bytes()) + .send() + .await + .unwrap() + .bytes() + .await + .unwrap(), + ) + .unwrap() +} + async fn post(url: &str, params: &AHashMap) -> T { post_with_auth(url, None, params).await } diff --git a/tests/src/jmap/mod.rs b/tests/src/jmap/mod.rs index 8634f3e7..fe0302ef 100644 --- a/tests/src/jmap/mod.rs +++ b/tests/src/jmap/mod.rs @@ -289,6 +289,10 @@ token = "1s" refresh-token = "3s" refresh-token-renew = "2s" +[oauth.client-registration] +anonymous = true +required = true + [oauth.oidc] signature-key = '''-----BEGIN PRIVATE KEY----- MIIEuwIBADANBgkqhkiG9w0BAQEFAASCBKUwggShAgEAAoIBAQDMXJI1bL3z8gaF @@ -339,7 +343,7 @@ type = "console" level = "{LEVEL}" multiline = false ansi = true -disabled-events = ["network.*"] +disabled-events = ["network.*", "telemetry.webhook-error"] [webhook."test"] url = "http://127.0.0.1:8821/hook"