feat: harden runtime lifecycle and application credentials
- enforce deterministic runtime lifecycle state transitions - add live graceful-to-forced shutdown escalation - align HTTP draining and worker shutdown with global deadline - guarantee deterministic shutdown hook ordering - add secure application client IDs and one-time client secrets - hash application secrets with BLAKE3 and constant-time verification - make credential creation and rotation transactionally auditable - enforce strict client_id authentication and redirect URI validation - add SQLite and PostgreSQL credential migrations - add application credential and runtime lifecycle acceptance tests - update Dioxus application management workflows - update security and architecture documentation
This commit is contained in:
1 parent
4c697e9adf
commit
dc5417334b
26 files changed
+2477
-183
No files matched your search
+369
-13
@@ -1,11 +1,106 @@
|
||||
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,
|
||||
) -> Result<Application, AppError> {
|
||||
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() {
|
||||
@@ -13,6 +108,9 @@ pub async fn create(
|
||||
"name and slug cannot be empty".into(),
|
||||
));
|
||||
}
|
||||
if let Some(ref uris) = redirect_uris {
|
||||
validate_redirect_uris(uris)?;
|
||||
}
|
||||
if provider
|
||||
.applications()
|
||||
.find_by_slug(slug)
|
||||
@@ -22,12 +120,52 @@ pub async fn create(
|
||||
{
|
||||
return Err(AppError::Conflict(format!("slug '{slug}' already exists")));
|
||||
}
|
||||
|
||||
let id = uuid::Uuid::new_v4().to_string();
|
||||
provider
|
||||
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(&id, tenant_id, name, slug)
|
||||
.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)
|
||||
.map_err(AppError::Database)?;
|
||||
|
||||
Ok((app, raw_secret))
|
||||
}
|
||||
|
||||
pub async fn list(
|
||||
@@ -65,14 +203,108 @@ pub async fn find_by_slug(
|
||||
.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 _ = provider.applications().find_by_id(id).await?;
|
||||
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() {
|
||||
@@ -80,6 +312,9 @@ pub async fn update(
|
||||
"name and slug cannot be empty".into(),
|
||||
));
|
||||
}
|
||||
if let Some(ref uris) = redirect_uris {
|
||||
validate_redirect_uris(uris)?;
|
||||
}
|
||||
if let Some(other) = provider
|
||||
.applications()
|
||||
.find_by_slug(slug)
|
||||
@@ -90,40 +325,161 @@ pub async fn update(
|
||||
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, enabled)
|
||||
.update(
|
||||
id,
|
||||
name,
|
||||
slug,
|
||||
description,
|
||||
redirect_json.as_deref(),
|
||||
scopes_json.as_deref(),
|
||||
enabled,
|
||||
)
|
||||
.await
|
||||
.map_err(AppError::Database)?;
|
||||
provider
|
||||
|
||||
let updated = provider
|
||||
.applications()
|
||||
.find_by_id(id)
|
||||
.await
|
||||
.map_err(crate::error::AppError::Database)?
|
||||
.ok_or_else(|| crate::error::AppError::NotFound)
|
||||
.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 _ = provider.applications().find_by_id(id).await?;
|
||||
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)
|
||||
.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 _ = provider.applications().find_by_id(id).await?;
|
||||
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)
|
||||
.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(())
|
||||
}
|
||||
Reference in new issue
Block a user