489 lines
14 KiB
Rust
489 lines
14 KiB
Rust
use crate::db::repository::traits::AuditRepositoryExt;
|
|
use crate::{db::models::Application, error::AppError};
|
|
use subtle::ConstantTimeEq;
|
|
|
|
pub const CLIENT_ID_PREFIX: &str = "nx9_app_";
|
|
pub const CLIENT_SECRET_PREFIX: &str = "nx9_secret_";
|
|
|
|
/// Generate a new unique server-side Client ID.
|
|
pub fn generate_client_id() -> String {
|
|
let mut bytes = [0u8; 16];
|
|
rand::RngCore::fill_bytes(&mut rand::thread_rng(), &mut bytes);
|
|
format!("{}{}", CLIENT_ID_PREFIX, hex::encode(bytes))
|
|
}
|
|
|
|
/// Generate a new CSPRNG Client Secret.
|
|
pub fn generate_client_secret() -> String {
|
|
let mut bytes = [0u8; 32];
|
|
rand::RngCore::fill_bytes(&mut rand::thread_rng(), &mut bytes);
|
|
format!("{}{}", CLIENT_SECRET_PREFIX, hex::encode(bytes))
|
|
}
|
|
|
|
/// Hash a raw client secret string into a hex-encoded BLAKE3 digest.
|
|
pub fn hash_client_secret(raw: &str) -> String {
|
|
hex::encode(blake3::hash(raw.as_bytes()).as_bytes())
|
|
}
|
|
|
|
/// Hash a raw client secret string into a 32-byte BLAKE3 digest.
|
|
pub fn hash_secret_bytes(raw: &str) -> [u8; 32] {
|
|
*blake3::hash(raw.as_bytes()).as_bytes()
|
|
}
|
|
|
|
/// Constant-time byte array comparison using subtle::ConstantTimeEq.
|
|
pub fn constant_time_compare(a: &[u8], b: &[u8]) -> bool {
|
|
if a.len() != b.len() {
|
|
return false;
|
|
}
|
|
a.ct_eq(b).into()
|
|
}
|
|
|
|
pub fn validate_redirect_uris(uris: &[String]) -> Result<(), AppError> {
|
|
if uris.len() > 10 {
|
|
return Err(AppError::InvalidInput(
|
|
"maximum 10 redirect URIs allowed".into(),
|
|
));
|
|
}
|
|
for uri in uris {
|
|
let trimmed = uri.trim();
|
|
if trimmed.is_empty() {
|
|
return Err(AppError::InvalidInput(
|
|
"redirect URI cannot be empty".into(),
|
|
));
|
|
}
|
|
if trimmed.len() > 512 {
|
|
return Err(AppError::InvalidInput(
|
|
"redirect URI exceeds maximum length of 512 characters".into(),
|
|
));
|
|
}
|
|
let parsed = url::Url::parse(trimmed).map_err(|e| {
|
|
AppError::InvalidInput(format!("invalid redirect URI '{trimmed}': {e}"))
|
|
})?;
|
|
if parsed.fragment().is_some() {
|
|
return Err(AppError::InvalidInput(format!(
|
|
"redirect URI '{trimmed}' must not contain a fragment"
|
|
)));
|
|
}
|
|
if !parsed.username().is_empty() || parsed.password().is_some() {
|
|
return Err(AppError::InvalidInput(format!(
|
|
"redirect URI '{trimmed}' must not contain user credentials"
|
|
)));
|
|
}
|
|
match parsed.scheme() {
|
|
"https" => {}
|
|
"http" => {
|
|
let host = parsed.host_str().unwrap_or("");
|
|
if host != "localhost" && host != "127.0.0.1" && host != "[::1]" && host != "::1" {
|
|
return Err(AppError::InvalidInput(format!(
|
|
"redirect URI '{trimmed}' with http scheme is only allowed for localhost/loopback development"
|
|
)));
|
|
}
|
|
}
|
|
other => {
|
|
return Err(AppError::InvalidInput(format!(
|
|
"redirect URI '{trimmed}' has unsupported scheme '{other}'; only https (or http for localhost) is allowed"
|
|
)));
|
|
}
|
|
}
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
#[allow(clippy::too_many_arguments)]
|
|
pub async fn create(
|
|
provider: &std::sync::Arc<dyn crate::db::provider::DatabaseProvider>,
|
|
tenant_id: &str,
|
|
name: &str,
|
|
slug: &str,
|
|
description: Option<&str>,
|
|
redirect_uris: Option<Vec<String>>,
|
|
scopes: Option<Vec<String>>,
|
|
audit_actor_id: Option<&str>,
|
|
audit_ip: Option<&str>,
|
|
audit_ua: Option<&str>,
|
|
) -> Result<(Application, String), AppError> {
|
|
let name = name.trim();
|
|
let slug = slug.trim();
|
|
if name.is_empty() || slug.is_empty() {
|
|
return Err(AppError::InvalidInput(
|
|
"name and slug cannot be empty".into(),
|
|
));
|
|
}
|
|
crate::identity::slug::validate_slug(slug)?;
|
|
|
|
if let Some(ref uris) = redirect_uris {
|
|
validate_redirect_uris(uris)?;
|
|
}
|
|
if provider
|
|
.global_slugs()
|
|
.find_by_slug(slug)
|
|
.await
|
|
.map_err(AppError::Database)?
|
|
.is_some()
|
|
{
|
|
return Err(AppError::Conflict(format!("slug '{slug}' already exists")));
|
|
}
|
|
|
|
let id = uuid::Uuid::new_v4().to_string();
|
|
let client_id = generate_client_id();
|
|
let raw_secret = generate_client_secret();
|
|
let secret_hash = hash_client_secret(&raw_secret);
|
|
|
|
let redirect_json = redirect_uris.map(|v| serde_json::to_string(&v).unwrap_or_default());
|
|
let scopes_json = scopes.map(|v| serde_json::to_string(&v).unwrap_or_default());
|
|
|
|
let metadata = serde_json::json!({
|
|
"application_id": id,
|
|
"name": name,
|
|
"client_id": client_id,
|
|
})
|
|
.to_string();
|
|
|
|
let audit_event = crate::audit::AuditEvent {
|
|
actor_id: audit_actor_id,
|
|
target_id: None,
|
|
action: "application.created",
|
|
resource_type: "application",
|
|
resource_id: Some(&id),
|
|
severity: crate::db::models::AuditSeverity::Info,
|
|
ip: audit_ip,
|
|
ua: audit_ua,
|
|
metadata: Some(&metadata),
|
|
};
|
|
|
|
let app = provider
|
|
.applications()
|
|
.create_with_audit(
|
|
&id,
|
|
tenant_id,
|
|
name,
|
|
slug,
|
|
&client_id,
|
|
Some(&secret_hash),
|
|
description,
|
|
redirect_json.as_deref(),
|
|
scopes_json.as_deref(),
|
|
Some(audit_event),
|
|
)
|
|
.await
|
|
.map_err(AppError::Database)?;
|
|
|
|
Ok((app, raw_secret))
|
|
}
|
|
|
|
pub async fn list(
|
|
provider: &std::sync::Arc<dyn crate::db::provider::DatabaseProvider>,
|
|
tenant_id: &str,
|
|
) -> Result<Vec<Application>, AppError> {
|
|
provider
|
|
.applications()
|
|
.list(tenant_id)
|
|
.await
|
|
.map_err(AppError::Database)
|
|
}
|
|
|
|
pub async fn get(
|
|
provider: &std::sync::Arc<dyn crate::db::provider::DatabaseProvider>,
|
|
id: &str,
|
|
) -> Result<Application, AppError> {
|
|
provider
|
|
.applications()
|
|
.find_by_id(id)
|
|
.await
|
|
.map_err(AppError::Database)?
|
|
.ok_or(AppError::NotFound)
|
|
}
|
|
|
|
pub async fn find_by_slug(
|
|
provider: &std::sync::Arc<dyn crate::db::provider::DatabaseProvider>,
|
|
slug: &str,
|
|
) -> Result<Application, AppError> {
|
|
provider
|
|
.applications()
|
|
.find_by_slug(slug)
|
|
.await
|
|
.map_err(AppError::Database)?
|
|
.ok_or(AppError::NotFound)
|
|
}
|
|
|
|
pub async fn rotate_secret(
|
|
provider: &std::sync::Arc<dyn crate::db::provider::DatabaseProvider>,
|
|
id: &str,
|
|
audit_actor_id: Option<&str>,
|
|
audit_ip: Option<&str>,
|
|
audit_ua: Option<&str>,
|
|
) -> Result<String, AppError> {
|
|
let app = provider
|
|
.applications()
|
|
.find_by_id(id)
|
|
.await
|
|
.map_err(AppError::Database)?
|
|
.ok_or(AppError::NotFound)?;
|
|
|
|
let raw_secret = generate_client_secret();
|
|
let secret_hash = hash_client_secret(&raw_secret);
|
|
|
|
let metadata = serde_json::json!({
|
|
"application_id": id,
|
|
"name": app.name,
|
|
"client_id": app.get_client_id(),
|
|
})
|
|
.to_string();
|
|
|
|
let audit_event = crate::audit::AuditEvent {
|
|
actor_id: audit_actor_id,
|
|
target_id: None,
|
|
action: "application.secret_rotated",
|
|
resource_type: "application",
|
|
resource_id: Some(id),
|
|
severity: crate::db::models::AuditSeverity::Warning,
|
|
ip: audit_ip,
|
|
ua: audit_ua,
|
|
metadata: Some(&metadata),
|
|
};
|
|
|
|
provider
|
|
.applications()
|
|
.rotate_secret_with_audit(id, &secret_hash, Some(audit_event))
|
|
.await
|
|
.map_err(AppError::Database)?;
|
|
|
|
Ok(raw_secret)
|
|
}
|
|
|
|
pub async fn validate_client_credentials(
|
|
provider: &std::sync::Arc<dyn crate::db::provider::DatabaseProvider>,
|
|
client_id: &str,
|
|
client_secret: &str,
|
|
) -> Result<Application, AppError> {
|
|
let supplied_digest = hash_secret_bytes(client_secret);
|
|
let app = provider
|
|
.applications()
|
|
.find_by_client_id(client_id)
|
|
.await
|
|
.map_err(AppError::Database)?;
|
|
|
|
let dummy_digest = [0u8; 32];
|
|
|
|
let (valid_app, stored_digest_opt) = match app {
|
|
Some(ref a) if a.enabled => {
|
|
let digest_opt = a
|
|
.client_secret_hash
|
|
.as_ref()
|
|
.and_then(|h| hex::decode(h).ok())
|
|
.and_then(|vec| <[u8; 32]>::try_from(vec).ok());
|
|
(digest_opt.is_some(), digest_opt)
|
|
}
|
|
_ => (false, None),
|
|
};
|
|
|
|
let target_digest = stored_digest_opt.as_ref().unwrap_or(&dummy_digest);
|
|
let matches = constant_time_compare(&supplied_digest, target_digest);
|
|
|
|
if valid_app && matches {
|
|
Ok(app.unwrap())
|
|
} else {
|
|
Err(AppError::Unauthorized)
|
|
}
|
|
}
|
|
|
|
#[allow(clippy::too_many_arguments)]
|
|
pub async fn update(
|
|
provider: &std::sync::Arc<dyn crate::db::provider::DatabaseProvider>,
|
|
id: &str,
|
|
name: &str,
|
|
slug: &str,
|
|
description: Option<&str>,
|
|
redirect_uris: Option<Vec<String>>,
|
|
scopes: Option<Vec<String>>,
|
|
enabled: bool,
|
|
audit_actor_id: Option<&str>,
|
|
audit_ip: Option<&str>,
|
|
audit_ua: Option<&str>,
|
|
) -> Result<Application, AppError> {
|
|
let existing = provider
|
|
.applications()
|
|
.find_by_id(id)
|
|
.await
|
|
.map_err(AppError::Database)?
|
|
.ok_or(AppError::NotFound)?;
|
|
|
|
let name = name.trim();
|
|
let slug = slug.trim();
|
|
if name.is_empty() || slug.is_empty() {
|
|
return Err(AppError::InvalidInput(
|
|
"name and slug cannot be empty".into(),
|
|
));
|
|
}
|
|
crate::identity::slug::validate_slug(slug)?;
|
|
|
|
if let Some(ref uris) = redirect_uris {
|
|
validate_redirect_uris(uris)?;
|
|
}
|
|
if let Some(other) = provider
|
|
.global_slugs()
|
|
.find_by_slug(slug)
|
|
.await
|
|
.map_err(AppError::Database)?
|
|
&& (other.entity_id != id || other.entity_type != "application")
|
|
{
|
|
return Err(AppError::Conflict(format!("slug '{slug}' already exists")));
|
|
}
|
|
|
|
let redirect_json = redirect_uris.map(|v| serde_json::to_string(&v).unwrap_or_default());
|
|
let scopes_json = scopes.map(|v| serde_json::to_string(&v).unwrap_or_default());
|
|
|
|
provider
|
|
.applications()
|
|
.update(
|
|
id,
|
|
name,
|
|
slug,
|
|
description,
|
|
redirect_json.as_deref(),
|
|
scopes_json.as_deref(),
|
|
enabled,
|
|
)
|
|
.await
|
|
.map_err(AppError::Database)?;
|
|
|
|
let updated = provider
|
|
.applications()
|
|
.find_by_id(id)
|
|
.await
|
|
.map_err(AppError::Database)?
|
|
.ok_or(AppError::NotFound)?;
|
|
|
|
let action = if existing.enabled != enabled {
|
|
if enabled {
|
|
"application.enabled"
|
|
} else {
|
|
"application.disabled"
|
|
}
|
|
} else {
|
|
"application.updated"
|
|
};
|
|
|
|
let metadata = serde_json::json!({
|
|
"application_id": id,
|
|
"name": updated.name,
|
|
"client_id": updated.get_client_id(),
|
|
"enabled": enabled,
|
|
})
|
|
.to_string();
|
|
|
|
provider
|
|
.audit()
|
|
.log(crate::audit::AuditEvent {
|
|
actor_id: audit_actor_id,
|
|
target_id: None,
|
|
action,
|
|
resource_type: "application",
|
|
resource_id: Some(id),
|
|
severity: crate::db::models::AuditSeverity::Info,
|
|
ip: audit_ip,
|
|
ua: audit_ua,
|
|
metadata: Some(&metadata),
|
|
})
|
|
.await?;
|
|
|
|
Ok(updated)
|
|
}
|
|
|
|
pub async fn set_enabled(
|
|
provider: &std::sync::Arc<dyn crate::db::provider::DatabaseProvider>,
|
|
id: &str,
|
|
enabled: bool,
|
|
audit_actor_id: Option<&str>,
|
|
audit_ip: Option<&str>,
|
|
audit_ua: Option<&str>,
|
|
) -> Result<(), AppError> {
|
|
let app = provider
|
|
.applications()
|
|
.find_by_id(id)
|
|
.await
|
|
.map_err(AppError::Database)?
|
|
.ok_or(AppError::NotFound)?;
|
|
|
|
provider
|
|
.applications()
|
|
.set_enabled(id, enabled)
|
|
.await
|
|
.map_err(AppError::Database)?;
|
|
|
|
let action = if enabled {
|
|
"application.enabled"
|
|
} else {
|
|
"application.disabled"
|
|
};
|
|
|
|
let metadata = serde_json::json!({
|
|
"application_id": id,
|
|
"name": app.name,
|
|
"client_id": app.get_client_id(),
|
|
"enabled": enabled,
|
|
})
|
|
.to_string();
|
|
|
|
provider
|
|
.audit()
|
|
.log(crate::audit::AuditEvent {
|
|
actor_id: audit_actor_id,
|
|
target_id: None,
|
|
action,
|
|
resource_type: "application",
|
|
resource_id: Some(id),
|
|
severity: crate::db::models::AuditSeverity::Info,
|
|
ip: audit_ip,
|
|
ua: audit_ua,
|
|
metadata: Some(&metadata),
|
|
})
|
|
.await?;
|
|
|
|
Ok(())
|
|
}
|
|
|
|
pub async fn delete(
|
|
provider: &std::sync::Arc<dyn crate::db::provider::DatabaseProvider>,
|
|
id: &str,
|
|
audit_actor_id: Option<&str>,
|
|
audit_ip: Option<&str>,
|
|
audit_ua: Option<&str>,
|
|
) -> Result<(), AppError> {
|
|
let app = provider
|
|
.applications()
|
|
.find_by_id(id)
|
|
.await
|
|
.map_err(AppError::Database)?
|
|
.ok_or(AppError::NotFound)?;
|
|
|
|
provider
|
|
.applications()
|
|
.delete(id)
|
|
.await
|
|
.map_err(AppError::Database)?;
|
|
|
|
let metadata = serde_json::json!({
|
|
"application_id": id,
|
|
"name": app.name,
|
|
"client_id": app.get_client_id(),
|
|
})
|
|
.to_string();
|
|
|
|
provider
|
|
.audit()
|
|
.log(crate::audit::AuditEvent {
|
|
actor_id: audit_actor_id,
|
|
target_id: None,
|
|
action: "application.deleted",
|
|
resource_type: "application",
|
|
resource_id: Some(id),
|
|
severity: crate::db::models::AuditSeverity::Warning,
|
|
ip: audit_ip,
|
|
ua: audit_ua,
|
|
metadata: Some(&metadata),
|
|
})
|
|
.await?;
|
|
|
|
Ok(())
|
|
}
|