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:
thakares committed 2026-07-23 15:17:30 +05:30
1 parent 4c697e9adf
commit dc5417334b
26 files changed
+2477 -183

No files matched your search

+15
View File
@@ -124,8 +124,12 @@ pub struct ApplicationView {
#[serde(default)]
pub client_id: String,
#[serde(default)]
pub description: Option<String>,
#[serde(default)]
pub enabled: bool,
#[serde(default)]
pub credentials_configured: bool,
#[serde(default)]
pub redirect_urls: Vec<String>,
#[serde(default)]
pub scopes: Vec<String>,
@@ -141,6 +145,17 @@ pub struct ApplicationsResponse {
pub applications: Vec<ApplicationView>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
pub struct CreateApplicationResponse {
pub application: ApplicationView,
pub client_secret: String,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
pub struct RotateSecretResponse {
pub client_secret: String,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, Default)]
pub struct ServiceAccountView {
pub id: String,
+130 -15
View File
@@ -16,7 +16,7 @@ use dioxus::prelude::*;
pub fn ApplicationsPage() -> Element {
let state = use_context::<AppState>();
let auth = state.auth;
let can_manage = auth().has_permission("roles:manage");
let can_manage = auth().has_permission("applications:manage");
let mut apps = use_signal(Vec::<ApplicationView>::new);
let mut error = use_signal(|| Option::<String>::None);
@@ -29,6 +29,12 @@ pub fn ApplicationsPage() -> Element {
let mut show_create = use_signal(|| false);
let mut name = use_signal(String::new);
let mut slug = use_signal(String::new);
let mut description = use_signal(String::new);
let mut redirect_urls_raw = use_signal(String::new);
let mut scopes_raw = use_signal(String::new);
let mut one_time_secret = use_signal(|| Option::<(ApplicationView, String)>::None);
let mut rotate_target = use_signal(|| Option::<ApplicationView>::None);
let mut delete_target = use_signal(|| Option::<ApplicationView>::None);
let reload = use_callback(move |_: ()| {
@@ -50,7 +56,7 @@ pub fn ApplicationsPage() -> Element {
let mut filtered: Vec<_> = apps()
.into_iter()
.filter(|a| matches_query(&a.name, &query()) || matches_query(&a.slug, &query()))
.filter(|a| matches_query(&a.name, &query()) || matches_query(&a.slug, &query()) || matches_query(&a.client_id, &query()))
.collect();
let sk = sort_key();
filtered.sort_by(|a, b| match sk.as_str() {
@@ -123,10 +129,16 @@ pub fn ApplicationsPage() -> Element {
for a in page_items {
{
let app = a.clone();
let app2 = a.clone();
let app_rotate = a.clone();
let app_delete = a.clone();
rsx! {
tr { key: "{a.id}",
td { strong { "{a.name}" } }
td {
strong { "{a.name}" }
if let Some(desc) = &a.description {
div { class: "text-muted", style: "font-size: 0.8rem;", "{desc}" }
}
}
td { code { "{a.client_id}" } }
td { class: "text-muted",
if a.redirect_urls.is_empty() { "—" } else { "{a.redirect_urls.join(\", \")}" }
@@ -142,17 +154,26 @@ pub fn ApplicationsPage() -> Element {
td { "{format_datetime(&a.created_at)}" }
if can_manage {
td { style: "text-align: right;",
div { class: "actions",
div { class: "actions", style: "display: inline-flex; gap: 0.25rem;",
button {
class: "btn btn-sm btn-outline",
r#type: "button",
onclick: move |_| rotate_target.set(Some(app_rotate.clone())),
"Rotate Secret"
}
button {
class: "btn btn-sm btn-outline",
r#type: "button",
onclick: move |_| {
let id = app.id.clone();
let name = app.name.clone();
let slug = app.slug.clone();
let n = app.name.clone();
let s = app.slug.clone();
let desc = app.description.clone();
let r_urls = if app.redirect_urls.is_empty() { None } else { Some(app.redirect_urls.clone()) };
let sc = if app.scopes.is_empty() { None } else { Some(app.scopes.clone()) };
let enabled = !app.enabled;
spawn(async move {
match api::update_application(&id, &name, &slug, enabled).await {
match api::update_application(&id, &n, &s, desc.as_deref(), r_urls, sc, enabled).await {
Ok(_) => {
state.toast(ToastKind::Success, if enabled { "Enabled" } else { "Disabled" });
reload.call(());
@@ -166,7 +187,7 @@ pub fn ApplicationsPage() -> Element {
button {
class: "btn btn-sm btn-danger",
r#type: "button",
onclick: move |_| delete_target.set(Some(app2.clone())),
onclick: move |_| delete_target.set(Some(app_delete.clone())),
"Delete"
}
}
@@ -189,16 +210,30 @@ pub fn ApplicationsPage() -> Element {
oninput: move |v: String| {
name.set(v.clone());
if slug().is_empty() || slug() == slugify(&name()) {
// keep in sync when empty-ish
slug.set(slugify(&v));
}
slug.set(slugify(&v));
},
}
TextInput {
label: "Slug / Client ID",
label: "Slug",
value: slug(),
oninput: move |v| slug.set(v),
}
TextInput {
label: "Description (optional)",
value: description(),
oninput: move |v| description.set(v),
}
TextInput {
label: "Redirect URLs (comma separated, optional)",
value: redirect_urls_raw(),
oninput: move |v| redirect_urls_raw.set(v),
}
TextInput {
label: "Allowed Scopes (comma separated, optional)",
value: scopes_raw(),
oninput: move |v| scopes_raw.set(v),
}
div { class: "modal-footer", style: "margin-top:1rem; padding:0; border:none; background:transparent;",
button { class: "btn btn-outline", r#type: "button",
onclick: move |_| show_create.set(false), "Cancel" }
@@ -207,13 +242,28 @@ pub fn ApplicationsPage() -> Element {
onclick: move |_| {
let n = name();
let s = slug();
let d = if description().trim().is_empty() { None } else { Some(description().trim().to_string()) };
let r_urls = if redirect_urls_raw().trim().is_empty() {
None
} else {
Some(redirect_urls_raw().split(',').map(|x| x.trim().to_string()).filter(|x| !x.is_empty()).collect::<Vec<_>>())
};
let sc = if scopes_raw().trim().is_empty() {
None
} else {
Some(scopes_raw().split(',').map(|x| x.trim().to_string()).filter(|x| !x.is_empty()).collect::<Vec<_>>())
};
spawn(async move {
match api::create_application(&n, &s).await {
Ok(_) => {
state.toast(ToastKind::Success, "Application created");
match api::create_application(&n, &s, d.as_deref(), r_urls, sc).await {
Ok(res) => {
state.toast(ToastKind::Success, "Application registered successfully");
show_create.set(false);
name.set(String::new());
slug.set(String::new());
description.set(String::new());
redirect_urls_raw.set(String::new());
scopes_raw.set(String::new());
one_time_secret.set(Some((res.application, res.client_secret)));
reload.call(());
}
Err(e) => state.toast(ToastKind::Error, e.to_string()),
@@ -225,6 +275,71 @@ pub fn ApplicationsPage() -> Element {
}
}
if let Some((app, sec)) = one_time_secret() {
Modal {
title: "Client Credentials Disclosed".to_string(),
open: true,
on_close: move |_| one_time_secret.set(None),
div { class: "alert alert-warning", style: "margin-bottom: 1rem; padding: 0.75rem; border-radius: 4px; background: #fff3cd; color: #856404; border: 1px solid #ffeeba;",
strong { "Important: " }
"Store this client secret securely. It will never be displayed again after closing this dialog."
}
div { style: "display: flex; flex-direction: column; gap: 0.75rem;",
div {
label { style: "font-weight: 600; display: block; font-size: 0.85rem;", "Application Name" }
div { "{app.name}" }
}
div {
label { style: "font-weight: 600; display: block; font-size: 0.85rem;", "Client ID" }
div { style: "display: flex; gap: 0.5rem; align-items: center;",
code { style: "flex: 1; padding: 0.4rem; background: #f8f9fa; border: 1px solid #e9ecef; border-radius: 4px;", "{app.client_id}" }
}
}
div {
label { style: "font-weight: 600; display: block; font-size: 0.85rem;", "Client Secret" }
div { style: "display: flex; gap: 0.5rem; align-items: center;",
code { style: "flex: 1; padding: 0.4rem; background: #f8f9fa; border: 1px solid #e9ecef; border-radius: 4px; color: #d63384; word-break: break-all;", "{sec}" }
}
}
}
div { class: "modal-footer", style: "margin-top:1.5rem; padding:0; border:none; background:transparent; justify-content: flex-end;",
button {
class: "btn btn-primary", r#type: "button",
onclick: move |_| one_time_secret.set(None),
"I have saved my secret"
}
}
}
}
ConfirmDialog {
title: "Rotate Client Secret".to_string(),
message: format!(
"Are you sure you want to rotate the client secret for \"{}\"? Any existing client using the current secret will be invalidated immediately.",
rotate_target().as_ref().map(|a| a.name.as_str()).unwrap_or("")
),
open: rotate_target().is_some(),
confirm_label: "Rotate Secret",
danger: true,
on_confirm: move |_| {
if let Some(a) = rotate_target() {
let target_app = a.clone();
spawn(async move {
match api::rotate_application_secret(&target_app.id).await {
Ok(new_sec) => {
state.toast(ToastKind::Success, "Client secret rotated");
rotate_target.set(None);
one_time_secret.set(Some((target_app, new_sec)));
reload.call(());
}
Err(e) => state.toast(ToastKind::Error, e.to_string()),
}
});
}
},
on_cancel: move |_| rotate_target.set(None),
}
ConfirmDialog {
title: "Delete application".to_string(),
message: format!(
+50 -8
View File
@@ -59,9 +59,25 @@ fn client() -> Client {
Client::new()
}
pub trait RequestBuilderExtHelper {
fn with_credentials_include(self) -> Self;
}
impl RequestBuilderExtHelper for reqwest::RequestBuilder {
#[cfg(target_arch = "wasm32")]
fn with_credentials_include(self) -> Self {
self.fetch_credentials_include()
}
#[cfg(not(target_arch = "wasm32"))]
fn with_credentials_include(self) -> Self {
self
}
}
/// Attach credentials + optional bearer session token.
fn authorize(builder: reqwest::RequestBuilder) -> reqwest::RequestBuilder {
let builder = builder.fetch_credentials_include();
let builder = builder.with_credentials_include();
if let Some(token) = session::load_access_token() {
builder.header("Authorization", format!("Bearer {token}"))
} else {
@@ -180,7 +196,7 @@ pub async fn login(username: &str, password: &str) -> Result<LoginResponse, ApiE
let url = api_url("/auth/login");
let resp = client()
.post(&url)
.fetch_credentials_include()
.with_credentials_include()
.header("Accept", "application/json")
.header("Content-Type", "application/json")
.json(&body)
@@ -369,25 +385,51 @@ pub async fn list_applications() -> Result<Vec<ApplicationView>, ApiError> {
Ok(r.applications)
}
pub async fn create_application(name: &str, slug: &str) -> Result<ApplicationView, ApiError> {
let body = serde_json::json!({ "name": name, "slug": slug });
let r: Value = post_json("/applications", &body).await?;
serde_json::from_value(r.get("application").cloned().unwrap_or(Value::Null))
.map_err(|e| ApiError::Other(e.to_string()))
pub async fn create_application(
name: &str,
slug: &str,
description: Option<&str>,
redirect_urls: Option<Vec<String>>,
scopes: Option<Vec<String>>,
) -> Result<CreateApplicationResponse, ApiError> {
let body = serde_json::json!({
"name": name,
"slug": slug,
"description": description,
"redirect_urls": redirect_urls,
"scopes": scopes,
});
post_json("/applications", &body).await
}
pub async fn update_application(
id: &str,
name: &str,
slug: &str,
description: Option<&str>,
redirect_urls: Option<Vec<String>>,
scopes: Option<Vec<String>>,
enabled: bool,
) -> Result<ApplicationView, ApiError> {
let body = serde_json::json!({ "name": name, "slug": slug, "enabled": enabled });
let body = serde_json::json!({
"name": name,
"slug": slug,
"description": description,
"redirect_urls": redirect_urls,
"scopes": scopes,
"enabled": enabled,
});
let r: Value = patch_json(&format!("/applications/{id}"), &body).await?;
serde_json::from_value(r.get("application").cloned().unwrap_or(Value::Null))
.map_err(|e| ApiError::Other(e.to_string()))
}
pub async fn rotate_application_secret(id: &str) -> Result<String, ApiError> {
let r: RotateSecretResponse =
post_json(&format!("/applications/{id}/secret"), &serde_json::json!({})).await?;
Ok(r.client_secret)
}
pub async fn delete_application(id: &str) -> Result<(), ApiError> {
let _: Value = delete_json(&format!("/applications/{id}")).await?;
Ok(())