Files
inbuxa-server/crates/http/src/auth/oauth/registration.rs
T
jcoffey-dev ca6484c356 Honor the client registration override only in setup and recovery
The recovery administrator signs in before any OAuth client is
registered, so it needs to skip the registration check. Outside
bootstrap and recovery mode, every account now signs in through a
registered client and one of its redirect URIs.
2026-09-29 08:25:13 -07:00

345 lines
12 KiB
Rust

/*
* SPDX-FileCopyrightText: 2020 Stalwart Labs LLC <[email protected]>
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-SEL
*
* Modified by Coffey Labs in 2026 for INBUXA.
*/
use super::ErrorType;
use crate::auth::authenticate::Authenticator;
use common::{
Server,
auth::{
BuildAccessToken,
oauth::{
client_id::{ClientMeta, decode_client_id, encode_client_id, scopes_to_mask},
registration::{
ClientRegistrationError, ClientRegistrationRequest, ClientRegistrationResponse,
TokenEndpointAuthMethod, redirect_uri_matches, validate_grant_metadata,
validate_redirect_uri,
},
},
},
};
use directory::core::secret::{hash_secret, verify_secret_hash};
use http_proto::{request::fetch_body, *};
use hyper::StatusCode;
use registry::schema::{
enums::{PasswordHashAlgorithm, Permission},
prelude::{ObjectType, Property, UTCDateTime},
structs::OAuthClient,
};
use std::future::Future;
use store::{
rand::{RngExt, distr::Alphanumeric, rng},
registry::write::{RegistryWrite, RegistryWriteResult},
write::now,
};
use trc::{AddContext, AuthEvent};
use types::id::Id;
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;
fn verify_client_secret(
&self,
client_id: &str,
client_secret: Option<&str>,
) -> 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> {
// 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)
})?;
// Validate redirect URIs and grant metadata (RFC 7591 + OAuth Public Clients profile)
if request.redirect_uris.is_empty() {
return Ok(registration_error(
ClientRegistrationError::invalid_redirect_uri(
"At least one redirect URI is required.",
),
));
}
for uri in &request.redirect_uris {
if let Err(err) = validate_redirect_uri(uri) {
return Ok(registration_error(err));
}
}
if let Err(err) = validate_grant_metadata(&request) {
return Ok(registration_error(err));
}
let is_public = matches!(
request.token_endpoint_auth_method,
None | Some(TokenEndpointAuthMethod::None)
);
if is_public {
// Public client: issue a stateless, self-describing client id with no database write
if self.core.oauth.allow_anonymous_client_registration {
self.is_http_anonymous_request_allowed(session.remote_ip)
.await?;
} else {
let (_, access_token) = self.authenticate_headers(req, &session).await?;
access_token.enforce_permission(Permission::OAuthClientRegistration)?;
}
let client_id = encode_client_id(
self.core.oauth.oauth_key.as_bytes(),
&ClientMeta {
redirect_uris: request.redirect_uris.clone(),
scope_mask: scopes_to_mask(request.scope.as_deref().unwrap_or_default()),
client_name: request.client_name.clone(),
},
)
.map_err(|err| {
trc::AuthEvent::Error
.into_err()
.details("Failed to encode client id.")
.reason(err)
.caused_by(trc::location!())
})?;
trc::event!(
Auth(AuthEvent::ClientRegistration),
Id = client_id.clone(),
RemoteIp = session.remote_ip
);
return Ok(JsonResponse::with_status(
StatusCode::CREATED,
ClientRegistrationResponse {
client_id_issued_at: Some(now()),
client_id,
request,
..Default::default()
},
)
.no_cache()
.into_http_response());
}
// Confidential client: authenticate and persist the registration
let (_, access_token) = self.authenticate_headers(req, &session).await?;
access_token.enforce_permission(Permission::OAuthClientRegistration)?;
let tenant_id = access_token.tenant_id();
// Generate client ID
let client_id = rng()
.sample_iter(Alphanumeric)
.take(20)
.map(|ch| char::from(ch.to_ascii_lowercase()))
.collect::<String>();
// Generate client secret
let client_secret = rng()
.sample_iter(Alphanumeric)
.take(48)
.map(char::from)
.collect::<String>();
let secret_hash = hash_secret(
PasswordHashAlgorithm::Argon2id,
client_secret.clone().into_bytes(),
)
.await
.caused_by(trc::location!())?;
// inbuxa: AU-1.10: a client registering itself
let result = inbuxa_features::audit::scope::system(
"oauth-registration",
self.registry().write(RegistryWrite::insert(
&OAuthClient {
client_id: client_id.clone(),
description: request.client_name.clone(),
contacts: request.contacts.clone().into(),
member_tenant_id: tenant_id.map(|id| Id::new(id as u64)),
redirect_uris: request.redirect_uris.clone().into(),
logo: request.logo_uri.clone(),
secret: Some(secret_hash),
created_at: UTCDateTime::now(),
..Default::default()
}
.into(),
)),
)
.await
.caused_by(trc::location!())?;
if !matches!(result, RegistryWriteResult::Success(_)) {
return Err(trc::StoreEvent::UnexpectedError
.into_err()
.details("Failed to register OAuth client.")
.reason(result.to_string())
.caused_by(trc::location!()));
}
trc::event!(
Auth(AuthEvent::ClientRegistration),
Id = client_id.to_string(),
RemoteIp = session.remote_ip
);
Ok(JsonResponse::with_status(
StatusCode::CREATED,
ClientRegistrationResponse {
client_id,
client_secret: Some(client_secret),
client_id_issued_at: Some(now()),
client_secret_expires_at: Some(0),
request,
..Default::default()
},
)
.no_cache()
.into_http_response())
}
async fn validate_client_registration(
&self,
client_id: &str,
redirect_uri: Option<&str>,
account_id: u32,
) -> trc::Result<Option<ErrorType>> {
// Stateless client ids are self-describing and validated at the authorization endpoint
if decode_client_id(self.core.oauth.oauth_key.as_bytes(), client_id).is_some() {
return Ok(None);
}
if !self.core.oauth.require_client_authentication {
return Ok(None);
}
// Fetch client registration
let found_registration = if let Some(client_id) = self
.registry()
.primary_key(
ObjectType::OAuthClient.into(),
Property::ClientId,
client_id.as_bytes().to_vec(),
)
.await?
{
if let Some(redirect_uri) = redirect_uri {
let client = self
.registry()
.object::<OAuthClient>(client_id.id())
.await?
.ok_or_else(|| {
trc::StoreEvent::UnexpectedError
.into_err()
.details("OAuth client not found.")
.caused_by(trc::location!())
.ctx(trc::Key::Id, client_id.id().id())
})?;
if client
.redirect_uris
.iter()
.any(|uri| redirect_uri_matches(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.
// inbuxa: only while setting up or recovering, when the recovery
// administrator signs in before any client is registered (contract C-5)
let registry = self.registry();
if (registry.is_bootstrap_mode() || registry.is_recovery_mode())
&& self
.access_token(account_id)
.await
.caused_by(trc::location!())?
.build()
.has_permission(Permission::OAuthClientOverride)
{
return Ok(None);
}
Ok(Some(if found_registration {
ErrorType::InvalidClient
} else {
ErrorType::InvalidRequest
}))
}
async fn verify_client_secret(
&self,
client_id: &str,
client_secret: Option<&str>,
) -> trc::Result<Option<ErrorType>> {
// Stateless and unregistered clients have no secret to verify
if decode_client_id(self.core.oauth.oauth_key.as_bytes(), client_id).is_some() {
return Ok(None);
}
let Some(client_id) = self
.registry()
.primary_key(
ObjectType::OAuthClient.into(),
Property::ClientId,
client_id.as_bytes().to_vec(),
)
.await?
else {
return Ok(None);
};
let Some(client) = self
.registry()
.object::<OAuthClient>(client_id.id())
.await
.caused_by(trc::location!())?
else {
return Ok(None);
};
match client.secret.as_deref() {
Some(hash) if !hash.is_empty() => match client_secret {
Some(secret)
if verify_secret_hash(hash, secret.as_bytes())
.await
.caused_by(trc::location!())? =>
{
Ok(None)
}
_ => Ok(Some(ErrorType::InvalidClient)),
},
_ => Ok(None),
}
}
}
fn registration_error(error: ClientRegistrationError) -> HttpResponse {
JsonResponse::with_status(StatusCode::BAD_REQUEST, error)
.no_cache()
.into_http_response()
}