RFC7591 OAuth dynamic client registration + OpenID Connect Dynamic Client Registration (closes #136 closes #4)

This commit is contained in:
mdecimus
2024-10-01 10:35:35 +02:00
parent 6a5f963b43
commit 200d8d7c45
22 changed files with 619 additions and 108 deletions

View File

@@ -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) => {

View File

@@ -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<Vec<u8>>,
req: &mut HttpRequest,
access_token: Arc<AccessToken>,
session: &HttpSessionData,
) -> impl Future<Output = trc::Result<HttpResponse>> + Send;
@@ -80,11 +82,11 @@ impl ManagementApi for Server {
#[allow(unused_variables)]
async fn handle_api_manage_request(
&self,
req: &HttpRequest,
body: Option<Vec<u8>>,
req: &mut HttpRequest,
access_token: Arc<AccessToken>,
session: &HttpSessionData,
) -> trc::Result<HttpResponse> {
let body = fetch_body(req, 1024 * 1024, session.session_id).await;
let path = req.uri().path().split('/').skip(2).collect::<Vec<_>>();
match path.first().copied().unwrap_or_default() {

View File

@@ -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() {

View File

@@ -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(),
})
}

View File

@@ -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())

View File

@@ -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)]

View File

@@ -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
}))
}
}

View File

@@ -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)