From bd14bd0bcb62c59ac78b0899105882360e644788 Mon Sep 17 00:00:00 2001 From: Oleh Martsokha Date: Wed, 9 Sep 2026 14:08:19 +0200 Subject: [PATCH 01/13] Redesign auth extractors, harden reject extractors, add extract tests Auth: remove the mostly-dead AuthProvider trait and AuthResult, folding workspace authorization into an inherent AuthState method. Seal AuthState as a verified-principal newtype (private field, private constructor, no DerefMut); reach account_id/is_admin through Deref to AuthClaims. Rename handler bindings to auth_state and drop the redundant is_expired() check in slide_session (AuthState is already DB-verified). Reject extractors: derive FromRequest/FromRequestParts via axum's `#[from_request(via, rejection)]` where possible (Json/Path/Query/Form), keeping the rejection-to-Error mapping (the actual value) hand-written; drop unused new()/into_inner() and never-used Optional impls. Fix a PII-into-logs leak in Form (sanitize before logging), stop the JSON extractor from claiming an unenforced 1 MB limit, and match BytesRejection's length-limit case by type instead of sniffing its Display string. Delete Path's fragile error-text-sniffing type hints. Read models: add Account::test()/AccountApiToken::test() constructors behind test_util. user_agent: fall back to the raw UA string when woothee cannot parse it rather than reporting UNKNOWN. Tests: cover connection_info IP classification (incl. the IPv6 link-local fix), version display, and the reject error formatters. Note: validated_json.rs is captured mid-migration to garde and is replaced in the following work. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_018bKk1YEG4tZ69jzYVQvQL8 --- crates/nvisy-postgres/src/model/account.rs | 29 ++ .../src/model/account_api_token.rs | 23 + crates/nvisy-server/Cargo.toml | 2 +- .../src/extract/auth/auth_provider.rs | 342 -------------- .../src/extract/auth/auth_state.rs | 160 ++++++- .../src/extract/auth/authorized.rs | 4 +- .../src/extract/auth/jwt_claims.rs | 88 ++++ crates/nvisy-server/src/extract/auth/mod.rs | 17 +- .../src/extract/auth/permission.rs | 138 +++--- crates/nvisy-server/src/extract/avatar.rs | 2 +- .../src/extract/connection_info.rs | 78 +++- .../src/extract/reject/form_with_rej.rs | 109 ++--- .../src/extract/reject/json_with_rej.rs | 110 ++--- .../src/extract/reject/mutlipart_with_rej.rs | 18 +- .../src/extract/reject/path_with_rej.rs | 96 +--- .../src/extract/reject/query_with_rej.rs | 83 +--- .../src/extract/reject/validated_json.rs | 440 +++++++++++------- crates/nvisy-server/src/extract/version.rs | 22 +- crates/nvisy-server/src/handler/accounts.rs | 40 +- crates/nvisy-server/src/handler/auth_oidc.rs | 22 +- .../src/handler/authentication.rs | 10 +- crates/nvisy-server/src/handler/catalog.rs | 6 +- crates/nvisy-server/src/handler/files.rs | 14 +- crates/nvisy-server/src/handler/identities.rs | 29 +- crates/nvisy-server/src/handler/invites.rs | 4 +- crates/nvisy-server/src/handler/members.rs | 2 +- .../nvisy-server/src/handler/notifications.rs | 10 +- .../src/handler/request/activities.rs | 6 +- crates/nvisy-server/src/handler/tokens.rs | 16 +- crates/nvisy-server/src/handler/workspaces.rs | 8 +- .../src/middleware/auth/session.rs | 40 +- crates/nvisy-server/src/service/user_agent.rs | 122 +++-- 32 files changed, 1009 insertions(+), 1081 deletions(-) delete mode 100644 crates/nvisy-server/src/extract/auth/auth_provider.rs diff --git a/crates/nvisy-postgres/src/model/account.rs b/crates/nvisy-postgres/src/model/account.rs index c6e2cc6d..33eb1853 100644 --- a/crates/nvisy-postgres/src/model/account.rs +++ b/crates/nvisy-postgres/src/model/account.rs @@ -52,6 +52,35 @@ pub struct Account { pub deleted_at: Option, } +impl Account { + /// An account row with a fresh unique handle and matching email, for tests. + /// Not admin/verified/suspended; timestamps are now. Useful to downstream + /// crates that need an `Account` without a database. + #[cfg(any(feature = "test_util", test))] + #[must_use] + pub fn test() -> Self { + let username = Handle::test(); + let email_address = format!("{}@example.com", username.as_str()); + let now: Timestamp = jiff::Timestamp::now().into(); + Self { + id: Uuid::now_v7(), + is_admin: false, + is_verified: false, + is_suspended: false, + username, + display_name: None, + email_address, + avatar_url: None, + timezone: "UTC".to_owned(), + locale: "en".to_owned(), + password_changed_at: None, + created_at: now, + updated_at: now, + deleted_at: None, + } + } +} + /// Data for creating a new account. #[derive(Debug, Clone, Insertable)] #[diesel(table_name = accounts)] diff --git a/crates/nvisy-postgres/src/model/account_api_token.rs b/crates/nvisy-postgres/src/model/account_api_token.rs index c84b98ce..820f27c4 100644 --- a/crates/nvisy-postgres/src/model/account_api_token.rs +++ b/crates/nvisy-postgres/src/model/account_api_token.rs @@ -37,6 +37,29 @@ pub struct AccountApiToken { pub deleted_at: Option, } +impl AccountApiToken { + /// A token row of `session_type` for `account_id`, for tests. `issued_at` is + /// now and there is no expiry; the remaining fields are defaulted. Useful to + /// downstream crates building auth flows without a database. + #[cfg(any(feature = "test_util", test))] + #[must_use] + pub fn test(account_id: Uuid, session_type: ApiTokenType) -> Self { + Self { + id: Uuid::now_v7(), + account_id, + display_name: "Test Token".to_owned(), + session_type, + ip_address: None, + user_agent: None, + is_remembered: false, + issued_at: jiff::Timestamp::now().into(), + expired_at: None, + last_used_at: None, + deleted_at: None, + } + } +} + /// Data for creating a new account API token. #[derive(Debug, Default, Clone, Insertable)] #[diesel(table_name = account_api_tokens)] diff --git a/crates/nvisy-server/Cargo.toml b/crates/nvisy-server/Cargo.toml index c6b2adbc..16799f70 100644 --- a/crates/nvisy-server/Cargo.toml +++ b/crates/nvisy-server/Cargo.toml @@ -142,7 +142,7 @@ url = { workspace = true, features = [] } [dev-dependencies] # Internal crates nvisy-nats = { workspace = true, features = [] } -nvisy-postgres = { workspace = true, features = ["schema"] } +nvisy-postgres = { workspace = true, features = ["schema", "test_util"] } nvisy-webhook = { workspace = true, features = ["reqwest"] } axum-test = { workspace = true, features = [] } diff --git a/crates/nvisy-server/src/extract/auth/auth_provider.rs b/crates/nvisy-server/src/extract/auth/auth_provider.rs deleted file mode 100644 index bf2919c3..00000000 --- a/crates/nvisy-server/src/extract/auth/auth_provider.rs +++ /dev/null @@ -1,342 +0,0 @@ -//! Authorization provider trait for authenticated users. -//! -//! This module defines the [`AuthProvider`] trait that provides authorization -//! methods for checking permissions at different levels: workspace, document, admin, and self-access. -//! The trait is designed to be implemented by types that represent authenticated users. - -use nvisy_postgres::model::WorkspaceMember; -use nvisy_postgres::query::{WorkspaceFileRepository, WorkspaceMemberRepository}; -use nvisy_postgres::{Error as PgError, PgConn}; -use uuid::Uuid; - -use super::{AuthResult, Permission}; -use crate::handler::Result; - -/// Tracing target for authorization operations. -const TRACING_TARGET: &str = "nvisy_server::authorization"; - -/// Authorization provider for authenticated users. -/// -/// This trait provides methods for checking and enforcing permissions at various levels. -/// Implementors must provide access to the user's account ID and admin status. -/// All authorization methods have default implementations with comprehensive database verification. -/// -/// # Implementation Requirements -/// -/// - [`account_id`]: Must return the authenticated user's UUID -/// - [`is_admin`]: Must return current admin status -/// -/// # Authorization Levels -/// -/// - **Global Admin**: Bypasses all workspace-level restrictions -/// - **Workspace-Level**: Based on membership and role within specific workspaces -/// - **Document-Level**: Extends workspace permissions with ownership rules -/// - **Self-Access**: Operations on the user's own account data -/// -/// [`account_id`]: Self::account_id -/// [`is_admin`]: Self::is_admin -pub trait AuthProvider { - /// Returns the account ID of the authenticated user. - fn account_id(&self) -> Uuid; - - /// Returns whether the user has global administrator privileges. - fn is_admin(&self) -> bool; - - /// Checks if a user has permission to access a workspace. - /// - /// # Arguments - /// - /// * `pg_client` - Database client - /// * `workspace_id` - Workspace to check access for - /// * `permission` - Required permission level - /// - /// # Returns - /// - /// Returns `AuthResult` with grant status and optional member information. - /// - /// # Errors - /// - /// Returns database errors if queries fail. - #[allow(async_fn_in_trait)] - async fn check_workspace_permission( - &self, - conn: &mut PgConn, - workspace_id: Uuid, - permission: Permission, - ) -> Result { - // Global administrators bypass workspace-level permissions - if self.is_admin() { - tracing::debug!( - target: TRACING_TARGET, - account_id = %self.account_id(), - workspace_id = %workspace_id, - permission = ?permission, - "access granted: global administrator" - ); - - return Ok(AuthResult::granted()); - } - - // Check workspace membership - let member = conn - .find_workspace_member(workspace_id, self.account_id()) - .await?; - - let Some(member) = member else { - tracing::warn!( - target: TRACING_TARGET, - account_id = %self.account_id(), - workspace_id = %workspace_id, - permission = ?permission, - "access denied: not a workspace member" - ); - - return Ok(AuthResult::denied("Not a workspace member")); - }; - - // Check role permission - if permission.is_permitted_by_role(member.member_role) { - tracing::debug!( - target: TRACING_TARGET, - account_id = %self.account_id(), - workspace_id = %workspace_id, - permission = ?permission, - role = ?member.member_role, - "Access granted: sufficient role" - ); - - Ok(AuthResult::granted_with_member(member)) - } else { - tracing::warn!( - target: TRACING_TARGET, - account_id = %self.account_id(), - workspace_id = %workspace_id, - permission = ?permission, - role = ?member.member_role, - "Access denied: insufficient role" - ); - - Ok(AuthResult::denied(format!( - "Role {member_role:?} insufficient for {permission:?} permission", - member_role = member.member_role - ))) - } - } - - /// Checks if a user has permission to access a file. - /// - /// This method resolves the file's workspace and checks workspace-level permissions. - /// File owners have special privileges for write operations. - /// - /// # Arguments - /// - /// * `conn` - Database connection - /// * `file_id` - File to check access for - /// * `permission` - Required permission level - /// - /// # Returns - /// - /// Returns `AuthResult` with grant status and optional member information. - /// - /// # Errors - /// - /// Returns database errors if queries fail. - #[allow(async_fn_in_trait)] - async fn check_file_permission( - &self, - conn: &mut PgConn, - file_id: Uuid, - permission: Permission, - ) -> Result { - // Get the file to find its workspace - let file = conn.find_workspace_file_by_id(file_id).await?; - - let Some(file) = file else { - tracing::warn!( - target: TRACING_TARGET, - account_id = %self.account_id(), - file_id = %file_id, - "access denied: file not found" - ); - return Ok(AuthResult::denied("File not found")); - }; - - // File owners have special privileges for destructive operations - let is_file_owner = file.account_id == self.account_id(); - let requires_ownership = matches!( - permission, - Permission::UpdateFiles | Permission::DeleteFiles - ); - - if requires_ownership && !is_file_owner && !self.is_admin() { - // Non-owners need explicit workspace-level permissions for destructive operations - return self - .check_workspace_permission(conn, file.workspace_id, permission) - .await; - } - - self.check_workspace_permission(conn, file.workspace_id, permission) - .await - } - - /// Validates that a user can perform an action on their own account. - /// - /// # Arguments - /// - /// * `target_account_id` - Account ID to check access for - /// - /// # Returns - /// - /// Returns `AuthResult` with grant status. - fn check_self_permission(&self, target_account_id: Uuid) -> Result { - let is_self_access = self.account_id() == target_account_id; - let is_admin = self.is_admin(); - - if is_self_access || is_admin { - tracing::debug!( - target: TRACING_TARGET, - account_id = %self.account_id(), - target_account_id = %target_account_id, - is_admin = is_admin, - access_type = if is_self_access { "self" } else { "admin" }, - "self-permission granted" - ); - - Ok(AuthResult::granted()) - } else { - tracing::warn!( - target: TRACING_TARGET, - account_id = %self.account_id(), - target_account_id = %target_account_id, - "self-permission denied: insufficient privileges" - ); - - Ok(AuthResult::denied("Can only access your own account data")) - } - } - - /// Validates that a user has global administrative privileges. - /// - /// Global administrators can perform any operation within the system, - /// including cross-workspace operations and system administration tasks. - /// - /// # Returns - /// - /// Returns [`AuthResult`] indicating whether admin access is granted. - fn check_admin_permission(&self) -> Result { - if self.is_admin() { - tracing::debug!( - target: TRACING_TARGET, - account_id = %self.account_id(), - "global admin permission granted" - ); - Ok(AuthResult::granted()) - } else { - tracing::warn!( - target: TRACING_TARGET, - account_id = %self.account_id(), - "global admin permission denied" - ); - - Ok(AuthResult::denied( - "Global administrator privileges required", - )) - } - } - - /// Authorizes workspace access and returns member information on success. - /// - /// This convenience method performs authorization and converts the result - /// into a standard [`Result`] with optional member information. - /// - /// # Arguments - /// - /// * `conn` - Database connection for verification - /// * `workspace_id` - Target workspace identifier - /// * `permission` - Required permission level - /// - /// # Returns - /// - /// Returns [`WorkspaceMember`] if authorized with member info, - /// `Ok(None)` if authorized without member info (e.g., global admin), - /// or [`Err`] if access is denied. - /// - /// # Errors - /// - /// Returns `Forbidden` error if access is denied, or propagates database errors. - #[allow(async_fn_in_trait)] - async fn authorize_workspace( - &self, - conn: &mut PgConn, - workspace_id: Uuid, - permission: Permission, - ) -> Result> { - let auth_result = self - .check_workspace_permission(conn, workspace_id, permission) - .await?; - auth_result.into_result() - } - - /// Authorizes file access with ownership and workspace-level checks. - /// - /// This convenience method handles complex file authorization logic: - /// - File owners have enhanced privileges for their own files - /// - All access requires at least workspace membership - /// - Global administrators bypass all restrictions - /// - /// # Arguments - /// - /// * `conn` - Database connection for verification - /// * `file_id` - Target file identifier - /// * `permission` - Required permission level - /// - /// # Returns - /// - /// Returns member information if authorized, or error if access denied. - /// - /// # Errors - /// - /// Returns `Forbidden` error if access is denied, or propagates database errors. - #[allow(async_fn_in_trait)] - async fn authorize_file( - &self, - conn: &mut PgConn, - file_id: Uuid, - permission: Permission, - ) -> Result> { - let auth_result = self - .check_file_permission(conn, file_id, permission) - .await?; - auth_result.into_result() - } - - /// Authorizes access to account-specific data. - /// - /// Users can access their own account data, and global administrators - /// can access any account data for system administration purposes. - /// - /// # Arguments - /// - /// * `target_account_id` - Account ID to authorize access for - /// - /// # Errors - /// - /// Returns `Forbidden` error if the user cannot access the target account. - fn authorize_self(&self, target_account_id: Uuid) -> Result<()> { - let auth_result = self.check_self_permission(target_account_id)?; - auth_result.into_result().map(|_| ()) - } - - /// Authorizes global administrator access. - /// - /// This method enforces global administrator privileges for system-level - /// operations that require elevated access across all workspaces and resources. - /// - /// # Errors - /// - /// Returns `Forbidden` error if the user lacks global admin privileges. - fn authorize_admin(&self) -> Result<()> { - let auth_result = self.check_admin_permission()?; - auth_result.into_result().map(|_| ()) - } -} diff --git a/crates/nvisy-server/src/extract/auth/auth_state.rs b/crates/nvisy-server/src/extract/auth/auth_state.rs index 5849eb6e..a41935e1 100644 --- a/crates/nvisy-server/src/extract/auth/auth_state.rs +++ b/crates/nvisy-server/src/extract/auth/auth_state.rs @@ -11,14 +11,17 @@ use aide::generate::GenContext; use aide::openapi::Operation; use axum::extract::{FromRef, FromRequestParts, OptionalFromRequestParts}; use axum::http::request::Parts; -use derive_more::{Deref, DerefMut}; -use nvisy_postgres::model::Account; -use nvisy_postgres::query::{AccountApiTokenRepository, AccountRepository}; +use derive_more::Deref; +use nvisy_postgres::model::{Account, WorkspaceMember}; +use nvisy_postgres::query::{ + AccountApiTokenRepository, AccountRepository, WorkspaceMemberRepository, +}; use nvisy_postgres::types::session; use nvisy_postgres::{PgClient, PgConn}; use serde::Deserialize; +use uuid::Uuid; -use super::{AuthClaims, AuthHeader}; +use super::{AuthClaims, AuthHeader, Permission}; use crate::handler::{Error, ErrorKind, Result}; use crate::service::SessionKeys; @@ -61,29 +64,89 @@ const TRACING_TARGET: &str = "nvisy_server::authentication"; /// /// [`AuthState`] is [`Send`] + [`Sync`] and can be safely shared across threads. /// All contained data is immutable after creation. -#[derive(Debug, Clone, Deref, DerefMut, Hash, PartialEq, Eq)] -pub struct AuthState(pub AuthClaims); +#[derive(Debug, Clone, Deref, Hash, PartialEq, Eq)] +pub struct AuthState(AuthClaims); impl AuthState { - /// Creates a new [`AuthState`] from pre-verified claims. - /// - /// # Safety Requirements - /// - /// This method should **only** be used when the claims have already undergone - /// complete database verification. Using this with unverified claims bypasses - /// critical security checks. - /// - /// # Arguments + /// Wraps claims that have already been verified against the database. Private + /// on purpose: the only way to obtain an `AuthState` is by going through + /// [`from_unverified_header`](Self::from_unverified_header) (or the extractor), + /// so the type is a proof of verification that cannot be forged from raw claims. + #[inline] + const fn from_verified_claims(auth_claims: AuthClaims) -> Self { + Self(auth_claims) + } + + /// Authorizes the caller for `permission` in `workspace_id`, returning their + /// membership on success (or `None` for a global admin, who is authorized + /// without being a member). /// - /// * `auth_claims` - Claims that have been verified against the database + /// A global admin bypasses the workspace check. Otherwise the caller must be a + /// member whose role satisfies `permission`; a non-member or an insufficient + /// role is `403 Forbidden`. /// - /// # Returns + /// # Errors /// - /// Returns a new [`AuthState`] without additional verification. - #[inline] - #[must_use] - pub const fn from_verified_claims(auth_claims: AuthClaims) -> Self { - Self(auth_claims) + /// Returns `Forbidden` if access is denied, or propagates database errors from + /// the membership lookup. + pub async fn authorize_workspace( + &self, + conn: &mut PgConn, + workspace_id: Uuid, + permission: Permission, + ) -> Result> { + // Global administrators bypass workspace-level permissions. + if self.0.is_admin { + tracing::debug!( + target: TRACING_TARGET, + account_id = %self.0.account_id, + workspace_id = %workspace_id, + permission = ?permission, + "access granted: global administrator" + ); + return Ok(None); + } + + let member = conn + .find_workspace_member(workspace_id, self.0.account_id) + .await + .map_err(Error::from)?; + + let Some(member) = member else { + tracing::warn!( + target: TRACING_TARGET, + account_id = %self.0.account_id, + workspace_id = %workspace_id, + "access denied: not a workspace member" + ); + return Err(ErrorKind::Forbidden + .with_message("Not a workspace member") + .with_resource("workspace")); + }; + + if permission.is_permitted_by_role(member.member_role) { + tracing::debug!( + target: TRACING_TARGET, + account_id = %self.0.account_id, + workspace_id = %workspace_id, + permission = ?permission, + role = ?member.member_role, + "access granted: sufficient role" + ); + Ok(Some(member)) + } else { + tracing::warn!( + target: TRACING_TARGET, + account_id = %self.0.account_id, + workspace_id = %workspace_id, + permission = ?permission, + role = ?member.member_role, + "access denied: insufficient role" + ); + Err(ErrorKind::Forbidden + .with_message("Insufficient role for this action") + .with_resource("workspace")) + } } } @@ -429,3 +492,56 @@ where operation.security = vec![[("BearerAuth".to_string(), vec![])].into()]; } } + +#[cfg(test)] +mod tests { + use nvisy_postgres::model::{Account, AccountApiToken}; + use nvisy_postgres::types::ApiTokenType; + + use super::{AuthClaims, AuthState}; + + /// Builds claims for `account` (the claim's `is_admin` mirrors the account it + /// was minted from). + fn claims_for(account: &Account) -> AuthClaims<()> { + let token = AccountApiToken::test(account.id, ApiTokenType::Web); + AuthClaims::new(account, &token) + } + + #[test] + fn privilege_consistency_accepts_a_matching_admin_flag() { + let mut admin = Account::test(); + admin.is_admin = true; + assert!(AuthState::<()>::verify_privilege_consistency(&claims_for(&admin), &admin).is_ok()); + + let user = Account::test(); // is_admin: false + assert!(AuthState::<()>::verify_privilege_consistency(&claims_for(&user), &user).is_ok()); + } + + #[test] + fn privilege_consistency_rejects_a_stale_admin_claim() { + // Token was minted while the account was admin; the account has since been + // demoted. The stale admin claim must be rejected (fail closed). + let mut was_admin = Account::test(); + was_admin.is_admin = true; + let stale_claims = claims_for(&was_admin); + + let mut now_demoted = was_admin.clone(); + now_demoted.is_admin = false; + + assert!( + AuthState::<()>::verify_privilege_consistency(&stale_claims, &now_demoted).is_err() + ); + } + + #[test] + fn privilege_consistency_rejects_a_forged_admin_claim() { + // A non-admin account whose token nonetheless claims admin must be + // rejected — a claim can never grant a privilege the DB does not hold. + let mut forged = Account::test(); + forged.is_admin = true; + let forged_claims = claims_for(&forged); + + let real = Account::test(); // is_admin: false + assert!(AuthState::<()>::verify_privilege_consistency(&forged_claims, &real).is_err()); + } +} diff --git a/crates/nvisy-server/src/extract/auth/authorized.rs b/crates/nvisy-server/src/extract/auth/authorized.rs index c9ab49d0..ce8e8515 100644 --- a/crates/nvisy-server/src/extract/auth/authorized.rs +++ b/crates/nvisy-server/src/extract/auth/authorized.rs @@ -18,7 +18,7 @@ use axum::http::request::Parts; use nvisy_postgres::model::{Workspace, WorkspaceMember}; use uuid::Uuid; -use super::{AuthProvider, AuthState, Permission}; +use super::{AuthState, Permission}; use crate::extract::{PgPool, WorkspaceContext}; use crate::handler::Error; @@ -68,7 +68,7 @@ where WorkspaceContext::from_request_parts(parts, state).await?; let PgPool(mut conn) = PgPool::from_request_parts(parts, state).await?; - let account_id = auth.account_id(); + let account_id = auth.account_id; let member = auth .authorize_workspace(&mut conn, workspace.id, P::PERMISSION) .await?; diff --git a/crates/nvisy-server/src/extract/auth/jwt_claims.rs b/crates/nvisy-server/src/extract/auth/jwt_claims.rs index e6149f88..34682ddd 100644 --- a/crates/nvisy-server/src/extract/auth/jwt_claims.rs +++ b/crates/nvisy-server/src/extract/auth/jwt_claims.rs @@ -349,3 +349,91 @@ where Ok(claims) } } + +#[cfg(test)] +mod tests { + use std::borrow::Cow; + + use jiff::{Span, Timestamp}; + use nvisy_postgres::model::{Account, AccountApiToken}; + use nvisy_postgres::types::{ApiTokenType, session}; + + use super::{AuthClaims, NEVER_EXPIRES_SECONDS}; + + /// Builds bare claims with a chosen `expires_at`, for the time predicates. + fn claims_expiring_at(expires_at: i64) -> AuthClaims<()> { + AuthClaims { + issued_by: Cow::Borrowed("nvisy"), + audience: Cow::Borrowed("nvisy:server"), + token_id: uuid::Uuid::now_v7(), + account_id: uuid::Uuid::now_v7(), + issued_at: Timestamp::now().as_second(), + expires_at, + custom_claims: (), + is_admin: false, + } + } + + #[test] + fn web_exp_is_the_absolute_cap_from_issued_at() { + let account = Account::test(); + let token = AccountApiToken::test(account.id, ApiTokenType::Web); + let claims = AuthClaims::new(&account, &token); + + // A web session's JWT `exp` is `issued_at + MAX_AGE`, independent of the + // row's (sliding) `expired_at`. + let issued = Timestamp::from(token.issued_at).as_second(); + let expected = issued + session::MAX_AGE.as_secs() as i64; + assert_eq!(claims.expires_at, expected); + assert_eq!(claims.account_id, account.id); + assert_eq!(claims.token_id, token.id); + } + + #[test] + fn api_and_app_exp_follow_the_rows_own_expiry() { + let account = Account::test(); + let chosen = Timestamp::now() + Span::new().hours(3); + + for kind in [ApiTokenType::Api, ApiTokenType::App] { + let mut token = AccountApiToken::test(account.id, kind); + token.expired_at = Some(chosen.into()); + let claims = AuthClaims::new(&account, &token); + // Not the browser cap — the token's chosen lifetime. + assert_eq!(claims.expires_at, chosen.as_second()); + } + } + + #[test] + fn api_and_app_without_expiry_never_effectively_expire() { + let account = Account::test(); + let token = AccountApiToken::test(account.id, ApiTokenType::Api); // expired_at: None + let claims = AuthClaims::new(&account, &token); + + // Falls back to a far-future value (~100 years), so the JWT never lapses. + let lower_bound = Timestamp::now().as_second() + NEVER_EXPIRES_SECONDS - 60; + assert!(claims.expires_at >= lower_bound); + assert!(!claims.is_expired()); + } + + #[test] + fn time_predicates_track_expiry() { + let now = Timestamp::now().as_second(); + + // Already past. + let expired = claims_expiring_at(now - 10); + assert!(expired.is_expired()); + assert!(expired.expires_soon()); + assert_eq!(expired.remaining_lifetime().get_seconds(), 0); + + // Comfortably in the future. + let fresh = claims_expiring_at(now + 3600); + assert!(!fresh.is_expired()); + assert!(!fresh.expires_soon()); + assert!(fresh.remaining_lifetime().get_seconds() > 0); + + // Within the 5-minute refresh threshold but not yet expired. + let soon = claims_expiring_at(now + 60); + assert!(!soon.is_expired()); + assert!(soon.expires_soon()); + } +} diff --git a/crates/nvisy-server/src/extract/auth/mod.rs b/crates/nvisy-server/src/extract/auth/mod.rs index 3c069012..3705c643 100644 --- a/crates/nvisy-server/src/extract/auth/mod.rs +++ b/crates/nvisy-server/src/extract/auth/mod.rs @@ -4,7 +4,6 @@ //! for the nvisy API, including JWT token handling, session validation, and //! permission checking at various levels. -mod auth_provider; mod auth_state; mod authorized; mod jwt_claims; @@ -12,16 +11,12 @@ mod jwt_header; mod optional_auth; mod permission; -use uuid::Uuid; - -pub use self::auth_provider::AuthProvider; pub use self::auth_state::AuthState; -// Glob: the permission markers (one per `Permission`) are generated by a macro. pub use self::authorized::*; pub use self::jwt_claims::AuthClaims; pub use self::jwt_header::{AuthHeader, AuthTransport}; pub use self::optional_auth::OptionalAuth; -pub use self::permission::{AuthResult, Permission}; +pub use self::permission::Permission; /// Name of the `HttpOnly` cookie that carries the session JWT for browser /// clients. The same JWT reaches programmatic callers as an `Authorization: @@ -36,13 +31,3 @@ pub const CSRF_COOKIE_NAME: &str = "nvisy.csrf"; /// Request header a cookie-authenticated client must echo the CSRF token in, for /// the double-submit check on state-changing requests. pub const CSRF_HEADER_NAME: &str = "x-csrf-token"; - -impl AuthProvider for AuthClaims { - fn account_id(&self) -> Uuid { - self.account_id - } - - fn is_admin(&self) -> bool { - self.is_admin - } -} diff --git a/crates/nvisy-server/src/extract/auth/permission.rs b/crates/nvisy-server/src/extract/auth/permission.rs index 1a966cd3..cb4cb9b5 100644 --- a/crates/nvisy-server/src/extract/auth/permission.rs +++ b/crates/nvisy-server/src/extract/auth/permission.rs @@ -1,16 +1,11 @@ -//! Core authorization types and utilities. +//! Core authorization types. //! -//! This module provides the fundamental types used for authorization throughout -//! the nvisy system, including permissions and results. +//! Defines [`Permission`] and its mapping to the minimum [`WorkspaceRole`] that +//! satisfies it. -use std::borrow::Cow; - -use nvisy_postgres::model::WorkspaceMember; use nvisy_postgres::types::WorkspaceRole; use strum::{EnumIter, EnumString, IntoEnumIterator}; -use crate::handler::{ErrorKind, Result}; - /// Granular workspace permissions for authorization checks. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] #[derive(EnumIter, EnumString)] @@ -177,63 +172,84 @@ impl Permission { } } -/// Result of an authorization check with detailed information. -#[derive(Debug, Clone, PartialEq)] -pub struct AuthResult { - pub granted: bool, - pub member: Option, - pub reason: Option>, -} - -impl AuthResult { - /// Creates a granted authorization result without member information. - pub const fn granted() -> Self { - Self { - granted: true, - member: None, - reason: None, - } - } - - /// Creates a granted authorization result with member information. - pub const fn granted_with_member(member: WorkspaceMember) -> Self { - Self { - granted: true, - member: Some(member), - reason: None, - } +#[cfg(test)] +mod tests { + use std::collections::HashSet; + + use nvisy_postgres::types::WorkspaceRole; + + use super::Permission; + + #[test] + fn security_boundaries_map_to_the_right_minimum_role() { + // The review-vs-original split is a real boundary: a Reviewer may see and + // download redacted output and audit, but never the original bytes. + assert_eq!( + Permission::ViewFiles.minimum_required_role(), + WorkspaceRole::Reviewer + ); + assert_eq!( + Permission::DownloadRedactedFiles.minimum_required_role(), + WorkspaceRole::Reviewer + ); + assert_eq!( + Permission::DownloadOriginalFiles.minimum_required_role(), + WorkspaceRole::Editor + ); + + // Managing the workspace is Admin; destroying it or changing roles is + // Owner-only. + assert_eq!( + Permission::InviteMembers.minimum_required_role(), + WorkspaceRole::Admin + ); + assert_eq!( + Permission::DeleteWorkspace.minimum_required_role(), + WorkspaceRole::Owner + ); + assert_eq!( + Permission::ManageRoles.minimum_required_role(), + WorkspaceRole::Owner + ); } - /// Creates a denied authorization result with a reason. - pub fn denied(reason: impl Into>) -> Self { - Self { - granted: false, - member: None, - reason: Some(reason.into()), + #[test] + fn is_permitted_follows_the_role_hierarchy() { + // A Reviewer-tier permission is granted to everyone at Reviewer or above. + for role in [ + WorkspaceRole::Reviewer, + WorkspaceRole::Editor, + WorkspaceRole::Admin, + WorkspaceRole::Owner, + ] { + assert!(Permission::ViewFiles.is_permitted_by_role(role)); } + // An Owner-only permission is denied to everyone below Owner. + assert!(!Permission::ManageRoles.is_permitted_by_role(WorkspaceRole::Admin)); + assert!(!Permission::ManageRoles.is_permitted_by_role(WorkspaceRole::Editor)); + assert!(Permission::ManageRoles.is_permitted_by_role(WorkspaceRole::Owner)); } - /// Converts the result to a `Result` type, returning an error if access is denied. - /// - /// # Examples - /// - /// ```rust - /// # use nvisy_server::extract::AuthResult; - /// let result = AuthResult::granted(); - /// assert!(result.into_result().is_ok()); - /// - /// let result = AuthResult::denied("Access denied"); - /// assert!(result.into_result().is_err()); - /// ``` - pub fn into_result(self) -> Result> { - if self.granted { - Ok(self.member) - } else { - let error = match self.reason { - Some(reason) => ErrorKind::Forbidden.with_context(reason), - None => ErrorKind::Forbidden.into_error(), - }; - Err(error) - } + #[test] + fn permissions_are_monotonic_up_the_hierarchy() { + // A higher role must hold every permission a lower role does (roles are a + // strict hierarchy, so permission sets nest). This catches any permission + // that was mis-tiered such that it regressed for a higher role. + let set = |role| -> HashSet { + Permission::permissions_for_role(role).into_iter().collect() + }; + let reviewer = set(WorkspaceRole::Reviewer); + let editor = set(WorkspaceRole::Editor); + let admin = set(WorkspaceRole::Admin); + let owner = set(WorkspaceRole::Owner); + + assert!(reviewer.is_subset(&editor)); + assert!(editor.is_subset(&admin)); + assert!(admin.is_subset(&owner)); + + // Owner holds every permission; each step up strictly adds at least one. + assert!(reviewer.len() < editor.len()); + assert!(editor.len() < admin.len()); + assert!(admin.len() < owner.len()); } } diff --git a/crates/nvisy-server/src/extract/avatar.rs b/crates/nvisy-server/src/extract/avatar.rs index bd115dc6..70d9cc6d 100644 --- a/crates/nvisy-server/src/extract/avatar.rs +++ b/crates/nvisy-server/src/extract/avatar.rs @@ -30,7 +30,7 @@ where type Rejection = Error<'static>; async fn from_request(req: Request, state: &S) -> Result { - let mut multipart = Multipart::from_request(req, state).await?.into_inner(); + let Multipart(mut multipart) = Multipart::from_request(req, state).await?; while let Some(field) = multipart.next_field().await.map_err(|err| { ErrorKind::BadRequest diff --git a/crates/nvisy-server/src/extract/connection_info.rs b/crates/nvisy-server/src/extract/connection_info.rs index a2b26487..24ff55fc 100644 --- a/crates/nvisy-server/src/extract/connection_info.rs +++ b/crates/nvisy-server/src/extract/connection_info.rs @@ -135,7 +135,10 @@ impl AppConnectInfo { || ipv4.is_unspecified() } IpAddr::V6(ipv6) => { - ipv6.is_loopback() || ipv6.is_unspecified() || ipv6.segments()[0] & 0xfe00 == 0xfc00 // Unique local addresses + ipv6.is_loopback() + || ipv6.is_unspecified() + || ipv6.is_unique_local() // fc00::/7 + || ipv6.is_unicast_link_local() // fe80::/10, mirroring the IPv4 link-local case } } } @@ -197,3 +200,76 @@ impl Connected for AppConnectInfo { Self::new(addr) } } + +#[cfg(test)] +mod tests { + use std::net::SocketAddr; + + use super::AppConnectInfo; + + fn info(addr: &str) -> AppConnectInfo { + AppConnectInfo::new(addr.parse::().unwrap()) + } + + #[test] + fn classifies_private_and_public_ipv4() { + for private in [ + "10.0.0.1:80", + "172.16.5.4:80", + "192.168.1.1:80", + "127.0.0.1:80", + "169.254.1.1:80", + ] { + assert!(info(private).is_private_ip(), "{private} should be private"); + assert!(!info(private).is_public_ip()); + } + for public in ["8.8.8.8:80", "1.1.1.1:443"] { + assert!(info(public).is_public_ip(), "{public} should be public"); + assert!(!info(public).is_private_ip()); + } + } + + #[test] + fn classifies_ipv6_including_link_local() { + // Loopback, unique-local (fc00::/7), and — the case the old bit-check + // missed — link-local (fe80::/10) are all private. + for private in [ + "[::1]:80", + "[fc00::1]:80", + "[fd12:3456::1]:80", + "[fe80::1]:80", + ] { + assert!(info(private).is_private_ip(), "{private} should be private"); + } + // A global-unicast address is public. + assert!(info("[2606:4700::1111]:443").is_public_ip()); + } + + #[test] + fn localhost_and_ip_family_predicates() { + assert!(info("127.0.0.1:80").is_localhost()); + assert!(info("[::1]:80").is_localhost()); + assert!(!info("8.8.8.8:80").is_localhost()); + + assert!(info("8.8.8.8:80").is_ipv4()); + assert!(!info("8.8.8.8:80").is_ipv6()); + assert!(info("[::1]:80").is_ipv6()); + } + + #[test] + fn client_ip_prefers_the_proxy_real_ip() { + let proxy: SocketAddr = "10.0.0.9:1234".parse().unwrap(); + let real = "203.0.113.7".parse().unwrap(); + let info = AppConnectInfo::with_real_ip(proxy, real); + + // The real (client) IP wins over the direct proxy address. + assert_eq!(info.client_ip(), real); + assert!( + info.is_public_ip(), + "classification follows the real client IP" + ); + assert_eq!(info.client_port(), 1234); + // The log string notes both the real IP and the proxy it came via. + assert_eq!(info.to_log_string(), "203.0.113.7 (via 10.0.0.9)"); + } +} diff --git a/crates/nvisy-server/src/extract/reject/form_with_rej.rs b/crates/nvisy-server/src/extract/reject/form_with_rej.rs index f594ef82..b72a0e9f 100644 --- a/crates/nvisy-server/src/extract/reject/form_with_rej.rs +++ b/crates/nvisy-server/src/extract/reject/form_with_rej.rs @@ -7,10 +7,9 @@ use aide::OperationInput; use aide::generate::GenContext; use aide::openapi::{Operation, Response}; use axum::extract::rejection::FormRejection; -use axum::extract::{Form as AxumForm, FromRequest, OptionalFromRequest, Request}; +use axum::extract::{Form as AxumForm, FromRequest}; use derive_more::{Deref, DerefMut, From}; use schemars::JsonSchema; -use serde::de::DeserializeOwned; use super::sanitize_error_message; use crate::extract::Query; @@ -26,89 +25,51 @@ use crate::handler::{Error, ErrorKind}; /// - Clear indication of which fields failed validation /// - Content-Type validation with helpful suggestions /// +/// The [`FromRequest`] impl is derived: extraction delegates to [`axum::Form`] +/// and its [`FormRejection`] is mapped into our [`Error`] by the `From` impl +/// below (that mapping is where the improved messages live). +/// /// All errors are automatically converted to appropriate HTTP responses /// with detailed error messages for better API debugging and user experience. /// /// [Form]: AxumForm #[must_use] -#[derive(Debug, Clone, Copy, Default, Deref, DerefMut, From)] +#[derive(Debug, Clone, Copy, Default, Deref, DerefMut, From, FromRequest)] +#[from_request(via(AxumForm), rejection(Error<'static>))] pub struct Form(pub T); -impl Form { - /// Creates a new instance of [`Form`]. - /// - /// # Arguments - /// - /// * `inner` - The deserialized form data - #[inline] - pub fn new(inner: T) -> Self { - Self(inner) - } - - /// Returns the inner form data. - #[inline] - pub fn into_inner(self) -> T { - self.0 - } -} - -impl FromRequest for Form -where - T: DeserializeOwned, - S: Send + Sync, -{ - type Rejection = Error<'static>; - - async fn from_request(req: Request, state: &S) -> Result { - match AxumForm::::from_request(req, state).await { - Ok(AxumForm(form)) => Ok(Form(form)), - Err(rejection) => Err(enhance_form_error(rejection)), - } - } -} - -impl OptionalFromRequest for Form -where - T: DeserializeOwned, - S: Send + Sync, -{ - type Rejection = Error<'static>; - - async fn from_request(req: Request, state: &S) -> Result, Self::Rejection> { - match AxumForm::::from_request(req, state).await { - Ok(AxumForm(form)) => Ok(Some(Form(form))), - Err(_) => Ok(None), - } - } -} - -/// Converts a form rejection into a structured bad-request [`Error`]. +/// Maps a form rejection into a structured bad-request [`Error`]. /// /// The deserializer message is sanitized before it becomes context so that /// submitted field values are not echoed back or logged. -fn enhance_form_error(rejection: FormRejection) -> Error<'static> { - tracing::debug!( - target: "nvisy::extract::form", - error = %rejection, - "Form data parsing failed" - ); +impl From for Error<'static> { + fn from(rejection: FormRejection) -> Self { + // Sanitize before logging: a deserialization rejection can echo submitted + // field values, so the raw rejection must never reach the log line. + let sanitized = sanitize_error_message(&rejection.to_string()); + tracing::debug!( + target: "nvisy::extract::form", + error = %sanitized, + "Form data parsing failed" + ); - match rejection { - FormRejection::FailedToDeserializeForm(err) => ErrorKind::BadRequest - .with_message("Invalid form data") - .with_context(sanitize_error_message(&err.to_string())), - FormRejection::InvalidFormContentType(_) => ErrorKind::BadRequest - .with_message("Invalid content type for form data") - .with_context( - "Expected 'application/x-www-form-urlencoded'. \ - Set the correct Content-Type header for form submissions", - ), - FormRejection::BytesRejection(_) => ErrorKind::BadRequest - .with_message("Failed to read form data") - .with_context("The request body could not be read as form data"), - _ => ErrorKind::BadRequest - .with_message("Invalid form submission") - .with_context("The form data could not be processed"), + match rejection { + FormRejection::FailedToDeserializeForm(_) => ErrorKind::BadRequest + .with_message("Invalid form data") + .with_context(sanitized), + FormRejection::InvalidFormContentType(_) => ErrorKind::BadRequest + .with_message("Invalid content type for form data") + .with_context( + "Expected 'application/x-www-form-urlencoded'. \ + Set the correct Content-Type header for form submissions", + ), + FormRejection::BytesRejection(_) => ErrorKind::BadRequest + .with_message("Failed to read form data") + .with_context("The request body could not be read as form data"), + _ => ErrorKind::BadRequest + .with_message("Invalid form submission") + .with_context("The form data could not be processed"), + } } } diff --git a/crates/nvisy-server/src/extract/reject/json_with_rej.rs b/crates/nvisy-server/src/extract/reject/json_with_rej.rs index 08dd10a9..80024615 100644 --- a/crates/nvisy-server/src/extract/reject/json_with_rej.rs +++ b/crates/nvisy-server/src/extract/reject/json_with_rej.rs @@ -6,7 +6,7 @@ use aide::generate::GenContext; use aide::openapi::Operation; use aide::{OperationInput, OperationOutput}; -use axum::extract::rejection::JsonRejection; +use axum::extract::rejection::{BytesRejection, FailedToBufferBody, JsonRejection}; use axum::extract::{FromRequest, Json as AxumJson, OptionalFromRequest, Request}; use axum::response::{IntoResponse, Response}; use derive_more::{Deref, DerefMut, From}; @@ -17,9 +17,6 @@ use serde::de::DeserializeOwned; use super::sanitize_error_message; use crate::handler::{Error, ErrorKind}; -/// Maximum allowed JSON payload size in bytes (1MB). -const MAX_JSON_PAYLOAD_SIZE: usize = 1024 * 1024; - /// Enhanced JSON extractor with improved error handling. /// /// This extractor provides better error messages compared to the @@ -28,50 +25,24 @@ const MAX_JSON_PAYLOAD_SIZE: usize = 1024 * 1024; /// - Detailed error messages for different failure types /// - Type-safe deserialization with proper error context /// +/// The [`FromRequest`] impl is derived: extraction delegates to [`axum::Json`] +/// and its [`JsonRejection`] is mapped into our [`Error`] by the `From` impl +/// below (that mapping is where the improved messages live). +/// /// # Size Limits /// -/// The extractor enforces a maximum payload size of 1MB to prevent -/// memory exhaustion attacks. +/// Request-body size limits apply via the router's body-limit layer; a body +/// that exceeds it is rejected before deserialization. /// /// All errors are automatically converted to appropriate HTTP responses /// with detailed error messages for better API debugging and user experience. /// /// [`Json`]: AxumJson #[must_use] -#[derive(Debug, Clone, Copy, Default, Deref, DerefMut, From)] +#[derive(Debug, Clone, Copy, Default, Deref, DerefMut, From, FromRequest)] +#[from_request(via(AxumJson), rejection(Error<'static>))] pub struct Json(pub T); -impl Json { - /// Creates a new [`Json`] wrapper around the provided value. - /// - /// # Arguments - /// - /// * `inner` - The value to wrap in the JSON extractor - #[inline] - pub fn new(inner: T) -> Self { - Self(inner) - } - - /// Returns the inner value. - #[inline] - pub fn into_inner(self) -> T { - self.0 - } -} - -impl FromRequest for Json -where - T: DeserializeOwned + 'static, - S: Send + Sync, -{ - type Rejection = Error<'static>; - - async fn from_request(req: Request, state: &S) -> Result { - let extractor = as FromRequest>::from_request(req, state).await; - extractor.map(|x| Self::new(x.0)).map_err(Into::into) - } -} - impl OptionalFromRequest for Json where T: DeserializeOwned + 'static, @@ -80,18 +51,12 @@ where type Rejection = Error<'static>; async fn from_request(req: Request, state: &S) -> Result, Self::Rejection> { - let result = >::from_request(req, state).await; - - match result { + match >::from_request(req, state).await { Ok(json) => Ok(Some(json)), - Err(error) => { - // For optional extraction, only propagate server errors - // Client errors (like malformed JSON) result in None - match error.kind() { - ErrorKind::InternalServerError => Err(error), - _ => Ok(None), - } - } + // Only a server error is worth surfacing; a malformed or absent body + // is a legitimately empty optional. + Err(error) if error.kind() == ErrorKind::InternalServerError => Err(error), + Err(_) => Ok(None), } } } @@ -108,8 +73,6 @@ where impl From for Error<'static> { fn from(rejection: JsonRejection) -> Self { - let error_context = format!("JSON rejection details: {:?}", rejection); - match rejection { JsonRejection::JsonDataError(err) => { ErrorKind::BadRequest @@ -132,32 +95,25 @@ impl From for Error<'static> { .with_message("Invalid content type") .with_context("Request must have Content-Type header set to 'application/json'. Include the header: Content-Type: application/json") } - JsonRejection::BytesRejection(err) => { - let message = err.to_string(); - if message.contains("length limit") { - ErrorKind::BadRequest - .with_message("Request body too large") - .with_context(format!( - "Request body exceeds maximum allowed size of {} bytes. Consider reducing the payload size or splitting into multiple requests.", - MAX_JSON_PAYLOAD_SIZE - )) - } else { - ErrorKind::BadRequest - .with_message("Failed to read request body") - .with_context(format!( - "Request body processing failed: {}. Body may be corrupted, incomplete, or connection interrupted.", - sanitize_error_message(&message) - )) - } - } - _ => { - ErrorKind::InternalServerError - .with_message("Request processing failed") - .with_context(format!( - "Unexpected error occurred during JSON request body processing: {}", - error_context - )) - } + // A body that trips the router's size limit is a distinct, typed + // variant — match it rather than sniffing the Display string. + JsonRejection::BytesRejection(BytesRejection::FailedToBufferBody( + FailedToBufferBody::LengthLimitError(_), + )) => ErrorKind::BadRequest + .with_message("Request body too large") + .with_context( + "Request body exceeds the maximum allowed size. Consider reducing the payload size or splitting into multiple requests.", + ), + JsonRejection::BytesRejection(err) => ErrorKind::BadRequest + .with_message("Failed to read request body") + .with_context(format!( + "Request body processing failed: {}. Body may be corrupted, incomplete, or connection interrupted.", + sanitize_error_message(&err.to_string()) + )), + // `JsonRejection` is `#[non_exhaustive]`; a future variant lands here. + _ => ErrorKind::BadRequest + .with_message("Invalid JSON request body") + .with_context("The request body could not be processed as JSON."), } } } diff --git a/crates/nvisy-server/src/extract/reject/mutlipart_with_rej.rs b/crates/nvisy-server/src/extract/reject/mutlipart_with_rej.rs index 7160e344..45981c68 100644 --- a/crates/nvisy-server/src/extract/reject/mutlipart_with_rej.rs +++ b/crates/nvisy-server/src/extract/reject/mutlipart_with_rej.rs @@ -10,24 +10,21 @@ use axum::extract::multipart::MultipartRejection; use axum::extract::{FromRequest, Multipart as AxumMultipart, Request}; use derive_more::{Deref, DerefMut, From}; +use super::sanitize_error_message; use crate::handler::{Error, ErrorKind}; /// Enhanced Multipart extractor with improved error handling. /// /// This extractor wraps the default Axum Multipart extractor and provides /// better error messages for multipart form parsing failures. +/// +/// The [`FromRequest`] impl is hand-written rather than derived because the +/// `#[from_request(via(...))]` derive only supports wrapping a generic newtype +/// extractor, and [`axum::extract::Multipart`] is not generic. #[must_use] #[derive(Debug, Deref, DerefMut, From)] pub struct Multipart(pub AxumMultipart); -impl Multipart { - /// Returns the inner Axum Multipart extractor. - #[inline] - pub fn into_inner(self) -> AxumMultipart { - self.0 - } -} - impl FromRequest for Multipart where S: Send + Sync, @@ -53,7 +50,10 @@ impl From for Error<'static> { ), _ => ErrorKind::BadRequest .with_message("Invalid multipart request") - .with_context(format!("Multipart parsing failed: {}", rejection)), + .with_context(format!( + "Multipart parsing failed: {}", + sanitize_error_message(&rejection.to_string()) + )), } } } diff --git a/crates/nvisy-server/src/extract/reject/path_with_rej.rs b/crates/nvisy-server/src/extract/reject/path_with_rej.rs index da9d3ef5..e8205a76 100644 --- a/crates/nvisy-server/src/extract/reject/path_with_rej.rs +++ b/crates/nvisy-server/src/extract/reject/path_with_rej.rs @@ -7,11 +7,11 @@ use aide::OperationInput; use aide::generate::GenContext; use aide::openapi::{Operation, Response}; use axum::extract::rejection::PathRejection; -use axum::extract::{FromRequestParts, OptionalFromRequestParts, Path as AxumPath}; -use axum::http::request::Parts; +use axum::extract::{FromRequestParts, Path as AxumPath}; use derive_more::{Deref, DerefMut, From}; +// `FromRequestParts` above is both the trait and the derive macro re-exported by +// axum's `macros` feature; the derive on `Path` resolves to it. use schemars::JsonSchema; -use serde::de::DeserializeOwned; use super::sanitize_error_message; use crate::handler::{Error, ErrorKind}; @@ -24,80 +24,25 @@ use crate::handler::{Error, ErrorKind}; /// - Detailed error messages for different parameter types /// - Type-safe deserialization with proper error context /// +/// The [`FromRequestParts`] impl is derived: extraction delegates to +/// [`axum::extract::Path`] and its [`PathRejection`] is mapped into our +/// [`Error`] by the `From` impl below (that mapping is where the improved +/// messages live). +/// /// All errors are automatically converted to appropriate HTTP responses /// with detailed error messages for better API debugging and user experience. /// /// [`Path`]: AxumPath #[must_use] -#[derive(Debug, Clone, Copy, Default, Deref, DerefMut, From)] +#[derive(Debug, Clone, Copy, Default, Deref, DerefMut, From, FromRequestParts)] +#[from_request(via(AxumPath), rejection(Error<'static>))] pub struct Path(pub T); -impl Path { - /// Creates a new instance of [`Path`]. - /// - /// # Arguments - /// - /// * `inner` - The deserialized path parameters - #[inline] - pub fn new(inner: T) -> Self { - Self(inner) - } - - /// Returns the inner path parameters. - #[inline] - pub fn into_inner(self) -> T { - self.0 - } -} - -impl FromRequestParts for Path -where - T: DeserializeOwned + Send + 'static, - S: Send + Sync, -{ - type Rejection = Error<'static>; - - async fn from_request_parts(parts: &mut Parts, state: &S) -> Result { - let extractor = - as FromRequestParts>::from_request_parts(parts, state).await; - extractor.map(|x| Self(x.0)).map_err(Into::into) - } -} - -impl OptionalFromRequestParts for Path -where - T: DeserializeOwned + Send + 'static, - S: Send + Sync, -{ - type Rejection = Error<'static>; - - async fn from_request_parts( - parts: &mut Parts, - state: &S, - ) -> Result, Self::Rejection> { - let extractor = - as OptionalFromRequestParts>::from_request_parts(parts, state).await; - - match extractor { - Ok(maybe_path) => Ok(maybe_path.map(|x| Self::new(x.0))), - Err(rejection) => { - // For optional extraction, only propagate server errors - match rejection { - PathRejection::FailedToDeserializePathParams(_) - | PathRejection::MissingPathParams(_) => Ok(None), - _ => Err(rejection.into()), - } - } - } - } -} - impl From for Error<'static> { fn from(rejection: PathRejection) -> Self { match rejection { PathRejection::FailedToDeserializePathParams(err) => { let error_message = sanitize_error_message(&err.to_string()); - let enhanced_context = enhance_deserialization_error(&error_message); tracing::warn!( error = %error_message, @@ -107,8 +52,8 @@ impl From for Error<'static> { ErrorKind::BadRequest .with_message("Invalid path parameter format") .with_context(format!( - "Path parameter deserialization failed: {}. {}", - error_message, enhanced_context + "Path parameter deserialization failed: {}. Check that the parameter matches the expected type.", + error_message )) } PathRejection::MissingPathParams(err) => { @@ -137,23 +82,6 @@ impl From for Error<'static> { } } -/// Enhances deserialization error messages with type-specific guidance. -fn enhance_deserialization_error(error_message: &str) -> &'static str { - let error_lower = error_message.to_lowercase(); - - if error_lower.contains("uuid") || error_lower.contains("invalid character") { - "UUID parameters must be in format: xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx (32 hexadecimal digits with hyphens)" - } else if error_lower.contains("invalid digit") || error_lower.contains("cannot parse") { - "Numeric parameters must contain only digits and be within the valid range for the expected type" - } else if error_lower.contains("bool") { - "Boolean parameters must be 'true' or 'false'" - } else if error_lower.contains("enum") { - "Enum parameters must match one of the defined variants exactly" - } else { - "Check that the parameter format matches the expected type definition" - } -} - impl OperationInput for Path where T: JsonSchema, diff --git a/crates/nvisy-server/src/extract/reject/query_with_rej.rs b/crates/nvisy-server/src/extract/reject/query_with_rej.rs index 77026ac7..00c7e876 100644 --- a/crates/nvisy-server/src/extract/reject/query_with_rej.rs +++ b/crates/nvisy-server/src/extract/reject/query_with_rej.rs @@ -7,12 +7,10 @@ use aide::OperationInput; use aide::generate::GenContext; use aide::openapi::{Operation, Response}; -use axum::extract::{FromRequestParts, OptionalFromRequestParts}; -use axum::http::request::Parts; +use axum::extract::FromRequestParts; use axum_extra::extract::{Query as AxumQuery, QueryRejection}; use derive_more::{Deref, DerefMut, From}; use schemars::JsonSchema; -use serde::de::DeserializeOwned; use super::sanitize_error_message; use crate::handler::{Error, ErrorKind}; @@ -26,77 +24,38 @@ use crate::handler::{Error, ErrorKind}; /// - Type-safe deserialization with proper error context /// - Clear indication of which parameters failed validation /// +/// The [`FromRequestParts`] impl is derived: extraction delegates to +/// [`axum_extra::extract::Query`] and its [`QueryRejection`] is mapped into our +/// [`Error`] by the `From` impl below (that mapping is where the improved +/// messages live). +/// /// All errors are automatically converted to appropriate HTTP responses /// with detailed error messages for better API debugging. /// /// [`Query`]: AxumQuery #[must_use] -#[derive(Debug, Clone, Copy, Default, Deref, DerefMut, From)] +#[derive(Debug, Clone, Copy, Default, Deref, DerefMut, From, FromRequestParts)] +#[from_request(via(AxumQuery), rejection(Error<'static>))] pub struct Query(pub T); -impl Query { - /// Creates a new instance of [`Query`]. - #[inline] - pub fn new(inner: T) -> Self { - Self(inner) - } - - /// Returns the inner query parameters. - #[inline] - pub fn into_inner(self) -> T { - self.0 - } -} - -impl FromRequestParts for Query -where - T: DeserializeOwned, - S: Send + Sync, -{ - type Rejection = Error<'static>; - - async fn from_request_parts(parts: &mut Parts, state: &S) -> Result { - match AxumQuery::::from_request_parts(parts, state).await { - Ok(AxumQuery(query)) => Ok(Query(query)), - Err(rejection) => Err(enhance_query_error(rejection)), - } - } -} - -impl OptionalFromRequestParts for Query -where - T: DeserializeOwned, - S: Send + Sync, -{ - type Rejection = Error<'static>; - - async fn from_request_parts( - parts: &mut Parts, - state: &S, - ) -> Result, Self::Rejection> { - match AxumQuery::::from_request_parts(parts, state).await { - Ok(AxumQuery(query)) => Ok(Some(Query(query))), - Err(_) => Ok(None), - } - } -} - -/// Converts a query rejection into a structured bad-request [`Error`]. +/// Maps a query rejection into a structured bad-request [`Error`]. /// /// The deserializer message is sanitized before it becomes context so that /// submitted query values are not echoed back or logged. -fn enhance_query_error(rejection: QueryRejection) -> Error<'static> { - let context = sanitize_error_message(&rejection.to_string()); +impl From for Error<'static> { + fn from(rejection: QueryRejection) -> Self { + let context = sanitize_error_message(&rejection.to_string()); - tracing::debug!( - target: "nvisy::extract::query", - error = %context, - "Query parameter parsing failed" - ); + tracing::debug!( + target: "nvisy::extract::query", + error = %context, + "Query parameter parsing failed" + ); - ErrorKind::BadRequest - .with_message("Invalid query parameters") - .with_context(context) + ErrorKind::BadRequest + .with_message("Invalid query parameters") + .with_context(context) + } } impl OperationInput for Query diff --git a/crates/nvisy-server/src/extract/reject/validated_json.rs b/crates/nvisy-server/src/extract/reject/validated_json.rs index c8181dc1..3aef9a75 100644 --- a/crates/nvisy-server/src/extract/reject/validated_json.rs +++ b/crates/nvisy-server/src/extract/reject/validated_json.rs @@ -1,10 +1,23 @@ //! Validated JSON extractor with automatic validation. //! -//! This module provides [`ValidateJson`], an enhanced JSON extractor that -//! combines deserialization with automatic validation using the `validator` crate. +//! This module provides [`ValidateJson`], a JSON extractor that deserializes a +//! body (via [`Json`]) and then runs `validator::Validate` on it, turning any +//! [`ValidationErrors`] into a structured [`Error`]. +//! +//! Two invariants shape the error mapping: +//! +//! - **No submitted values escape.** `validator` records the rejected field +//! value in `params["value"]` for several validators (`length`, `custom`, +//! `credit_card`, …). Neither the user-facing message nor the log line ever +//! reads `params["value"]`, so request contents cannot leak through a +//! validation failure. +//! - **Nested errors are preserved.** Validation of `#[validate(nested)]` fields +//! produces a tree, not a flat map, so the mapping walks the tree and reports +//! each leaf under its dotted path (`address.zip`, `items[2].name`). use std::borrow::Cow; use std::collections::HashMap; +use std::fmt::Write as _; use aide::OperationInput; use aide::generate::GenContext; @@ -14,38 +27,22 @@ use derive_more::{Deref, DerefMut, From}; use schemars::JsonSchema; use serde::de::DeserializeOwned; use serde_json::Value; -use validator::{Validate, ValidationError, ValidationErrors}; +use validator::{Validate, ValidationError, ValidationErrors, ValidationErrorsKind}; use super::Json; use crate::handler::{Error, ErrorKind}; -/// Enhanced JSON extractor with automatic validation using the `validator` crate. -/// -/// This extractor combines JSON deserialization with automatic validation, -/// providing comprehensive error messages for validation failures. It works -/// with any type that implements both `serde::Deserialize` and `validator::Validate`. -/// -/// Also see [`Json`] +/// JSON extractor that deserializes and then validates the request body. /// -/// [`Json`]: axum::extract::Json +/// Works with any type that implements both [`serde::Deserialize`] and +/// [`validator::Validate`]. Deserialization is delegated to [`Json`], so JSON +/// syntax and content-type errors carry that extractor's messages; a body that +/// parses but fails validation is rejected with a field-by-field message built +/// by [`ValidationErrors`] mapping below. #[must_use] #[derive(Debug, Clone, Copy, Default, Deref, DerefMut, From)] pub struct ValidateJson(pub T); -impl ValidateJson { - /// Creates a new instance of [`ValidateJson`]. - #[inline] - pub fn new(inner: T) -> Self { - Self(inner) - } - - /// Returns the inner validated value. - #[inline] - pub fn into_inner(self) -> T { - self.0 - } -} - impl FromRequest for ValidateJson where T: DeserializeOwned + Validate + 'static, @@ -54,12 +51,9 @@ where type Rejection = Error<'static>; async fn from_request(req: Request, state: &S) -> Result { - // First, deserialize the JSON let Json(data) = as FromRequest>::from_request(req, state).await?; - - // Then validate the deserialized data data.validate()?; - Ok(Self::new(data)) + Ok(Self(data)) } } @@ -78,162 +72,137 @@ where Ok(validated) => Ok(Some(validated)), // For optional extraction, only propagate server errors; client errors // (absent body, malformed JSON, validation failure) result in `None`. - Err(error) => match error.kind() { - ErrorKind::InternalServerError => Err(error), - _ => Ok(None), - }, + Err(error) if error.kind() == ErrorKind::InternalServerError => Err(error), + Err(_) => Ok(None), } } } -/// Formats length validation errors with appropriate units and context. -fn format_length_error(field: &str, params: &HashMap, Value>) -> String { - if params.is_empty() { - return format!("Field '{}' has invalid length", field); - } +impl From for Error<'static> { + fn from(errors: ValidationErrors) -> Self { + let mut leaves = Vec::new(); + collect_leaf_errors(&mut String::new(), &errors, &mut leaves); - // Determine if we're dealing with characters or items - let unit = if field.contains("password") || field.contains("text") || field.contains("name") { - "characters" - } else { - "items" - }; + // Log field paths and codes only — never `params`, which can hold the + // submitted value for `length`/`custom`/`credit_card`/… validators. + let logged: Vec = leaves + .iter() + .map(|(path, error)| format!("{}:{}", path, error.code)) + .collect(); + tracing::warn!(errors = ?logged, "Request validation failed"); - match (params.get("min"), params.get("max")) { - (Some(min), Some(max)) => { - let min_val = extract_number_from_json(min).unwrap_or(0.0) as u64; - let max_val = extract_number_from_json(max).unwrap_or(0.0) as u64; - format!( - "Field '{}' must be between {} and {} {} long", - field, min_val, max_val, unit - ) - } - (Some(min), None) => { - let min_val = extract_number_from_json(min).unwrap_or(0.0) as u64; - format!( - "Field '{}' must be at least {} {} long", - field, min_val, unit - ) - } - (None, Some(max)) => { - let max_val = extract_number_from_json(max).unwrap_or(0.0) as u64; - format!( - "Field '{}' must be at most {} {} long", - field, max_val, unit - ) - } - _ => format!("Field '{}' has invalid length", field), - } -} + let messages: Vec = leaves + .iter() + .map(|(path, error)| describe_error(path, error)) + .collect(); + + let user_message = match messages.as_slice() { + [] => "Validation failed".to_string(), + [single] => single.clone(), + many => many.join(". "), + }; -/// Formats range validation errors with appropriate context and units. -fn format_range_error(field: &str, params: &HashMap, Value>) -> String { - if params.is_empty() { - return format!("Field '{}' is out of valid range", field); + ErrorKind::BadRequest + .with_message(user_message) + .with_resource("request") } +} - match (params.get("min"), params.get("max")) { - (Some(min), Some(max)) => { - let min_val = extract_number_from_json(min).unwrap_or(0.0); - let max_val = extract_number_from_json(max).unwrap_or(0.0); - format!( - "Field '{}' must be between {} and {}", - field, min_val, max_val - ) - } - (Some(min), None) => { - let min_val = extract_number_from_json(min).unwrap_or(0.0); - format!("Field '{}' must be at least {}", field, min_val) - } - (None, Some(max)) => { - let max_val = extract_number_from_json(max).unwrap_or(0.0); - format!("Field '{}' must be at most {}", field, max_val) +/// Walks the validation-error tree, pushing each leaf as `(dotted path, error)`. +/// +/// `prefix` is the path accumulated from enclosing structs and list indices; +/// leaves at the top level are reported under their bare field name. +fn collect_leaf_errors<'a>( + prefix: &mut String, + errors: &'a ValidationErrors, + leaves: &mut Vec<(String, &'a ValidationError)>, +) { + for (field, kind) in errors.errors() { + match kind { + ValidationErrorsKind::Field(field_errors) => { + let path = join_path(prefix, field); + for error in field_errors { + leaves.push((path.clone(), error)); + } + } + ValidationErrorsKind::Struct(nested) => { + let mut nested_prefix = join_path(prefix, field); + collect_leaf_errors(&mut nested_prefix, nested, leaves); + } + ValidationErrorsKind::List(items) => { + for (index, nested) in items { + let mut nested_prefix = join_path(prefix, field); + let _ = write!(nested_prefix, "[{}]", index); + collect_leaf_errors(&mut nested_prefix, nested, leaves); + } + } } - _ => format!("Field '{}' is out of valid range", field), } } -/// Extracts a number from a JSON value, supporting both integers and floats. -fn extract_number_from_json(value: &Value) -> Option { - match value { - Value::Number(n) => n.as_f64(), - _ => None, +/// Joins a path prefix and a field segment with a `.`, or returns the bare +/// segment when there is no prefix. +fn join_path(prefix: &str, field: &str) -> String { + if prefix.is_empty() { + field.to_string() + } else { + format!("{}.{}", prefix, field) } } -/// Formats validation errors with context-aware, user-friendly messages. -fn format_validation_error(field: &str, error: &ValidationError) -> String { - // Use custom message if provided, otherwise generate appropriate message - if let Some(custom_message) = &error.message { - return format!("Field '{}': {}", field, custom_message); - } - - let message = match error.code.as_ref() { - "required" => "is required and cannot be empty".to_string(), - "length" => return format_length_error(field, &error.params), - "email" => "must be a valid email address (e.g., user@example.com)".to_string(), - "range" => return format_range_error(field, &error.params), - "url" => "must be a valid URL (e.g., https://example.com)".to_string(), - "phone" => "must be a valid phone number in international format".to_string(), - "credit_card" => "must be a valid credit card number".to_string(), - "must_match" => { - let other_field = error - .params - .get("other") - .and_then(|v| v.as_str()) - .unwrap_or("other field"); - format!("must match '{}'", other_field) - } - "regex" => "format is invalid - please check the required pattern".to_string(), - "contains" => { - let needle = error - .params - .get("needle") - .and_then(|v| v.as_str()) - .unwrap_or("required text"); - format!("must contain '{}'", needle) - } - "does_not_contain" => { - let needle = error - .params - .get("needle") - .and_then(|v| v.as_str()) - .unwrap_or("forbidden text"); - format!("must not contain '{}'", needle) - } - code => format!("failed validation: {}", code), - }; +/// Renders one validation error into a user-facing message. +/// +/// A custom message on the error wins. Otherwise the code is mapped for the +/// validators actually used on request DTOs (`length`, `range`, `email`, +/// `url`); anything else gets a neutral fallback. No branch reads +/// `params["value"]`, so a submitted value never reaches the response. +fn describe_error(field: &str, error: &ValidationError) -> String { + if let Some(message) = &error.message { + return format!("Field '{}': {}", field, message); + } - format!("Field '{}' {}", field, message) + match error.code.as_ref() { + "length" => format!("Field '{}' {}", field, format_bounds(&error.params, "long")), + "range" => format!("Field '{}' {}", field, format_bounds(&error.params, "")), + "email" => format!( + "Field '{}' must be a valid email address (e.g., user@example.com)", + field + ), + "url" => format!( + "Field '{}' must be a valid URL (e.g., https://example.com)", + field + ), + _ => format!("Field '{}' is invalid", field), + } } -impl From for Error<'static> { - fn from(errors: ValidationErrors) -> Self { - let error_messages: Vec = errors - .field_errors() - .iter() - .flat_map(|(field, field_errors)| { - field_errors - .iter() - .map(move |error| format_validation_error(field, error)) - }) - .collect(); - - // Show validation details in the user-facing message - let user_message = match error_messages.as_slice() { - [] => "Validation failed".to_string(), - [single_error] => single_error.clone(), - multiple => multiple.join(". "), - }; +/// Renders the `min`/`max` bounds shared by `length` and `range` errors. +/// +/// `suffix` is appended after each bound (e.g. `"long"` for lengths, empty for +/// ranges). Bounds that are absent or non-numeric fall back to a generic phrase. +fn format_bounds(params: &HashMap, Value>, suffix: &str) -> String { + let tail = if suffix.is_empty() { + String::new() + } else { + format!(" {}", suffix) + }; - tracing::warn!( - errors = ?errors.field_errors(), - "Request validation failed" - ); + match (number(params, "min"), number(params, "max")) { + (Some(min), Some(max)) => format!("must be between {} and {}{}", min, max, tail), + (Some(min), None) => format!("must be at least {}{}", min, tail), + (None, Some(max)) => format!("must be at most {}{}", max, tail), + (None, None) => "is out of the allowed range".to_string(), + } +} - ErrorKind::BadRequest - .with_message(user_message) - .with_resource("request") +/// Reads a numeric bound parameter, rendering it without a trailing `.0` when it +/// is integral so `length` bounds read as `64` rather than `64.0`. +fn number(params: &HashMap, Value>, key: &str) -> Option { + let value = params.get(key)?.as_f64()?; + if value.fract() == 0.0 { + Some((value as i64).to_string()) + } else { + Some(value.to_string()) } } @@ -252,3 +221,146 @@ where Json::::inferred_early_responses(ctx, operation) } } + +#[cfg(test)] +mod tests { + use std::borrow::Cow; + + use serde_json::json; + use validator::{ValidationError, ValidationErrors, ValidationErrorsKind}; + + use super::{collect_leaf_errors, describe_error, format_bounds, number}; + + fn params( + pairs: &[(&'static str, serde_json::Value)], + ) -> std::collections::HashMap, serde_json::Value> { + pairs + .iter() + .map(|(k, v)| (Cow::Borrowed(*k), v.clone())) + .collect() + } + + fn error_with_params(code: &'static str, pairs: &[(&'static str, serde_json::Value)]) -> ValidationError { + let mut error = ValidationError::new(code); + for (key, value) in pairs { + error.add_param(Cow::Borrowed(*key), value); + } + error + } + + #[test] + fn length_bounds_render_as_integers_with_the_long_suffix() { + let hint = describe_error( + "display_name", + &error_with_params("length", &[("min", json!(2)), ("max", json!(64))]), + ); + assert_eq!( + hint, + "Field 'display_name' must be between 2 and 64 long" + ); + } + + #[test] + fn length_with_only_one_bound() { + assert_eq!( + describe_error("tags", &error_with_params("length", &[("min", json!(1))])), + "Field 'tags' must be at least 1 long" + ); + assert_eq!( + describe_error("tags", &error_with_params("length", &[("max", json!(5))])), + "Field 'tags' must be at most 5 long" + ); + } + + #[test] + fn range_renders_without_a_suffix() { + assert_eq!( + describe_error( + "age", + &error_with_params("range", &[("min", json!(0)), ("max", json!(120))]) + ), + "Field 'age' must be between 0 and 120" + ); + } + + #[test] + fn email_and_url_get_canned_messages() { + assert!(describe_error("email", &ValidationError::new("email")) + .contains("valid email address")); + assert!(describe_error("homepage", &ValidationError::new("url")).contains("valid URL")); + } + + #[test] + fn a_custom_message_wins_over_the_code() { + let mut custom = ValidationError::new("length"); + custom.message = Some(Cow::Borrowed("Please keep it short")); + assert_eq!( + describe_error("bio", &custom), + "Field 'bio': Please keep it short" + ); + } + + #[test] + fn an_unknown_code_gets_a_neutral_fallback() { + assert_eq!( + describe_error("x", &ValidationError::new("bespoke_rule")), + "Field 'x' is invalid" + ); + } + + #[test] + fn bounds_fall_back_when_absent_or_non_numeric() { + assert_eq!(format_bounds(¶ms(&[]), "long"), "is out of the allowed range"); + assert_eq!( + format_bounds(¶ms(&[("min", json!("oops"))]), "long"), + "is out of the allowed range" + ); + } + + #[test] + fn number_drops_the_trailing_zero_on_integral_bounds() { + assert_eq!(number(¶ms(&[("min", json!(64.0))]), "min").as_deref(), Some("64")); + assert_eq!(number(¶ms(&[("min", json!(1.5))]), "min").as_deref(), Some("1.5")); + assert_eq!(number(¶ms(&[]), "min"), None); + } + + #[test] + fn nested_struct_errors_are_reported_under_a_dotted_path() { + // Build: { address: Struct { zip: Field[length] } } + let mut inner = ValidationErrors::new(); + inner.add("zip", error_with_params("length", &[("min", json!(5))])); + + let mut outer = ValidationErrors::new(); + outer + .errors_mut() + .insert(Cow::Borrowed("address"), ValidationErrorsKind::Struct(Box::new(inner))); + + let mut leaves = Vec::new(); + collect_leaf_errors(&mut String::new(), &outer, &mut leaves); + + assert_eq!(leaves.len(), 1); + assert_eq!(leaves[0].0, "address.zip"); + assert_eq!(leaves[0].1.code.as_ref(), "length"); + } + + #[test] + fn list_errors_are_reported_with_an_index() { + // Build: { items: List { 2 => Struct { name: Field[length] } } } + let mut item = ValidationErrors::new(); + item.add("name", error_with_params("length", &[("max", json!(10))])); + + let mut list = std::collections::BTreeMap::new(); + list.insert(2usize, Box::new(item)); + + let mut outer = ValidationErrors::new(); + outer + .errors_mut() + .insert(Cow::Borrowed("items"), ValidationErrorsKind::List(list)); + + let mut leaves = Vec::new(); + collect_leaf_errors(&mut String::new(), &outer, &mut leaves); + + assert_eq!(leaves.len(), 1); + assert_eq!(leaves[0].0, "items[2].name"); + } +} diff --git a/crates/nvisy-server/src/extract/version.rs b/crates/nvisy-server/src/extract/version.rs index fa869edc..fb77bbf0 100644 --- a/crates/nvisy-server/src/extract/version.rs +++ b/crates/nvisy-server/src/extract/version.rs @@ -211,13 +211,6 @@ impl fmt::Display for Version { } } -impl From for bool { - #[inline] - fn from(value: Version) -> Self { - value.is_stable() - } -} - /// Path parameters for version extraction. #[derive(Debug, Clone, Deserialize, JsonSchema)] struct VersionParams { @@ -251,3 +244,18 @@ impl OperationInput for Version { Path::::inferred_early_responses(ctx, operation) } } + +#[cfg(test)] +mod tests { + use super::Version; + + // `Version::new` and the predicates are covered by the doctests above; this + // covers the `Display` strings, which they do not. + #[test] + fn display_renders_each_variant() { + assert_eq!(Version::new("v1").to_string(), "v1"); + assert_eq!(Version::new("v42").to_string(), "v42"); + assert_eq!(Version::new("v0").to_string(), "unstable"); + assert_eq!(Version::new("nonsense").to_string(), "unrecognized"); + } +} diff --git a/crates/nvisy-server/src/handler/accounts.rs b/crates/nvisy-server/src/handler/accounts.rs index 357197e3..bbb67aa4 100644 --- a/crates/nvisy-server/src/handler/accounts.rs +++ b/crates/nvisy-server/src/handler/accounts.rs @@ -25,16 +25,16 @@ const TRACING_TARGET: &str = "nvisy_server::handler::accounts"; /// Retrieves the authenticated account. #[tracing::instrument( skip_all, - fields(account_id = %auth_claims.account_id) + fields(account_id = %auth_state.account_id) )] async fn get_own_account( State(pg_client): State, - AuthState(auth_claims): AuthState, + auth_state: AuthState, ) -> Result<(StatusCode, Json)> { tracing::debug!(target: TRACING_TARGET, "Reading account"); let mut conn = pg_client.get_connection().await?; - let account = find_account(&mut conn, auth_claims.account_id).await?; + let account = find_account(&mut conn, auth_state.account_id).await?; tracing::info!(target: TRACING_TARGET, "Account read"); @@ -57,13 +57,13 @@ fn get_own_account_docs(op: TransformOperation) -> TransformOperation { #[tracing::instrument( skip_all, fields( - requester_id = %auth_claims.account_id, + requester_id = %auth_state.account_id, target_id = tracing::field::Empty, ) )] async fn get_account( State(pg_client): State, - AuthState(auth_claims): AuthState, + auth_state: AuthState, Path(path_params): Path, ) -> Result<(StatusCode, Json)> { tracing::debug!(target: TRACING_TARGET, "Reading account by username"); @@ -80,7 +80,7 @@ async fn get_account( // reported as not-found (not forbidden) so this endpoint cannot be used to // distinguish existing from non-existing handles. let shares_workspace = conn - .accounts_share_workspace(auth_claims.account_id, account.id) + .accounts_share_workspace(auth_state.account_id, account.id) .await?; if !shares_workspace { @@ -111,11 +111,11 @@ fn get_account_docs(op: TransformOperation) -> TransformOperation { /// Updates the authenticated account. #[tracing::instrument( skip_all, - fields(account_id = %auth_claims.account_id) + fields(account_id = %auth_state.account_id) )] async fn update_own_account( State(pg_client): State, - AuthState(auth_claims): AuthState, + auth_state: AuthState, ValidateJson(request): ValidateJson, ) -> Result<(StatusCode, Json)> { tracing::debug!(target: TRACING_TARGET, "Updating account"); @@ -125,7 +125,7 @@ async fn update_own_account( // Check if email already exists for another account if let Some(ref email) = request.email_address && conn - .email_exists_for_other(email, auth_claims.account_id) + .email_exists_for_other(email, auth_state.account_id) .await? { tracing::warn!(target: TRACING_TARGET, "Account update failed: email already exists"); @@ -137,7 +137,7 @@ async fn update_own_account( // Check if username is already taken by another account if let Some(ref username) = request.username && conn - .username_exists_for_other(username, auth_claims.account_id) + .username_exists_for_other(username, auth_state.account_id) .await? { tracing::warn!(target: TRACING_TARGET, "Account update failed: username already taken"); @@ -147,7 +147,7 @@ async fn update_own_account( } let account = conn - .update_account(auth_claims.account_id, request.into_model()) + .update_account(auth_state.account_id, request.into_model()) .await?; tracing::info!(target: TRACING_TARGET, "Account updated"); @@ -167,16 +167,16 @@ fn update_own_account_docs(op: TransformOperation) -> TransformOperation { /// Deletes the authenticated account. #[tracing::instrument( skip_all, - fields(account_id = %auth_claims.account_id) + fields(account_id = %auth_state.account_id) )] async fn delete_own_account( State(pg_client): State, - AuthState(auth_claims): AuthState, + auth_state: AuthState, ) -> Result { tracing::debug!(target: TRACING_TARGET, "Deleting account"); let mut conn = pg_client.get_connection().await?; - conn.delete_account(auth_claims.account_id) + conn.delete_account(auth_state.account_id) .await? .ok_or_else(|| Error::not_found("account"))?; @@ -200,11 +200,11 @@ fn delete_own_account_docs(op: TransformOperation) -> TransformOperation { /// to its serve path. Only the account itself may set its avatar, so the /// `{username}` in the path must resolve to the caller. Requires a multipart body /// with an image field. -#[tracing::instrument(skip_all, fields(account_id = %auth_claims.account_id))] +#[tracing::instrument(skip_all, fields(account_id = %auth_state.account_id))] async fn upload_account_avatar( State(pg_client): State, State(avatar): State, - AuthState(auth_claims): AuthState, + auth_state: AuthState, Path(path_params): Path, Avatar(bytes): Avatar, ) -> Result<(StatusCode, Json)> { @@ -216,7 +216,7 @@ async fn upload_account_avatar( // connections for the whole upload. let account_id = { let mut conn = pg_client.get_connection().await?; - let account = find_account(&mut conn, auth_claims.account_id).await?; + let account = find_account(&mut conn, auth_state.account_id).await?; authorize_self(&account, &path_params.username)?; account.id }; @@ -240,17 +240,17 @@ fn upload_account_avatar_docs(op: TransformOperation) -> TransformOperation { } /// Removes the authenticated account's avatar. Only the account itself may. -#[tracing::instrument(skip_all, fields(account_id = %auth_claims.account_id))] +#[tracing::instrument(skip_all, fields(account_id = %auth_state.account_id))] async fn delete_account_avatar( State(pg_client): State, State(avatar): State, - AuthState(auth_claims): AuthState, + auth_state: AuthState, Path(path_params): Path, ) -> Result { tracing::debug!(target: TRACING_TARGET, "Deleting account avatar"); let mut conn = pg_client.get_connection().await?; - let account = find_account(&mut conn, auth_claims.account_id).await?; + let account = find_account(&mut conn, auth_state.account_id).await?; authorize_self(&account, &path_params.username)?; avatar.delete_account_avatar(account.id).await?; diff --git a/crates/nvisy-server/src/handler/auth_oidc.rs b/crates/nvisy-server/src/handler/auth_oidc.rs index d8dde970..21fbca95 100644 --- a/crates/nvisy-server/src/handler/auth_oidc.rs +++ b/crates/nvisy-server/src/handler/auth_oidc.rs @@ -220,11 +220,11 @@ fn start_sign_in_docs(op: TransformOperation) -> TransformOperation { /// (`POST /account/identities/{provider}`), not `/auth`, so linking and /// unlinking a provider sit symmetrically on the same resource. The OIDC redirect /// machinery lives here beside the shared callback. -#[tracing::instrument(skip_all, fields(provider = ?path_params.provider, account_id = %auth_claims.account_id))] +#[tracing::instrument(skip_all, fields(provider = ?path_params.provider, account_id = %auth_state.account_id))] pub(crate) async fn start_link( State(nats): State, State(oidc): State, - AuthState(auth_claims): AuthState, + auth_state: AuthState, Path(path_params): Path, Query(query): Query, ) -> Result<(StatusCode, Json)> { @@ -239,7 +239,7 @@ pub(crate) async fn start_link( .with_message("Re-authentication required to link a provider") .with_resource("account") })?; - consume_reauth_proof(&nats, auth_claims.account_id, proof).await?; + consume_reauth_proof(&nats, auth_state.account_id, proof).await?; let authorize_url = begin_flow( &nats, @@ -247,7 +247,7 @@ pub(crate) async fn start_link( path_params.provider, query.redirect_uri, OidcPurpose::Link { - account_id: auth_claims.account_id, + account_id: auth_state.account_id, }, ) .await?; @@ -272,11 +272,11 @@ pub(crate) fn start_link_docs(op: TransformOperation) -> TransformOperation { /// provider identity already linked to their account, so a credential-adding /// action (setting a first password, linking a new provider) can require more /// than a merely-live session. The callback mints a single-use proof. -#[tracing::instrument(skip_all, fields(provider = ?path_params.provider, account_id = %auth_claims.account_id))] +#[tracing::instrument(skip_all, fields(provider = ?path_params.provider, account_id = %auth_state.account_id))] async fn start_reauth( State(nats): State, State(oidc): State, - AuthState(auth_claims): AuthState, + auth_state: AuthState, Path(path_params): Path, Query(query): Query, ) -> Result<(StatusCode, Json)> { @@ -287,7 +287,7 @@ async fn start_reauth( path_params.provider, query.redirect_uri, OidcPurpose::Reauth { - account_id: auth_claims.account_id, + account_id: auth_state.account_id, }, ) .await?; @@ -317,13 +317,13 @@ fn start_reauth_docs(op: TransformOperation) -> TransformOperation { /// token and returns it for the frontend to hand back to the app via the /// deep-link. Authenticated by the just-established session, so a caller can only /// mint a token for their own account. No cookie is set on the response. -#[tracing::instrument(skip_all, fields(account_id = %auth_claims.account_id))] +#[tracing::instrument(skip_all, fields(account_id = %auth_state.account_id))] async fn mint_desktop_token( State(pg_client): State, State(oidc): State, State(auth_keys): State, State(ua_parser): State, - AuthState(auth_claims): AuthState, + auth_state: AuthState, TypedHeader(user_agent): TypedHeader, ValidateJson(request): ValidateJson, ) -> Result<(StatusCode, Json)> { @@ -345,7 +345,7 @@ async fn mint_desktop_token( // long-lived `app` token from renewing itself indefinitely by minting fresh // `app` tokens. let session = conn - .find_account_api_token_by_id(auth_claims.token_id) + .find_account_api_token_by_id(auth_state.token_id) .await? .ok_or_else(|| ErrorKind::Unauthorized.with_message("Session not found"))?; if session.session_type != ApiTokenType::Web { @@ -354,7 +354,7 @@ async fn mint_desktop_token( .with_resource("session")); } - let account = load_active_account(&mut conn, auth_claims.account_id).await?; + let account = load_active_account(&mut conn, auth_state.account_id).await?; gate_account_status(&account)?; let api_token = mint_app_token( diff --git a/crates/nvisy-server/src/handler/authentication.rs b/crates/nvisy-server/src/handler/authentication.rs index a8a2cafd..59f43f08 100644 --- a/crates/nvisy-server/src/handler/authentication.rs +++ b/crates/nvisy-server/src/handler/authentication.rs @@ -329,14 +329,14 @@ fn signup_docs(op: TransformOperation) -> TransformOperation { #[tracing::instrument( skip_all, fields( - account_id = %auth_claims.account_id, - token_id = %auth_claims.token_id, + account_id = %auth_state.account_id, + token_id = %auth_state.token_id, ) )] async fn logout( State(pg_client): State, State(cookie): State, - AuthState(auth_claims): AuthState, + auth_state: AuthState, ) -> Result { tracing::debug!(target: TRACING_TARGET, "Logging out"); @@ -344,7 +344,7 @@ async fn logout( // Verify API token exists before attempting to delete let token_exists = conn - .find_account_api_token_by_id(auth_claims.token_id) + .find_account_api_token_by_id(auth_state.token_id) .await? .is_some(); @@ -361,7 +361,7 @@ async fn logout( } // Delete the API token (revocation: the row is the session authority). - let deleted = conn.delete_account_api_token(auth_claims.token_id).await?; + let deleted = conn.delete_account_api_token(auth_state.token_id).await?; if deleted { tracing::info!(target: TRACING_TARGET, "Logout successful"); diff --git a/crates/nvisy-server/src/handler/catalog.rs b/crates/nvisy-server/src/handler/catalog.rs index 26780638..ea7473b0 100644 --- a/crates/nvisy-server/src/handler/catalog.rs +++ b/crates/nvisy-server/src/handler/catalog.rs @@ -22,7 +22,7 @@ use crate::handler::response::{ErrorResponse, RecognizerCatalog}; use crate::service::{EngineService, ServiceState}; /// Lists the deployment's supported labels (the built-in taxonomy). -async fn list_labels(AuthState(_): AuthState) -> Json { +async fn list_labels(_: AuthState) -> Json { Json(LabelCatalog::with_builtins()) } @@ -39,7 +39,7 @@ fn list_labels_docs(op: TransformOperation) -> TransformOperation { /// Lists the recognizers the engine has registered, grouped into NER and LLM. async fn list_recognizers( State(engine): State, - AuthState(_): AuthState, + _: AuthState, ) -> Json { let components = engine.engine().components(); Json(RecognizerCatalog { @@ -114,7 +114,7 @@ pub struct ConnectorCatalog { /// Reports which connectors this deployment can create. async fn list_connectors( State(file_service): State, - AuthState(_): AuthState, + _: AuthState, ) -> Json { Json(ConnectorCatalog { file_services: FileProviders::from_service(&file_service), diff --git a/crates/nvisy-server/src/handler/files.rs b/crates/nvisy-server/src/handler/files.rs index 510f27ce..d354322b 100644 --- a/crates/nvisy-server/src/handler/files.rs +++ b/crates/nvisy-server/src/handler/files.rs @@ -23,8 +23,8 @@ use tokio_util::io::{ReaderStream, StreamReader}; use uuid::Uuid; use crate::extract::{ - AuthProvider, AuthState, Authorized, DeleteFiles, Json, Multipart, Path, Permission, Query, - SecurityContext, UpdateFiles, UploadFiles, ValidateJson, ViewFiles, WorkspaceContext, + AuthState, Authorized, DeleteFiles, Json, Multipart, Path, Permission, Query, SecurityContext, + UpdateFiles, UploadFiles, ValidateJson, ViewFiles, WorkspaceContext, }; use crate::handler::request::{ CursorPagination, DeleteFiles as DeleteFilesRequest, ListFiles, UpdateFile, @@ -539,7 +539,7 @@ fn update_file_docs(op: TransformOperation) -> TransformOperation { #[tracing::instrument( skip_all, fields( - account_id = %auth_claims.account_id, + account_id = %auth.account_id, workspace_id = %workspace.id, file_id = %path_params.file_id, ) @@ -550,7 +550,7 @@ async fn download_file( State(crypto): State, WorkspaceContext(workspace): WorkspaceContext, Path(path_params): Path, - AuthState(auth_claims): AuthState, + auth: AuthState, ) -> Result<(StatusCode, HeaderMap, Body)> { tracing::debug!(target: TRACING_TARGET, "Downloading file"); @@ -560,8 +560,7 @@ async fn download_file( // cannot see files cannot distinguish a missing file from a forbidden one. // Every kind-specific download permission below already requires at least the // role this check does, so it never rejects an otherwise-authorized caller. - auth_claims - .authorize_workspace(&mut conn, workspace.id, Permission::ViewFiles) + auth.authorize_workspace(&mut conn, workspace.id, Permission::ViewFiles) .await?; // The permission a download requires depends on the file's kind, so that the @@ -577,8 +576,7 @@ async fn download_file( FileKind::Audit | FileKind::Review => Permission::DownloadAudit, }; - auth_claims - .authorize_workspace(&mut conn, workspace.id, permission) + auth.authorize_workspace(&mut conn, workspace.id, permission) .await?; let file_key = FileKey::from_str(&file.storage_path).map_err(|err| { diff --git a/crates/nvisy-server/src/handler/identities.rs b/crates/nvisy-server/src/handler/identities.rs index 23951b1b..a8f38b9c 100644 --- a/crates/nvisy-server/src/handler/identities.rs +++ b/crates/nvisy-server/src/handler/identities.rs @@ -40,16 +40,16 @@ use crate::service::{PasswordService, ServiceState}; const TRACING_TARGET: &str = "nvisy_server::handler::identities"; /// Lists the authenticated account's sign-in methods. -#[tracing::instrument(skip_all, fields(account_id = %auth_claims.account_id))] +#[tracing::instrument(skip_all, fields(account_id = %auth_state.account_id))] async fn list_identities( State(pg_client): State, - AuthState(auth_claims): AuthState, + auth_state: AuthState, ) -> Result> { tracing::debug!(target: TRACING_TARGET, "Listing account identities"); let mut conn = pg_client.get_connection().await?; let identities = conn - .list_account_identities(auth_claims.account_id) + .list_account_identities(auth_state.account_id) .await? .into_iter() .collect(); @@ -64,17 +64,17 @@ fn list_identities_docs(op: TransformOperation) -> TransformOperation { } /// Sets or changes the authenticated account's password. -#[tracing::instrument(skip_all, fields(account_id = %auth_claims.account_id))] +#[tracing::instrument(skip_all, fields(account_id = %auth_state.account_id))] async fn set_password( State(pg_client): State, State(nats): State, State(password): State, - AuthState(auth_claims): AuthState, + auth_state: AuthState, ValidateJson(request): ValidateJson, ) -> Result { tracing::debug!(target: TRACING_TARGET, "Setting account password"); - let account_id = auth_claims.account_id; + let account_id = auth_state.account_id; let mut conn = pg_client.get_connection().await?; let account = conn.find_account_by_id(account_id).await?.ok_or_else(|| { ErrorKind::NotFound @@ -159,19 +159,14 @@ fn set_password_docs(op: TransformOperation) -> TransformOperation { } /// Removes the authenticated account's password identity. -#[tracing::instrument(skip_all, fields(account_id = %auth_claims.account_id))] +#[tracing::instrument(skip_all, fields(account_id = %auth_state.account_id))] async fn delete_password( State(pg_client): State, - AuthState(auth_claims): AuthState, + auth_state: AuthState, ) -> Result { tracing::debug!(target: TRACING_TARGET, "Removing account password"); let mut conn = pg_client.get_connection().await?; - remove_identity( - &mut conn, - auth_claims.account_id, - IdentityProvider::Password, - ) - .await + remove_identity(&mut conn, auth_state.account_id, IdentityProvider::Password).await } fn delete_password_docs(op: TransformOperation) -> TransformOperation { @@ -187,10 +182,10 @@ fn delete_password_docs(op: TransformOperation) -> TransformOperation { } /// Unlinks a provider from the authenticated account. -#[tracing::instrument(skip_all, fields(account_id = %auth_claims.account_id, provider = ?path_params.provider))] +#[tracing::instrument(skip_all, fields(account_id = %auth_state.account_id, provider = ?path_params.provider))] async fn unlink_provider( State(pg_client): State, - AuthState(auth_claims): AuthState, + auth_state: AuthState, Path(path_params): Path, ) -> Result { tracing::debug!(target: TRACING_TARGET, "Unlinking provider"); @@ -204,7 +199,7 @@ async fn unlink_provider( } let mut conn = pg_client.get_connection().await?; - remove_identity(&mut conn, auth_claims.account_id, path_params.provider).await + remove_identity(&mut conn, auth_state.account_id, path_params.provider).await } fn unlink_provider_docs(op: TransformOperation) -> TransformOperation { diff --git a/crates/nvisy-server/src/handler/invites.rs b/crates/nvisy-server/src/handler/invites.rs index 7fbde033..0aa5293c 100644 --- a/crates/nvisy-server/src/handler/invites.rs +++ b/crates/nvisy-server/src/handler/invites.rs @@ -357,7 +357,7 @@ fn cancel_invite_docs(op: TransformOperation) -> TransformOperation { async fn reply_to_invite( State(pg_client): State, State(notification_emitter): State, - AuthState(auth_state): AuthState, + auth_state: AuthState, WorkspaceContext(workspace): WorkspaceContext, security: SecurityContext, Path(path_params): Path, @@ -559,7 +559,7 @@ fn preview_invite_code_docs(op: TransformOperation) -> TransformOperation { #[tracing::instrument(skip_all, fields(account_id = %auth_state.account_id))] async fn reply_to_invite_code( State(pg_client): State, - AuthState(auth_state): AuthState, + auth_state: AuthState, security: SecurityContext, Path(path_params): Path, Json(request): Json>, diff --git a/crates/nvisy-server/src/handler/members.rs b/crates/nvisy-server/src/handler/members.rs index 8dfe5a01..e43dd458 100644 --- a/crates/nvisy-server/src/handler/members.rs +++ b/crates/nvisy-server/src/handler/members.rs @@ -336,7 +336,7 @@ fn update_member_docs(op: TransformOperation) -> TransformOperation { )] async fn leave_workspace( State(pg_client): State, - AuthState(auth_state): AuthState, + auth_state: AuthState, WorkspaceContext(workspace): WorkspaceContext, security: SecurityContext, ) -> Result { diff --git a/crates/nvisy-server/src/handler/notifications.rs b/crates/nvisy-server/src/handler/notifications.rs index 9d749b42..757170f3 100644 --- a/crates/nvisy-server/src/handler/notifications.rs +++ b/crates/nvisy-server/src/handler/notifications.rs @@ -40,7 +40,7 @@ const TRACING_TARGET: &str = "nvisy_server::handler::notifications"; )] async fn list_notifications( State(pg_client): State, - AuthState(auth_state): AuthState, + auth_state: AuthState, Query(pagination): Query, ) -> Result<(StatusCode, Json)> { tracing::debug!(target: TRACING_TARGET, "Listing notifications"); @@ -80,7 +80,7 @@ fn list_notifications_docs(op: TransformOperation) -> TransformOperation { )] async fn get_unread_status( State(pg_client): State, - AuthState(auth_state): AuthState, + auth_state: AuthState, ) -> Result<(StatusCode, Json)> { tracing::debug!(target: TRACING_TARGET, "Checking unread notifications count"); @@ -133,7 +133,7 @@ async fn stream_unread_status( State(pg_client): State, State(notification_emitter): State, State(shutdown): State, - AuthState(auth_state): AuthState, + auth_state: AuthState, ) -> Result> { tracing::debug!(target: TRACING_TARGET, "Opening unread notifications stream"); @@ -233,7 +233,7 @@ fn stream_unread_status_docs(op: TransformOperation) -> TransformOperation { async fn mark_all_notifications_read( State(pg_client): State, State(notification_emitter): State, - AuthState(auth_state): AuthState, + auth_state: AuthState, ) -> Result<(StatusCode, Json)> { tracing::debug!(target: TRACING_TARGET, "Marking all notifications as read"); @@ -279,7 +279,7 @@ fn mark_all_notifications_read_docs(op: TransformOperation) -> TransformOperatio async fn mark_notification_read( State(pg_client): State, State(notification_emitter): State, - AuthState(auth_state): AuthState, + auth_state: AuthState, Path(path_params): Path, ) -> Result { tracing::debug!(target: TRACING_TARGET, "Marking notification as read"); diff --git a/crates/nvisy-server/src/handler/request/activities.rs b/crates/nvisy-server/src/handler/request/activities.rs index 3efe7502..dc49f9c8 100644 --- a/crates/nvisy-server/src/handler/request/activities.rs +++ b/crates/nvisy-server/src/handler/request/activities.rs @@ -102,10 +102,10 @@ mod tests { .body(()) .expect("request should build") .into_parts(); - Query::::from_request_parts(&mut parts, &()) + let Query(inner) = Query::::from_request_parts(&mut parts, &()) .await - .expect("query should extract") - .into_inner() + .expect("query should extract"); + inner } #[tokio::test] diff --git a/crates/nvisy-server/src/handler/tokens.rs b/crates/nvisy-server/src/handler/tokens.rs index f289725f..b29da2a8 100644 --- a/crates/nvisy-server/src/handler/tokens.rs +++ b/crates/nvisy-server/src/handler/tokens.rs @@ -30,11 +30,11 @@ const TRACING_TARGET: &str = "nvisy_server::handler::tokens"; /// /// Returns the token with a JWT that can be used for authentication. /// The JWT is only shown once upon creation. -#[tracing::instrument(skip_all, fields(account_id = %auth_claims.account_id))] +#[tracing::instrument(skip_all, fields(account_id = %auth_state.account_id))] async fn create_api_token( State(pg_client): State, State(auth_keys): State, - AuthState(auth_claims): AuthState, + auth_state: AuthState, TypedHeader(user_agent): TypedHeader, ValidateJson(request): ValidateJson, ) -> Result<(StatusCode, Json)> { @@ -44,7 +44,7 @@ async fn create_api_token( // Fetch the account to generate JWT claims let account = conn - .find_account_by_id(auth_claims.account_id) + .find_account_by_id(auth_state.account_id) .await? .ok_or_else(|| { ErrorKind::NotFound @@ -52,7 +52,7 @@ async fn create_api_token( .with_message("Account not found") })?; - let new_token = request.into_model(auth_claims.account_id, user_agent.to_string())?; + let new_token = request.into_model(auth_state.account_id, user_agent.to_string())?; let api_token = conn.create_account_api_token(new_token).await?; // Generate JWT for the new token @@ -83,7 +83,7 @@ fn create_api_token_docs(op: TransformOperation) -> TransformOperation { #[tracing::instrument(skip_all, fields(account_id = %auth_state.account_id))] async fn list_api_tokens( State(pg_client): State, - AuthState(auth_state): AuthState, + auth_state: AuthState, Query(pagination): Query, ) -> Result<(StatusCode, Json)> { tracing::debug!(target: TRACING_TARGET, "Listing API tokens"); @@ -123,7 +123,7 @@ fn list_api_tokens_docs(op: TransformOperation) -> TransformOperation { #[tracing::instrument(skip_all, fields(account_id = %auth_state.account_id))] async fn read_api_token( State(pg_client): State, - AuthState(auth_state): AuthState, + auth_state: AuthState, Path(path): Path, ) -> Result<(StatusCode, Json)> { tracing::debug!(target: TRACING_TARGET, "Reading API token"); @@ -153,7 +153,7 @@ fn read_api_token_docs(op: TransformOperation) -> TransformOperation { #[tracing::instrument(skip_all, fields(account_id = %auth_state.account_id))] async fn update_api_token( State(pg_client): State, - AuthState(auth_state): AuthState, + auth_state: AuthState, Path(path): Path, ValidateJson(request): ValidateJson, ) -> Result<(StatusCode, Json)> { @@ -202,7 +202,7 @@ fn update_api_token_docs(op: TransformOperation) -> TransformOperation { #[tracing::instrument(skip_all, fields(account_id = %auth_state.account_id))] async fn revoke_api_token( State(pg_client): State, - AuthState(auth_state): AuthState, + auth_state: AuthState, Path(path): Path, ) -> Result { tracing::debug!(target: TRACING_TARGET, "Revoking API token"); diff --git a/crates/nvisy-server/src/handler/workspaces.rs b/crates/nvisy-server/src/handler/workspaces.rs index 828ab3ff..5ef512f0 100644 --- a/crates/nvisy-server/src/handler/workspaces.rs +++ b/crates/nvisy-server/src/handler/workspaces.rs @@ -72,7 +72,7 @@ async fn backfill_retention( async fn create_workspace( State(pg_client): State, State(upload): State, - AuthState(auth_state): AuthState, + auth_state: AuthState, security: SecurityContext, ValidateJson(request): ValidateJson, ) -> Result<(StatusCode, Json)> { @@ -140,7 +140,7 @@ fn create_workspace_docs(op: TransformOperation) -> TransformOperation { async fn list_workspaces( State(pg_client): State, State(upload): State, - AuthState(auth_state): AuthState, + auth_state: AuthState, Query(pagination): Query, ) -> Result<(StatusCode, Json)> { let mut conn = pg_client.get_connection().await?; @@ -361,7 +361,7 @@ fn delete_workspace_docs(op: TransformOperation) -> TransformOperation { )] async fn get_notification_settings( State(pg_client): State, - AuthState(auth_state): AuthState, + auth_state: AuthState, WorkspaceContext(workspace): WorkspaceContext, ) -> Result<(StatusCode, Json)> { let mut conn = pg_client.get_connection().await?; @@ -400,7 +400,7 @@ fn get_notification_settings_docs(op: TransformOperation) -> TransformOperation )] async fn update_notification_settings( State(pg_client): State, - AuthState(auth_state): AuthState, + auth_state: AuthState, WorkspaceContext(workspace): WorkspaceContext, ValidateJson(request): ValidateJson, ) -> Result<(StatusCode, Json)> { diff --git a/crates/nvisy-server/src/middleware/auth/session.rs b/crates/nvisy-server/src/middleware/auth/session.rs index f2e5918c..04d486a0 100644 --- a/crates/nvisy-server/src/middleware/auth/session.rs +++ b/crates/nvisy-server/src/middleware/auth/session.rs @@ -15,46 +15,30 @@ use nvisy_postgres::types::session::SlidingWindow; use super::TRACING_TARGET; use crate::extract::AuthState; -use crate::handler::{ErrorKind, Result}; +use crate::handler::Result; /// Requires a valid session to proceed with the request. /// /// The [`AuthState`] extractor performs credential extraction (session cookie or /// Bearer token) and full database verification; reaching the body means the /// request is authenticated. -pub async fn require_authentication( - AuthState(_): AuthState, - request: Request, - next: Next, -) -> Response { +pub async fn require_authentication(_: AuthState, request: Request, next: Next) -> Response { next.run(request).await } /// Slides a browser session's idle bound forward on use. /// /// Session validity (existence, revocation, idle and absolute expiry) is decided -/// authoritatively by [`AuthState`]'s database check when the extractor runs; this -/// middleware rejects an expired JWT early and then extends the session. It must -/// be layered *inside* CSRF protection, so a request that will be rejected for a -/// missing CSRF token never reaches the session-extending write. +/// authoritatively by [`AuthState`]'s database check when the extractor runs, so +/// reaching this body means the session is already valid — this middleware only +/// extends it. It must be layered *inside* CSRF protection, so a request that will +/// be rejected for a missing CSRF token never reaches the session-extending write. pub async fn slide_session( - AuthState(auth_claims): AuthState, + auth_state: AuthState, State(pg_database): State, request: Request, next: Next, ) -> Result { - if auth_claims.is_expired() { - tracing::warn!( - target: TRACING_TARGET, - account_id = %auth_claims.account_id, - token_id = %auth_claims.token_id, - "expired token used in request" - ); - return Err(ErrorKind::Unauthorized - .with_context("Authentication token has expired") - .with_resource("authorization")); - } - // Slide the session's idle bound forward on use, throttled so it writes at // most once per throttle interval rather than on every request. Only `web` // sessions actually slide (the query no-ops for programmatic tokens). This is @@ -71,14 +55,14 @@ pub async fn slide_session( match pg_database.get_connection().await { Ok(mut conn) => { if let Err(error) = conn - .slide_account_api_token(auth_claims.token_id, SlidingWindow::standard()) + .slide_account_api_token(auth_state.token_id, SlidingWindow::standard()) .await { tracing::warn!( target: TRACING_TARGET, error = %error, - account_id = %auth_claims.account_id, - token_id = %auth_claims.token_id, + account_id = %auth_state.account_id, + token_id = %auth_state.token_id, "failed to slide session; session left unextended this request" ); } @@ -87,8 +71,8 @@ pub async fn slide_session( tracing::warn!( target: TRACING_TARGET, error = %error, - account_id = %auth_claims.account_id, - token_id = %auth_claims.token_id, + account_id = %auth_state.account_id, + token_id = %auth_state.token_id, "could not acquire a connection to slide session; left unextended" ); } diff --git a/crates/nvisy-server/src/service/user_agent.rs b/crates/nvisy-server/src/service/user_agent.rs index b1e2c5d1..3767b948 100644 --- a/crates/nvisy-server/src/service/user_agent.rs +++ b/crates/nvisy-server/src/service/user_agent.rs @@ -37,9 +37,12 @@ impl UserAgentParser { /// Parses a user agent string and returns a human-readable token name. /// - /// Extracts the browser/application name, version, OS, and device category - /// from the user agent, falling back to "Unknown" if parsing fails. The - /// result is truncated to 64 characters. + /// A recognized browser becomes a label like `Chrome 120 on Mac OSX + /// (Desktop)`. A user agent that is not a browser — a non-browser client such + /// as the SDK, whose UA (`@nvisy/sdk/1.2.3`) already identifies it — falls + /// back to the raw user agent rather than a useless placeholder. An empty user + /// agent, which carries nothing to show, becomes `UNKNOWN`. The result is + /// truncated to 64 characters. /// /// # Examples /// @@ -50,50 +53,67 @@ impl UserAgentParser { /// let name = parser.parse("Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) ... Chrome/120.0.0.0 ..."); /// assert_eq!(name, "Chrome 120 on macOS (Desktop)"); /// - /// // Safari on iOS - /// let name = parser.parse("Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) ... Safari/604.1"); - /// assert_eq!(name, "Safari 17 on iOS (Mobile)"); + /// // A non-browser client keeps its own identifier. + /// let name = parser.parse("@nvisy/sdk/1.2.3"); + /// assert_eq!(name, "@nvisy/sdk/1.2.3"); /// ``` pub fn parse(&self, user_agent: &str) -> String { - let name = match self.parser.parse(user_agent) { - Some(result) => { - let mut parts = Vec::with_capacity(4); - - // Browser name and version - let browser = result.name; - let version = result.version; - if version.is_empty() || version == VALUE_UNKNOWN { - parts.push(browser.to_string()); - } else { - // Use only major version for brevity - let major_version = version.split('.').next().unwrap_or(version); - parts.push(format!("{} {}", browser, major_version)); - } - - // OS - let os = result.os; - if !os.is_empty() && os != VALUE_UNKNOWN { - parts.push(format!("on {}", os)); - } - - // Device category - let category = result.category; - if !category.is_empty() && category != VALUE_UNKNOWN { - let device = match category { - "pc" => "Desktop", - "smartphone" | "mobilephone" | "tablet" => "Mobile", - "crawler" => "Bot", - _ => "Other", - }; - parts.push(format!("({})", device)); - } - - parts.join(" ") + let label = self.browser_label(user_agent).unwrap_or_else(|| { + // Not a recognized browser. The raw user agent is more useful than a + // placeholder (it identifies a non-browser client, e.g. the SDK); + // only a genuinely empty one has nothing to name. + let trimmed = user_agent.trim(); + if trimmed.is_empty() { + "UNKNOWN".to_string() + } else { + trimmed.to_string() } - None => "UNKNOWN".to_string(), - }; + }); - truncate(&name, TOKEN_NAME_MAX_LENGTH) + truncate(&label, TOKEN_NAME_MAX_LENGTH) + } + + /// The composed browser label (`name [version] [on os] [(device)]`) for a + /// recognized browser user agent, or `None` when the user agent is not a + /// browser or carries no browser fields at all. + fn browser_label(&self, user_agent: &str) -> Option { + let result = self.parser.parse(user_agent)?; + let mut parts = Vec::with_capacity(4); + + // Browser name and version. A parse with no usable name is treated as + // "not a browser" so the caller falls back to the raw user agent. + let browser = result.name; + if browser.is_empty() || browser == VALUE_UNKNOWN { + return None; + } + let version = result.version; + if version.is_empty() || version == VALUE_UNKNOWN { + parts.push(browser.to_string()); + } else { + // Use only the major version for brevity. + let major_version = version.split('.').next().unwrap_or(version); + parts.push(format!("{browser} {major_version}")); + } + + // OS + let os = result.os; + if !os.is_empty() && os != VALUE_UNKNOWN { + parts.push(format!("on {os}")); + } + + // Device category + let category = result.category; + if !category.is_empty() && category != VALUE_UNKNOWN { + let device = match category { + "pc" => "Desktop", + "smartphone" | "mobilephone" | "tablet" => "Mobile", + "crawler" => "Bot", + _ => "Other", + }; + parts.push(format!("({device})")); + } + + Some(parts.join(" ")) } } @@ -125,6 +145,22 @@ mod tests { assert_eq!(parser.parse(ua), "Chrome 120 on Mac OSX (Desktop)"); } + /// A non-browser client (e.g. the SDK) keeps its own user agent as the label, + /// rather than the useless "UNKNOWN" a browser parser would otherwise yield. + #[test] + fn parse_falls_back_to_the_raw_user_agent_for_a_non_browser() { + let parser = UserAgentParser::new(); + assert_eq!(parser.parse("@nvisy/sdk/1.2.3"), "@nvisy/sdk/1.2.3"); + } + + /// An empty (or whitespace-only) user agent has nothing to name. + #[test] + fn parse_is_unknown_only_for_an_empty_user_agent() { + let parser = UserAgentParser::new(); + assert_eq!(parser.parse(""), "UNKNOWN"); + assert_eq!(parser.parse(" "), "UNKNOWN"); + } + /// Our length cap keeps token names within the column limit. #[test] fn truncate_caps_length() { From 47dc1a7a44d6ef8b6cfccc7847f328168f9dcc6f Mon Sep 17 00:00:00 2001 From: Oleh Martsokha Date: Wed, 9 Sep 2026 14:33:59 +0200 Subject: [PATCH 02/13] Migrate validation from validator to garde; regroup into extract/valid MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the `validator` crate with `garde` 0.23 across every request DTO. The motivation is structural: garde's error type carries only a field path and a message — never the rejected value — so a validation failure cannot leak submitted request contents, whereas `validator` deliberately records the value in `params["value"]` (for length/custom/credit_card/…), which had been leaking into both responses and logs. Rewrite ValidateJson's error mapping against garde's Report: iterate (path, message) pairs and prefix each with its dotted path. garde reports nested (`#[garde(dive)]`) failures under an indexed path (`files[2].name`) natively, fixing the previous silent drop of nested errors that validator's flat `field_errors()` caused. Attribute conversion preserves semantics: string `length` becomes `length(chars, …)` because garde's default length counts bytes, not characters; collection and `range` bounds stay plain. `nested` becomes `dive`; each Validate-deriving struct gains `#[garde(allow_unvalidated)]`. The two custom validators move to a shared `extract::validators` module and adopt garde's `fn(&T, &()) -> garde::Result` signature. Move the validation extractor out of `extract/reject/` into a new `extract/valid/` (ValidateJson + the shared validators); `reject/` keeps the rejection-message wrappers. Remove the dead, validator-coupled validation surface from ErrorResponse (from_validation_errors, ValidationErrorDetail, the `validation` field, VALIDATION_ERROR) — it was unused and copied `params` into responses. http_error: derive EnumIter on ErrorKind and make the coverage test iterate all variants instead of a hand-kept list, which had silently omitted three. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_018bKk1YEG4tZ69jzYVQvQL8 --- Cargo.lock | 78 ++-- Cargo.toml | 2 +- crates/nvisy-server/Cargo.toml | 2 +- crates/nvisy-server/src/extract/mod.rs | 4 +- crates/nvisy-server/src/extract/reject/mod.rs | 2 - .../src/extract/reject/validated_json.rs | 366 ------------------ crates/nvisy-server/src/extract/valid/mod.rs | 12 + .../src/extract/valid/validated_json.rs | 204 ++++++++++ .../src/extract/valid/validators.rs | 39 ++ .../src/handler/error/http_error.rs | 31 +- .../src/handler/request/accounts.rs | 21 +- .../src/handler/request/authentications.rs | 27 +- .../nvisy-server/src/handler/request/chat.rs | 8 +- .../src/handler/request/connection_syncs.rs | 13 +- .../src/handler/request/connections.rs | 33 +- .../src/handler/request/detections.rs | 3 +- .../nvisy-server/src/handler/request/files.rs | 8 +- .../src/handler/request/identities.rs | 5 +- .../src/handler/request/invites.rs | 8 +- .../src/handler/request/members.rs | 3 +- .../nvisy-server/src/handler/request/mod.rs | 2 - .../src/handler/request/paginations.rs | 10 +- .../src/handler/request/pipelines.rs | 22 +- .../src/handler/request/policies.rs | 12 +- .../src/handler/request/providers.rs | 8 +- .../src/handler/request/tokens.rs | 8 +- .../src/handler/request/validations.rs | 38 -- .../src/handler/request/webhooks.rs | 17 +- .../src/handler/request/workspaces.rs | 13 +- .../src/handler/response/errors.rs | 68 ---- 30 files changed, 420 insertions(+), 647 deletions(-) delete mode 100644 crates/nvisy-server/src/extract/reject/validated_json.rs create mode 100644 crates/nvisy-server/src/extract/valid/mod.rs create mode 100644 crates/nvisy-server/src/extract/valid/validated_json.rs create mode 100644 crates/nvisy-server/src/extract/valid/validators.rs delete mode 100644 crates/nvisy-server/src/handler/request/validations.rs diff --git a/Cargo.lock b/Cargo.lock index d0f0c04c..f977316f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -4403,6 +4403,32 @@ dependencies = [ "slab", ] +[[package]] +name = "garde" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d7f479d28d1daedf23970890723b8774d120aa627abcb33c1d7c93d0965e6c3" +dependencies = [ + "compact_str", + "garde_derive", + "once_cell", + "regex", + "smallvec", + "url", +] + +[[package]] +name = "garde_derive" +version = "0.23.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b0252cacdca8e6f30a900e6d436fbf294e23f674b9288ca4a5bd35976bef87c" +dependencies = [ + "proc-macro2", + "quote", + "regex", + "syn 2.0.119", +] + [[package]] name = "generator" version = "0.8.9" @@ -7116,6 +7142,7 @@ dependencies = [ "dotenvy", "elide-pipeline", "futures", + "garde", "hex", "hkdf 0.13.0", "http-body 1.1.0", @@ -7152,7 +7179,6 @@ dependencies = [ "tracing", "url", "uuid", - "validator", "woothee", "zxcvbn", ] @@ -7925,28 +7951,6 @@ dependencies = [ "toml_edit", ] -[[package]] -name = "proc-macro-error-attr3" -version = "3.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b0084e6206a967a2dad822180626b2f6b07a3b379325e8f1ec0438e33a469ba7" -dependencies = [ - "proc-macro2", - "quote", -] - -[[package]] -name = "proc-macro-error3" -version = "3.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0cf066225f2373bc711684792b69bdeac0356019b007e721090c24d92d5d5a50" -dependencies = [ - "proc-macro-error-attr3", - "proc-macro2", - "quote", - "syn 3.0.4", -] - [[package]] name = "proc-macro2" version = "1.0.107" @@ -10928,34 +10932,6 @@ dependencies = [ "wasm-bindgen", ] -[[package]] -name = "validator" -version = "0.21.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3d68c6633c483df6780cc5277a417c7c2d1bceee2649d06c8ab6b0fd2dd3c81" -dependencies = [ - "idna", - "regex", - "serde", - "serde_derive", - "serde_json", - "url", - "validator_derive", -] - -[[package]] -name = "validator_derive" -version = "0.20.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "240e4b81c20a1d6d50d1d7265c658dfbd204e8b9ac4d80f3c931f39462196335" -dependencies = [ - "darling 0.23.0", - "proc-macro-error3", - "proc-macro2", - "quote", - "syn 2.0.119", -] - [[package]] name = "valuable" version = "0.1.1" diff --git a/Cargo.toml b/Cargo.toml index 542451a7..9fbf6a14 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -167,7 +167,7 @@ csv = { version = "1.4", features = [] } serde = { version = "1.0", features = ["derive"] } serde_json = { version = "1.0", features = [] } toml = { version = "1.1", features = [] } -validator = { version = "0.21", features = ["derive"] } +garde = { version = "0.23", features = ["derive", "email", "url"] } # Text processing slug = { version = "0.1", features = [] } diff --git a/crates/nvisy-server/Cargo.toml b/crates/nvisy-server/Cargo.toml index 16799f70..5e8d1d78 100644 --- a/crates/nvisy-server/Cargo.toml +++ b/crates/nvisy-server/Cargo.toml @@ -112,7 +112,7 @@ serde_json = { workspace = true, features = [] } toml = { workspace = true, features = ["parse"] } bytes = { workspace = true, features = [] } http-body = { workspace = true, features = [] } -validator = { workspace = true, features = [] } +garde = { workspace = true, features = [] } # Text processing woothee = { workspace = true, features = [] } diff --git a/crates/nvisy-server/src/extract/mod.rs b/crates/nvisy-server/src/extract/mod.rs index 33ecfa6d..d8e024f9 100644 --- a/crates/nvisy-server/src/extract/mod.rs +++ b/crates/nvisy-server/src/extract/mod.rs @@ -13,6 +13,7 @@ mod pg_connection; mod reject; mod security_context; mod typed_header; +mod valid; mod version; mod workspace_context; @@ -23,8 +24,9 @@ pub use crate::extract::avatar::Avatar; pub use crate::extract::connection_info::{AppConnectInfo, ClientIp}; pub use crate::extract::idempotency_key::IdempotencyKey; pub use crate::extract::pg_connection::PgPool; -pub use crate::extract::reject::{Form, Json, Multipart, Path, Query, ValidateJson}; +pub use crate::extract::reject::{Form, Json, Multipart, Path, Query}; pub use crate::extract::security_context::SecurityContext; pub use crate::extract::typed_header::TypedHeader; +pub use crate::extract::valid::{ValidateJson, validators}; pub use crate::extract::version::Version; pub use crate::extract::workspace_context::WorkspaceContext; diff --git a/crates/nvisy-server/src/extract/reject/mod.rs b/crates/nvisy-server/src/extract/reject/mod.rs index 78eab041..5bf2683c 100644 --- a/crates/nvisy-server/src/extract/reject/mod.rs +++ b/crates/nvisy-server/src/extract/reject/mod.rs @@ -10,14 +10,12 @@ mod json_with_rej; mod mutlipart_with_rej; mod path_with_rej; mod query_with_rej; -mod validated_json; pub use self::form_with_rej::Form; pub use self::json_with_rej::Json; pub use self::mutlipart_with_rej::Multipart; pub use self::path_with_rej::Path; pub use self::query_with_rej::Query; -pub use self::validated_json::ValidateJson; /// Sanitizes a deserializer error message before it is surfaced or logged. /// diff --git a/crates/nvisy-server/src/extract/reject/validated_json.rs b/crates/nvisy-server/src/extract/reject/validated_json.rs deleted file mode 100644 index 3aef9a75..00000000 --- a/crates/nvisy-server/src/extract/reject/validated_json.rs +++ /dev/null @@ -1,366 +0,0 @@ -//! Validated JSON extractor with automatic validation. -//! -//! This module provides [`ValidateJson`], a JSON extractor that deserializes a -//! body (via [`Json`]) and then runs `validator::Validate` on it, turning any -//! [`ValidationErrors`] into a structured [`Error`]. -//! -//! Two invariants shape the error mapping: -//! -//! - **No submitted values escape.** `validator` records the rejected field -//! value in `params["value"]` for several validators (`length`, `custom`, -//! `credit_card`, …). Neither the user-facing message nor the log line ever -//! reads `params["value"]`, so request contents cannot leak through a -//! validation failure. -//! - **Nested errors are preserved.** Validation of `#[validate(nested)]` fields -//! produces a tree, not a flat map, so the mapping walks the tree and reports -//! each leaf under its dotted path (`address.zip`, `items[2].name`). - -use std::borrow::Cow; -use std::collections::HashMap; -use std::fmt::Write as _; - -use aide::OperationInput; -use aide::generate::GenContext; -use aide::openapi::{Operation, Response}; -use axum::extract::{FromRequest, OptionalFromRequest, Request}; -use derive_more::{Deref, DerefMut, From}; -use schemars::JsonSchema; -use serde::de::DeserializeOwned; -use serde_json::Value; -use validator::{Validate, ValidationError, ValidationErrors, ValidationErrorsKind}; - -use super::Json; -use crate::handler::{Error, ErrorKind}; - -/// JSON extractor that deserializes and then validates the request body. -/// -/// Works with any type that implements both [`serde::Deserialize`] and -/// [`validator::Validate`]. Deserialization is delegated to [`Json`], so JSON -/// syntax and content-type errors carry that extractor's messages; a body that -/// parses but fails validation is rejected with a field-by-field message built -/// by [`ValidationErrors`] mapping below. -#[must_use] -#[derive(Debug, Clone, Copy, Default, Deref, DerefMut, From)] -pub struct ValidateJson(pub T); - -impl FromRequest for ValidateJson -where - T: DeserializeOwned + Validate + 'static, - S: Send + Sync, -{ - type Rejection = Error<'static>; - - async fn from_request(req: Request, state: &S) -> Result { - let Json(data) = as FromRequest>::from_request(req, state).await?; - data.validate()?; - Ok(Self(data)) - } -} - -impl OptionalFromRequest for ValidateJson -where - T: DeserializeOwned + Validate + 'static, - S: Send + Sync, -{ - type Rejection = Error<'static>; - - /// Extracts and validates a body when one is present; an absent or malformed - /// body yields `None`. Mirrors [`Json`]'s optional semantics: only a server - /// error propagates, so a missing optional body is not an error. - async fn from_request(req: Request, state: &S) -> Result, Self::Rejection> { - match >::from_request(req, state).await { - Ok(validated) => Ok(Some(validated)), - // For optional extraction, only propagate server errors; client errors - // (absent body, malformed JSON, validation failure) result in `None`. - Err(error) if error.kind() == ErrorKind::InternalServerError => Err(error), - Err(_) => Ok(None), - } - } -} - -impl From for Error<'static> { - fn from(errors: ValidationErrors) -> Self { - let mut leaves = Vec::new(); - collect_leaf_errors(&mut String::new(), &errors, &mut leaves); - - // Log field paths and codes only — never `params`, which can hold the - // submitted value for `length`/`custom`/`credit_card`/… validators. - let logged: Vec = leaves - .iter() - .map(|(path, error)| format!("{}:{}", path, error.code)) - .collect(); - tracing::warn!(errors = ?logged, "Request validation failed"); - - let messages: Vec = leaves - .iter() - .map(|(path, error)| describe_error(path, error)) - .collect(); - - let user_message = match messages.as_slice() { - [] => "Validation failed".to_string(), - [single] => single.clone(), - many => many.join(". "), - }; - - ErrorKind::BadRequest - .with_message(user_message) - .with_resource("request") - } -} - -/// Walks the validation-error tree, pushing each leaf as `(dotted path, error)`. -/// -/// `prefix` is the path accumulated from enclosing structs and list indices; -/// leaves at the top level are reported under their bare field name. -fn collect_leaf_errors<'a>( - prefix: &mut String, - errors: &'a ValidationErrors, - leaves: &mut Vec<(String, &'a ValidationError)>, -) { - for (field, kind) in errors.errors() { - match kind { - ValidationErrorsKind::Field(field_errors) => { - let path = join_path(prefix, field); - for error in field_errors { - leaves.push((path.clone(), error)); - } - } - ValidationErrorsKind::Struct(nested) => { - let mut nested_prefix = join_path(prefix, field); - collect_leaf_errors(&mut nested_prefix, nested, leaves); - } - ValidationErrorsKind::List(items) => { - for (index, nested) in items { - let mut nested_prefix = join_path(prefix, field); - let _ = write!(nested_prefix, "[{}]", index); - collect_leaf_errors(&mut nested_prefix, nested, leaves); - } - } - } - } -} - -/// Joins a path prefix and a field segment with a `.`, or returns the bare -/// segment when there is no prefix. -fn join_path(prefix: &str, field: &str) -> String { - if prefix.is_empty() { - field.to_string() - } else { - format!("{}.{}", prefix, field) - } -} - -/// Renders one validation error into a user-facing message. -/// -/// A custom message on the error wins. Otherwise the code is mapped for the -/// validators actually used on request DTOs (`length`, `range`, `email`, -/// `url`); anything else gets a neutral fallback. No branch reads -/// `params["value"]`, so a submitted value never reaches the response. -fn describe_error(field: &str, error: &ValidationError) -> String { - if let Some(message) = &error.message { - return format!("Field '{}': {}", field, message); - } - - match error.code.as_ref() { - "length" => format!("Field '{}' {}", field, format_bounds(&error.params, "long")), - "range" => format!("Field '{}' {}", field, format_bounds(&error.params, "")), - "email" => format!( - "Field '{}' must be a valid email address (e.g., user@example.com)", - field - ), - "url" => format!( - "Field '{}' must be a valid URL (e.g., https://example.com)", - field - ), - _ => format!("Field '{}' is invalid", field), - } -} - -/// Renders the `min`/`max` bounds shared by `length` and `range` errors. -/// -/// `suffix` is appended after each bound (e.g. `"long"` for lengths, empty for -/// ranges). Bounds that are absent or non-numeric fall back to a generic phrase. -fn format_bounds(params: &HashMap, Value>, suffix: &str) -> String { - let tail = if suffix.is_empty() { - String::new() - } else { - format!(" {}", suffix) - }; - - match (number(params, "min"), number(params, "max")) { - (Some(min), Some(max)) => format!("must be between {} and {}{}", min, max, tail), - (Some(min), None) => format!("must be at least {}{}", min, tail), - (None, Some(max)) => format!("must be at most {}{}", max, tail), - (None, None) => "is out of the allowed range".to_string(), - } -} - -/// Reads a numeric bound parameter, rendering it without a trailing `.0` when it -/// is integral so `length` bounds read as `64` rather than `64.0`. -fn number(params: &HashMap, Value>, key: &str) -> Option { - let value = params.get(key)?.as_f64()?; - if value.fract() == 0.0 { - Some((value as i64).to_string()) - } else { - Some(value.to_string()) - } -} - -impl OperationInput for ValidateJson -where - T: JsonSchema, -{ - fn operation_input(ctx: &mut GenContext, operation: &mut Operation) { - Json::::operation_input(ctx, operation); - } - - fn inferred_early_responses( - ctx: &mut GenContext, - operation: &mut Operation, - ) -> Vec<(Option, Response)> { - Json::::inferred_early_responses(ctx, operation) - } -} - -#[cfg(test)] -mod tests { - use std::borrow::Cow; - - use serde_json::json; - use validator::{ValidationError, ValidationErrors, ValidationErrorsKind}; - - use super::{collect_leaf_errors, describe_error, format_bounds, number}; - - fn params( - pairs: &[(&'static str, serde_json::Value)], - ) -> std::collections::HashMap, serde_json::Value> { - pairs - .iter() - .map(|(k, v)| (Cow::Borrowed(*k), v.clone())) - .collect() - } - - fn error_with_params(code: &'static str, pairs: &[(&'static str, serde_json::Value)]) -> ValidationError { - let mut error = ValidationError::new(code); - for (key, value) in pairs { - error.add_param(Cow::Borrowed(*key), value); - } - error - } - - #[test] - fn length_bounds_render_as_integers_with_the_long_suffix() { - let hint = describe_error( - "display_name", - &error_with_params("length", &[("min", json!(2)), ("max", json!(64))]), - ); - assert_eq!( - hint, - "Field 'display_name' must be between 2 and 64 long" - ); - } - - #[test] - fn length_with_only_one_bound() { - assert_eq!( - describe_error("tags", &error_with_params("length", &[("min", json!(1))])), - "Field 'tags' must be at least 1 long" - ); - assert_eq!( - describe_error("tags", &error_with_params("length", &[("max", json!(5))])), - "Field 'tags' must be at most 5 long" - ); - } - - #[test] - fn range_renders_without_a_suffix() { - assert_eq!( - describe_error( - "age", - &error_with_params("range", &[("min", json!(0)), ("max", json!(120))]) - ), - "Field 'age' must be between 0 and 120" - ); - } - - #[test] - fn email_and_url_get_canned_messages() { - assert!(describe_error("email", &ValidationError::new("email")) - .contains("valid email address")); - assert!(describe_error("homepage", &ValidationError::new("url")).contains("valid URL")); - } - - #[test] - fn a_custom_message_wins_over_the_code() { - let mut custom = ValidationError::new("length"); - custom.message = Some(Cow::Borrowed("Please keep it short")); - assert_eq!( - describe_error("bio", &custom), - "Field 'bio': Please keep it short" - ); - } - - #[test] - fn an_unknown_code_gets_a_neutral_fallback() { - assert_eq!( - describe_error("x", &ValidationError::new("bespoke_rule")), - "Field 'x' is invalid" - ); - } - - #[test] - fn bounds_fall_back_when_absent_or_non_numeric() { - assert_eq!(format_bounds(¶ms(&[]), "long"), "is out of the allowed range"); - assert_eq!( - format_bounds(¶ms(&[("min", json!("oops"))]), "long"), - "is out of the allowed range" - ); - } - - #[test] - fn number_drops_the_trailing_zero_on_integral_bounds() { - assert_eq!(number(¶ms(&[("min", json!(64.0))]), "min").as_deref(), Some("64")); - assert_eq!(number(¶ms(&[("min", json!(1.5))]), "min").as_deref(), Some("1.5")); - assert_eq!(number(¶ms(&[]), "min"), None); - } - - #[test] - fn nested_struct_errors_are_reported_under_a_dotted_path() { - // Build: { address: Struct { zip: Field[length] } } - let mut inner = ValidationErrors::new(); - inner.add("zip", error_with_params("length", &[("min", json!(5))])); - - let mut outer = ValidationErrors::new(); - outer - .errors_mut() - .insert(Cow::Borrowed("address"), ValidationErrorsKind::Struct(Box::new(inner))); - - let mut leaves = Vec::new(); - collect_leaf_errors(&mut String::new(), &outer, &mut leaves); - - assert_eq!(leaves.len(), 1); - assert_eq!(leaves[0].0, "address.zip"); - assert_eq!(leaves[0].1.code.as_ref(), "length"); - } - - #[test] - fn list_errors_are_reported_with_an_index() { - // Build: { items: List { 2 => Struct { name: Field[length] } } } - let mut item = ValidationErrors::new(); - item.add("name", error_with_params("length", &[("max", json!(10))])); - - let mut list = std::collections::BTreeMap::new(); - list.insert(2usize, Box::new(item)); - - let mut outer = ValidationErrors::new(); - outer - .errors_mut() - .insert(Cow::Borrowed("items"), ValidationErrorsKind::List(list)); - - let mut leaves = Vec::new(); - collect_leaf_errors(&mut String::new(), &outer, &mut leaves); - - assert_eq!(leaves.len(), 1); - assert_eq!(leaves[0].0, "items[2].name"); - } -} diff --git a/crates/nvisy-server/src/extract/valid/mod.rs b/crates/nvisy-server/src/extract/valid/mod.rs new file mode 100644 index 00000000..cf01bb8b --- /dev/null +++ b/crates/nvisy-server/src/extract/valid/mod.rs @@ -0,0 +1,12 @@ +//! Validation extractor and shared field validators. +//! +//! [`ValidateJson`] deserializes a request body (via the [`Json`] extractor) and +//! then runs `garde::Validate` on it. The [`validators`] module holds custom +//! `garde` validators that DTO fields reference by name. +//! +//! [`Json`]: crate::extract::Json + +mod validated_json; +pub mod validators; + +pub use self::validated_json::ValidateJson; diff --git a/crates/nvisy-server/src/extract/valid/validated_json.rs b/crates/nvisy-server/src/extract/valid/validated_json.rs new file mode 100644 index 00000000..a52ff121 --- /dev/null +++ b/crates/nvisy-server/src/extract/valid/validated_json.rs @@ -0,0 +1,204 @@ +//! Validated JSON extractor with automatic validation. +//! +//! This module provides [`ValidateJson`], a JSON extractor that deserializes a +//! body (via [`Json`]) and then runs `garde::Validate` on it, turning any +//! [`Report`] into a structured [`Error`]. +//! +//! garde is used rather than `validator` specifically because its error type +//! carries only the field path and a message — never the rejected value — so a +//! failed validation cannot leak submitted request contents into the response +//! or the logs. Nested (`#[garde(dive)]`) failures are reported under their +//! dotted path (`files[2].name`) by garde itself. + +use aide::OperationInput; +use aide::generate::GenContext; +use aide::openapi::{Operation, Response}; +use axum::extract::{FromRequest, OptionalFromRequest, Request}; +use derive_more::{Deref, DerefMut, From}; +use garde::{Report, Validate}; +use schemars::JsonSchema; +use serde::de::DeserializeOwned; + +use crate::extract::Json; +use crate::handler::{Error, ErrorKind}; + +/// JSON extractor that deserializes and then validates the request body. +/// +/// Works with any type that implements both [`serde::Deserialize`] and +/// [`garde::Validate`] with a `()` context. Deserialization is delegated to +/// [`Json`], so JSON syntax and content-type errors carry that extractor's +/// messages; a body that parses but fails validation is rejected with a +/// field-by-field message built from the garde [`Report`]. +#[must_use] +#[derive(Debug, Clone, Copy, Default, Deref, DerefMut, From)] +pub struct ValidateJson(pub T); + +impl FromRequest for ValidateJson +where + T: DeserializeOwned + Validate + 'static, + S: Send + Sync, +{ + type Rejection = Error<'static>; + + async fn from_request(req: Request, state: &S) -> Result { + let Json(data) = as FromRequest>::from_request(req, state).await?; + data.validate()?; + Ok(Self(data)) + } +} + +impl OptionalFromRequest for ValidateJson +where + T: DeserializeOwned + Validate + 'static, + S: Send + Sync, +{ + type Rejection = Error<'static>; + + /// Extracts and validates a body when one is present; an absent or malformed + /// body yields `None`. Mirrors [`Json`]'s optional semantics: only a server + /// error propagates, so a missing optional body is not an error. + async fn from_request(req: Request, state: &S) -> Result, Self::Rejection> { + match >::from_request(req, state).await { + Ok(validated) => Ok(Some(validated)), + // For optional extraction, only propagate server errors; client errors + // (absent body, malformed JSON, validation failure) result in `None`. + Err(error) if error.kind() == ErrorKind::InternalServerError => Err(error), + Err(_) => Ok(None), + } + } +} + +impl From for Error<'static> { + fn from(report: Report) -> Self { + let messages: Vec = report + .iter() + .map(|(path, error)| describe_error(path, error)) + .collect(); + + // The report holds only paths and messages, never the submitted value, + // so logging it in full cannot leak request contents. + tracing::warn!(errors = %report, "Request validation failed"); + + let user_message = match messages.as_slice() { + [] => "Validation failed".to_string(), + [single] => single.clone(), + many => many.join(". "), + }; + + ErrorKind::BadRequest + .with_message(user_message) + .with_resource("request") + } +} + +/// Renders one report entry into a user-facing message. +/// +/// A top-level error (empty path) is surfaced as-is; a field error is prefixed +/// with its dotted path so the client can see which field failed. +fn describe_error(path: &garde::Path, error: &garde::Error) -> String { + if path.is_empty() { + error.message().to_string() + } else { + format!("Field '{}' {}", path, error.message()) + } +} + +impl OperationInput for ValidateJson +where + T: JsonSchema, +{ + fn operation_input(ctx: &mut GenContext, operation: &mut Operation) { + Json::::operation_input(ctx, operation); + } + + fn inferred_early_responses( + ctx: &mut GenContext, + operation: &mut Operation, + ) -> Vec<(Option, Response)> { + Json::::inferred_early_responses(ctx, operation) + } +} + +#[cfg(test)] +mod tests { + use garde::Validate; + + use super::describe_error; + + #[derive(Validate)] + struct Sample { + #[garde(length(chars, min = 2, max = 4))] + name: String, + #[garde(email)] + email: String, + #[garde(dive)] + nested: Vec, + } + + #[derive(Validate)] + struct Inner { + #[garde(range(min = 1, max = 10))] + count: u32, + } + + /// Collects the report of a failed validation as `path -> message` pairs. + fn report_of(sample: &Sample) -> Vec<(String, String)> { + let report = sample.validate().expect_err("sample should be invalid"); + report + .iter() + .map(|(path, error)| (path.to_string(), error.message().to_string())) + .collect() + } + + #[test] + fn a_field_error_is_prefixed_with_its_path() { + let out = describe_error( + &garde::Path::new("name"), + &garde::Error::new("length is lower than 2"), + ); + assert_eq!(out, "Field 'name' length is lower than 2"); + } + + #[test] + fn a_top_level_error_has_no_prefix() { + let out = describe_error(&garde::Path::empty(), &garde::Error::new("is invalid")); + assert_eq!(out, "is invalid"); + } + + #[test] + fn length_counts_characters_not_bytes() { + // Four multi-byte characters (12 bytes) must pass a max=4 *character* + // bound; a byte-counting rule would wrongly reject this. + let sample = Sample { + name: "\u{00e9}\u{00e9}\u{00e9}\u{00e9}".to_string(), + email: "user@example.com".to_string(), + nested: vec![Inner { count: 5 }], + }; + assert!(sample.validate().is_ok()); + } + + #[test] + fn nested_dive_errors_are_reported_under_an_indexed_path() { + let sample = Sample { + name: "ok".to_string(), + email: "user@example.com".to_string(), + nested: vec![Inner { count: 0 }], + }; + let report = report_of(&sample); + assert!( + report.iter().any(|(path, _)| path == "nested[0].count"), + "expected an indexed nested path, got {report:?}" + ); + } + + #[test] + fn an_invalid_email_is_reported_on_its_field() { + let sample = Sample { + name: "ok".to_string(), + email: "not-an-email".to_string(), + nested: vec![Inner { count: 5 }], + }; + let report = report_of(&sample); + assert!(report.iter().any(|(path, _)| path == "email")); + } +} diff --git a/crates/nvisy-server/src/extract/valid/validators.rs b/crates/nvisy-server/src/extract/valid/validators.rs new file mode 100644 index 00000000..5fa6c1bf --- /dev/null +++ b/crates/nvisy-server/src/extract/valid/validators.rs @@ -0,0 +1,39 @@ +//! Shared `garde` custom validators for request DTOs. +//! +//! These are referenced from field attributes as +//! `#[garde(custom(validate_non_blank))]`. A garde custom validator has the +//! signature `fn(&T, &Context) -> garde::Result`, where `T` is the field type +//! and the context is `()` for our DTOs. The returned [`garde::Error`] carries +//! only a message — never the submitted value — so a failure cannot leak +//! request contents. + +/// Rejects a value that is empty once trimmed, matching the database's +/// non-empty-trimmed constraint on display names. +pub fn validate_non_blank(value: &str, _: &()) -> garde::Result { + if value.trim().is_empty() { + return Err(garde::Error::new("must not be blank")); + } + Ok(()) +} + +/// Restricts a display name to letters, digits, whitespace, hyphens, and +/// apostrophes. +/// +/// Takes an `Option` because garde passes a custom validator the field value +/// as-is — it does not unwrap `Option` the way the built-in rules do — and this +/// validator is applied to optional name fields; an absent name is nothing to +/// check. +pub fn validate_display_name_format(name: &Option, _: &()) -> garde::Result { + let Some(name) = name else { + return Ok(()); + }; + if !name + .chars() + .all(|c| c.is_alphanumeric() || c.is_whitespace() || c == '-' || c == '\'') + { + return Err(garde::Error::new( + "may contain only letters, digits, spaces, hyphens, and apostrophes", + )); + } + Ok(()) +} diff --git a/crates/nvisy-server/src/handler/error/http_error.rs b/crates/nvisy-server/src/handler/error/http_error.rs index da9b2891..752d6dd8 100644 --- a/crates/nvisy-server/src/handler/error/http_error.rs +++ b/crates/nvisy-server/src/handler/error/http_error.rs @@ -11,6 +11,7 @@ use aide::generate::GenContext; use aide::openapi::Operation; use axum::http::StatusCode; use axum::response::{IntoResponse, Response}; +use strum::EnumIter; use crate::handler::response::ErrorResponse; @@ -249,7 +250,7 @@ pub type Result> = std::result::Result; /// Each variant corresponds to a specific HTTP status code and error scenario. /// The variants are organized by HTTP status code family. #[must_use = "error kinds do nothing unless used to create errors"] -#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash)] +#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash, EnumIter)] pub enum ErrorKind { // 4xx Client Errors /// 400 Bad Request - Missing required path parameter @@ -489,24 +490,18 @@ mod tests { } #[test] - fn all_error_kinds_have_responses() { - let kinds = vec![ - ErrorKind::BadRequest, - ErrorKind::Conflict, - ErrorKind::Forbidden, - ErrorKind::InternalServerError, - ErrorKind::MalformedAuthToken, - ErrorKind::MissingAuthToken, - ErrorKind::MissingPathParam, - ErrorKind::NotFound, - ErrorKind::NotImplemented, - ErrorKind::Unauthorized, - ]; - - for kind in kinds { + fn every_error_kind_has_a_client_or_server_response() { + use strum::IntoEnumIterator; + + // Iterating the variants (rather than a hand-kept list) means a newly + // added `ErrorKind` is covered here automatically. + for kind in ErrorKind::iter() { let response = kind.response(); - assert!(!response.name.is_empty()); - assert!(response.status.as_u16() >= 400); + assert!(!response.name.is_empty(), "{kind:?} has an empty name"); + assert!( + response.status.as_u16() >= 400, + "{kind:?} maps to a non-error status" + ); let _ = kind.into_response(); } } diff --git a/crates/nvisy-server/src/handler/request/accounts.rs b/crates/nvisy-server/src/handler/request/accounts.rs index 25aca50d..b9c581da 100644 --- a/crates/nvisy-server/src/handler/request/accounts.rs +++ b/crates/nvisy-server/src/handler/request/accounts.rs @@ -1,10 +1,12 @@ //! Account request types. +use garde::Validate; use nvisy_postgres::model::UpdateAccount as UpdateAccountModel; use nvisy_postgres::types::Handle; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; -use validator::{Validate, ValidationError}; + +use crate::extract::validators::validate_display_name_format; /// Request payload to update an account's profile. /// @@ -14,16 +16,15 @@ use validator::{Validate, ValidationError}; #[must_use] #[derive(Debug, Serialize, Deserialize, Validate, JsonSchema)] #[serde(rename_all = "camelCase")] +#[garde(allow_unvalidated)] pub struct UpdateAccount { /// New account handle. pub username: Option, /// New display name (2-32 characters). - #[validate(length(min = 2, max = 32))] - #[validate(custom(function = "validate_display_name_format"))] + #[garde(length(chars, min = 2, max = 32), custom(validate_display_name_format))] pub display_name: Option, /// New email address (must be valid email format). - #[validate(email)] - #[validate(length(min = 5, max = 254))] + #[garde(email, length(chars, min = 5, max = 254))] pub email_address: Option, } @@ -38,13 +39,3 @@ impl UpdateAccount { } } } - -fn validate_display_name_format(name: &str) -> Result<(), ValidationError> { - if !name - .chars() - .all(|c| c.is_alphanumeric() || c.is_whitespace() || c == '-' || c == '\'') - { - return Err(ValidationError::new("display_name_format")); - } - Ok(()) -} diff --git a/crates/nvisy-server/src/handler/request/authentications.rs b/crates/nvisy-server/src/handler/request/authentications.rs index 03100094..722aa6cd 100644 --- a/crates/nvisy-server/src/handler/request/authentications.rs +++ b/crates/nvisy-server/src/handler/request/authentications.rs @@ -1,20 +1,21 @@ //! Authentication request types. +use garde::Validate; use nvisy_postgres::types::Handle; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; -use validator::Validate; /// Request payload for login. #[must_use] #[derive(Debug, Serialize, Deserialize, Validate, JsonSchema)] #[serde(rename_all = "camelCase")] +#[garde(allow_unvalidated)] pub struct Login { /// Email address or username of the account. - #[validate(length(min = 3, max = 254))] + #[garde(length(chars, min = 3, max = 254))] pub identifier: String, /// Password of the account. - #[validate(length(min = 1, max = 1000))] + #[garde(length(chars, min = 1, max = 1000))] pub password: String, /// Whether to remember this device for extended session. Defaults to false. #[serde(default)] @@ -25,21 +26,21 @@ pub struct Login { #[must_use] #[derive(Debug, Serialize, Deserialize, Validate, JsonSchema)] #[serde(rename_all = "camelCase")] +#[garde(allow_unvalidated)] pub struct Signup { /// Public account handle, unique across all accounts. pub username: Handle, /// Optional display name of the account. - #[validate(length(min = 2, max = 32))] + #[garde(length(chars, min = 2, max = 32))] pub display_name: Option, /// Email address of the account. - #[validate(email)] - #[validate(length(min = 5, max = 254))] + #[garde(email, length(chars, min = 5, max = 254))] pub email_address: String, /// Password of the account. - #[validate(length(min = 8, max = 128))] + #[garde(length(chars, min = 8, max = 128))] pub password: String, /// Whether to remember the device for extended session. Defaults to false. @@ -53,10 +54,10 @@ pub struct Signup { #[must_use] #[derive(Debug, Serialize, Deserialize, Validate, JsonSchema)] #[serde(rename_all = "camelCase")] +#[garde(allow_unvalidated)] pub struct RequestPasswordReset { /// Email address of the account to reset password for. - #[validate(email)] - #[validate(length(min = 5, max = 254))] + #[garde(email, length(chars, min = 5, max = 254))] pub email_address: String, } @@ -64,13 +65,14 @@ pub struct RequestPasswordReset { #[must_use] #[derive(Debug, Serialize, Deserialize, Validate, JsonSchema)] #[serde(rename_all = "camelCase")] +#[garde(allow_unvalidated)] pub struct ConfirmPasswordReset { /// Password reset token. - #[validate(length(min = 10, max = 200))] + #[garde(length(chars, min = 10, max = 200))] pub token: String, /// New password. - #[validate(length(min = 8, max = 128))] + #[garde(length(chars, min = 8, max = 128))] pub new_password: String, } @@ -84,10 +86,11 @@ pub struct ConfirmPasswordReset { #[must_use] #[derive(Debug, Serialize, Deserialize, Validate, JsonSchema)] #[serde(rename_all = "camelCase")] +#[garde(allow_unvalidated)] pub struct DesktopTokenRequest { /// The desktop deep-link the app will receive the token on. Must match a /// configured desktop redirect scheme. - #[validate(length(min = 1, max = 2048))] + #[garde(length(chars, min = 1, max = 2048))] pub redirect_uri: String, } diff --git a/crates/nvisy-server/src/handler/request/chat.rs b/crates/nvisy-server/src/handler/request/chat.rs index 5a0a093b..adf1d476 100644 --- a/crates/nvisy-server/src/handler/request/chat.rs +++ b/crates/nvisy-server/src/handler/request/chat.rs @@ -1,9 +1,9 @@ //! Assistant chat request types. +use garde::Validate; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use uuid::Uuid; -use validator::Validate; /// Path parameters for a chat session. #[must_use] @@ -17,18 +17,20 @@ pub struct ChatSessionPathParams { /// Request to create a chat session. #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, Validate)] #[serde(rename_all = "camelCase")] +#[garde(allow_unvalidated)] pub struct CreateChatSession { /// Optional title. Defaults to a title seeded from the first message. - #[validate(length(min = 1, max = 255))] + #[garde(length(chars, min = 1, max = 255))] pub title: Option, } /// Request to send a message and stream the assistant's reply. #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, Validate)] #[serde(rename_all = "camelCase")] +#[garde(allow_unvalidated)] pub struct SendChatMessage { /// The user's message. - #[validate(length(min = 1, max = 65536))] + #[garde(length(chars, min = 1, max = 65536))] pub content: String, /// The message this turn replies to (the branch being extended). Omit to /// continue from the session's current leaf; use an earlier message's id to diff --git a/crates/nvisy-server/src/handler/request/connection_syncs.rs b/crates/nvisy-server/src/handler/request/connection_syncs.rs index 05546c0e..7c59947a 100644 --- a/crates/nvisy-server/src/handler/request/connection_syncs.rs +++ b/crates/nvisy-server/src/handler/request/connection_syncs.rs @@ -1,10 +1,10 @@ //! Connection sync request types. +use garde::Validate; use nvisy_postgres::types::{ConnectionId, SyncStatus}; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use uuid::Uuid; -use validator::Validate; /// Query parameters for listing all syncs across a workspace. #[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema)] @@ -33,12 +33,13 @@ pub struct ConnectionSyncPathParams { /// One file the user selected in the provider's picker. #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, Validate)] #[serde(rename_all = "camelCase")] +#[garde(allow_unvalidated)] pub struct PickedFile { /// The provider's file identifier (used to fetch the bytes). - #[validate(length(min = 1, max = 1024))] + #[garde(length(min = 1, max = 1024, chars))] pub id: String, /// The file's display name, as the picker reported it. - #[validate(length(min = 1, max = 1024))] + #[garde(length(min = 1, max = 1024, chars))] pub name: String, } @@ -46,9 +47,10 @@ pub struct PickedFile { /// connection (the provider picker returns id + name per file). #[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema, Validate)] #[serde(rename_all = "camelCase")] +#[garde(allow_unvalidated)] pub struct ImportFiles { /// The files to import. Already-imported files are skipped. - #[validate(length(min = 1, max = 500), nested)] + #[garde(length(min = 1, max = 500), dive)] pub files: Vec, } @@ -57,9 +59,10 @@ pub struct ImportFiles { /// source. Mirrors [`ImportFiles`] on the export side. #[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema, Validate)] #[serde(rename_all = "camelCase")] +#[garde(allow_unvalidated)] pub struct ExportFiles { /// The workspace files to export, by id. Files already exported to the /// connection are exported again (a fresh copy). - #[validate(length(min = 1, max = 500))] + #[garde(length(min = 1, max = 500))] pub file_ids: Vec, } diff --git a/crates/nvisy-server/src/handler/request/connections.rs b/crates/nvisy-server/src/handler/request/connections.rs index 8e93795d..40d27747 100644 --- a/crates/nvisy-server/src/handler/request/connections.rs +++ b/crates/nvisy-server/src/handler/request/connections.rs @@ -1,22 +1,14 @@ //! Connection request types. +use garde::Validate; use nvisy_file_service::provider::FileServiceProvider; use nvisy_postgres::types::{ConnectionId, SyncDeletionPolicy, SyncMode}; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; -use validator::{Validate, ValidationError}; +use crate::extract::validators::validate_non_blank; use crate::service::ConnectionConfig; -/// Rejects a value that is empty once trimmed, matching the database's -/// non-empty-trimmed constraint on connection display names. -fn validate_non_blank(value: &str) -> Result<(), ValidationError> { - if value.trim().is_empty() { - return Err(ValidationError::new("blank")); - } - Ok(()) -} - /// Path parameters for connection operations. /// /// The workspace is resolved separately from the `{workspaceSlug}` segment by @@ -40,11 +32,12 @@ pub struct ConnectionPathParams { #[must_use] #[derive(Debug, Default, Serialize, Deserialize, JsonSchema, Validate)] #[serde(rename_all = "camelCase")] +#[garde(allow_unvalidated)] pub struct PickerTokenRequest { /// The resource the picker asked for (its `authenticate` command's /// `resource`), e.g. `https://contoso-my.sharepoint.com`. Optional; when /// absent the server uses the connection's default picker resource. - #[validate(length(min = 1, max = 2048))] + #[garde(length(chars, min = 1, max = 2048))] pub resource: Option, } @@ -55,12 +48,13 @@ pub struct PickerTokenRequest { /// export), and an LLM does not transfer at all. Omit for on-demand only. #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, Validate)] #[serde(rename_all = "camelCase")] +#[garde(allow_unvalidated)] pub struct SyncScheduleInput { /// Whether the connection imports data in or exports data out. Required: the /// caller states the direction explicitly rather than defaulting to one. pub sync_mode: SyncMode, /// Cron expression for scheduled imports; omit for manual-only. - #[validate(length(min = 9, max = 100))] + #[garde(length(chars, min = 9, max = 100))] pub schedule_cron: Option, /// How an import reconciles files whose source object was deleted. #[serde(default)] @@ -70,9 +64,10 @@ pub struct SyncScheduleInput { /// Request payload for creating a new workspace connection. #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, Validate)] #[serde(rename_all = "camelCase")] +#[garde(allow_unvalidated)] pub struct CreateConnection { /// Human-readable connection display name. - #[validate(length(min = 1, max = 255))] + #[garde(length(chars, min = 1, max = 255))] pub display_name: String, /// Whether the connection is enabled. Omit to default to active; set `false` /// to create it disabled. @@ -83,7 +78,7 @@ pub struct CreateConnection { pub config: ConnectionConfig, /// Scheduled-sync configuration. Accepted only for schedulable providers /// (object stores); rejected for others. Omit for on-demand only. - #[validate(nested)] + #[garde(dive)] #[serde(default, skip_serializing_if = "Option::is_none")] pub sync: Option, } @@ -102,13 +97,14 @@ pub struct OAuthStartPathParams { /// Request payload for starting a cloud file-service OAuth authorization. #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, Validate)] #[serde(rename_all = "camelCase")] +#[garde(allow_unvalidated)] pub struct StartFileServiceOAuth { /// Human-readable name for the connection to be created on success. - #[validate(length(min = 1, max = 255), custom(function = "validate_non_blank"))] + #[garde(length(chars, min = 1, max = 255), custom(validate_non_blank))] pub display_name: String, /// Where to scope the sync: a folder id (Drive, OneDrive, Box) or a folder /// path (Dropbox). Omit to use the account root. - #[validate(length(min = 1, max = 255))] + #[garde(length(chars, min = 1, max = 255))] #[serde(default, skip_serializing_if = "Option::is_none")] pub root: Option, } @@ -134,9 +130,10 @@ pub struct OAuthCallbackQuery { /// Request payload for updating an existing workspace connection. #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, Validate)] #[serde(rename_all = "camelCase")] +#[garde(allow_unvalidated)] pub struct UpdateConnection { /// Human-readable connection display name. - #[validate(length(min = 1, max = 255))] + #[garde(length(chars, min = 1, max = 255))] pub display_name: Option, /// Whether the connection is enabled. `false` disables it (pausing scheduled /// syncs and rejecting manual ones); omit to leave unchanged. @@ -146,7 +143,7 @@ pub struct UpdateConnection { pub config: Option, /// Scheduled-sync configuration. Accepted only for schedulable providers /// (object stores); rejected for others. Omit to leave unchanged. - #[validate(nested)] + #[garde(dive)] #[serde(default, skip_serializing_if = "Option::is_none")] pub sync: Option, } diff --git a/crates/nvisy-server/src/handler/request/detections.rs b/crates/nvisy-server/src/handler/request/detections.rs index bd18a52e..21a4d5dd 100644 --- a/crates/nvisy-server/src/handler/request/detections.rs +++ b/crates/nvisy-server/src/handler/request/detections.rs @@ -2,11 +2,11 @@ use elide_pipeline::DocumentContext; use elide_pipeline::entity::EditSet; +use garde::Validate; use nvisy_postgres::types::{DetectionFilter, DetectionStatus, PipelineTriggerType}; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use uuid::Uuid; -use validator::Validate; /// Query parameters for listing detections across a workspace. /// @@ -74,6 +74,7 @@ impl From for DetectionFilter { #[must_use] #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, Validate)] #[serde(rename_all = "camelCase")] +#[garde(allow_unvalidated)] pub struct CreateDetection { /// The file to analyze. pub file_id: Uuid, diff --git a/crates/nvisy-server/src/handler/request/files.rs b/crates/nvisy-server/src/handler/request/files.rs index d1724717..5fc8151c 100644 --- a/crates/nvisy-server/src/handler/request/files.rs +++ b/crates/nvisy-server/src/handler/request/files.rs @@ -5,12 +5,12 @@ use std::collections::{BTreeSet, HashSet}; use derive_more::{AsRef, Into}; use elide_pipeline::FormatRegistry; +use garde::Validate; use nvisy_postgres::model::UpdateWorkspaceFile as UpdateFileModel; use nvisy_postgres::types::FileFilter; use schemars::{JsonSchema, Schema, SchemaGenerator}; use serde::{Deserialize, Serialize}; use uuid::Uuid; -use validator::Validate; use crate::handler::utility::FileHash; use crate::service::{EngineService, UnknownFormatToken}; @@ -19,9 +19,10 @@ use crate::service::{EngineService, UnknownFormatToken}; #[must_use] #[derive(Debug, Default, Serialize, Deserialize, Validate, JsonSchema)] #[serde(rename_all = "camelCase")] +#[garde(allow_unvalidated)] pub struct UpdateFile { /// New display name for the file. - #[validate(length(min = 1, max = 255))] + #[garde(length(chars, min = 1, max = 255))] pub display_name: Option, /// Updated metadata. pub metadata: Option, @@ -44,10 +45,11 @@ impl UpdateFile { #[must_use] #[derive(Debug, Default, Serialize, Deserialize, Validate, JsonSchema)] #[serde(rename_all = "camelCase")] +#[garde(allow_unvalidated)] pub struct DeleteFiles { /// Ids of the files to delete. Ids that are unknown, already deleted, or in /// another workspace are skipped rather than failing the request. - #[validate(length(min = 1, max = 100))] + #[garde(length(min = 1, max = 100))] pub file_ids: Vec, } diff --git a/crates/nvisy-server/src/handler/request/identities.rs b/crates/nvisy-server/src/handler/request/identities.rs index ad375c2f..284cc2fe 100644 --- a/crates/nvisy-server/src/handler/request/identities.rs +++ b/crates/nvisy-server/src/handler/request/identities.rs @@ -1,9 +1,9 @@ //! Account identity (credential) request types. +use garde::Validate; use nvisy_postgres::types::IdentityProvider; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; -use validator::Validate; /// Path parameters for a provider-scoped identity operation: signing in with, /// re-authenticating with, linking, or unlinking a provider. Named identically to @@ -28,6 +28,7 @@ pub struct IdentityPathParams { #[must_use] #[derive(Debug, Serialize, Deserialize, Validate, JsonSchema)] #[serde(rename_all = "camelCase")] +#[garde(allow_unvalidated)] pub struct SetPassword { /// The account's current password. Required when the account already has a /// password; omitted when setting a first password on an account that has @@ -40,6 +41,6 @@ pub struct SetPassword { #[serde(default, skip_serializing_if = "Option::is_none")] pub reauth_proof: Option, /// The new password (will be hashed before storage). - #[validate(length(min = 8, max = 128))] + #[garde(length(chars, min = 8, max = 128))] pub new_password: String, } diff --git a/crates/nvisy-server/src/handler/request/invites.rs b/crates/nvisy-server/src/handler/request/invites.rs index 9e0767db..b8b1963d 100644 --- a/crates/nvisy-server/src/handler/request/invites.rs +++ b/crates/nvisy-server/src/handler/request/invites.rs @@ -1,5 +1,6 @@ //! Workspace invite request types. +use garde::Validate; use nvisy_postgres::model::NewWorkspaceInvite; use nvisy_postgres::types::{ InviteFilter, InviteSortBy, InviteSortField, SortOrder, WorkspaceRole, @@ -7,16 +8,15 @@ use nvisy_postgres::types::{ use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use uuid::Uuid; -use validator::Validate; /// Request payload for creating a new workspace invite. #[must_use] #[derive(Debug, Serialize, Deserialize, JsonSchema, Validate)] #[serde(rename_all = "camelCase")] +#[garde(allow_unvalidated)] pub struct CreateInvite { /// Email address of the person to invite. - #[validate(email)] - #[validate(length(min = 5, max = 254))] + #[garde(email, length(chars, min = 5, max = 254))] pub invitee_email: String, /// Role the invitee will have if they accept the invitation. pub invited_role: WorkspaceRole, @@ -43,6 +43,7 @@ impl CreateInvite { #[must_use] #[derive(Debug, Serialize, Deserialize, JsonSchema, Validate)] #[serde(rename_all = "camelCase")] +#[garde(allow_unvalidated)] pub struct ReplyInvite { /// Whether to accept or decline the invitation. pub accept_invite: bool, @@ -86,6 +87,7 @@ impl InviteExpiration { #[must_use] #[derive(Debug, Serialize, Deserialize, JsonSchema, Validate)] #[serde(rename_all = "camelCase")] +#[garde(allow_unvalidated)] pub struct GenerateInviteCode { /// Role to assign when someone joins via this invite code. pub invited_role: WorkspaceRole, diff --git a/crates/nvisy-server/src/handler/request/members.rs b/crates/nvisy-server/src/handler/request/members.rs index 7aefcfe0..ef611794 100644 --- a/crates/nvisy-server/src/handler/request/members.rs +++ b/crates/nvisy-server/src/handler/request/members.rs @@ -1,17 +1,18 @@ //! Workspace member request types. +use garde::Validate; use nvisy_postgres::model::UpdateWorkspaceMember; use nvisy_postgres::types::{ MemberFilter, MemberSortBy, MemberSortField, SortOrder, WorkspaceRole, }; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; -use validator::Validate; /// Request to update a member's role. #[must_use] #[derive(Debug, Serialize, Deserialize, Validate, JsonSchema)] #[serde(rename_all = "camelCase")] +#[garde(allow_unvalidated)] pub struct UpdateMember { /// New role for the member. pub role: WorkspaceRole, diff --git a/crates/nvisy-server/src/handler/request/mod.rs b/crates/nvisy-server/src/handler/request/mod.rs index d46168b9..7cada41c 100644 --- a/crates/nvisy-server/src/handler/request/mod.rs +++ b/crates/nvisy-server/src/handler/request/mod.rs @@ -18,7 +18,6 @@ mod pipelines; mod policies; mod providers; mod tokens; -mod validations; mod webhooks; mod windows; mod workspaces; @@ -41,7 +40,6 @@ pub use pipelines::*; pub use policies::*; pub use providers::*; pub use tokens::*; -pub use validations::*; pub use webhooks::*; pub use windows::*; pub use workspaces::*; diff --git a/crates/nvisy-server/src/handler/request/paginations.rs b/crates/nvisy-server/src/handler/request/paginations.rs index 43ee4ddb..9685bb32 100644 --- a/crates/nvisy-server/src/handler/request/paginations.rs +++ b/crates/nvisy-server/src/handler/request/paginations.rs @@ -3,10 +3,10 @@ //! This module re-exports pagination types from nvisy-postgres and provides //! API-specific wrappers with validation for HTTP query parameters. +use garde::Validate; use nvisy_postgres::types; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; -use validator::Validate; /// Default pagination limit. const DEFAULT_LIMIT: u32 = 20; @@ -21,14 +21,15 @@ const MAX_OFFSET: u32 = 100_000; /// For infinite scroll or API iteration, prefer [`CursorPagination`]. #[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, Validate)] #[serde(rename_all = "camelCase")] +#[garde(allow_unvalidated)] pub struct OffsetPagination { /// The number of records to skip before starting to return results. - #[validate(range(max = 100000))] + #[garde(range(max = 100000))] #[serde(skip_serializing_if = "Option::is_none")] pub offset: Option, /// The maximum number of records to return (1-100, default: 20). - #[validate(range(min = 1, max = 100))] + #[garde(range(min = 1, max = 100))] #[serde(skip_serializing_if = "Option::is_none")] pub limit: Option, } @@ -61,9 +62,10 @@ impl From for types::OffsetPagination { /// - Efficient "load more" / infinite scroll patterns #[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, Validate)] #[serde(rename_all = "camelCase")] +#[garde(allow_unvalidated)] pub struct CursorPagination { /// The maximum number of records to return (1-100, default: 20). - #[validate(range(min = 1, max = 100))] + #[garde(range(min = 1, max = 100))] #[serde(skip_serializing_if = "Option::is_none")] pub limit: Option, diff --git a/crates/nvisy-server/src/handler/request/pipelines.rs b/crates/nvisy-server/src/handler/request/pipelines.rs index 3aadac1d..2f2f54df 100644 --- a/crates/nvisy-server/src/handler/request/pipelines.rs +++ b/crates/nvisy-server/src/handler/request/pipelines.rs @@ -5,12 +5,12 @@ //! and validation. use elide_pipeline::DocumentContext; +use garde::Validate; use nvisy_postgres::model::{NewWorkspacePipeline, UpdateWorkspacePipeline as UpdatePipelineModel}; use nvisy_postgres::types::{Handle, Json, PipelineMetadata, PipelineStatus, RetentionOverride}; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use uuid::Uuid; -use validator::Validate; /// A pipeline's detection + governance intent. /// @@ -31,6 +31,7 @@ use validator::Validate; #[must_use] #[derive(Debug, Clone, Default, Serialize, Deserialize, JsonSchema, Validate)] #[serde(rename_all = "camelCase", deny_unknown_fields)] +#[garde(allow_unvalidated)] pub struct PipelineDefinition { /// Optional pipeline-wide scope (languages, jurisdictions, document labels). /// @@ -43,7 +44,7 @@ pub struct PipelineDefinition { /// Stored relationally in the `workspace_pipeline_policies` join table, not the JSON /// definition; surfaced here so the API exposes one coherent object. #[serde(default, skip_serializing_if = "Vec::is_empty")] - #[validate(length(max = 64))] + #[garde(length(max = 64))] pub policy_slugs: Vec, } @@ -83,18 +84,19 @@ impl PipelineDefinition { #[must_use] #[derive(Debug, Serialize, Deserialize, JsonSchema, Validate)] #[serde(rename_all = "camelCase")] +#[garde(allow_unvalidated)] pub struct CreatePipeline { /// Pipeline display name (2-128 characters). - #[validate(length(min = 2, max = 128))] + #[garde(length(chars, min = 2, max = 128))] pub display_name: String, /// URL slug, unique within the workspace and immutable after creation. pub slug: Handle, /// Optional description of the pipeline (max 500 characters). - #[validate(length(max = 500))] + #[garde(length(chars, max = 500))] pub description: Option, /// Optional detection + redaction configuration. Defaults to an empty /// definition that can be filled in via update. - #[validate(nested)] + #[garde(dive)] pub definition: Option, /// Optional lifecycle status. Defaults to `draft`; pass `enabled` to create a /// pipeline ready to run without a follow-up update. @@ -168,17 +170,18 @@ fn split_definition( #[must_use] #[derive(Debug, Default, Serialize, Deserialize, JsonSchema, Validate)] #[serde(rename_all = "camelCase")] +#[garde(allow_unvalidated)] pub struct UpdatePipeline { /// New display name for the pipeline (2-128 characters). - #[validate(length(min = 2, max = 128))] + #[garde(length(chars, min = 2, max = 128))] pub display_name: Option, /// New description for the pipeline (max 500 characters). - #[validate(length(max = 500))] + #[garde(length(chars, max = 500))] pub description: Option, /// New status for the pipeline. pub status: Option, /// New detection + redaction configuration (replaces the whole definition). - #[validate(nested)] + #[garde(dive)] pub definition: Option, /// Replacement per-scope data-retention override. When omitted, the /// pipeline's retention override is left unchanged. @@ -230,10 +233,11 @@ impl UpdatePipeline { #[must_use] #[derive(Debug, Default, Clone, Serialize, Deserialize, JsonSchema, Validate)] #[serde(rename_all = "camelCase")] +#[garde(allow_unvalidated)] pub struct PipelineFilter { /// Filter by pipeline status. pub status: Option, /// Search by pipeline name (trigram similarity). - #[validate(length(max = 100))] + #[garde(length(chars, max = 100))] pub search: Option, } diff --git a/crates/nvisy-server/src/handler/request/policies.rs b/crates/nvisy-server/src/handler/request/policies.rs index ed0a4e96..1ada2e32 100644 --- a/crates/nvisy-server/src/handler/request/policies.rs +++ b/crates/nvisy-server/src/handler/request/policies.rs @@ -6,11 +6,11 @@ use elide_pipeline::policy::{ CustomMatcher, LabelScope, PolicyDefinition, PolicyRule, TemplateOrigin, }; use elide_pipeline::template::PolicyTemplate; +use garde::Validate; use nvisy_postgres::types::Handle; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use uuid::Uuid; -use validator::Validate; /// Path parameters for policy operations. /// @@ -132,14 +132,15 @@ impl PolicyBody { /// overridden here. #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, Validate)] #[serde(rename_all = "camelCase")] +#[garde(allow_unvalidated)] pub struct CreatePolicy { /// Optional display name override. Defaults to the policy's own name. - #[validate(length(min = 1, max = 255))] + #[garde(length(chars, min = 1, max = 255))] pub display_name: Option, /// URL slug, unique within the workspace and immutable after creation. pub slug: Handle, /// Optional description override. Defaults to the policy's own description. - #[validate(length(max = 4096))] + #[garde(length(chars, max = 4096))] pub description: Option, /// The source of the policy body. #[serde(flatten)] @@ -153,12 +154,13 @@ pub struct CreatePolicy { /// settable here. #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, Validate)] #[serde(rename_all = "camelCase")] +#[garde(allow_unvalidated)] pub struct UpdatePolicy { /// Human-readable policy display name. - #[validate(length(min = 1, max = 255))] + #[garde(length(chars, min = 1, max = 255))] pub display_name: Option, /// Policy description. - #[validate(length(max = 4096))] + #[garde(length(chars, max = 4096))] pub description: Option>, /// New policy body (replaces the stored definition). pub definition: Option, diff --git a/crates/nvisy-server/src/handler/request/providers.rs b/crates/nvisy-server/src/handler/request/providers.rs index 9b2b045c..490a3f4f 100644 --- a/crates/nvisy-server/src/handler/request/providers.rs +++ b/crates/nvisy-server/src/handler/request/providers.rs @@ -1,9 +1,9 @@ //! Provider request types. +use garde::Validate; use nvisy_postgres::types::ProviderId; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; -use validator::Validate; use crate::service::ProviderConfig; @@ -24,9 +24,10 @@ pub struct ProviderPathParams { /// Request payload for creating a new workspace provider. #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, Validate)] #[serde(rename_all = "camelCase")] +#[garde(allow_unvalidated)] pub struct CreateProvider { /// Human-readable provider display name. - #[validate(length(min = 1, max = 255))] + #[garde(length(min = 1, max = 255, chars))] pub display_name: String, /// Whether the provider is enabled. Omit to default to active; set `false` to /// create it disabled. @@ -40,9 +41,10 @@ pub struct CreateProvider { /// Request payload for updating an existing workspace provider. #[derive(Debug, Clone, Serialize, Deserialize, JsonSchema, Validate)] #[serde(rename_all = "camelCase")] +#[garde(allow_unvalidated)] pub struct UpdateProvider { /// Human-readable provider display name. - #[validate(length(min = 1, max = 255))] + #[garde(length(min = 1, max = 255, chars))] pub display_name: Option, /// Whether the provider is enabled. Omit to leave unchanged. pub is_active: Option, diff --git a/crates/nvisy-server/src/handler/request/tokens.rs b/crates/nvisy-server/src/handler/request/tokens.rs index a7191c01..5561e42d 100644 --- a/crates/nvisy-server/src/handler/request/tokens.rs +++ b/crates/nvisy-server/src/handler/request/tokens.rs @@ -5,12 +5,12 @@ use std::time::Duration; +use garde::Validate; use nvisy_postgres::model::NewAccountApiToken; use nvisy_postgres::types::ApiTokenType; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use uuid::Uuid; -use validator::Validate; use crate::handler::Result; @@ -69,9 +69,10 @@ impl TokenExpiration { /// Request to create a new API token. #[derive(Debug, Clone, Serialize, Deserialize, Validate, JsonSchema, Default)] #[serde(rename_all = "camelCase")] +#[garde(allow_unvalidated)] pub struct CreateApiToken { /// Human-readable display name for the API token (1-100 characters). - #[validate(length(min = 1, max = 100))] + #[garde(length(chars, min = 1, max = 100))] pub display_name: String, /// When the token expires. @@ -108,8 +109,9 @@ impl CreateApiToken { /// Request to update an existing API token. #[derive(Debug, Clone, Serialize, Deserialize, Validate, JsonSchema, Default)] #[serde(rename_all = "camelCase")] +#[garde(allow_unvalidated)] pub struct UpdateApiToken { /// Updated display name for the API token (1-100 characters). - #[validate(length(min = 1, max = 100))] + #[garde(length(chars, min = 1, max = 100))] pub display_name: Option, } diff --git a/crates/nvisy-server/src/handler/request/validations.rs b/crates/nvisy-server/src/handler/request/validations.rs deleted file mode 100644 index 9e67c0f5..00000000 --- a/crates/nvisy-server/src/handler/request/validations.rs +++ /dev/null @@ -1,38 +0,0 @@ -//! Request validation utilities. - -use validator::ValidationError; - -pub fn validation_error(code: &'static str, message: &str) -> ValidationError { - let mut error = ValidationError::new(code); - error.message = Some(message.to_string().into()); - error -} - -// Private helper functions for text sanitization -fn normalize_string(input: &str) -> String { - input.trim().to_lowercase() -} - -/// Trait for normalizing required/non-optional request data -pub trait Normalized { - /// Normalize a string field (trim whitespace) - fn normalized_string(&self) -> String; -} - -/// Trait for normalizing optional request data -pub trait OptionNormalized { - /// Normalize an optional string field - fn normalized_option(&self) -> Option; -} - -impl Normalized for String { - fn normalized_string(&self) -> String { - normalize_string(self) - } -} - -impl OptionNormalized for Option { - fn normalized_option(&self) -> Option { - self.as_ref().map(|s| normalize_string(s)) - } -} diff --git a/crates/nvisy-server/src/handler/request/webhooks.rs b/crates/nvisy-server/src/handler/request/webhooks.rs index f9b9b79b..10bdeb43 100644 --- a/crates/nvisy-server/src/handler/request/webhooks.rs +++ b/crates/nvisy-server/src/handler/request/webhooks.rs @@ -5,6 +5,7 @@ use std::collections::HashMap; +use garde::Validate; use nvisy_postgres::model::{ NewWorkspaceWebhook, UpdateWorkspaceWebhook as UpdateWorkspaceWebhookModel, }; @@ -12,7 +13,6 @@ use nvisy_postgres::types::{Json, WebhookEvent, WebhookHeaders, WebhookStatus}; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use uuid::Uuid; -use validator::Validate; use crate::handler::{ErrorKind, Result}; @@ -20,15 +20,16 @@ use crate::handler::{ErrorKind, Result}; #[must_use] #[derive(Debug, Serialize, Deserialize, JsonSchema, Validate)] #[serde(rename_all = "camelCase")] +#[garde(allow_unvalidated)] pub struct CreateWebhook { /// Human-readable name for the webhook (1-128 characters). - #[validate(length(min = 1, max = 128))] + #[garde(length(chars, min = 1, max = 128))] pub display_name: String, /// Detailed description of the webhook's purpose (max 500 characters). - #[validate(length(max = 500))] + #[garde(length(chars, max = 500))] pub description: String, /// The URL to send webhook payloads to. - #[validate(url, length(max = 2048))] + #[garde(url, length(chars, max = 2048))] pub url: String, /// List of event types this webhook should receive. pub events: Vec, @@ -95,15 +96,16 @@ fn validate_headers( #[must_use] #[derive(Debug, Default, Serialize, Deserialize, JsonSchema, Validate)] #[serde(rename_all = "camelCase")] +#[garde(allow_unvalidated)] pub struct UpdateWebhook { /// Updated human-readable name for the webhook (1-128 characters). - #[validate(length(min = 1, max = 128))] + #[garde(length(chars, min = 1, max = 128))] pub display_name: Option, /// Updated description of the webhook's purpose (max 500 characters). - #[validate(length(max = 500))] + #[garde(length(chars, max = 500))] pub description: Option, /// Updated URL to send webhook payloads to. - #[validate(url, length(max = 2048))] + #[garde(url, length(chars, max = 2048))] pub url: Option, /// Updated list of event types this webhook should receive. pub events: Option>, @@ -150,6 +152,7 @@ impl UpdateWebhook { #[must_use] #[derive(Debug, Default, Serialize, Deserialize, JsonSchema, Validate)] #[serde(rename_all = "camelCase")] +#[garde(allow_unvalidated)] pub struct TestWebhook { /// Optional custom payload to send in the test request. /// If not provided, a default test payload will be used. diff --git a/crates/nvisy-server/src/handler/request/workspaces.rs b/crates/nvisy-server/src/handler/request/workspaces.rs index 66fd7b09..73890c5c 100644 --- a/crates/nvisy-server/src/handler/request/workspaces.rs +++ b/crates/nvisy-server/src/handler/request/workspaces.rs @@ -4,6 +4,7 @@ //! creation, updates, and archival. All request types support JSON serialization //! and validation. +use garde::Validate; use nvisy_postgres::model::{ NewWorkspace, UpdateWorkspace as UpdateWorkspaceModel, UpdateWorkspaceMember, }; @@ -11,7 +12,6 @@ use nvisy_postgres::types::{Handle, Json, NotificationEvent, WorkspaceSettings}; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use uuid::Uuid; -use validator::Validate; use crate::handler::{ErrorKind, Result}; @@ -22,14 +22,15 @@ use crate::handler::{ErrorKind, Result}; #[must_use] #[derive(Debug, Default, Serialize, Deserialize, JsonSchema, Validate)] #[serde(rename_all = "camelCase")] +#[garde(allow_unvalidated)] pub struct CreateWorkspace { /// Display name of the workspace (2-32 characters). - #[validate(length(min = 2, max = 32))] + #[garde(length(min = 2, max = 32, chars))] pub display_name: String, /// Optional URL slug. Derived from the display name when omitted. pub slug: Option, /// Optional description of the workspace (max 500 characters). - #[validate(length(max = 500))] + #[garde(length(max = 500, chars))] pub description: Option, /// Workspace settings (approval requirement, data-retention rules). Defaults /// to requiring approval and keeping everything when omitted. @@ -80,12 +81,13 @@ impl CreateWorkspace { #[must_use] #[derive(Debug, Default, Serialize, Deserialize, JsonSchema, Validate)] #[serde(rename_all = "camelCase")] +#[garde(allow_unvalidated)] pub struct UpdateWorkspace { /// New display name for the workspace (2-32 characters). - #[validate(length(min = 2, max = 32))] + #[garde(length(min = 2, max = 32, chars))] pub display_name: Option, /// New description for the workspace (max 500 characters). - #[validate(length(max = 500))] + #[garde(length(max = 500, chars))] pub description: Option, /// Replacement workspace settings (approval requirement, data-retention /// rules). When omitted, settings are left unchanged. @@ -107,6 +109,7 @@ impl UpdateWorkspace { #[must_use] #[derive(Debug, Default, Serialize, Deserialize, JsonSchema, Validate)] #[serde(rename_all = "camelCase")] +#[garde(allow_unvalidated)] pub struct UpdateNotificationSettings { /// Whether to send email notifications. pub notify_via_email: Option, diff --git a/crates/nvisy-server/src/handler/response/errors.rs b/crates/nvisy-server/src/handler/response/errors.rs index f8663266..b4634a1e 100644 --- a/crates/nvisy-server/src/handler/response/errors.rs +++ b/crates/nvisy-server/src/handler/response/errors.rs @@ -1,26 +1,10 @@ use std::borrow::Cow; -use std::collections::HashMap; use axum::Json; use axum::http::StatusCode; use axum::response::{IntoResponse, Response}; use schemars::JsonSchema; use serde::Serialize; -use validator::ValidationErrors; - -/// Validation error details for field-specific errors. -#[derive(Debug, Clone, Serialize, JsonSchema)] -pub struct ValidationErrorDetail { - /// Field name that failed validation - pub field: String, - /// Error code for the validation failure - pub code: String, - /// Human-readable error message - pub message: String, - /// Additional parameters related to the validation error - #[serde(skip_serializing_if = "Option::is_none")] - pub params: Option>, -} /// HTTP error response representation with security-conscious design. /// @@ -41,9 +25,6 @@ pub struct ErrorResponse<'a> { /// Helpful suggestion for resolving the error (optional) #[serde(skip_serializing_if = "Option::is_none")] pub suggestion: Option>, - /// Validation error details for field-specific errors - #[serde(skip_serializing_if = "Option::is_none")] - pub validation: Option>, /// Error correlation ID for tracking #[serde(skip)] @@ -124,11 +105,6 @@ impl<'a> ErrorResponse<'a> { "Unsupported media type", StatusCode::UNSUPPORTED_MEDIA_TYPE, ); - pub const VALIDATION_ERROR: Self = Self::new( - "validation_error", - "Validation failed", - StatusCode::BAD_REQUEST, - ); /// Creates a new error response. #[inline] @@ -139,7 +115,6 @@ impl<'a> ErrorResponse<'a> { resource: None, context: None, suggestion: None, - validation: None, correlation_id: None, status, } @@ -184,48 +159,11 @@ impl<'a> ErrorResponse<'a> { self } - /// Adds validation errors to the error response. - pub fn with_validation_errors(mut self, errors: Vec) -> Self { - self.validation = Some(errors); - self - } - /// Adds a correlation ID to the error response. pub fn with_correlation_id(mut self, correlation_id: impl Into>) -> Self { self.correlation_id = Some(correlation_id.into()); self } - - /// Creates an error response from validator ValidationErrors. - pub fn from_validation_errors(validation_errors: ValidationErrors) -> Self { - let mut error_details = Vec::new(); - - for (field, field_errors) in validation_errors.field_errors() { - for error in field_errors { - let mut params = HashMap::new(); - for (key, value) in &error.params { - params.insert(key.to_string(), value.clone()); - } - - error_details.push(ValidationErrorDetail { - field: field.to_string(), - code: error.code.to_string(), - message: error - .message - .as_ref() - .map(|m| m.to_string()) - .unwrap_or_else(|| format!("Validation failed for field '{}'", field)), - params: if params.is_empty() { - None - } else { - Some(params) - }, - }); - } - } - - Self::VALIDATION_ERROR.with_validation_errors(error_details) - } } impl Default for ErrorResponse<'_> { @@ -235,12 +173,6 @@ impl Default for ErrorResponse<'_> { } } -impl From for ErrorResponse<'_> { - fn from(errors: ValidationErrors) -> Self { - Self::from_validation_errors(errors) - } -} - impl IntoResponse for ErrorResponse<'_> { fn into_response(self) -> Response { tracing::warn!( From 59bffec2844b428526b3cd37ab2c30b7533c412b Mon Sep 17 00:00:00 2001 From: Oleh Martsokha Date: Wed, 9 Sep 2026 15:28:12 +0200 Subject: [PATCH 03/13] Make ErrorKind the single source of truth; slim Error and ErrorResponse MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ErrorKind now owns each variant's name, status, and default message in one `response()` match (deriving EnumIter), so a variant is described in exactly one place. Delete the parallel per-kind ErrorResponse consts and the dead ones (TokenExpired, UnsupportedMediaType, GatewayTimeout); the coverage test iterates all variants instead of a hand-kept list that had silently omitted three. Drop the `suggestion` field (one caller, folded into the message at the password-strength site) and the never-set `correlation_id`. ErrorResponse becomes an inert wire/schema type: no builder methods (they duplicated Error's builder with never-exercised merge logic) — Error::into_response builds it in one shot. Error's fields are now public with the pass-through getters removed (kind() kept as a Copy convenience). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_018bKk1YEG4tZ69jzYVQvQL8 --- .../src/handler/error/http_error.rs | 215 ++++++++--------- .../src/handler/error/nats_error.rs | 30 ++- .../src/handler/response/errors.rs | 218 ++++-------------- .../src/service/integration/service.rs | 2 +- .../src/service/password/strength.rs | 16 +- 5 files changed, 170 insertions(+), 311 deletions(-) diff --git a/crates/nvisy-server/src/handler/error/http_error.rs b/crates/nvisy-server/src/handler/error/http_error.rs index 752d6dd8..365f9928 100644 --- a/crates/nvisy-server/src/handler/error/http_error.rs +++ b/crates/nvisy-server/src/handler/error/http_error.rs @@ -22,11 +22,14 @@ use crate::handler::response::ErrorResponse; #[derive(Clone)] #[must_use = "errors do nothing unless serialized"] pub struct Error<'a> { - kind: ErrorKind, - resource: Option>, - context: Option>, - message: Option>, - suggestion: Option>, + /// The error category, which determines the HTTP status and default message. + pub kind: ErrorKind, + /// The resource the error relates to, if any. + pub resource: Option>, + /// Debugging context appended to the response, if any. + pub context: Option>, + /// A custom user-facing message overriding the kind's default, if any. + pub message: Option>, } impl Error<'static> { @@ -38,7 +41,6 @@ impl Error<'static> { resource: None, context: None, message: None, - suggestion: None, } } @@ -81,45 +83,15 @@ impl<'a> Error<'a> { } } - /// Sets a suggestion for how to resolve the error. - #[inline] - pub fn with_suggestion(self, suggestion: impl Into>) -> Self { - Self { - suggestion: Some(suggestion.into()), - ..self - } - } - /// Returns the error kind. + /// + /// A convenience over the public [`kind`](Self::kind) field for the common + /// `error.kind() == ErrorKind::X` check, returning it by copy from `&self`. #[inline] pub fn kind(&self) -> ErrorKind { self.kind } - /// Returns the context if present. - #[inline] - pub fn context(&self) -> Option<&str> { - self.context.as_deref() - } - - /// Returns the custom message if present. - #[inline] - pub fn message(&self) -> Option<&str> { - self.message.as_deref() - } - - /// Returns the resource if present. - #[inline] - pub fn resource(&self) -> Option<&str> { - self.resource.as_deref() - } - - /// Returns the suggestion if present. - #[inline] - pub fn suggestion(&self) -> Option<&str> { - self.suggestion.as_deref() - } - /// Converts this error into a static version by cloning all borrowed data. pub fn into_owned(self) -> Error<'static> { Error { @@ -127,7 +99,6 @@ impl<'a> Error<'a> { context: self.context.map(|c| Cow::Owned(c.into_owned())), message: self.message.map(|m| Cow::Owned(m.into_owned())), resource: self.resource.map(|r| Cow::Owned(r.into_owned())), - suggestion: self.suggestion.map(|s| Cow::Owned(s.into_owned())), } } } @@ -140,7 +111,6 @@ impl Default for Error<'static> { context: None, message: None, resource: None, - suggestion: None, } } } @@ -169,10 +139,6 @@ impl fmt::Debug for Error<'_> { debug_struct.field("custom_resource", resource); } - if let Some(ref suggestion) = self.suggestion { - debug_struct.field("suggestion", suggestion); - } - debug_struct.finish() } } @@ -192,10 +158,6 @@ impl fmt::Display for Error<'_> { write!(f, " [resource: {}]", resource)?; } - if let Some(ref suggestion) = self.suggestion { - write!(f, " | suggestion: {}", suggestion)?; - } - Ok(()) } } @@ -204,29 +166,18 @@ impl std::error::Error for Error<'_> {} impl IntoResponse for Error<'_> { fn into_response(self) -> Response { - let mut response = self.kind.response(); - - // Set custom message if provided - if let Some(message) = self.message { - response = response.with_message(message); - } - - // Set custom resource if provided - if let Some(resource) = self.resource { - response = response.with_resource(resource); - } - - // Set context if present - if let Some(context) = self.context { - response = response.with_context(context); - } - - // Set suggestion if present - if let Some(suggestion) = self.suggestion { - response = response.with_suggestion(suggestion); + // The kind supplies the defaults (name, status, and fallback message); + // this error's own fields override the message and add the per-occurrence + // resource and context. + let defaults = self.kind.response(); + ErrorResponse { + name: defaults.name, + message: self.message.unwrap_or(defaults.message), + resource: self.resource, + context: self.context, + status: defaults.status, } - - response.into_response() + .into_response() } } @@ -315,12 +266,75 @@ impl ErrorKind { Error::new(self).with_resource(resource) } - /// Creates an [`Error`] with the specified suggestion. + /// Returns the default [`ErrorResponse`] for this kind: its machine-readable + /// name, HTTP status, and default user-facing message. /// - /// This is a convenience method for creating errors with helpful suggestions. + /// This match is the single source of truth for each variant's wire + /// metadata — a new variant is described in exactly one place — and + /// [`status_code`](Self::status_code) reads its status from here. #[inline] - pub fn with_suggestion<'a>(self, suggestion: impl Into>) -> Error<'a> { - Error::new(self).with_suggestion(suggestion) + pub const fn response(self) -> ErrorResponse<'static> { + match self { + Self::MissingPathParam => ErrorResponse::new( + "missing_path_param", + "Missing path parameter", + StatusCode::BAD_REQUEST, + ), + Self::BadRequest => ErrorResponse::new( + "bad_request", + "Invalid request data", + StatusCode::BAD_REQUEST, + ), + Self::MissingAuthToken => ErrorResponse::new( + "missing_auth_token", + "Missing auth token", + StatusCode::UNAUTHORIZED, + ), + Self::MalformedAuthToken => ErrorResponse::new( + "malformed_auth_token", + "Malformed auth token", + StatusCode::UNAUTHORIZED, + ), + Self::Unauthorized => ErrorResponse::new( + "unauthorized", + "Invalid credentials", + StatusCode::UNAUTHORIZED, + ), + Self::Forbidden => { + ErrorResponse::new("forbidden", "Resource access denied", StatusCode::FORBIDDEN) + } + Self::NotFound => { + ErrorResponse::new("not_found", "Resource not found", StatusCode::NOT_FOUND) + } + Self::Conflict => { + ErrorResponse::new("conflict", "Resource state conflict", StatusCode::CONFLICT) + } + Self::PayloadTooLarge => ErrorResponse::new( + "payload_too_large", + "Payload too large", + StatusCode::PAYLOAD_TOO_LARGE, + ), + Self::TooManyRequests => ErrorResponse::new( + "too_many_requests", + "Rate limit exceeded", + StatusCode::TOO_MANY_REQUESTS, + ), + Self::InternalServerError => ErrorResponse::new( + "internal_server_error", + "Internal server error", + StatusCode::INTERNAL_SERVER_ERROR, + ), + Self::NotImplemented => ErrorResponse::new( + "not_implemented", + "Not implemented", + StatusCode::NOT_IMPLEMENTED, + ), + Self::ServiceUnavailable => ErrorResponse::new( + "service_unavailable", + "Service unavailable", + StatusCode::SERVICE_UNAVAILABLE, + ), + } } /// Returns the HTTP status code for this error kind. @@ -328,26 +342,6 @@ impl ErrorKind { pub fn status_code(self) -> StatusCode { self.response().status } - - /// Returns the internal representation of this error kind. - #[inline] - pub fn response(self) -> ErrorResponse<'static> { - match self { - Self::MissingPathParam => ErrorResponse::MISSING_PATH_PARAM, - Self::BadRequest => ErrorResponse::BAD_REQUEST, - Self::MissingAuthToken => ErrorResponse::MISSING_AUTH_TOKEN, - Self::MalformedAuthToken => ErrorResponse::MALFORMED_AUTH_TOKEN, - Self::Unauthorized => ErrorResponse::UNAUTHORIZED, - Self::Forbidden => ErrorResponse::FORBIDDEN, - Self::NotFound => ErrorResponse::NOT_FOUND, - Self::Conflict => ErrorResponse::CONFLICT, - Self::PayloadTooLarge => ErrorResponse::PAYLOAD_TOO_LARGE, - Self::TooManyRequests => ErrorResponse::TOO_MANY_REQUESTS, - Self::InternalServerError => ErrorResponse::INTERNAL_SERVER_ERROR, - Self::NotImplemented => ErrorResponse::NOT_IMPLEMENTED, - Self::ServiceUnavailable => ErrorResponse::SERVICE_UNAVAILABLE, - } - } } impl fmt::Display for ErrorKind { @@ -404,21 +398,21 @@ mod tests { #[test] fn error_with_context() { let error = ErrorKind::BadRequest.with_context("Invalid format"); - assert_eq!(error.context(), Some("Invalid format")); + assert_eq!(error.context.as_deref(), Some("Invalid format")); let _ = error.into_response(); } #[test] fn error_with_message() { let error = ErrorKind::NotFound.with_message("Custom not found message"); - assert_eq!(error.message(), Some("Custom not found message")); + assert_eq!(error.message.as_deref(), Some("Custom not found message")); let _ = error.into_response(); } #[test] fn error_with_resource() { let error = ErrorKind::Forbidden.with_resource("document"); - assert_eq!(error.resource(), Some("document")); + assert_eq!(error.resource.as_deref(), Some("document")); let _ = error.into_response(); } @@ -427,17 +421,12 @@ mod tests { let error = ErrorKind::NotFound .with_message("Document not found") .with_resource("document") - .with_context("ID: 123") - .with_suggestion("Check if the document ID is correct"); + .with_context("ID: 123"); - assert_eq!(error.kind(), ErrorKind::NotFound); - assert_eq!(error.message(), Some("Document not found")); - assert_eq!(error.resource(), Some("document")); - assert_eq!(error.context(), Some("ID: 123")); - assert_eq!( - error.suggestion(), - Some("Check if the document ID is correct") - ); + assert_eq!(error.kind, ErrorKind::NotFound); + assert_eq!(error.message.as_deref(), Some("Document not found")); + assert_eq!(error.resource.as_deref(), Some("document")); + assert_eq!(error.context.as_deref(), Some("ID: 123")); } #[test] @@ -479,14 +468,12 @@ mod tests { let error = ErrorKind::NotFound .with_message("Test message".to_string()) .with_resource("test_resource".to_string()) - .with_context("Test context".to_string()) - .with_suggestion("Test suggestion".to_string()); + .with_context("Test context".to_string()); let static_error = error.into_owned(); - assert_eq!(static_error.message(), Some("Test message")); - assert_eq!(static_error.resource(), Some("test_resource")); - assert_eq!(static_error.context(), Some("Test context")); - assert_eq!(static_error.suggestion(), Some("Test suggestion")); + assert_eq!(static_error.message.as_deref(), Some("Test message")); + assert_eq!(static_error.resource.as_deref(), Some("test_resource")); + assert_eq!(static_error.context.as_deref(), Some("Test context")); } #[test] diff --git a/crates/nvisy-server/src/handler/error/nats_error.rs b/crates/nvisy-server/src/handler/error/nats_error.rs index a3d73b5a..9b9e9ec0 100644 --- a/crates/nvisy-server/src/handler/error/nats_error.rs +++ b/crates/nvisy-server/src/handler/error/nats_error.rs @@ -107,7 +107,7 @@ mod tests { let http_err: HttpError = nats_err.into(); assert_eq!(http_err.kind(), ErrorKind::InternalServerError); - assert!(http_err.message().unwrap().contains("timed out")); + assert!(http_err.message.as_deref().unwrap().contains("timed out")); } #[test] @@ -117,7 +117,13 @@ mod tests { let http_err: HttpError = nats_err.into(); assert_eq!(http_err.kind(), ErrorKind::BadRequest); - assert!(http_err.message().unwrap().contains("Invalid request")); + assert!( + http_err + .message + .as_deref() + .unwrap() + .contains("Invalid request") + ); } #[test] @@ -126,8 +132,8 @@ mod tests { let http_err: HttpError = nats_err.into(); assert_eq!(http_err.kind(), ErrorKind::NotFound); - assert_eq!(http_err.resource(), Some("missing_key")); - assert!(http_err.context().unwrap().contains("test_bucket")); + assert_eq!(http_err.resource.as_deref(), Some("missing_key")); + assert!(http_err.context.as_deref().unwrap().contains("test_bucket")); } #[test] @@ -136,8 +142,8 @@ mod tests { let http_err: HttpError = nats_err.into(); assert_eq!(http_err.kind(), ErrorKind::Conflict); - assert_eq!(http_err.resource(), Some("test_key")); - assert!(http_err.context().unwrap().contains("modified")); + assert_eq!(http_err.resource.as_deref(), Some("test_key")); + assert!(http_err.context.as_deref().unwrap().contains("modified")); } #[test] @@ -146,8 +152,8 @@ mod tests { let http_err: HttpError = nats_err.into(); assert_eq!(http_err.kind(), ErrorKind::InternalServerError); - assert_eq!(http_err.resource(), Some("test_stream")); - assert!(http_err.context().unwrap().contains("stream")); + assert_eq!(http_err.resource.as_deref(), Some("test_stream")); + assert!(http_err.context.as_deref().unwrap().contains("stream")); } #[test] @@ -156,6 +162,12 @@ mod tests { let http_err: HttpError = nats_err.into(); assert_eq!(http_err.kind(), ErrorKind::BadRequest); - assert!(http_err.context().unwrap().contains("configuration")); + assert!( + http_err + .context + .as_deref() + .unwrap() + .contains("configuration") + ); } } diff --git a/crates/nvisy-server/src/handler/response/errors.rs b/crates/nvisy-server/src/handler/response/errors.rs index b4634a1e..86b17733 100644 --- a/crates/nvisy-server/src/handler/response/errors.rs +++ b/crates/nvisy-server/src/handler/response/errors.rs @@ -6,107 +6,41 @@ use axum::response::{IntoResponse, Response}; use schemars::JsonSchema; use serde::Serialize; -/// HTTP error response representation with security-conscious design. +/// The serialized shape of an HTTP error: the inert wire/OpenAPI-schema view +/// that [`Error`](crate::handler::Error) renders to at the response boundary. /// -/// This struct contains all the information needed to serialize an error -/// response, including the error name, message, HTTP status code, resource -/// information, and user-friendly messages. +/// It carries no builder logic — [`Error`](crate::handler::Error) is the type +/// handlers construct and thread through `Result`, and it builds an +/// `ErrorResponse` directly in its `IntoResponse` impl. `context` and `status` +/// are not part of the JSON body (`context` is logged, `status` sets the HTTP +/// status line). #[must_use = "error responses do nothing unless serialized"] #[derive(Debug, Clone, Serialize, JsonSchema)] #[serde(rename_all = "camelCase")] pub struct ErrorResponse<'a> { - /// The error name/type identifier + /// The error name/type identifier. pub name: Cow<'a, str>, - /// User-friendly error message safe for client display + /// User-friendly error message safe for client display. pub message: Cow<'a, str>, - /// The resource that the error relates to (optional, set by handler) + /// The resource that the error relates to, if any. #[serde(skip_serializing_if = "Option::is_none")] pub resource: Option>, - /// Helpful suggestion for resolving the error (optional) - #[serde(skip_serializing_if = "Option::is_none")] - pub suggestion: Option>, - /// Error correlation ID for tracking - #[serde(skip)] - pub correlation_id: Option>, - /// Internal context for debugging (optional, not exposed to client) + /// Internal context for debugging; logged, never sent to the client. #[serde(skip)] pub context: Option>, - /// HTTP status code (not serialized in JSON) + /// HTTP status code; sets the response status line, not part of the body. #[serde(skip)] pub status: StatusCode, } impl<'a> ErrorResponse<'a> { - pub const BAD_REQUEST: Self = Self::new( - "bad_request", - "Invalid request data", - StatusCode::BAD_REQUEST, - ); - pub const CONFLICT: Self = - Self::new("conflict", "Resource state conflict", StatusCode::CONFLICT); - pub const FORBIDDEN: Self = - Self::new("forbidden", "Resource access denied", StatusCode::FORBIDDEN); - pub const GATEWAY_TIMEOUT: Self = Self::new( - "gateway_timeout", - "Request timed out", - StatusCode::GATEWAY_TIMEOUT, - ); - pub const INTERNAL_SERVER_ERROR: Self = Self::new( - "internal_server_error", - "Internal server error", - StatusCode::INTERNAL_SERVER_ERROR, - ); - pub const MALFORMED_AUTH_TOKEN: Self = Self::new( - "malformed_auth_token", - "Malformed auth token", - StatusCode::UNAUTHORIZED, - ); - pub const MISSING_AUTH_TOKEN: Self = Self::new( - "missing_auth_token", - "Missing auth token", - StatusCode::UNAUTHORIZED, - ); - pub const MISSING_PATH_PARAM: Self = Self::new( - "missing_path_param", - "Missing path parameter", - StatusCode::BAD_REQUEST, - ); - pub const NOT_FOUND: Self = Self::new("not_found", "Resource not found", StatusCode::NOT_FOUND); - pub const NOT_IMPLEMENTED: Self = Self::new( - "not_implemented", - "Not implemented", - StatusCode::NOT_IMPLEMENTED, - ); - pub const PAYLOAD_TOO_LARGE: Self = Self::new( - "payload_too_large", - "Payload too large", - StatusCode::PAYLOAD_TOO_LARGE, - ); - pub const SERVICE_UNAVAILABLE: Self = Self::new( - "service_unavailable", - "Service unavailable", - StatusCode::SERVICE_UNAVAILABLE, - ); - pub const TOKEN_EXPIRED: Self = - Self::new("token_expired", "Token expired", StatusCode::UNAUTHORIZED); - pub const TOO_MANY_REQUESTS: Self = Self::new( - "too_many_requests", - "Rate limit exceeded", - StatusCode::TOO_MANY_REQUESTS, - ); - pub const UNAUTHORIZED: Self = Self::new( - "unauthorized", - "Invalid credentials", - StatusCode::UNAUTHORIZED, - ); - pub const UNSUPPORTED_MEDIA_TYPE: Self = Self::new( - "unsupported_media_type", - "Unsupported media type", - StatusCode::UNSUPPORTED_MEDIA_TYPE, - ); - - /// Creates a new error response. + /// Creates a response carrying only a kind's static defaults (name, message, + /// status), with no per-occurrence resource or context. + /// + /// This is the building block for + /// [`ErrorKind::response`](crate::handler::ErrorKind::response), the single + /// source of truth for each kind's name, status, and message. #[inline] pub const fn new(name: &'a str, message: &'a str, status: StatusCode) -> Self { Self { @@ -114,62 +48,15 @@ impl<'a> ErrorResponse<'a> { message: Cow::Borrowed(message), resource: None, context: None, - suggestion: None, - correlation_id: None, status, } } - - /// Creates a new error response with custom resource. - /// If a resource already exists, it merges them with a separator. - pub fn with_resource(mut self, resource: impl Into>) -> Self { - let new_resource = resource.into(); - self.resource = Some(match self.resource { - Some(existing) => Cow::Owned(format!("{}/{}", existing, new_resource)), - None => new_resource, - }); - self - } - - /// Sets the error message, replacing the kind's default. - pub fn with_message(mut self, message: impl Into>) -> Self { - self.message = message.into(); - self - } - - /// Attaches context to the error response. - /// If context already exists, it merges them with a separator. - pub fn with_context(mut self, context: impl Into>) -> Self { - let new_context = context.into(); - self.context = Some(match self.context { - Some(existing) => Cow::Owned(format!("{}; {}", existing, new_context)), - None => new_context, - }); - self - } - - /// Attaches a suggestion to the error response. - /// If a suggestion already exists, it merges them with a separator. - pub fn with_suggestion(mut self, suggestion: impl Into>) -> Self { - let new_suggestion = suggestion.into(); - self.suggestion = Some(match self.suggestion { - Some(existing) => Cow::Owned(format!("{}; {}", existing, new_suggestion)), - None => new_suggestion, - }); - self - } - - /// Adds a correlation ID to the error response. - pub fn with_correlation_id(mut self, correlation_id: impl Into>) -> Self { - self.correlation_id = Some(correlation_id.into()); - self - } } impl Default for ErrorResponse<'_> { #[inline] fn default() -> Self { - Self::INTERNAL_SERVER_ERROR + crate::handler::ErrorKind::InternalServerError.response() } } @@ -189,66 +76,39 @@ impl IntoResponse for ErrorResponse<'_> { #[cfg(test)] mod tests { - use super::*; + use axum::http::StatusCode; - #[test] - fn error_response_merging_resource() { - let response = ErrorResponse::NOT_FOUND - .with_resource("workspace") - .with_resource("document"); - - assert_eq!(response.resource.as_deref(), Some("workspace/document")); - } + use super::ErrorResponse; + use crate::handler::ErrorKind; #[test] - fn with_message_replaces_default() { - let response = ErrorResponse::UNAUTHORIZED.with_message("Invalid credentials"); - assert_eq!(&response.message, "Invalid credentials"); - } - - #[test] - fn error_response_merging_context() { - let response = ErrorResponse::INTERNAL_SERVER_ERROR - .with_context("Database connection failed") - .with_context("Retry attempted 3 times"); - - assert_eq!( - response.context.as_deref(), - Some("Database connection failed; Retry attempted 3 times") - ); + fn a_kinds_response_carries_its_defaults() { + let response = ErrorKind::NotFound.response(); + assert_eq!(response.name, "not_found"); + assert_eq!(response.message, "Resource not found"); + assert_eq!(response.status, StatusCode::NOT_FOUND); + assert!(response.resource.is_none()); } #[test] - fn error_response_serialization() { - let response = ErrorResponse::BAD_REQUEST - .with_resource("test_resource") - .with_message("Test message") - .with_context("Test context") - .with_suggestion("Try fixing the data"); + fn only_the_public_fields_serialize() { + // `context` and `status` are `#[serde(skip)]`; the body carries just + // name, message, and (when present) resource. + let response = ErrorResponse { + name: "bad_request".into(), + message: "Test message".into(), + resource: Some("test_resource".into()), + context: Some("secret debugging detail".into()), + status: StatusCode::BAD_REQUEST, + }; let json = serde_json::to_string(&response).unwrap(); - - // Should contain all serialized fields assert!(json.contains("name")); assert!(json.contains("message")); assert!(json.contains("resource")); - assert!(json.contains("suggestion")); - // Should not contain skipped fields assert!(!json.contains("context")); + assert!(!json.contains("secret debugging detail")); assert!(!json.contains("status")); - assert!(!json.contains("correlationId")); - } - - #[test] - fn error_response_merging_suggestion() { - let response = ErrorResponse::BAD_REQUEST - .with_suggestion("Check your input") - .with_suggestion("Verify the format"); - - assert_eq!( - response.suggestion.as_deref(), - Some("Check your input; Verify the format") - ); } } diff --git a/crates/nvisy-server/src/service/integration/service.rs b/crates/nvisy-server/src/service/integration/service.rs index 0eb54d3b..f22de58f 100644 --- a/crates/nvisy-server/src/service/integration/service.rs +++ b/crates/nvisy-server/src/service/integration/service.rs @@ -301,7 +301,7 @@ impl ConnectionSyncService { WorkspaceEvent::ConnectionSyncFailed { connection_id, connection_name: connection_name.to_owned(), - error: Some(err.message().unwrap_or("Sync failed").to_owned()), + error: Some(err.message.as_deref().unwrap_or("Sync failed").to_owned()), notify: Some(origin.account_id), } } diff --git a/crates/nvisy-server/src/service/password/strength.rs b/crates/nvisy-server/src/service/password/strength.rs index ab5d85b5..7f40de1e 100644 --- a/crates/nvisy-server/src/service/password/strength.rs +++ b/crates/nvisy-server/src/service/password/strength.rs @@ -155,23 +155,23 @@ impl PasswordStrength { "password validation failed: insufficient strength" ); - let mut error = ErrorKind::BadRequest - .with_message("Password does not meet minimum strength requirements") - .with_resource("password"); + // Build a single user-facing message: the base requirement, then any + // strength-estimator suggestions appended so the client sees how to + // fix it. The warning stays in context (debug detail, not advice). + let mut message = String::from("Password does not meet minimum strength requirements"); + let mut error = ErrorKind::BadRequest.with_resource("password"); if let Some(feedback) = result.feedback { - // Add warning as context if present if let Some(warning) = feedback.warning { error = error.with_context(warning); } - - // Add suggestions as suggestion field if !feedback.suggestions.is_empty() { - error = error.with_suggestion(feedback.suggestions.join("; ")); + message.push_str(": "); + message.push_str(&feedback.suggestions.join("; ")); } } - return Err(error); + return Err(error.with_message(message)); } tracing::debug!( From 2bec755f1325ddad4afd96f412051b01ff8f0e47 Mon Sep 17 00:00:00 2001 From: Oleh Martsokha Date: Wed, 9 Sep 2026 15:28:12 +0200 Subject: [PATCH 04/13] Review extract/ root: delete two footgun extractors, wire client-IP recording Delete PgPool: it acquired a pooled connection at extraction time and pinned it for the whole handler body (pool-exhaustion footgun) for a single consumer. Its one use, Authorized, now acquires a connection scoped to just the authorize_workspace call and drops it before the handler runs. Complete client-IP recording, which never worked: SecurityContext.ip_address was always None because axum-client-ip had no source configured, and the hand-rolled AppConnectInfo (installed as connect-info but with 9 unused accessors and an unpopulated real_ip) was never joined to it. Delete connection_info.rs entirely; SecurityContext uses axum_client_ip::ClientIp directly. Add a CLIENT_IP_SOURCE config (env-driven, FromStr) defaulting to the un-spoofable ConnectInfo peer, install its .into_extension() layer in with_security, and switch the CLI servers to into_make_service_with_connect_info::(). A proxied deployment (e.g. Render) sets CLIENT_IP_SOURCE=RightmostXForwardedFor. Trim dead speculative accessors that every consumer bypassed by destructuring: IdempotencyKey::{as_deref,into_inner} (add tests), Version's four is_* predicates, WorkspaceContext::{workspace,id,into_inner}. Document CLIENT_IP_SOURCE in .env.example. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_018bKk1YEG4tZ69jzYVQvQL8 --- .env.example | 8 + crates/nvisy-cli/src/server/http_server.rs | 4 +- crates/nvisy-cli/src/server/https_server.rs | 4 +- .../src/extract/auth/authorized.rs | 34 ++- .../src/extract/connection_info.rs | 275 ------------------ .../src/extract/idempotency_key.rs | 65 ++++- crates/nvisy-server/src/extract/mod.rs | 4 - .../nvisy-server/src/extract/pg_connection.rs | 50 ---- .../src/extract/security_context.rs | 3 +- crates/nvisy-server/src/extract/version.rs | 73 ----- .../src/extract/workspace_context.rs | 23 -- crates/nvisy-server/src/middleware/args.rs | 44 ++- .../nvisy-server/src/middleware/security.rs | 8 + 13 files changed, 135 insertions(+), 460 deletions(-) delete mode 100644 crates/nvisy-server/src/extract/connection_info.rs delete mode 100644 crates/nvisy-server/src/extract/pg_connection.rs diff --git a/.env.example b/.env.example index 842782eb..6bd0fe45 100644 --- a/.env.example +++ b/.env.example @@ -35,6 +35,14 @@ CORS_ORIGINS=http://localhost:3000,http://localhost:3001,https://app.nvisy.com CORS_MAX_AGE=1h CORS_ALLOW_CREDENTIALS=true +# Client IP source: where the caller's IP is read from for security/audit +# records. Defaults to `ConnectInfo` (the TCP peer), which cannot be spoofed and +# is correct for a directly exposed server. Behind a proxy that sets a +# forwarding header, set the matching source or the recorded IP is the proxy's: +# RightmostXForwardedFor — proxy appends to X-Forwarded-For (e.g. Render) +# XRealIp | CfConnectingIp | TrueClientIp | FlyClientIp — provider-specific +CLIENT_IP_SOURCE=ConnectInfo + # OpenAPI OPENAPI_JSON_PATH=/api/openapi.json OPENAPI_SCALAR_PATH=/api/scalar diff --git a/crates/nvisy-cli/src/server/http_server.rs b/crates/nvisy-cli/src/server/http_server.rs index e60b0695..f5076837 100644 --- a/crates/nvisy-cli/src/server/http_server.rs +++ b/crates/nvisy-cli/src/server/http_server.rs @@ -1,10 +1,10 @@ //! HTTP server implementation using enhanced lifecycle management. use std::io; +use std::net::SocketAddr; use std::time::Duration; use axum::Router; -use nvisy_server::extract::AppConnectInfo; use tokio::net::TcpListener; use tokio_util::sync::CancellationToken; @@ -46,7 +46,7 @@ pub async fn serve_http( } }; - let app = app.into_make_service_with_connect_info::(); + let app = app.into_make_service_with_connect_info::(); let serve = axum::serve(listener, app).with_graceful_shutdown(graceful); // Bound the graceful drain: if a connection has not closed within the diff --git a/crates/nvisy-cli/src/server/https_server.rs b/crates/nvisy-cli/src/server/https_server.rs index 76f90d34..ad9cfeb9 100644 --- a/crates/nvisy-cli/src/server/https_server.rs +++ b/crates/nvisy-cli/src/server/https_server.rs @@ -1,11 +1,11 @@ //! HTTPS server implementation using enhanced lifecycle management. use std::io; +use std::net::SocketAddr; use std::path::Path; use axum::Router; use axum_server::tls_rustls::RustlsConfig; -use nvisy_server::extract::AppConnectInfo; use tokio_util::sync::CancellationToken; use super::TRACING_TARGET_STARTUP; @@ -68,7 +68,7 @@ pub async fn serve_https( axum_server::bind_rustls(server_addr, tls_config) .handle(handle) - .serve(app.into_make_service_with_connect_info::()) + .serve(app.into_make_service_with_connect_info::()) .await }) .await diff --git a/crates/nvisy-server/src/extract/auth/authorized.rs b/crates/nvisy-server/src/extract/auth/authorized.rs index ce8e8515..f6975ed4 100644 --- a/crates/nvisy-server/src/extract/auth/authorized.rs +++ b/crates/nvisy-server/src/extract/auth/authorized.rs @@ -13,14 +13,15 @@ use std::marker::PhantomData; use aide::OperationInput; use aide::generate::GenContext; use aide::openapi::{Operation, Response}; -use axum::extract::FromRequestParts; +use axum::extract::{FromRef, FromRequestParts}; use axum::http::request::Parts; +use nvisy_postgres::PgClient; use nvisy_postgres::model::{Workspace, WorkspaceMember}; use uuid::Uuid; use super::{AuthState, Permission}; -use crate::extract::{PgPool, WorkspaceContext}; -use crate::handler::Error; +use crate::extract::WorkspaceContext; +use crate::handler::{Error, ErrorKind}; /// A workspace permission required by a handler, expressed as a marker type so it /// can parameterize [`Authorized`]. Implemented for one zero-sized type per @@ -53,25 +54,36 @@ impl FromRequestParts for Authorized

where P: RequiredPermission, S: Sync + Send + 'static, + PgClient: FromRef, AuthState: FromRequestParts>, WorkspaceContext: FromRequestParts>, - PgPool: FromRequestParts>, { type Rejection = Error<'static>; async fn from_request_parts(parts: &mut Parts, state: &S) -> Result { - // Each collaborator resolves independently: AuthState verifies the token - // (cached in extensions), WorkspaceContext resolves the slug, PgPool hands - // us a pooled connection for the membership/role check. + // AuthState verifies the token (cached in extensions) and WorkspaceContext + // resolves the slug. The membership/role check needs a connection only for + // its own duration, so acquire one here and drop it before returning — + // never hand a pooled connection to the handler body, which would pin it + // for the request's whole lifetime and exhaust the pool under load. let auth = AuthState::from_request_parts(parts, state).await?; let WorkspaceContext(workspace) = WorkspaceContext::from_request_parts(parts, state).await?; - let PgPool(mut conn) = PgPool::from_request_parts(parts, state).await?; let account_id = auth.account_id; - let member = auth - .authorize_workspace(&mut conn, workspace.id, P::PERMISSION) - .await?; + let member = { + let mut conn = PgClient::from_ref(state) + .get_connection() + .await + .map_err(|e| { + tracing::error!(error = %e, "Failed to acquire database connection"); + ErrorKind::InternalServerError + .with_message("Database connection unavailable") + .with_context(e.to_string()) + })?; + auth.authorize_workspace(&mut conn, workspace.id, P::PERMISSION) + .await? + }; Ok(Self { account_id, diff --git a/crates/nvisy-server/src/extract/connection_info.rs b/crates/nvisy-server/src/extract/connection_info.rs deleted file mode 100644 index 24ff55fc..00000000 --- a/crates/nvisy-server/src/extract/connection_info.rs +++ /dev/null @@ -1,275 +0,0 @@ -//! Connection information extractor for HTTP requests. -//! -//! This module provides the [`AppConnectInfo`] extractor for obtaining detailed -//! information about client connections in Axum handlers. It captures network -//! addresses, connection timing, and provides utilities for IP classification -//! and security analysis. - -use std::net::{IpAddr, SocketAddr}; -use std::ops::Deref; -use std::time::{Duration, SystemTime}; - -use axum::extract::FromRequestParts; -use axum::extract::connect_info::Connected; -use axum::http::request::Parts; -use axum::serve::IncomingStream; -use tokio::net::TcpListener; - -/// Client IP address extractor with OpenAPI support. -/// -/// This is a wrapper around [`axum_client_ip::ClientIp`] that adds -/// [`aide::OperationInput`] implementation for OpenAPI schema generation. -/// It extracts the client's IP address from the request, handling proxy -/// headers like `X-Forwarded-For` when configured. -#[derive(Debug, Clone, Copy)] -pub struct ClientIp(pub IpAddr); - -impl Deref for ClientIp { - type Target = IpAddr; - - fn deref(&self) -> &Self::Target { - &self.0 - } -} - -impl FromRequestParts for ClientIp -where - S: Send + Sync, -{ - type Rejection = >::Rejection; - - async fn from_request_parts(parts: &mut Parts, state: &S) -> Result { - let axum_client_ip::ClientIp(ip) = - axum_client_ip::ClientIp::from_request_parts(parts, state).await?; - Ok(Self(ip)) - } -} - -impl aide::OperationInput for ClientIp {} - -/// Enhanced connection information extractor for incoming HTTP requests. -/// -/// This extractor provides comprehensive information about client connections, -/// including network addresses, connection timing, and security metadata. -/// It can be used for logging, rate limiting, geolocation, and security analysis. -/// -/// # Features -/// -/// - Client socket address (IP + port) -/// - Connection establishment timestamp -/// - IP address classification (IPv4/IPv6, private/public) -/// - Real IP detection (handles proxy headers) -/// - Connection metadata for security analysis -/// -/// # Security Considerations -/// -/// When deployed behind a proxy or load balancer, the `addr` field will -/// contain the proxy's address, not the original client IP. For production -/// deployments, consider using middleware to extract real client IPs from -/// proxy headers (X-Forwarded-For, X-Real-IP, etc.). -#[derive(Debug, Clone)] -#[must_use] -pub struct AppConnectInfo { - /// The socket address (IP + port) of the connecting client. - /// - /// Note: When behind a proxy, this will be the proxy's address. - pub addr: SocketAddr, - - /// Timestamp when the connection was established. - /// - /// This can be used for connection duration tracking and security analysis. - pub connected_at: SystemTime, - - /// Optional real client IP address extracted from proxy headers. - /// - /// This field should be populated by middleware that processes - /// X-Forwarded-For, X-Real-IP, or similar proxy headers. - pub real_ip: Option, -} - -impl AppConnectInfo { - /// Creates a new `AppConnectInfo` with the current timestamp. - pub fn new(addr: SocketAddr) -> Self { - Self { - addr, - connected_at: SystemTime::now(), - real_ip: None, - } - } - - /// Creates a new `AppConnectInfo` with a real IP address override. - pub fn with_real_ip(addr: SocketAddr, real_ip: IpAddr) -> Self { - Self { - addr, - connected_at: SystemTime::now(), - real_ip: Some(real_ip), - } - } - - /// Returns the client's IP address. - /// - /// If a real IP was detected (from proxy headers), returns that. - /// Otherwise, returns the direct connection IP. - #[inline] - pub fn client_ip(&self) -> IpAddr { - self.real_ip.unwrap_or_else(|| self.addr.ip()) - } - - /// Returns the client's port number from the direct connection. - #[inline] - pub fn client_port(&self) -> u16 { - self.addr.port() - } - - /// Returns `true` if the client IP is a private/internal address. - /// - /// This includes loopback addresses, private IPv4 ranges (10.0.0.0/8, - /// 172.16.0.0/12, 192.168.0.0/16), and IPv6 private addresses. - #[inline] - pub fn is_private_ip(&self) -> bool { - match self.client_ip() { - IpAddr::V4(ipv4) => { - ipv4.is_private() - || ipv4.is_loopback() - || ipv4.is_link_local() - || ipv4.is_unspecified() - } - IpAddr::V6(ipv6) => { - ipv6.is_loopback() - || ipv6.is_unspecified() - || ipv6.is_unique_local() // fc00::/7 - || ipv6.is_unicast_link_local() // fe80::/10, mirroring the IPv4 link-local case - } - } - } - - /// Returns `true` if the client IP is a public/external address. - #[inline] - pub fn is_public_ip(&self) -> bool { - !self.is_private_ip() - } - - /// Returns `true` if the connection is from localhost. - #[inline] - pub fn is_localhost(&self) -> bool { - self.client_ip().is_loopback() - } - - /// Returns `true` if the client is connecting via IPv4. - #[inline] - pub fn is_ipv4(&self) -> bool { - matches!(self.client_ip(), IpAddr::V4(_)) - } - - /// Returns `true` if the client is connecting via IPv6. - #[inline] - pub fn is_ipv6(&self) -> bool { - matches!(self.client_ip(), IpAddr::V6(_)) - } - - /// Returns the duration since the connection was established. - /// - /// Returns `None` if the system clock has moved backward. - pub fn connection_duration(&self) -> Option { - SystemTime::now().duration_since(self.connected_at).ok() - } - - /// Returns a string representation suitable for logging. - /// - /// Includes both the direct address and real IP (if different). - pub fn to_log_string(&self) -> String { - match self.real_ip { - Some(real_ip) if real_ip != self.addr.ip() => { - format!("{} (via {})", real_ip, self.addr.ip()) - } - _ => self.addr.to_string(), - } - } -} - -impl Connected> for AppConnectInfo { - fn connect_info(stream: IncomingStream<'_, TcpListener>) -> Self { - let addr = SocketAddr::connect_info(stream); - Self::new(addr) - } -} - -// https://github.com/programatik29/axum-server/issues/12 -impl Connected for AppConnectInfo { - fn connect_info(addr: SocketAddr) -> Self { - Self::new(addr) - } -} - -#[cfg(test)] -mod tests { - use std::net::SocketAddr; - - use super::AppConnectInfo; - - fn info(addr: &str) -> AppConnectInfo { - AppConnectInfo::new(addr.parse::().unwrap()) - } - - #[test] - fn classifies_private_and_public_ipv4() { - for private in [ - "10.0.0.1:80", - "172.16.5.4:80", - "192.168.1.1:80", - "127.0.0.1:80", - "169.254.1.1:80", - ] { - assert!(info(private).is_private_ip(), "{private} should be private"); - assert!(!info(private).is_public_ip()); - } - for public in ["8.8.8.8:80", "1.1.1.1:443"] { - assert!(info(public).is_public_ip(), "{public} should be public"); - assert!(!info(public).is_private_ip()); - } - } - - #[test] - fn classifies_ipv6_including_link_local() { - // Loopback, unique-local (fc00::/7), and — the case the old bit-check - // missed — link-local (fe80::/10) are all private. - for private in [ - "[::1]:80", - "[fc00::1]:80", - "[fd12:3456::1]:80", - "[fe80::1]:80", - ] { - assert!(info(private).is_private_ip(), "{private} should be private"); - } - // A global-unicast address is public. - assert!(info("[2606:4700::1111]:443").is_public_ip()); - } - - #[test] - fn localhost_and_ip_family_predicates() { - assert!(info("127.0.0.1:80").is_localhost()); - assert!(info("[::1]:80").is_localhost()); - assert!(!info("8.8.8.8:80").is_localhost()); - - assert!(info("8.8.8.8:80").is_ipv4()); - assert!(!info("8.8.8.8:80").is_ipv6()); - assert!(info("[::1]:80").is_ipv6()); - } - - #[test] - fn client_ip_prefers_the_proxy_real_ip() { - let proxy: SocketAddr = "10.0.0.9:1234".parse().unwrap(); - let real = "203.0.113.7".parse().unwrap(); - let info = AppConnectInfo::with_real_ip(proxy, real); - - // The real (client) IP wins over the direct proxy address. - assert_eq!(info.client_ip(), real); - assert!( - info.is_public_ip(), - "classification follows the real client IP" - ); - assert_eq!(info.client_port(), 1234); - // The log string notes both the real IP and the proxy it came via. - assert_eq!(info.to_log_string(), "203.0.113.7 (via 10.0.0.9)"); - } -} diff --git a/crates/nvisy-server/src/extract/idempotency_key.rs b/crates/nvisy-server/src/extract/idempotency_key.rs index d92c9b1f..ea4d6738 100644 --- a/crates/nvisy-server/src/extract/idempotency_key.rs +++ b/crates/nvisy-server/src/extract/idempotency_key.rs @@ -26,22 +26,6 @@ const MAX_KEY_LENGTH: usize = 255; #[derive(Debug, Clone, Default)] pub struct IdempotencyKey(pub Option); -impl IdempotencyKey { - /// Returns the key as a string slice, if one was supplied. - #[inline] - #[must_use] - pub fn as_deref(&self) -> Option<&str> { - self.0.as_deref() - } - - /// Consumes the extractor, returning the owned optional key. - #[inline] - #[must_use] - pub fn into_inner(self) -> Option { - self.0 - } -} - impl FromRequestParts for IdempotencyKey where S: Sync, @@ -65,3 +49,52 @@ where } impl OperationInput for IdempotencyKey {} + +#[cfg(test)] +mod tests { + use axum::extract::FromRequestParts; + use axum::http::Request; + + use super::{IdempotencyKey, MAX_KEY_LENGTH}; + use crate::handler::ErrorKind; + + /// Drives the extractor against a request carrying `header` (or none). + async fn extract(header: Option<&str>) -> Result, ErrorKind> { + let mut builder = Request::builder().uri("/"); + if let Some(value) = header { + builder = builder.header("idempotency-key", value); + } + let (mut parts, ()) = builder.body(()).expect("request should build").into_parts(); + IdempotencyKey::from_request_parts(&mut parts, &()) + .await + .map(|k| k.0) + .map_err(|e| e.kind()) + } + + #[tokio::test] + async fn an_absent_header_is_none() { + assert_eq!(extract(None).await, Ok(None)); + } + + #[tokio::test] + async fn a_present_header_is_carried_through() { + assert_eq!( + extract(Some("abc-123")).await, + Ok(Some("abc-123".to_owned())) + ); + } + + #[tokio::test] + async fn an_empty_header_is_rejected() { + assert_eq!(extract(Some("")).await, Err(ErrorKind::BadRequest)); + } + + #[tokio::test] + async fn a_key_at_the_length_cap_is_accepted_but_one_over_is_rejected() { + let at_cap = "k".repeat(MAX_KEY_LENGTH); + assert_eq!(extract(Some(&at_cap)).await, Ok(Some(at_cap.clone()))); + + let over_cap = "k".repeat(MAX_KEY_LENGTH + 1); + assert_eq!(extract(Some(&over_cap)).await, Err(ErrorKind::BadRequest)); + } +} diff --git a/crates/nvisy-server/src/extract/mod.rs b/crates/nvisy-server/src/extract/mod.rs index d8e024f9..c894aa23 100644 --- a/crates/nvisy-server/src/extract/mod.rs +++ b/crates/nvisy-server/src/extract/mod.rs @@ -7,9 +7,7 @@ mod auth; mod avatar; -mod connection_info; mod idempotency_key; -mod pg_connection; mod reject; mod security_context; mod typed_header; @@ -21,9 +19,7 @@ mod workspace_context; // generated by a macro) used as `Authorized

`, alongside the named types. pub use crate::extract::auth::*; pub use crate::extract::avatar::Avatar; -pub use crate::extract::connection_info::{AppConnectInfo, ClientIp}; pub use crate::extract::idempotency_key::IdempotencyKey; -pub use crate::extract::pg_connection::PgPool; pub use crate::extract::reject::{Form, Json, Multipart, Path, Query}; pub use crate::extract::security_context::SecurityContext; pub use crate::extract::typed_header::TypedHeader; diff --git a/crates/nvisy-server/src/extract/pg_connection.rs b/crates/nvisy-server/src/extract/pg_connection.rs deleted file mode 100644 index ba1dacc5..00000000 --- a/crates/nvisy-server/src/extract/pg_connection.rs +++ /dev/null @@ -1,50 +0,0 @@ -//! PostgreSQL connection extractor for request handlers. -//! -//! This module provides the [`PgPool`] extractor that acquires a database -//! connection from the pool for use in request handlers. - -use axum::extract::{FromRef, FromRequestParts}; -use axum::http::request::Parts; -use derive_more::{Deref, DerefMut}; -use nvisy_postgres::{PgClient, PgConn}; - -use crate::handler::{Error, ErrorKind}; - -/// Extractor that provides a database connection from the pool. -/// -/// This extractor acquires a [`PgConn`] from the connection pool, which -/// implements all repository traits for database operations. -/// -/// # Example -/// -/// ```rust -/// use nvisy_server::extract::PgPool; -/// -/// async fn get_account(PgPool(conn): PgPool) { -/// // Use conn with repository traits -/// } -/// ``` -#[derive(Debug, Deref, DerefMut)] -pub struct PgPool(pub PgConn); - -impl FromRequestParts for PgPool -where - PgClient: FromRef, - S: Sync, -{ - type Rejection = Error<'static>; - - async fn from_request_parts(_parts: &mut Parts, state: &S) -> Result { - let pg_client = PgClient::from_ref(state); - let conn = pg_client.get_connection().await.map_err(|e| { - tracing::error!(error = %e, "Failed to acquire database connection"); - ErrorKind::InternalServerError - .with_message("Database connection unavailable") - .with_context(e.to_string()) - })?; - - Ok(PgPool(conn)) - } -} - -impl aide::OperationInput for PgPool {} diff --git a/crates/nvisy-server/src/extract/security_context.rs b/crates/nvisy-server/src/extract/security_context.rs index 27019079..497eabdb 100644 --- a/crates/nvisy-server/src/extract/security_context.rs +++ b/crates/nvisy-server/src/extract/security_context.rs @@ -9,12 +9,11 @@ use aide::generate::GenContext; use aide::openapi::Operation; use axum::extract::FromRequestParts; use axum::http::request::Parts; +use axum_client_ip::ClientIp; use axum_extra::TypedHeader; use axum_extra::headers::UserAgent; use ipnet::IpNet; -use crate::extract::ClientIp; - /// The caller's client IP and user agent, for stamping onto security-relevant /// records. Both are best-effort: a missing or unreadable value is `None` rather /// than a request rejection, since the context annotates an action but never diff --git a/crates/nvisy-server/src/extract/version.rs b/crates/nvisy-server/src/extract/version.rs index fb77bbf0..0af1e161 100644 --- a/crates/nvisy-server/src/extract/version.rs +++ b/crates/nvisy-server/src/extract/version.rs @@ -103,79 +103,6 @@ impl Version { } } - /// Returns `true` if this represents an unrecognized or invalid version. - /// - /// # Examples - /// - /// ```rust - /// # use nvisy_server::extract::Version; - /// assert!(Version::new("invalid").is_unrecognized()); - /// assert!(!Version::new("v1").is_unrecognized()); - /// ``` - #[inline] - #[must_use] - pub fn is_unrecognized(&self) -> bool { - matches!(self, Self::Unrecognized) - } - - /// Returns `true` if this represents the unstable version (v0). - /// - /// # Examples - /// - /// ```rust - /// # use nvisy_server::extract::Version; - /// assert!(Version::new("v0").is_unstable()); - /// assert!(!Version::new("v1").is_unstable()); - /// ``` - #[inline] - #[must_use] - pub fn is_unstable(&self) -> bool { - matches!(self, Self::Unstable) - } - - /// Returns `true` if this represents a stable version (v1, v2, etc.). - /// - /// # Examples - /// - /// ```rust - /// # use nvisy_server::extract::Version; - /// assert!(Version::new("v1").is_stable()); - /// assert!(Version::new("v2").is_stable()); - /// assert!(!Version::new("v0").is_stable()); - /// ``` - #[inline] - #[must_use] - pub fn is_stable(&self) -> bool { - matches!(self, Self::Stable(_)) - } - - /// Returns `true` if this version matches the specified version number. - /// - /// # Arguments - /// - /// * `version` - The version number to check against (0 for unstable, 1+ for stable) - /// - /// # Examples - /// - /// ```rust - /// # use nvisy_server::extract::Version; - /// let v1 = Version::new("v1"); - /// let v0 = Version::new("v0"); - /// - /// assert!(v1.is_v(1)); - /// assert!(!v1.is_v(2)); - /// assert!(v0.is_v(0)); - /// assert!(!v0.is_v(1)); - /// ``` - #[must_use] - pub fn is_v(&self, version: u16) -> bool { - match self { - Self::Unstable => version == UNSTABLE_VERSION, - Self::Stable(x) => x.get() == version, - Self::Unrecognized => false, - } - } - /// Returns the underlying version number, if available. /// /// # Returns diff --git a/crates/nvisy-server/src/extract/workspace_context.rs b/crates/nvisy-server/src/extract/workspace_context.rs index 3b392585..d2039bb8 100644 --- a/crates/nvisy-server/src/extract/workspace_context.rs +++ b/crates/nvisy-server/src/extract/workspace_context.rs @@ -33,29 +33,6 @@ use crate::handler::{Error, ErrorKind}; #[derive(Debug, Clone)] pub struct WorkspaceContext(pub Workspace); -impl WorkspaceContext { - /// Returns the resolved workspace. - #[inline] - #[must_use] - pub fn workspace(&self) -> &Workspace { - &self.0 - } - - /// Returns the resolved workspace's identifier. - #[inline] - #[must_use] - pub fn id(&self) -> uuid::Uuid { - self.0.id - } - - /// Consumes the context, returning the owned workspace. - #[inline] - #[must_use] - pub fn into_inner(self) -> Workspace { - self.0 - } -} - /// The `{workspaceSlug}` path segment. Named to match the OpenAPI parameter and /// the route definition. #[derive(Debug, Deserialize, JsonSchema)] diff --git a/crates/nvisy-server/src/middleware/args.rs b/crates/nvisy-server/src/middleware/args.rs index d59809b9..4915b3b5 100644 --- a/crates/nvisy-server/src/middleware/args.rs +++ b/crates/nvisy-server/src/middleware/args.rs @@ -14,6 +14,7 @@ use aide::axum::ApiRouter; use axum::Router; +use axum_client_ip::ClientIpSource; use crate::args::TRACING_TARGET_CONFIG; use crate::middleware::{ @@ -24,7 +25,7 @@ use crate::middleware::{ /// The HTTP-middleware configs applied to the router. /// /// The `clap::Args` derive is gated on the `cli` feature. -#[derive(Debug, Clone, Default)] +#[derive(Debug, Clone)] #[cfg_attr(feature = "cli", derive(clap::Args))] #[must_use = "config does nothing unless you use it"] pub struct MiddlewareArgs { @@ -39,6 +40,34 @@ pub struct MiddlewareArgs { /// Recovery (timeout/panic-handling) middleware configuration. #[cfg_attr(feature = "cli", clap(flatten))] pub recovery: RecoveryConfig, + + /// Where the client-IP extractor reads the caller's IP from (feeding + /// [`SecurityContext`](crate::extract::SecurityContext)). Defaults to the + /// connection peer (`ConnectInfo`), which cannot be spoofed; a deployment + /// behind a proxy that sets a forwarding header must set this to the matching + /// source (e.g. `RightmostXForwardedFor`) or the recorded IP will be the + /// proxy's. + #[cfg_attr( + feature = "cli", + arg( + long, + env = "CLIENT_IP_SOURCE", + default_value = "ConnectInfo", + value_parser = ::from_str, + ) + )] + pub client_ip_source: ClientIpSource, +} + +impl Default for MiddlewareArgs { + fn default() -> Self { + Self { + cors: CorsConfig::default(), + openapi: OpenApiConfig::default(), + recovery: RecoveryConfig::default(), + client_ip_source: ClientIpSource::ConnectInfo, + } + } } impl MiddlewareArgs { @@ -67,6 +96,12 @@ impl MiddlewareArgs { request_timeout = ?self.recovery.request_timeout, "Recovery configuration" ); + + tracing::info!( + target: TRACING_TARGET_CONFIG, + client_ip_source = %self.client_ip_source, + "Client IP source configuration" + ); } } @@ -90,7 +125,12 @@ where fn with_middleware(self, middleware: &MiddlewareArgs, upload: &UploadConfig) -> Router { self.with_open_api(&middleware.openapi) .with_metrics() - .with_security(&middleware.cors, upload, &SecurityHeadersConfig::default()) + .with_security( + &middleware.cors, + upload, + &SecurityHeadersConfig::default(), + &middleware.client_ip_source, + ) .with_observability() .with_recovery(&middleware.recovery) } diff --git a/crates/nvisy-server/src/middleware/security.rs b/crates/nvisy-server/src/middleware/security.rs index 20755cd4..ca31cd4b 100644 --- a/crates/nvisy-server/src/middleware/security.rs +++ b/crates/nvisy-server/src/middleware/security.rs @@ -11,6 +11,7 @@ use axum::Router; use axum::extract::DefaultBodyLimit; use axum::http::Method; use axum::http::header::{self, HeaderName, HeaderValue}; +use axum_client_ip::ClientIpSource; use tower_http::compression::CompressionLayer; use tower_http::cors::CorsLayer; use tower_http::limit::RequestBodyLimitLayer; @@ -33,6 +34,7 @@ pub trait RouterSecurityExt { cors: &CorsConfig, upload: &UploadConfig, headers: &SecurityHeadersConfig, + client_ip_source: &ClientIpSource, ) -> Self; /// Layers security middlewares with default configurations. @@ -52,6 +54,7 @@ where cors: &CorsConfig, upload: &UploadConfig, headers: &SecurityHeadersConfig, + client_ip_source: &ClientIpSource, ) -> Self { let cors_layer = CorsLayer::new() .allow_origin(cors.to_header_values()) @@ -75,6 +78,10 @@ where .max_age(cors.max_age); let mut router = self + // Tells the `ClientIp` extractor where to read the caller's IP from + // (the connection peer, or a named proxy header). Without this the + // extractor cannot resolve an IP and `SecurityContext` records none. + .layer(client_ip_source.clone().into_extension()) .layer(DefaultBodyLimit::max(upload.max_body_bytes)) // The router-wide hard ceiling must not sit below any per-route // default, or an ordinary request within `max_body_bytes` would be @@ -115,6 +122,7 @@ where &CorsConfig::default(), &UploadConfig::default(), &SecurityHeadersConfig::default(), + &ClientIpSource::ConnectInfo, ) } } From 08938778219f01d6a75a7f8b8d86e14f826085af Mon Sep 17 00:00:00 2001 From: Oleh Martsokha Date: Wed, 9 Sep 2026 23:07:55 +0200 Subject: [PATCH 05/13] Redesign auth flow: extractors in, IntoResponse out, one issuer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Restructure the auth flow so every operation is an extractor (inbound) or an IntoResponse type (outbound), mirroring axum's extract/response split, and collapse the scattered token-issuance into one service. Issuance: add an AuthIssuer service (the single place that turns an account into a signed JWT) with sign(), issue_web_session(), issue_app_token(). It replaces the mint_* free functions in authentication.rs and the identical claim-build-and-sign dance that tokens.rs did by hand; login/signup/OIDC/desktop and the API-token endpoint all funnel through it. Split the old AuthHeader: it did both inbound extraction and outbound signing. The signing role moves to AuthIssuer (via AuthClaims::into_string), and the extractor is renamed SessionToken (jwt_header.rs -> session_token.rs) — the verified session credential plus the transport that carried it, no keys, no header production. Its transport is now non-optional (always set on extraction). Record the client IP on every issued token. All three issuance paths hard-coded ip_address: None, so session/token rows never recorded their origin despite the client-IP feature. AuthIssuer's session methods and the API-token into_model now take SecurityContext and stamp both the IP and user agent on the row; the handlers extract SecurityContext instead of a bare UserAgent header. Add a root crate::response module (sibling of crate::extract) for outbound response-behavior types, and move WebSession/ClearedSession/CookieConfig there from handler/utility. Serializable DTOs stay in handler/response. login and signup now return the WebSession directly (it is IntoResponse) instead of building a Response by hand. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_018bKk1YEG4tZ69jzYVQvQL8 --- crates/nvisy-server/src/args.rs | 2 +- .../src/extract/auth/auth_state.rs | 12 +- .../src/extract/auth/jwt_claims.rs | 151 +------------ crates/nvisy-server/src/extract/auth/mod.rs | 4 +- .../auth/{jwt_header.rs => session_token.rs} | 162 +++----------- crates/nvisy-server/src/handler/auth_oidc.rs | 69 ++---- .../src/handler/authentication.rs | 193 +++------------- crates/nvisy-server/src/handler/mod.rs | 6 +- .../src/handler/request/tokens.rs | 16 +- crates/nvisy-server/src/handler/tokens.rs | 19 +- .../nvisy-server/src/handler/utility/mod.rs | 2 - .../src/handler/utility/session_cookies.rs | 144 ------------ crates/nvisy-server/src/lib.rs | 1 + .../nvisy-server/src/middleware/auth/csrf.rs | 10 +- crates/nvisy-server/src/response/mod.rs | 10 + crates/nvisy-server/src/response/session.rs | 211 ++++++++++++++++++ .../nvisy-server/src/service/auth_issuer.rs | 177 +++++++++++++++ crates/nvisy-server/src/service/mod.rs | 12 +- 18 files changed, 532 insertions(+), 669 deletions(-) rename crates/nvisy-server/src/extract/auth/{jwt_header.rs => session_token.rs} (60%) delete mode 100644 crates/nvisy-server/src/handler/utility/session_cookies.rs create mode 100644 crates/nvisy-server/src/response/mod.rs create mode 100644 crates/nvisy-server/src/response/session.rs create mode 100644 crates/nvisy-server/src/service/auth_issuer.rs diff --git a/crates/nvisy-server/src/args.rs b/crates/nvisy-server/src/args.rs index c7fd392c..ab742553 100644 --- a/crates/nvisy-server/src/args.rs +++ b/crates/nvisy-server/src/args.rs @@ -15,8 +15,8 @@ use nvisy_postgres::PgConfig; use nvisy_webhook::WebhookService; use crate::Result; -use crate::handler::CookieConfig; use crate::middleware::UploadConfig; +use crate::response::CookieConfig; use crate::service::{ CryptoConfig, EngineConfig, FileConnectorsConfig, HealthConfig, IntegrationConfig, OidcConfig, S3Config, ServiceState, SessionKeysConfig, diff --git a/crates/nvisy-server/src/extract/auth/auth_state.rs b/crates/nvisy-server/src/extract/auth/auth_state.rs index a41935e1..ecb276f8 100644 --- a/crates/nvisy-server/src/extract/auth/auth_state.rs +++ b/crates/nvisy-server/src/extract/auth/auth_state.rs @@ -21,7 +21,7 @@ use nvisy_postgres::{PgClient, PgConn}; use serde::Deserialize; use uuid::Uuid; -use super::{AuthClaims, AuthHeader, Permission}; +use super::{AuthClaims, Permission, SessionToken}; use crate::handler::{Error, ErrorKind, Result}; use crate::service::SessionKeys; @@ -169,7 +169,7 @@ where /// /// # Arguments /// - /// * `auth_header` - The authenticated JWT header from the request + /// * `session_token` - The authenticated JWT header from the request /// * `pg_database` - Database connection pool for verification queries /// /// # Returns @@ -189,10 +189,10 @@ where /// This method performs optimized database queries and should be called /// only once per request (caching handles subsequent uses). pub async fn from_unverified_header( - auth_header: AuthHeader, + session_token: SessionToken, pg_client: PgClient, ) -> Result { - let auth_claims = auth_header.into_auth_claims(); + let auth_claims = session_token.into_auth_claims(); tracing::debug!( target: TRACING_TARGET, @@ -435,9 +435,9 @@ where } // Extract JWT token and perform comprehensive database verification - let auth_header = AuthHeader::from_request_parts(parts, state).await?; + let session_token = SessionToken::from_request_parts(parts, state).await?; let pg_database = PgClient::from_ref(state); - let auth_state = Self::from_unverified_header(auth_header, pg_database).await?; + let auth_state = Self::from_unverified_header(session_token, pg_database).await?; // Cache the verified state for subsequent extractors in the same request parts.extensions.insert(auth_state.clone()); diff --git a/crates/nvisy-server/src/extract/auth/jwt_claims.rs b/crates/nvisy-server/src/extract/auth/jwt_claims.rs index 34682ddd..ffe2827c 100644 --- a/crates/nvisy-server/src/extract/auth/jwt_claims.rs +++ b/crates/nvisy-server/src/extract/auth/jwt_claims.rs @@ -6,9 +6,6 @@ use std::borrow::Cow; -use axum_extra::TypedHeader; -use axum_extra::headers::Authorization; -use axum_extra::headers::authorization::Bearer; use jiff::{Span, Timestamp}; use jsonwebtoken::{Algorithm, DecodingKey, EncodingKey, Header, Validation, decode, encode}; use nvisy_postgres::model::{Account, AccountApiToken}; @@ -85,8 +82,6 @@ impl AuthClaims { const JWT_AUDIENCE: &str = "nvisy:server"; /// Default JWT issuer identifier for authentication tokens. const JWT_ISSUER: &str = "nvisy"; - /// Default threshold for token expiration (5 minutes). - const SOON_THRESHOLD_MINUTES: i64 = 5; /// Creates a new JWT claims structure from account, session data and custom claims. /// @@ -143,45 +138,6 @@ impl AuthClaims { is_admin: account_model.is_admin, } } - - /// Checks if the token has expired based on current UTC time. - /// - /// # Returns - /// - /// Returns `true` if the token's expiration time has passed. - #[inline] - #[must_use] - pub fn is_expired(&self) -> bool { - self.expires_at <= Timestamp::now().as_second() - } - - /// Checks if the token will expire soon and should be refreshed. - /// - /// # Returns - /// - /// Returns `true` if the token expires within the configured threshold. - #[inline] - #[must_use] - pub fn expires_soon(&self) -> bool { - let remaining_seconds = self.expires_at - Timestamp::now().as_second(); - remaining_seconds < Self::SOON_THRESHOLD_MINUTES * 60 - } - - /// Returns the remaining lifetime of this token. - /// - /// # Returns - /// - /// The duration until expiration, or zero if already expired. - #[inline] - #[must_use] - pub fn remaining_lifetime(&self) -> Span { - let remaining_seconds = self.expires_at - Timestamp::now().as_second(); - if remaining_seconds > 0 { - Span::new().seconds(remaining_seconds) - } else { - Span::new() - } - } } impl AuthClaims @@ -217,54 +173,6 @@ where .with_resource("authentication") }) } - - /// Encodes the claims into a signed JWT token and creates an Authorization header. - /// - /// # Arguments - /// - /// * `encoding_key` - The private key for token signing - /// - /// # Returns - /// - /// Returns a typed Authorization Bearer header ready for HTTP responses. - /// - /// # Errors - /// - /// Returns errors for JWT encoding failures or invalid token format. - pub fn into_header( - self, - encoding_key: &EncodingKey, - ) -> Result>> { - let header = Header::new(Algorithm::EdDSA); - let jwt_token = encode(&header, &self, encoding_key).map_err(|e| { - tracing::error!( - target: TRACING_TARGET, - error = %e, - account_id = %self.account_id, - "Failed to encode JWT token" - ); - - ErrorKind::InternalServerError - .with_message("Authentication token generation failed") - .with_context("Unable to create session token") - .with_resource("authentication") - })?; - - let bearer_auth = Authorization::bearer(&jwt_token).map_err(|_| { - tracing::error!( - target: TRACING_TARGET, - account_id = %self.account_id, - "Generated JWT token has invalid format for Authorization header" - ); - - ErrorKind::InternalServerError - .with_message("Authentication header creation failed") - .with_context("Generated token format is invalid") - .with_resource("authentication") - })?; - - Ok(TypedHeader(bearer_auth)) - } } impl AuthClaims @@ -320,29 +228,13 @@ where })?; let claims = token_data.claims; - // Double-check expiration for security - if claims.is_expired() { - tracing::warn!( - target: TRACING_TARGET, - token_id = %claims.token_id, - account_id = %claims.account_id, - expired_at = %claims.expires_at, - "JWT token validation failed: token expired" - ); - - return Err(ErrorKind::Unauthorized - .with_message("Authentication session has expired") - .with_context("Please sign in again to continue") - .with_resource("authentication")); - } - + // `validate_exp` above makes `decode` reject an expired token, so reaching + // here means the token is within its lifetime — no manual re-check needed. tracing::debug!( target: TRACING_TARGET, token_id = %claims.token_id, account_id = %claims.account_id, is_admin = claims.is_admin, - expires_soon = claims.expires_soon(), - remaining = ?claims.remaining_lifetime(), "JWT token validation completed successfully" ); @@ -352,28 +244,12 @@ where #[cfg(test)] mod tests { - use std::borrow::Cow; - use jiff::{Span, Timestamp}; use nvisy_postgres::model::{Account, AccountApiToken}; use nvisy_postgres::types::{ApiTokenType, session}; use super::{AuthClaims, NEVER_EXPIRES_SECONDS}; - /// Builds bare claims with a chosen `expires_at`, for the time predicates. - fn claims_expiring_at(expires_at: i64) -> AuthClaims<()> { - AuthClaims { - issued_by: Cow::Borrowed("nvisy"), - audience: Cow::Borrowed("nvisy:server"), - token_id: uuid::Uuid::now_v7(), - account_id: uuid::Uuid::now_v7(), - issued_at: Timestamp::now().as_second(), - expires_at, - custom_claims: (), - is_admin: false, - } - } - #[test] fn web_exp_is_the_absolute_cap_from_issued_at() { let account = Account::test(); @@ -412,28 +288,5 @@ mod tests { // Falls back to a far-future value (~100 years), so the JWT never lapses. let lower_bound = Timestamp::now().as_second() + NEVER_EXPIRES_SECONDS - 60; assert!(claims.expires_at >= lower_bound); - assert!(!claims.is_expired()); - } - - #[test] - fn time_predicates_track_expiry() { - let now = Timestamp::now().as_second(); - - // Already past. - let expired = claims_expiring_at(now - 10); - assert!(expired.is_expired()); - assert!(expired.expires_soon()); - assert_eq!(expired.remaining_lifetime().get_seconds(), 0); - - // Comfortably in the future. - let fresh = claims_expiring_at(now + 3600); - assert!(!fresh.is_expired()); - assert!(!fresh.expires_soon()); - assert!(fresh.remaining_lifetime().get_seconds() > 0); - - // Within the 5-minute refresh threshold but not yet expired. - let soon = claims_expiring_at(now + 60); - assert!(!soon.is_expired()); - assert!(soon.expires_soon()); } } diff --git a/crates/nvisy-server/src/extract/auth/mod.rs b/crates/nvisy-server/src/extract/auth/mod.rs index 3705c643..075a04a4 100644 --- a/crates/nvisy-server/src/extract/auth/mod.rs +++ b/crates/nvisy-server/src/extract/auth/mod.rs @@ -7,16 +7,16 @@ mod auth_state; mod authorized; mod jwt_claims; -mod jwt_header; mod optional_auth; mod permission; +mod session_token; pub use self::auth_state::AuthState; pub use self::authorized::*; pub use self::jwt_claims::AuthClaims; -pub use self::jwt_header::{AuthHeader, AuthTransport}; pub use self::optional_auth::OptionalAuth; pub use self::permission::Permission; +pub use self::session_token::{AuthTransport, SessionToken}; /// Name of the `HttpOnly` cookie that carries the session JWT for browser /// clients. The same JWT reaches programmatic callers as an `Authorization: diff --git a/crates/nvisy-server/src/extract/auth/jwt_header.rs b/crates/nvisy-server/src/extract/auth/session_token.rs similarity index 60% rename from crates/nvisy-server/src/extract/auth/jwt_header.rs rename to crates/nvisy-server/src/extract/auth/session_token.rs index 3efcdfe1..52dc3ede 100644 --- a/crates/nvisy-server/src/extract/auth/jwt_header.rs +++ b/crates/nvisy-server/src/extract/auth/session_token.rs @@ -1,21 +1,21 @@ -//! JWT authentication header extraction and generation. +//! Session-token extraction. //! -//! This module provides JWT token handling for HTTP Authorization headers. -//! It supports both extracting tokens from incoming requests and generating -//! tokens for outgoing responses. +//! Provides [`SessionToken`], the extractor that reads and validates the session +//! JWT from an incoming request — a session cookie (browser) or an +//! `Authorization: Bearer` header (programmatic) — and records which transport +//! carried it. Signing outbound tokens is [`AuthIssuer`](crate::service::AuthIssuer). use std::fmt::Debug; use axum::extract::{FromRef, FromRequestParts}; use axum::http::request::Parts; -use axum::response::{IntoResponse, IntoResponseParts, Response, ResponseParts}; use axum_extra::TypedHeader; use axum_extra::extract::CookieJar; use axum_extra::headers::Authorization; use axum_extra::headers::authorization::Bearer; use axum_extra::typed_header::TypedHeaderRejectionReason; use jsonwebtoken::errors::{Error as JwtError, ErrorKind as JwtErrorKind}; -use serde::{Deserialize, Serialize}; +use serde::Deserialize; use super::AuthClaims; use crate::extract::auth::SESSION_COOKIE_NAME; @@ -37,132 +37,67 @@ pub enum AuthTransport { Bearer, } -/// JWT authentication header extractor and response generator. +/// The verified session credential together with the transport that carried it. /// -/// This type handles JWT tokens in HTTP Authorization Bearer headers. It can both -/// extract and validate tokens from incoming requests, and generate signed tokens -/// for outgoing responses. +/// Read from a request, it validates the session JWT (from the session cookie or +/// an `Authorization: Bearer` header) and records which transport delivered it — +/// the distinction CSRF protection depends on. Signing outbound tokens is not its +/// job; that is [`AuthIssuer`](crate::service::AuthIssuer). /// /// # Security /// -/// When used as an extractor, the JWT token is validated for: -/// - Signature integrity using the configured keys -/// - Token expiration -/// - Required claims (iss, aud, jti, sub, iat, exp) -/// - Issuer and audience matching +/// The JWT is validated for signature integrity, expiration, the required claims +/// (iss, aud, jti, sub, iat, exp), and issuer/audience matching. /// /// # Notes /// -/// This extractor only performs JWT validation. For full authentication -/// including database verification, use [`AuthState`] instead. +/// This extractor only performs JWT validation. For full authentication including +/// database verification, use [`AuthState`] instead. /// /// [`AuthState`]: crate::extract::AuthState #[must_use] #[derive(Debug, Clone)] -pub struct AuthHeader { +pub struct SessionToken { auth_claims: AuthClaims, - auth_secret_keys: SessionKeys, - /// The transport the token arrived on when extracted from a request. `None` - /// when the header was constructed for an outgoing response (there is no - /// inbound transport in that direction). - transport: Option, + /// The transport the token arrived on. + transport: AuthTransport, } -impl AuthHeader { - /// Creates a new authentication header with the given claims and keys, for - /// producing an outgoing token (no inbound transport). - /// - /// # Arguments - /// - /// * `claims` - The JWT claims to include in the token - /// * `keys` - The cryptographic keys for signing the token - #[inline] - pub const fn new(claims: AuthClaims, keys: SessionKeys) -> Self { - Self { - auth_claims: claims, - auth_secret_keys: keys, - transport: None, - } - } - - /// Returns a reference to the JWT claims. - #[inline] - pub const fn as_auth_claims(&self) -> &AuthClaims { - &self.auth_claims - } - - /// The transport this token arrived on, when it was extracted from a request. +impl SessionToken { + /// The transport this token arrived on. #[inline] #[must_use] - pub const fn transport(&self) -> Option { + pub const fn transport(&self) -> AuthTransport { self.transport } - /// Consumes this header and returns the JWT claims. + /// Consumes this token and returns the verified JWT claims. #[inline] pub fn into_auth_claims(self) -> AuthClaims { self.auth_claims } - - /// Returns the encoded JWT token string. - /// - /// # Errors - /// - /// Returns an error if JWT encoding fails. - pub fn into_string(&self) -> Result - where - T: Clone + Serialize, - { - let encoding_key = self.auth_secret_keys.encoding_key(); - self.auth_claims.clone().into_string(encoding_key) - } } -impl AuthHeader +impl SessionToken where T: Clone + for<'de> Deserialize<'de>, { - /// Creates an `AuthHeader` from a raw JWT string carried by `transport`. - /// - /// This validates the JWT (signature, claims, expiry) and records which - /// transport delivered it. + /// Validates a raw JWT `token` carried by `transport` (signature, claims, + /// expiry) and records the transport. /// /// # Errors /// /// Returns an error if the token is invalid, expired, or malformed. - fn from_token( - token: &str, - transport: AuthTransport, - auth_secret_keys: SessionKeys, - ) -> Result { - let decoding_key = auth_secret_keys.decoding_key(); - let auth_claims = AuthClaims::from_token(token, decoding_key)?; + fn from_token(token: &str, transport: AuthTransport, keys: &SessionKeys) -> Result { + let auth_claims = AuthClaims::from_token(token, keys.decoding_key())?; Ok(Self { auth_claims, - auth_secret_keys, - transport: Some(transport), + transport, }) } } -impl AuthHeader -where - T: Clone + Serialize, -{ - /// Converts this header into an HTTP Authorization header. - /// - /// This method signs the JWT token and creates the appropriate header. - /// - /// # Errors - /// - /// Returns an error if JWT signing fails. - fn into_header(self) -> Result>> { - let encoding_key = self.auth_secret_keys.encoding_key(); - self.auth_claims.into_header(encoding_key) - } -} - -impl FromRequestParts for AuthHeader +impl FromRequestParts for SessionToken where T: Clone + for<'de> Deserialize<'de> + Send + Sync + 'static, S: Sync + Send, @@ -171,9 +106,9 @@ where type Rejection = Error<'static>; async fn from_request_parts(parts: &mut Parts, state: &S) -> Result { - // Return cached header if available to avoid re-parsing. - if let Some(auth_header) = parts.extensions.get::() { - return Ok(auth_header.clone()); + // Return the cached token if a prior extractor already verified it. + if let Some(session_token) = parts.extensions.get::() { + return Ok(session_token.clone()); } let auth_keys = SessionKeys::from_ref(state); @@ -188,8 +123,8 @@ where .get(SESSION_COOKIE_NAME) .map(|cookie| cookie.value().to_owned()); - let auth_header = if let Some(token) = cookie_token { - Self::from_token(&token, AuthTransport::Cookie, auth_keys)? + let session_token = if let Some(token) = cookie_token { + Self::from_token(&token, AuthTransport::Cookie, &auth_keys)? } else { // No session cookie: require a Bearer header. type AuthBearerHeader = TypedHeader>; @@ -209,35 +144,12 @@ where .with_context("Unexpected error during header extraction") .with_resource("authentication"), })?; - Self::from_token(bearer.token(), AuthTransport::Bearer, auth_keys)? + Self::from_token(bearer.token(), AuthTransport::Bearer, &auth_keys)? }; // Cache for subsequent extractors in the same request. - parts.extensions.insert(auth_header.clone()); - Ok(auth_header) - } -} - -impl IntoResponseParts for AuthHeader -where - T: Clone + Serialize, -{ - type Error = Error<'static>; - - fn into_response_parts(self, res: ResponseParts) -> Result { - // .into_response_parts() for a TypedHeader is infallible - self.into_header() - .map(|h| h.into_response_parts(res).unwrap()) - } -} - -impl IntoResponse for AuthHeader -where - T: Clone + Serialize, -{ - fn into_response(self) -> Response { - // .into_response() for a TypedHeader is infallible - self.into_header().map(|h| h.into_response()).unwrap() + parts.extensions.insert(session_token.clone()); + Ok(session_token) } } diff --git a/crates/nvisy-server/src/handler/auth_oidc.rs b/crates/nvisy-server/src/handler/auth_oidc.rs index 21fbca95..04a04331 100644 --- a/crates/nvisy-server/src/handler/auth_oidc.rs +++ b/crates/nvisy-server/src/handler/auth_oidc.rs @@ -43,7 +43,6 @@ use axum::extract::State; use axum::http::StatusCode; use axum::response::{IntoResponse, Redirect, Response}; use axum::routing::get; -use axum_extra::headers::UserAgent; use nvisy_nats::NatsClient; use nvisy_nats::kv::{ OidcStateBucket as OidcStateKvBucket, OidcStateKey, ReauthProofBucket as ReauthProofKvBucket, @@ -58,15 +57,13 @@ use nvisy_postgres::{AsyncConnection, Error as PgError, PgClient, PgConn}; use serde::{Deserialize, Serialize}; use uuid::Uuid; -use super::authentication::{mint_app_token, mint_web_session}; -use crate::extract::{AuthState, Json, Path, Query, TypedHeader, ValidateJson}; +use crate::extract::{AuthState, Json, Path, Query, SecurityContext, ValidateJson}; use crate::handler::request::{DesktopTokenRequest, IdentityPathParams, OidcCallbackQuery}; use crate::handler::response::{DesktopToken, ErrorResponse}; -use crate::handler::utility::CookieConfig; use crate::handler::{ErrorKind, Result}; +use crate::response::{CookieConfig, WebSession}; use crate::service::{ - OidcAuthorization, OidcIdentity, OidcService, RedirectKind, ServiceState, SessionKeys, - UserAgentParser, + AuthIssuer, OidcAuthorization, OidcIdentity, OidcService, RedirectKind, ServiceState, }; /// Tracing target for OIDC sign-in operations. @@ -321,10 +318,9 @@ fn start_reauth_docs(op: TransformOperation) -> TransformOperation { async fn mint_desktop_token( State(pg_client): State, State(oidc): State, - State(auth_keys): State, - State(ua_parser): State, + State(issuer): State, auth_state: AuthState, - TypedHeader(user_agent): TypedHeader, + security: SecurityContext, ValidateJson(request): ValidateJson, ) -> Result<(StatusCode, Json)> { tracing::debug!(target: TRACING_TARGET, "Minting desktop app token"); @@ -357,14 +353,9 @@ async fn mint_desktop_token( let account = load_active_account(&mut conn, auth_state.account_id).await?; gate_account_status(&account)?; - let api_token = mint_app_token( - &mut conn, - auth_keys, - &ua_parser, - &account, - user_agent.to_string(), - ) - .await?; + let api_token = issuer + .issue_app_token(&mut conn, &account, security) + .await?; Ok(( StatusCode::OK, @@ -449,16 +440,13 @@ async fn oidc_callback( State(pg_client): State, State(nats): State, State(oidc): State, - State(auth_keys): State, - State(ua_parser): State, + State(issuer): State, State(cookie): State, - TypedHeader(user_agent): TypedHeader, + security: SecurityContext, Query(query): Query, ) -> Response { tracing::debug!(target: TRACING_TARGET, "Completing OIDC callback"); - let user_agent = user_agent.to_string(); - // Recover the flow first, so the caller's redirect target is known even when // the subsequent work fails — a failed sign-in still returns the browser to // the frontend with `signin=error` rather than a dead fallback page. @@ -473,11 +461,7 @@ async fn oidc_callback( }; let redirect_uri = flow.redirect_uri.clone(); - match run_flow( - &pg_client, &oidc, &nats, &auth_keys, &ua_parser, user_agent, flow, query, - ) - .await - { + match run_flow(&pg_client, &oidc, &nats, &issuer, security, flow, query).await { Ok(outcome) => { tracing::info!(target: TRACING_TARGET, kind = outcome.kind(), "OIDC callback succeeded"); outcome.into_redirect(redirect_uri.as_deref(), cookie) @@ -523,7 +507,7 @@ impl CallbackOutcome { // Web sign-in delivers the session as an HttpOnly cookie (plus its // CSRF cookie) set on the success redirect — never in the URL. let redirect = redirect_to_frontend(redirect_uri, RedirectResult::Success); - (cookie.session_jar(jwt), redirect).into_response() + (WebSession::new(jwt, cookie).into_jar(), redirect).into_response() } Self::DesktopSignedIn { jwt } => { // Desktop sign-in hands the app token back in the deep-link's URL @@ -573,14 +557,12 @@ async fn consume_flow(nats: &NatsClient, query: &OidcCallbackQuery) -> Result Result { @@ -618,28 +600,17 @@ async fn run_flow( .and_then(|uri| oidc.classify_redirect(uri)); if kind == Some(RedirectKind::DesktopScheme) { - let jwt = mint_app_token( - &mut conn, - auth_keys.clone(), - ua_parser, - &account, - user_agent, - ) - .await?; + let jwt = issuer + .issue_app_token(&mut conn, &account, security) + .await?; Ok(CallbackOutcome::DesktopSignedIn { jwt }) } else { // OIDC web sign-in delivers a remembered browser session as an // HttpOnly cookie set on the callback redirect — the token never // appears in the URL. - let jwt = mint_web_session( - &mut conn, - auth_keys.clone(), - ua_parser, - &account, - true, - user_agent, - ) - .await?; + let jwt = issuer + .issue_web_session(&mut conn, &account, true, security) + .await?; Ok(CallbackOutcome::SignedIn { jwt }) } } diff --git a/crates/nvisy-server/src/handler/authentication.rs b/crates/nvisy-server/src/handler/authentication.rs index 59f43f08..569a63fc 100644 --- a/crates/nvisy-server/src/handler/authentication.rs +++ b/crates/nvisy-server/src/handler/authentication.rs @@ -9,23 +9,20 @@ use aide::transform::TransformOperation; use axum::extract::State; use axum::http::StatusCode; use axum::response::{IntoResponse, Response}; -use axum_extra::headers::UserAgent; -use jiff::{Span, Timestamp}; -use nvisy_postgres::model::{ - Account, AccountApiToken, NewAccount, NewAccountApiToken, NewAccountIdentity, -}; +use nvisy_postgres::model::{NewAccount, NewAccountIdentity}; use nvisy_postgres::query::{ AccountApiTokenRepository, AccountIdentityRepository, AccountRepository, }; -use nvisy_postgres::types::{ApiTokenType, IdentityProvider, session}; -use nvisy_postgres::{AsyncConnection, Error as PgError, JiffTimestamp, PgClient, PgConn}; +use nvisy_postgres::types::IdentityProvider; +use nvisy_postgres::{AsyncConnection, Error as PgError, PgClient}; use super::request::{Login, Signup}; use super::response::ErrorResponse; -use crate::extract::{AuthClaims, AuthHeader, AuthState, Json, TypedHeader, ValidateJson}; -use crate::handler::utility::{CookieConfig, build_password_user_inputs}; +use crate::extract::{AuthState, Json, SecurityContext, ValidateJson}; +use crate::handler::utility::build_password_user_inputs; use crate::handler::{ErrorKind, Result}; -use crate::service::{PasswordService, ServiceState, SessionKeys, UserAgentParser}; +use crate::response::{ClearedSession, CookieConfig, WebSession}; +use crate::service::{AuthIssuer, PasswordService, ServiceState}; /// Tracing target for authentication operations. const TRACING_TARGET: &str = "nvisy_server::handler::authentication"; @@ -33,137 +30,16 @@ const TRACING_TARGET: &str = "nvisy_server::handler::authentication"; /// Tracing target for authentication cleanup operations. const TRACING_TARGET_CLEANUP: &str = "nvisy_server::handler::authentication::cleanup"; -/// Creates a new authentication header. -pub(crate) fn create_auth_header( - auth_secret_keys: SessionKeys, - account_model: &Account, - account_api_token: &AccountApiToken, -) -> Result { - let auth_claims = AuthClaims::new(account_model, account_api_token); - let auth_header = AuthHeader::new(auth_claims, auth_secret_keys); - Ok(auth_header) -} - -/// Mints a new session token of `session_type` for `account`, persists its -/// `account_api_tokens` row, and returns the signed JWT. The shared core behind -/// [`mint_web_session`] and [`mint_app_token`], so every sign-in path produces a -/// consistently-shaped token. -/// -/// The caller is responsible for gating the account's status (suspended/deleted) -/// before minting. -async fn mint_session_token( - conn: &mut PgConn, - auth_keys: SessionKeys, - ua_parser: &UserAgentParser, - account: &Account, - session_type: ApiTokenType, - is_remembered: bool, - expired_at: JiffTimestamp, - user_agent: String, -) -> Result { - let new_token = NewAccountApiToken { - account_id: account.id, - display_name: ua_parser.parse(&user_agent), - ip_address: None, - user_agent: Some(user_agent), - is_remembered: Some(is_remembered), - session_type: Some(session_type), - expired_at: Some(expired_at), - }; - let token = conn.create_account_api_token(new_token).await?; - tracing::info!( - target: TRACING_TARGET, - token_id = %token.id, - account_id = %account.id, - session_type = ?session_type, - "Minted session token", - ); - create_auth_header(auth_keys, account, &token)?.into_string() -} - -/// Mints a `web` browser session for `account` and returns its signed JWT. -/// -/// The idle bound follows `remember_me`; the session then slides forward on use -/// up to the absolute cap. Password login, signup, and OIDC sign-in all go through -/// it, so the browser session shape is identical across the three paths. The -/// caller delivers the returned JWT to the browser as a session cookie. -pub(crate) async fn mint_web_session( - conn: &mut PgConn, - auth_keys: SessionKeys, - ua_parser: &UserAgentParser, - account: &Account, - remember_me: bool, - user_agent: String, -) -> Result { - mint_session_token( - conn, - auth_keys, - ua_parser, - account, - ApiTokenType::Web, - remember_me, - session::initial_expires_at(remember_me).into(), - user_agent, - ) - .await -} - -/// Mints a native-app (desktop) session token for `account` and returns its -/// signed JWT — a long-lived `app` token (see [`session::APP_TOKEN_LIFETIME`]) -/// that does not slide and is exempt from the browser absolute cap. The desktop -/// app stores it and sends it as a Bearer credential; it is never a cookie. -pub(crate) async fn mint_app_token( - conn: &mut PgConn, - auth_keys: SessionKeys, - ua_parser: &UserAgentParser, - account: &Account, - user_agent: String, -) -> Result { - let expired_at = - Timestamp::now() + Span::new().seconds(session::APP_TOKEN_LIFETIME.as_secs() as i64); - let jwt = mint_session_token( - conn, - auth_keys, - ua_parser, - account, - ApiTokenType::App, - false, - expired_at.into(), - user_agent, - ) - .await?; - - // Cap live app tokens per account so repeated desktop logins do not accumulate - // unbounded long-lived credentials: evict the oldest beyond the limit (the - // token just minted is the newest, so it is always retained). Best-effort — a - // pruning failure must not fail an otherwise-successful sign-in, so it is - // logged, not propagated. - if let Err(error) = conn - .prune_app_tokens(account.id, session::MAX_APP_TOKENS_PER_ACCOUNT) - .await - { - tracing::warn!( - target: TRACING_TARGET, - error = %error, - account_id = %account.id, - "failed to prune old app tokens after minting", - ); - } - - Ok(jwt) -} - /// Creates a new account API token (login). #[tracing::instrument(skip_all)] async fn login( State(pg_client): State, State(password): State, - State(auth_keys): State, - State(ua_parser): State, + State(issuer): State, State(cookie): State, - TypedHeader(user_agent): TypedHeader, + security: SecurityContext, ValidateJson(request): ValidateJson, -) -> Result { +) -> Result { tracing::debug!(target: TRACING_TARGET, "Login attempt"); let mut conn = pg_client.get_connection().await?; @@ -215,17 +91,11 @@ async fn login( Some(acc) => acc, }; - let jwt = mint_web_session( - &mut conn, - auth_keys, - &ua_parser, - &account, - request.remember_me, - user_agent.to_string(), - ) - .await?; + let jwt = issuer + .issue_web_session(&mut conn, &account, request.remember_me, security) + .await?; - Ok(cookie.session_response(jwt)) + Ok(WebSession::new(jwt, cookie)) } fn login_docs(op: TransformOperation) -> TransformOperation { @@ -245,12 +115,11 @@ fn login_docs(op: TransformOperation) -> TransformOperation { async fn signup( State(pg_client): State, State(password): State, - State(auth_keys): State, - State(ua_parser): State, + State(issuer): State, State(cookie): State, - TypedHeader(user_agent): TypedHeader, + security: SecurityContext, ValidateJson(request): ValidateJson, -) -> Result { +) -> Result { tracing::debug!(target: TRACING_TARGET, "Signing up"); // Validate password strength and hash @@ -301,17 +170,11 @@ async fn signup( "Account created", ); - let jwt = mint_web_session( - &mut conn, - auth_keys, - &ua_parser, - &account, - request.remember_me, - user_agent.to_string(), - ) - .await?; + let jwt = issuer + .issue_web_session(&mut conn, &account, request.remember_me, security) + .await?; - Ok(cookie.session_response(jwt)) + Ok(WebSession::new(jwt, cookie)) } fn signup_docs(op: TransformOperation) -> TransformOperation { @@ -352,7 +215,7 @@ async fn logout( // client simply has no cookies to clear and ignores them; a cookie client is // logged out on the client side too. Revocation is authoritative server-side // via the token soft-delete below. - let cleared = cookie.clearing_response_jar(); + let cleared = ClearedSession::new(cookie).into_jar(); if !token_exists { tracing::warn!(target: TRACING_TARGET, "Logout attempted on non-existent token"); @@ -408,7 +271,7 @@ mod tests { use jiff::{Span, Timestamp}; use nvisy_postgres::model::{NewAccount, NewAccountApiToken, UpdateAccountApiToken}; use nvisy_postgres::query::{AccountApiTokenRepository, AccountRepository}; - use nvisy_postgres::types::{Handle, session}; + use nvisy_postgres::types::{ApiTokenType, Handle, session}; use nvisy_postgres::{JiffTimestamp, PgClient, PgConfig, PgConn}; use uuid::Uuid; @@ -425,17 +288,17 @@ mod tests { /// Creates the fixture: a fresh account and a not-remembered `web` session /// token. async fn create() -> anyhow::Result { - Self::create_with(super::ApiTokenType::Web, session::initial_expires_at(false)).await + Self::create_with(ApiTokenType::Web, session::initial_expires_at(false)).await } /// Like [`create`](Self::create) but for an `app` token with the given /// idle/expiry bound — a native-app session. async fn create_app(expired_at: Timestamp) -> anyhow::Result { - Self::create_with(super::ApiTokenType::App, expired_at).await + Self::create_with(ApiTokenType::App, expired_at).await } async fn create_with( - session_type: super::ApiTokenType, + session_type: ApiTokenType, expired_at: Timestamp, ) -> anyhow::Result { dotenvy::dotenv().ok(); @@ -656,7 +519,7 @@ mod tests { .create_account_api_token(NewAccountApiToken { account_id: account.id, display_name: format!("app {i}"), - session_type: Some(super::ApiTokenType::App), + session_type: Some(ApiTokenType::App), expired_at: Some(JiffTimestamp::from(now + days(365))), ..Default::default() }) @@ -676,7 +539,7 @@ mod tests { .create_account_api_token(NewAccountApiToken { account_id: account.id, display_name: "web".to_owned(), - session_type: Some(super::ApiTokenType::Web), + session_type: Some(ApiTokenType::Web), expired_at: Some(session::initial_expires_at(false).into()), ..Default::default() }) diff --git a/crates/nvisy-server/src/handler/mod.rs b/crates/nvisy-server/src/handler/mod.rs index b1faee20..fe23f839 100644 --- a/crates/nvisy-server/src/handler/mod.rs +++ b/crates/nvisy-server/src/handler/mod.rs @@ -43,7 +43,7 @@ use axum::middleware::{from_fn, from_fn_with_state}; use axum::response::{IntoResponse, Response}; pub use error::{Error, ErrorKind, Result}; pub use invites::{CreatedInvite, InviteOutcome, create_invite}; -pub use utility::{CookieConfig, CustomRoutes}; +pub use utility::CustomRoutes; use crate::middleware::{csrf_protect, require_authentication, slide_session}; use crate::service::ServiceState; @@ -160,7 +160,7 @@ where // Layer order matters, and is security-relevant. `route_layer`s apply // bottom-up, so the LAST one added is the OUTERMOST (runs first). We want, per // request, in order: - // 1. require_authentication — resolves and caches the verified `AuthHeader` + // 1. require_authentication — resolves and caches the verified `SessionToken` // (which records the transport), rejecting an unauthenticated request, // 2. csrf_protect — enforces CSRF on cookie-authed state-changing requests, // reading the transport cached above; rejects a forged request here, @@ -195,9 +195,9 @@ mod test { use nvisy_postgres::PgConfig; use nvisy_webhook::reqwest::ReqwestClient; - use crate::handler::utility::CookieConfig; use crate::handler::{CustomRoutes, routes}; use crate::middleware::UploadConfig; + use crate::response::CookieConfig; use crate::service::{ CryptoConfig, EngineConfig, FileConnectorsConfig, HealthConfig, IntegrationConfig, OidcConfig, S3Config, ServiceState, SessionKeysConfig, diff --git a/crates/nvisy-server/src/handler/request/tokens.rs b/crates/nvisy-server/src/handler/request/tokens.rs index 5561e42d..0c11f593 100644 --- a/crates/nvisy-server/src/handler/request/tokens.rs +++ b/crates/nvisy-server/src/handler/request/tokens.rs @@ -12,6 +12,7 @@ use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use uuid::Uuid; +use crate::extract::SecurityContext; use crate::handler::Result; /// Expiration options for API tokens. @@ -80,13 +81,18 @@ pub struct CreateApiToken { } impl CreateApiToken { - /// Converts this request into a [`NewAccountApiToken`] model. + /// Converts this request into a [`NewAccountApiToken`] model, recording the + /// caller's IP and user agent from `security` on the token row. /// /// # Arguments /// /// * `account_id` - The account this token belongs to. - /// * `user_agent` - The user agent string of the client. - pub fn into_model(self, account_id: Uuid, user_agent: String) -> Result { + /// * `security` - The caller's request context (client IP and user agent). + pub fn into_model( + self, + account_id: Uuid, + security: SecurityContext, + ) -> Result { let sanitized_name = self.display_name.trim().to_string(); if sanitized_name.is_empty() { return Err(crate::handler::ErrorKind::BadRequest @@ -97,8 +103,8 @@ impl CreateApiToken { Ok(NewAccountApiToken { account_id, display_name: sanitized_name, - ip_address: None, - user_agent: Some(user_agent), + ip_address: security.ip_address, + user_agent: security.user_agent, session_type: Some(ApiTokenType::Api), is_remembered: Some(true), expired_at: self.expires_in.to_expiry_timestamp().map(Into::into), diff --git a/crates/nvisy-server/src/handler/tokens.rs b/crates/nvisy-server/src/handler/tokens.rs index b29da2a8..f50b984e 100644 --- a/crates/nvisy-server/src/handler/tokens.rs +++ b/crates/nvisy-server/src/handler/tokens.rs @@ -8,7 +8,6 @@ use aide::axum::ApiRouter; use aide::transform::TransformOperation; use axum::extract::State; use axum::http::StatusCode; -use axum_extra::headers::UserAgent; use nvisy_postgres::model::{AccountApiToken, UpdateAccountApiToken}; use nvisy_postgres::query::{AccountApiTokenRepository, AccountRepository}; use nvisy_postgres::types::ApiTokenType; @@ -17,11 +16,9 @@ use uuid::Uuid; use super::request::{CreateApiToken, CursorPagination, TokenPathParams, UpdateApiToken}; use super::response::{ApiToken, ApiTokenWithJWT, ApiTokensPage, ErrorResponse}; -use crate::extract::{ - AuthClaims, AuthHeader, AuthState, Json, Path, Query, TypedHeader, ValidateJson, -}; +use crate::extract::{AuthState, Json, Path, Query, SecurityContext, ValidateJson}; use crate::handler::{ErrorKind, Result}; -use crate::service::{ServiceState, SessionKeys}; +use crate::service::{AuthIssuer, ServiceState}; /// Tracing target for API token operations. const TRACING_TARGET: &str = "nvisy_server::handler::tokens"; @@ -33,9 +30,9 @@ const TRACING_TARGET: &str = "nvisy_server::handler::tokens"; #[tracing::instrument(skip_all, fields(account_id = %auth_state.account_id))] async fn create_api_token( State(pg_client): State, - State(auth_keys): State, + State(issuer): State, auth_state: AuthState, - TypedHeader(user_agent): TypedHeader, + security: SecurityContext, ValidateJson(request): ValidateJson, ) -> Result<(StatusCode, Json)> { tracing::debug!(target: TRACING_TARGET, "Creating API token"); @@ -52,13 +49,11 @@ async fn create_api_token( .with_message("Account not found") })?; - let new_token = request.into_model(auth_state.account_id, user_agent.to_string())?; + let new_token = request.into_model(auth_state.account_id, security)?; let api_token = conn.create_account_api_token(new_token).await?; - // Generate JWT for the new token - let auth_claims = AuthClaims::new(&account, &api_token); - let auth_header = AuthHeader::new(auth_claims, auth_keys); - let jwt_token = auth_header.into_string()?; + // Sign the JWT for the new token through the shared issuer. + let jwt_token = issuer.sign(&account, &api_token)?; let response = ApiToken::from_model(api_token.clone()).with_jwt(jwt_token); diff --git a/crates/nvisy-server/src/handler/utility/mod.rs b/crates/nvisy-server/src/handler/utility/mod.rs index ce775653..ba32cf2e 100644 --- a/crates/nvisy-server/src/handler/utility/mod.rs +++ b/crates/nvisy-server/src/handler/utility/mod.rs @@ -4,12 +4,10 @@ mod accounts; mod custom_routes; mod download; mod file_hash; -mod session_cookies; mod sse_response; pub use accounts::{ActorFilter, build_password_user_inputs, resolve_account_ref, resolve_actor}; pub use custom_routes::CustomRoutes; pub use download::{DownloadResponseExt, attachment_headers}; pub use file_hash::FileHash; -pub use session_cookies::CookieConfig; pub use sse_response::SseResponse; diff --git a/crates/nvisy-server/src/handler/utility/session_cookies.rs b/crates/nvisy-server/src/handler/utility/session_cookies.rs deleted file mode 100644 index cf89c676..00000000 --- a/crates/nvisy-server/src/handler/utility/session_cookies.rs +++ /dev/null @@ -1,144 +0,0 @@ -//! Building the session and CSRF cookies for browser (cookie-transport) clients. -//! -//! A browser signs in and receives the session JWT in an `HttpOnly` cookie -//! ([`SESSION_COOKIE_NAME`]) rather than in the response body, so the token is -//! never exposed to page script. Alongside it, a readable CSRF token cookie -//! ([`CSRF_COOKIE_NAME`]) is set for the double-submit check enforced on -//! state-changing requests. -//! -//! Programmatic (API / SDK) callers do not use cookies — they receive the JWT in -//! the response body and send it as an `Authorization: Bearer` header — so these -//! helpers are used only on the cookie-transport paths (browser login/signup and -//! the OIDC callback). -//! -//! [`SESSION_COOKIE_NAME`]: crate::extract::SESSION_COOKIE_NAME -//! [`CSRF_COOKIE_NAME`]: crate::extract::CSRF_COOKIE_NAME - -use axum::http::StatusCode; -use axum::response::{IntoResponse, Response}; -use axum_extra::extract::CookieJar; -use axum_extra::extract::cookie::{Cookie, SameSite}; -use base64::Engine; -use nvisy_postgres::types::session; - -use crate::extract::{CSRF_COOKIE_NAME, SESSION_COOKIE_NAME}; - -/// Session-cookie policy: the deployment-dependent attributes applied to the -/// session and CSRF cookies. -/// -/// The only knob is [`secure`](Self::secure). It is `true` by default and in -/// production: a `Secure` cookie is only sent over HTTPS, which is required for a -/// bearer credential. It must be set to `false` for local development served over -/// plain HTTP, where browsers silently drop `Secure` cookies and the session would -/// never be set. -#[derive(Debug, Clone, Copy)] -#[cfg_attr(feature = "cli", derive(clap::Args))] -pub struct CookieConfig { - /// Whether session cookies carry the `Secure` attribute (HTTPS-only). - /// - /// Keep `true` in production. Set `false` only for local HTTP development, or - /// the browser will not store the cookie. - #[cfg_attr( - feature = "cli", - arg(long = "cookie-secure", env = "COOKIE_SECURE", default_value_t = true) - )] - pub secure: bool, -} - -impl Default for CookieConfig { - fn default() -> Self { - // Production-safe default: `Secure` on. Dev over HTTP opts out explicitly. - Self { secure: true } - } -} - -impl CookieConfig { - /// The maximum age applied to session/CSRF cookies: the session's absolute - /// cap. The idle bound is enforced server-side against the session row, so the - /// cookie itself only needs to survive up to the hard age limit. - fn max_age() -> time::Duration { - time::Duration::seconds(session::MAX_AGE.as_secs() as i64) - } - - /// Builds the `HttpOnly` session cookie carrying `jwt`. - /// - /// `HttpOnly` keeps page script from reading the token; `Secure` (per config) - /// restricts it to HTTPS; `SameSite=Lax` lets it ride the provider's top-level - /// redirect back to the app (needed for the OIDC callback) while still not - /// being sent on cross-site background requests. - fn session_cookie(self, jwt: String) -> Cookie<'static> { - Cookie::build((SESSION_COOKIE_NAME, jwt)) - .http_only(true) - .secure(self.secure) - .same_site(SameSite::Lax) - .path("/") - .max_age(Self::max_age()) - .build() - } - - /// Builds the readable CSRF-token cookie for the double-submit check. - /// - /// Deliberately **not** `HttpOnly`: the SPA reads it and echoes it in the CSRF - /// header on state-changing requests. It is not a secret credential on its - /// own — it is only meaningful paired with the `HttpOnly` session cookie an - /// attacker cannot read or set cross-site. - fn csrf_cookie(self, token: String) -> Cookie<'static> { - Cookie::build((CSRF_COOKIE_NAME, token)) - .http_only(false) - .secure(self.secure) - .same_site(SameSite::Lax) - .path("/") - .max_age(Self::max_age()) - .build() - } - - /// A `CookieJar` holding a freshly minted session's cookies: the `HttpOnly` - /// session cookie carrying `jwt`, plus a paired CSRF cookie with a new token. - /// - /// Used both by [`session_response`](Self::session_response) (a plain sign-in) - /// and by the OIDC callback, which attaches the jar to a redirect. - pub fn session_jar(self, jwt: String) -> CookieJar { - CookieJar::new() - .add(self.session_cookie(jwt)) - .add(self.csrf_cookie(generate_csrf_token())) - } - - /// A `204 No Content` sign-in response that sets the session and CSRF cookies - /// for `jwt`. The token is delivered only in the `HttpOnly` cookie, never in - /// the body, so browser page script cannot read it. - #[must_use] - pub fn session_response(self, jwt: String) -> Response { - (StatusCode::NO_CONTENT, self.session_jar(jwt)).into_response() - } - - /// The pair of cookies that clear a browser session on logout. Empty value and - /// immediate expiry; attributes match the originals so the browser overwrites - /// them. - pub fn clearing_response_jar(self) -> CookieJar { - CookieJar::new() - .add(self.clearing_cookie(SESSION_COOKIE_NAME, true)) - .add(self.clearing_cookie(CSRF_COOKIE_NAME, false)) - } - - fn clearing_cookie(self, name: &'static str, http_only: bool) -> Cookie<'static> { - Cookie::build((name, "")) - .http_only(http_only) - .secure(self.secure) - .same_site(SameSite::Lax) - .path("/") - .max_age(time::Duration::ZERO) - .build() - } -} - -/// Generates an unguessable CSRF token: URL-safe base64 of 32 CSPRNG bytes. -/// -/// The value is a bearer-grade random string, so its bytes come from a -/// cryptographically secure RNG (`rand::rng()`), never a fast non-crypto one. -fn generate_csrf_token() -> String { - use rand::Rng; - - let mut bytes = [0u8; 32]; - rand::rng().fill_bytes(&mut bytes); - base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes) -} diff --git a/crates/nvisy-server/src/lib.rs b/crates/nvisy-server/src/lib.rs index 351ae8ea..f08d865e 100644 --- a/crates/nvisy-server/src/lib.rs +++ b/crates/nvisy-server/src/lib.rs @@ -9,6 +9,7 @@ mod error; pub mod extract; pub mod handler; pub mod middleware; +pub mod response; pub mod service; pub use crate::args::ServiceArgs; diff --git a/crates/nvisy-server/src/middleware/auth/csrf.rs b/crates/nvisy-server/src/middleware/auth/csrf.rs index ac132699..4b44e356 100644 --- a/crates/nvisy-server/src/middleware/auth/csrf.rs +++ b/crates/nvisy-server/src/middleware/auth/csrf.rs @@ -7,7 +7,7 @@ use axum::response::Response; use axum_extra::extract::CookieJar; use super::TRACING_TARGET; -use crate::extract::{AuthHeader, AuthTransport, CSRF_COOKIE_NAME, CSRF_HEADER_NAME}; +use crate::extract::{AuthTransport, CSRF_COOKIE_NAME, CSRF_HEADER_NAME, SessionToken}; use crate::handler::{ErrorKind, Result}; /// Enforces CSRF protection on cookie-authenticated, state-changing requests @@ -27,7 +27,7 @@ use crate::handler::{ErrorKind, Result}; /// nor set a custom header cross-site, so a forged request fails the match. /// /// Runs after authentication, so the transport that authenticated is known (the -/// [`AuthHeader`] the auth layer verified and cached on the request). +/// [`SessionToken`] the auth layer verified and cached on the request). pub async fn csrf_protect(request: Request, next: Next) -> Result { // Safe methods never mutate state, so they are exempt regardless of transport. let is_state_changing = matches!( @@ -35,13 +35,13 @@ pub async fn csrf_protect(request: Request, next: Next) -> Result { Method::POST | Method::PUT | Method::PATCH | Method::DELETE ); - // The transport is read from the verified `AuthHeader` the auth layer cached. + // The transport is read from the verified `SessionToken` the auth layer cached. // Its absence means this route was not authenticated (no auth layer ran), so // there is no cookie session to protect — CSRF does not apply. let via_cookie = request .extensions() - .get::() - .and_then(AuthHeader::transport) + .get::() + .map(SessionToken::transport) == Some(AuthTransport::Cookie); if is_state_changing && via_cookie { diff --git a/crates/nvisy-server/src/response/mod.rs b/crates/nvisy-server/src/response/mod.rs new file mode 100644 index 00000000..18dc7ffc --- /dev/null +++ b/crates/nvisy-server/src/response/mod.rs @@ -0,0 +1,10 @@ +//! Outbound response types — the `IntoResponse` side of the server, mirroring +//! axum's own split between [`extract`](crate::extract) (inbound) and response. +//! +//! Serializable payload DTOs live with their handlers in +//! [`handler::response`](crate::handler::response); this module is for types +//! whose job is response *behavior* — setting status, headers, or cookies. + +mod session; + +pub use session::{ClearedSession, CookieConfig, WebSession}; diff --git a/crates/nvisy-server/src/response/session.rs b/crates/nvisy-server/src/response/session.rs new file mode 100644 index 00000000..b270dca8 --- /dev/null +++ b/crates/nvisy-server/src/response/session.rs @@ -0,0 +1,211 @@ +//! Browser-session cookie emission for cookie-transport (browser) clients. +//! +//! A browser signs in and receives the session JWT in an `HttpOnly` cookie +//! ([`SESSION_COOKIE_NAME`]) rather than in the response body, so the token is +//! never exposed to page script. Alongside it, a readable CSRF token cookie +//! ([`CSRF_COOKIE_NAME`]) is set for the double-submit check enforced on +//! state-changing requests. +//! +//! [`CookieConfig`] is the deployment policy (just the `Secure` attribute). +//! [`WebSession`] emits the cookies for a freshly minted session, and +//! [`ClearedSession`] emits the pair that clears them on sign-out. Programmatic +//! (API / SDK) callers do not use cookies — they receive the JWT in the response +//! body and send it as an `Authorization: Bearer` header — so these types are +//! used only on the cookie-transport paths (browser login/signup, the OIDC +//! callback, and logout). +//! +//! [`SESSION_COOKIE_NAME`]: crate::extract::SESSION_COOKIE_NAME +//! [`CSRF_COOKIE_NAME`]: crate::extract::CSRF_COOKIE_NAME + +use aide::OperationOutput; +use aide::generate::GenContext; +use aide::openapi::Operation; +use axum::http::StatusCode; +use axum::response::{IntoResponse, Response}; +use axum_extra::extract::CookieJar; +use axum_extra::extract::cookie::{Cookie, SameSite}; +use base64::Engine; +use nvisy_postgres::types::session; + +use crate::extract::{CSRF_COOKIE_NAME, SESSION_COOKIE_NAME}; + +/// Session-cookie policy: the deployment-dependent attributes applied to the +/// session and CSRF cookies. +/// +/// The only knob is [`secure`](Self::secure). It is `true` by default and in +/// production: a `Secure` cookie is only sent over HTTPS, which is required for a +/// bearer credential. It must be set to `false` for local development served over +/// plain HTTP, where browsers silently drop `Secure` cookies and the session would +/// never be set. +/// +/// This is pure policy; the cookies themselves are built by [`WebSession`] and +/// [`ClearedSession`]. +#[derive(Debug, Clone, Copy)] +#[cfg_attr(feature = "cli", derive(clap::Args))] +pub struct CookieConfig { + /// Whether session cookies carry the `Secure` attribute (HTTPS-only). + /// + /// Keep `true` in production. Set `false` only for local HTTP development, or + /// the browser will not store the cookie. + #[cfg_attr( + feature = "cli", + arg(long = "cookie-secure", env = "COOKIE_SECURE", default_value_t = true) + )] + pub secure: bool, +} + +impl Default for CookieConfig { + fn default() -> Self { + // Production-safe default: `Secure` on. Dev over HTTP opts out explicitly. + Self { secure: true } + } +} + +/// The maximum age applied to session/CSRF cookies: the session's absolute cap. +/// The idle bound is enforced server-side against the session row, so the cookie +/// itself only needs to survive up to the hard age limit. +fn cookie_max_age() -> time::Duration { + time::Duration::seconds(session::MAX_AGE.as_secs() as i64) +} + +/// Builds the `HttpOnly` session cookie carrying `jwt`. +/// +/// `HttpOnly` keeps page script from reading the token; `Secure` (per config) +/// restricts it to HTTPS; `SameSite=Lax` lets it ride the provider's top-level +/// redirect back to the app (needed for the OIDC callback) while still not being +/// sent on cross-site background requests. +fn session_cookie(secure: bool, jwt: String) -> Cookie<'static> { + Cookie::build((SESSION_COOKIE_NAME, jwt)) + .http_only(true) + .secure(secure) + .same_site(SameSite::Lax) + .path("/") + .max_age(cookie_max_age()) + .build() +} + +/// Builds the readable CSRF-token cookie for the double-submit check. +/// +/// Deliberately **not** `HttpOnly`: the SPA reads it and echoes it in the CSRF +/// header on state-changing requests. It is not a secret credential on its own — +/// it is only meaningful paired with the `HttpOnly` session cookie an attacker +/// cannot read or set cross-site. +fn csrf_cookie(secure: bool, token: String) -> Cookie<'static> { + Cookie::build((CSRF_COOKIE_NAME, token)) + .http_only(false) + .secure(secure) + .same_site(SameSite::Lax) + .path("/") + .max_age(cookie_max_age()) + .build() +} + +/// Builds a cookie that clears `name`: empty value, immediate expiry, attributes +/// matching the originals so the browser overwrites them. +fn clearing_cookie(secure: bool, name: &'static str, http_only: bool) -> Cookie<'static> { + Cookie::build((name, "")) + .http_only(http_only) + .secure(secure) + .same_site(SameSite::Lax) + .path("/") + .max_age(time::Duration::ZERO) + .build() +} + +/// Generates an unguessable CSRF token: URL-safe base64 of 32 CSPRNG bytes. +/// +/// The value is a bearer-grade random string, so its bytes come from a +/// cryptographically secure RNG (`rand::rng()`), never a fast non-crypto one. +fn generate_csrf_token() -> String { + use rand::Rng; + + let mut bytes = [0u8; 32]; + rand::rng().fill_bytes(&mut bytes); + base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(bytes) +} + +/// A freshly minted browser session, ready to be delivered as `Set-Cookie`. +/// +/// Carries the session JWT and the deployment [`CookieConfig`]. It renders either +/// as a bare `204` sign-in response (via [`IntoResponse`]) or as a jar of cookies +/// to attach to another response (the OIDC callback attaches it to a redirect). +/// The token is delivered only in the `HttpOnly` cookie, never in a body. +#[must_use] +pub struct WebSession { + jwt: String, + config: CookieConfig, +} + +impl WebSession { + /// Wraps a minted session JWT with the cookie policy that will deliver it. + #[inline] + pub const fn new(jwt: String, config: CookieConfig) -> Self { + Self { jwt, config } + } + + /// The session and CSRF cookies as a jar, to attach to a caller-built + /// response — used by the OIDC callback, which sets them on a redirect. + pub fn into_jar(self) -> CookieJar { + CookieJar::new() + .add(session_cookie(self.config.secure, self.jwt)) + .add(csrf_cookie(self.config.secure, generate_csrf_token())) + } +} + +impl IntoResponse for WebSession { + /// A `204 No Content` sign-in response that sets the session and CSRF cookies. + fn into_response(self) -> Response { + (StatusCode::NO_CONTENT, self.into_jar()).into_response() + } +} + +impl OperationOutput for WebSession { + type Inner = (); + + fn operation_response( + _ctx: &mut GenContext, + _operation: &mut Operation, + ) -> Option { + // The sign-in response carries no body (204 + Set-Cookie); the concrete + // responses are documented on each operation via TransformOperation. + None + } + + fn inferred_responses( + _ctx: &mut GenContext, + _operation: &mut Operation, + ) -> Vec<(Option, aide::openapi::Response)> { + // Prevent aide from inferring a default 200; the 204 is documented explicitly. + Vec::new() + } +} + +/// The cookies that clear a browser session on sign-out: the session and CSRF +/// cookies emptied and immediately expired. +/// +/// Logout performs token revocation and returns its own status, so this exposes +/// only [`into_jar`](Self::into_jar) (attached to that response) rather than an +/// [`IntoResponse`] of its own. +#[must_use] +pub struct ClearedSession { + config: CookieConfig, +} + +impl ClearedSession { + /// A clearing effect under the given cookie policy. + #[inline] + pub const fn new(config: CookieConfig) -> Self { + Self { config } + } + + /// The clearing session and CSRF cookies as a jar. + pub fn into_jar(self) -> CookieJar { + CookieJar::new() + .add(clearing_cookie( + self.config.secure, + SESSION_COOKIE_NAME, + true, + )) + .add(clearing_cookie(self.config.secure, CSRF_COOKIE_NAME, false)) + } +} diff --git a/crates/nvisy-server/src/service/auth_issuer.rs b/crates/nvisy-server/src/service/auth_issuer.rs new file mode 100644 index 00000000..2d4342ee --- /dev/null +++ b/crates/nvisy-server/src/service/auth_issuer.rs @@ -0,0 +1,177 @@ +//! Session-token issuance: the one place that turns an account into a signed JWT. +//! +//! [`AuthIssuer`] owns the outbound half of authentication — signing an +//! [`AuthClaims`] into a JWT with the server's [`SessionKeys`], and the session +//! flavors that create an `account_api_tokens` row and sign a credential for it +//! (browser `web` sessions and native-app `app` tokens). Every sign-in path and +//! the API-token endpoint funnel through it, so there is a single implementation +//! of "mint a credential" rather than the same claim-build-and-sign repeated per +//! handler. +//! +//! It does not create the `account_api_tokens` row for the API-token endpoint — +//! that row is shaped from the request there — but it signs the credential for +//! it via [`sign`](AuthIssuer::sign), so the signing step is never duplicated. + +use jiff::{Span, Timestamp}; +use nvisy_postgres::PgConn; +use nvisy_postgres::model::{Account, AccountApiToken, NewAccountApiToken}; +use nvisy_postgres::query::AccountApiTokenRepository; +use nvisy_postgres::types::{ApiTokenType, session}; + +use crate::extract::{AuthClaims, SecurityContext}; +use crate::handler::Result; +use crate::service::{SessionKeys, UserAgentParser}; + +/// Tracing target for token issuance. +const TRACING_TARGET: &str = "nvisy_server::authentication"; + +/// Signs authentication credentials and mints session tokens. +/// +/// Composed from the server's [`SessionKeys`] (JWT signing) and the +/// [`UserAgentParser`] (session display names). Cheap to clone — both are +/// `Arc`-backed handles — so it is resolved per request from [`ServiceState`]. +/// +/// [`ServiceState`]: crate::service::ServiceState +#[derive(Clone)] +pub struct AuthIssuer { + session_keys: SessionKeys, + user_agent_parser: UserAgentParser, +} + +impl AuthIssuer { + /// Composes the issuer from its collaborators. + #[inline] + pub const fn new(session_keys: SessionKeys, user_agent_parser: UserAgentParser) -> Self { + Self { + session_keys, + user_agent_parser, + } + } + + /// Signs a JWT credential for `token`, belonging to `account`. + /// + /// The single signing step behind every credential the server issues — a + /// session cookie, a native-app token, or an API token — so the claims are + /// built and signed in exactly one place. + /// + /// # Errors + /// + /// Propagates a JWT encoding failure. + pub fn sign(&self, account: &Account, token: &AccountApiToken) -> Result { + AuthClaims::new(account, token).into_string(self.session_keys.encoding_key()) + } + + /// Mints a `web` browser session for `account`: creates the session row and + /// signs its JWT. + /// + /// The idle bound follows `remember_me`; the session then slides forward on + /// use up to the absolute cap. Password login, signup, and OIDC web sign-in + /// all go through it, so the browser session shape is identical across the + /// three paths. The caller delivers the returned JWT to the browser as a + /// session cookie (a `WebSession`). + /// + /// # Errors + /// + /// Propagates database and JWT-signing failures. + pub async fn issue_web_session( + &self, + conn: &mut PgConn, + account: &Account, + remember_me: bool, + security: SecurityContext, + ) -> Result { + self.issue_session( + conn, + account, + ApiTokenType::Web, + remember_me, + session::initial_expires_at(remember_me).into(), + security, + ) + .await + } + + /// Mints a native-app (desktop) session token for `account`: a long-lived + /// `app` token (see [`session::APP_TOKEN_LIFETIME`]) that does not slide and + /// is exempt from the browser absolute cap. The desktop app stores it and + /// sends it as a Bearer credential; it is never a cookie. + /// + /// Caps live `app` tokens per account (best-effort): repeated desktop logins + /// do not accumulate unbounded long-lived credentials — the oldest beyond the + /// limit are evicted, and a pruning failure is logged, not propagated. + /// + /// # Errors + /// + /// Propagates database and JWT-signing failures from the mint itself. + pub async fn issue_app_token( + &self, + conn: &mut PgConn, + account: &Account, + security: SecurityContext, + ) -> Result { + let expired_at = + Timestamp::now() + Span::new().seconds(session::APP_TOKEN_LIFETIME.as_secs() as i64); + let jwt = self + .issue_session( + conn, + account, + ApiTokenType::App, + false, + expired_at.into(), + security, + ) + .await?; + + if let Err(error) = conn + .prune_app_tokens(account.id, session::MAX_APP_TOKENS_PER_ACCOUNT) + .await + { + tracing::warn!( + target: TRACING_TARGET, + error = %error, + account_id = %account.id, + "failed to prune old app tokens after minting", + ); + } + + Ok(jwt) + } + + /// Shared session mint: persists an `account_api_tokens` row of `session_type` + /// and signs its JWT. The caller is responsible for gating the account's + /// status (suspended/deleted) before minting. + async fn issue_session( + &self, + conn: &mut PgConn, + account: &Account, + session_type: ApiTokenType, + is_remembered: bool, + expired_at: nvisy_postgres::JiffTimestamp, + security: SecurityContext, + ) -> Result { + // The session's display name is derived from the user agent; the client IP + // and raw user agent are recorded on the row for the account's session list + // and audit trail. + let display_name = self + .user_agent_parser + .parse(security.user_agent.as_deref().unwrap_or_default()); + let new_token = NewAccountApiToken { + account_id: account.id, + display_name, + ip_address: security.ip_address, + user_agent: security.user_agent, + is_remembered: Some(is_remembered), + session_type: Some(session_type), + expired_at: Some(expired_at), + }; + let token = conn.create_account_api_token(new_token).await?; + tracing::info!( + target: TRACING_TARGET, + token_id = %token.id, + account_id = %account.id, + session_type = ?session_type, + "Minted session token", + ); + self.sign(account, &token) + } +} diff --git a/crates/nvisy-server/src/service/mod.rs b/crates/nvisy-server/src/service/mod.rs index 458c8285..5cbce234 100644 --- a/crates/nvisy-server/src/service/mod.rs +++ b/crates/nvisy-server/src/service/mod.rs @@ -1,5 +1,6 @@ //! Application state and dependency injection. +mod auth_issuer; mod avatar; mod chat; mod crypto; @@ -32,8 +33,9 @@ pub use nvisy_s3::S3Config; use nvisy_webhook::WebhookService; use tokio_util::sync::CancellationToken; -use crate::handler::CookieConfig; use crate::middleware::UploadConfig; +use crate::response::CookieConfig; +pub use crate::service::auth_issuer::AuthIssuer; pub use crate::service::avatar::{AVATAR_CONTENT_TYPE, AvatarService, MAX_AVATAR_UPLOAD_BYTES}; pub use crate::service::chat::{ChatService, TurnLocation}; pub use crate::service::crypto::{CryptoConfig, CryptoService}; @@ -386,3 +388,11 @@ impl axum::extract::FromRef for ExternalObjectStore { ExternalObjectStore::new(state.endpoint_policy) } } + +// `AuthIssuer` composes from two security fields — the JWT signing keys and the +// user-agent parser (for session display names): +impl axum::extract::FromRef for AuthIssuer { + fn from_ref(state: &ServiceState) -> Self { + AuthIssuer::new(state.session_keys.clone(), state.user_agent_parser.clone()) + } +} From eac1c4a113dd9f6422614e441795256887c56375 Mon Sep 17 00:00:00 2001 From: Oleh Martsokha Date: Wed, 9 Sep 2026 23:23:58 +0200 Subject: [PATCH 06/13] Address PR review: optional-body propagation, 413, redaction, non-blank, nested inner Six review findings on the extract/validation layer: - Optional JSON/ValidateJson extraction returned None for a present-but-broken body, so an Option> handler (mint_picker_token) treated a malformed or invalid payload as absent and fell back to a default. Delegate to axum's OptionalFromRequest, which yields None only for a genuinely absent body (no Content-Type) and propagates malformed/invalid bodies as errors. - The JSON body-size-limit branch returned 400; use ErrorKind::PayloadTooLarge (413). - redact_quoted treated an escaped quote inside a submitted value as the closing delimiter, leaking the suffix into context/logs; skip escaped delimiters and add a regression test. - Whitespace-only display names passed length(min=1) and only failed at the DB trim() constraint; add validate_non_blank to required display-name fields and a new validate_non_blank_opt to the optional ones (connections, workspaces, accounts) for a clean 400. - UpdatePolicy::description is Option>; garde needs one inner per container layer, so length(chars, max = 4096) never reached the String. Use inner(inner(length(...))). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_018bKk1YEG4tZ69jzYVQvQL8 --- .../src/extract/reject/json_with_rej.rs | 17 ++++++------ crates/nvisy-server/src/extract/reject/mod.rs | 27 ++++++++++++++++--- .../src/extract/valid/validated_json.rs | 22 ++++++++------- .../src/extract/valid/validators.rs | 16 +++++++++++ .../src/handler/request/accounts.rs | 8 ++++-- .../src/handler/request/connections.rs | 6 ++--- .../src/handler/request/policies.rs | 2 +- .../src/handler/request/workspaces.rs | 5 ++-- .../src/response/{ => auth}/session.rs | 0 .../sse_response.rs => response/sse.rs} | 0 10 files changed, 73 insertions(+), 30 deletions(-) rename crates/nvisy-server/src/response/{ => auth}/session.rs (100%) rename crates/nvisy-server/src/{handler/utility/sse_response.rs => response/sse.rs} (100%) diff --git a/crates/nvisy-server/src/extract/reject/json_with_rej.rs b/crates/nvisy-server/src/extract/reject/json_with_rej.rs index 80024615..7e22dc43 100644 --- a/crates/nvisy-server/src/extract/reject/json_with_rej.rs +++ b/crates/nvisy-server/src/extract/reject/json_with_rej.rs @@ -51,13 +51,14 @@ where type Rejection = Error<'static>; async fn from_request(req: Request, state: &S) -> Result, Self::Rejection> { - match >::from_request(req, state).await { - Ok(json) => Ok(Some(json)), - // Only a server error is worth surfacing; a malformed or absent body - // is a legitimately empty optional. - Err(error) if error.kind() == ErrorKind::InternalServerError => Err(error), - Err(_) => Ok(None), - } + // Delegate to axum's optional JSON extraction, which distinguishes a + // genuinely absent body (no Content-Type → `None`) from a present-but-broken + // one (wrong content-type or malformed JSON → error). A broken body is + // propagated as our `Error`, not silently treated as absent. + as OptionalFromRequest>::from_request(req, state) + .await + .map(|opt| opt.map(|AxumJson(value)| Self(value))) + .map_err(Into::into) } } @@ -99,7 +100,7 @@ impl From for Error<'static> { // variant — match it rather than sniffing the Display string. JsonRejection::BytesRejection(BytesRejection::FailedToBufferBody( FailedToBufferBody::LengthLimitError(_), - )) => ErrorKind::BadRequest + )) => ErrorKind::PayloadTooLarge .with_message("Request body too large") .with_context( "Request body exceeds the maximum allowed size. Consider reducing the payload size or splitting into multiple requests.", diff --git a/crates/nvisy-server/src/extract/reject/mod.rs b/crates/nvisy-server/src/extract/reject/mod.rs index 5bf2683c..97714f07 100644 --- a/crates/nvisy-server/src/extract/reject/mod.rs +++ b/crates/nvisy-server/src/extract/reject/mod.rs @@ -43,10 +43,18 @@ fn redact_quoted(message: &str) -> String { while let Some(ch) = chars.next() { if ch == '"' || ch == '`' { output.push_str(""); - // Consume through the matching closing delimiter, if any. - for inner in chars.by_ref() { - if inner == ch { - break; + // Consume through the matching closing delimiter, treating a + // backslash as escaping the next character so an escaped delimiter + // (`\"`) inside the value does not end the span early and leak the + // suffix that follows it. + while let Some(inner) = chars.next() { + match inner { + '\\' => { + // Skip the escaped character, whatever it is. + chars.next(); + } + _ if inner == ch => break, + _ => {} } } } else { @@ -81,4 +89,15 @@ mod tests { let message = "x".repeat(500); assert_eq!(sanitize_error_message(&message).chars().count(), 200); } + + #[test] + fn an_escaped_delimiter_does_not_end_redaction_early() { + // A submitted value containing an escaped quote must stay fully redacted: + // the `\"` must not be treated as the closing delimiter, which would leak + // the `secret` suffix that follows it. + let message = r#"invalid value: string "a\"secret", expected an integer"#; + let sanitized = sanitize_error_message(message); + assert!(!sanitized.contains("secret"), "leaked: {sanitized}"); + assert!(sanitized.contains("")); + } } diff --git a/crates/nvisy-server/src/extract/valid/validated_json.rs b/crates/nvisy-server/src/extract/valid/validated_json.rs index a52ff121..f65d1f19 100644 --- a/crates/nvisy-server/src/extract/valid/validated_json.rs +++ b/crates/nvisy-server/src/extract/valid/validated_json.rs @@ -54,17 +54,19 @@ where { type Rejection = Error<'static>; - /// Extracts and validates a body when one is present; an absent or malformed - /// body yields `None`. Mirrors [`Json`]'s optional semantics: only a server - /// error propagates, so a missing optional body is not an error. + /// Extracts and validates a body only when one is present. A genuinely absent + /// body yields `None`; a present-but-broken body (malformed JSON, wrong + /// content-type) or one that fails validation is propagated as an error rather + /// than silently treated as absent, so an optional-body handler cannot mistake + /// an invalid payload for "no payload". async fn from_request(req: Request, state: &S) -> Result, Self::Rejection> { - match >::from_request(req, state).await { - Ok(validated) => Ok(Some(validated)), - // For optional extraction, only propagate server errors; client errors - // (absent body, malformed JSON, validation failure) result in `None`. - Err(error) if error.kind() == ErrorKind::InternalServerError => Err(error), - Err(_) => Ok(None), - } + let Some(Json(data)) = + as OptionalFromRequest>::from_request(req, state).await? + else { + return Ok(None); + }; + data.validate()?; + Ok(Some(Self(data))) } } diff --git a/crates/nvisy-server/src/extract/valid/validators.rs b/crates/nvisy-server/src/extract/valid/validators.rs index 5fa6c1bf..0545f650 100644 --- a/crates/nvisy-server/src/extract/valid/validators.rs +++ b/crates/nvisy-server/src/extract/valid/validators.rs @@ -9,6 +9,10 @@ /// Rejects a value that is empty once trimmed, matching the database's /// non-empty-trimmed constraint on display names. +/// +/// A `length(min = 1)` rule counts characters, so a whitespace-only value passes +/// it; this rule rejects such a value up front with a clean `400` rather than +/// letting it reach the database's `trim()` constraint (a late failure). pub fn validate_non_blank(value: &str, _: &()) -> garde::Result { if value.trim().is_empty() { return Err(garde::Error::new("must not be blank")); @@ -16,6 +20,18 @@ pub fn validate_non_blank(value: &str, _: &()) -> garde::Result { Ok(()) } +/// The [`validate_non_blank`] check for an optional field. +/// +/// Takes an `Option` because garde passes a custom validator the field value +/// as-is (it does not unwrap `Option` the way the built-in rules do). An absent +/// value is nothing to check; a present value must not be blank once trimmed. +pub fn validate_non_blank_opt(value: &Option, ctx: &()) -> garde::Result { + match value { + Some(value) => validate_non_blank(value, ctx), + None => Ok(()), + } +} + /// Restricts a display name to letters, digits, whitespace, hyphens, and /// apostrophes. /// diff --git a/crates/nvisy-server/src/handler/request/accounts.rs b/crates/nvisy-server/src/handler/request/accounts.rs index b9c581da..8c60794e 100644 --- a/crates/nvisy-server/src/handler/request/accounts.rs +++ b/crates/nvisy-server/src/handler/request/accounts.rs @@ -6,7 +6,7 @@ use nvisy_postgres::types::Handle; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; -use crate::extract::validators::validate_display_name_format; +use crate::extract::validators::{validate_display_name_format, validate_non_blank_opt}; /// Request payload to update an account's profile. /// @@ -21,7 +21,11 @@ pub struct UpdateAccount { /// New account handle. pub username: Option, /// New display name (2-32 characters). - #[garde(length(chars, min = 2, max = 32), custom(validate_display_name_format))] + #[garde( + length(chars, min = 2, max = 32), + custom(validate_non_blank_opt), + custom(validate_display_name_format) + )] pub display_name: Option, /// New email address (must be valid email format). #[garde(email, length(chars, min = 5, max = 254))] diff --git a/crates/nvisy-server/src/handler/request/connections.rs b/crates/nvisy-server/src/handler/request/connections.rs index 40d27747..80da76ff 100644 --- a/crates/nvisy-server/src/handler/request/connections.rs +++ b/crates/nvisy-server/src/handler/request/connections.rs @@ -6,7 +6,7 @@ use nvisy_postgres::types::{ConnectionId, SyncDeletionPolicy, SyncMode}; use schemars::JsonSchema; use serde::{Deserialize, Serialize}; -use crate::extract::validators::validate_non_blank; +use crate::extract::validators::{validate_non_blank, validate_non_blank_opt}; use crate::service::ConnectionConfig; /// Path parameters for connection operations. @@ -67,7 +67,7 @@ pub struct SyncScheduleInput { #[garde(allow_unvalidated)] pub struct CreateConnection { /// Human-readable connection display name. - #[garde(length(chars, min = 1, max = 255))] + #[garde(length(chars, min = 1, max = 255), custom(validate_non_blank))] pub display_name: String, /// Whether the connection is enabled. Omit to default to active; set `false` /// to create it disabled. @@ -133,7 +133,7 @@ pub struct OAuthCallbackQuery { #[garde(allow_unvalidated)] pub struct UpdateConnection { /// Human-readable connection display name. - #[garde(length(chars, min = 1, max = 255))] + #[garde(length(chars, min = 1, max = 255), custom(validate_non_blank_opt))] pub display_name: Option, /// Whether the connection is enabled. `false` disables it (pausing scheduled /// syncs and rejecting manual ones); omit to leave unchanged. diff --git a/crates/nvisy-server/src/handler/request/policies.rs b/crates/nvisy-server/src/handler/request/policies.rs index 1ada2e32..f6538eb3 100644 --- a/crates/nvisy-server/src/handler/request/policies.rs +++ b/crates/nvisy-server/src/handler/request/policies.rs @@ -160,7 +160,7 @@ pub struct UpdatePolicy { #[garde(length(chars, min = 1, max = 255))] pub display_name: Option, /// Policy description. - #[garde(length(chars, max = 4096))] + #[garde(inner(inner(length(chars, max = 4096))))] pub description: Option>, /// New policy body (replaces the stored definition). pub definition: Option, diff --git a/crates/nvisy-server/src/handler/request/workspaces.rs b/crates/nvisy-server/src/handler/request/workspaces.rs index 73890c5c..90fa6967 100644 --- a/crates/nvisy-server/src/handler/request/workspaces.rs +++ b/crates/nvisy-server/src/handler/request/workspaces.rs @@ -13,6 +13,7 @@ use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use uuid::Uuid; +use crate::extract::validators::{validate_non_blank, validate_non_blank_opt}; use crate::handler::{ErrorKind, Result}; /// Request payload for creating a new workspace. @@ -25,7 +26,7 @@ use crate::handler::{ErrorKind, Result}; #[garde(allow_unvalidated)] pub struct CreateWorkspace { /// Display name of the workspace (2-32 characters). - #[garde(length(min = 2, max = 32, chars))] + #[garde(length(min = 2, max = 32, chars), custom(validate_non_blank))] pub display_name: String, /// Optional URL slug. Derived from the display name when omitted. pub slug: Option, @@ -84,7 +85,7 @@ impl CreateWorkspace { #[garde(allow_unvalidated)] pub struct UpdateWorkspace { /// New display name for the workspace (2-32 characters). - #[garde(length(min = 2, max = 32, chars))] + #[garde(length(min = 2, max = 32, chars), custom(validate_non_blank_opt))] pub display_name: Option, /// New description for the workspace (max 500 characters). #[garde(length(max = 500, chars))] diff --git a/crates/nvisy-server/src/response/session.rs b/crates/nvisy-server/src/response/auth/session.rs similarity index 100% rename from crates/nvisy-server/src/response/session.rs rename to crates/nvisy-server/src/response/auth/session.rs diff --git a/crates/nvisy-server/src/handler/utility/sse_response.rs b/crates/nvisy-server/src/response/sse.rs similarity index 100% rename from crates/nvisy-server/src/handler/utility/sse_response.rs rename to crates/nvisy-server/src/response/sse.rs From 0191e7f809ff91472bcc06ba5dd90125ae5c8138 Mon Sep 17 00:00:00 2001 From: Oleh Martsokha Date: Wed, 9 Sep 2026 23:24:07 +0200 Subject: [PATCH 07/13] Group response types: response/auth/session, response/sse Tidy the root response module: move the session cookie types under a private response::auth submodule (response/auth/session.rs) and move SseResponse out of handler/utility into response/sse.rs, since it is a genuine IntoResponse type. Both are re-exported flat at crate::response, so consumers are unchanged (crate::response::{WebSession, ClearedSession, CookieConfig, SseResponse}). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_018bKk1YEG4tZ69jzYVQvQL8 --- crates/nvisy-server/src/handler/chat.rs | 2 +- crates/nvisy-server/src/handler/detections.rs | 3 ++- crates/nvisy-server/src/handler/notifications.rs | 2 +- crates/nvisy-server/src/handler/utility/mod.rs | 2 -- crates/nvisy-server/src/response/auth/mod.rs | 9 +++++++++ crates/nvisy-server/src/response/mod.rs | 6 ++++-- 6 files changed, 17 insertions(+), 7 deletions(-) create mode 100644 crates/nvisy-server/src/response/auth/mod.rs diff --git a/crates/nvisy-server/src/handler/chat.rs b/crates/nvisy-server/src/handler/chat.rs index 87eb17d3..4377ccd8 100644 --- a/crates/nvisy-server/src/handler/chat.rs +++ b/crates/nvisy-server/src/handler/chat.rs @@ -23,8 +23,8 @@ use crate::handler::request::{ ChatSessionPathParams, CreateChatSession, CursorPagination, SendChatMessage, }; use crate::handler::response::{ChatMessage, ChatSession, ChatSessionsPage, ErrorResponse}; -use crate::handler::utility::SseResponse; use crate::handler::{Error, Result}; +use crate::response::SseResponse; use crate::service::{ChatService, ServiceState, TurnLocation}; /// Tracing target for chat operations. diff --git a/crates/nvisy-server/src/handler/detections.rs b/crates/nvisy-server/src/handler/detections.rs index 2a674b77..72e4b9a8 100644 --- a/crates/nvisy-server/src/handler/detections.rs +++ b/crates/nvisy-server/src/handler/detections.rs @@ -34,8 +34,9 @@ use crate::handler::request::{ PipelineDetectionsQuery, PipelinePathParams, RedactDetection, WorkspaceDetectionsQuery, }; use crate::handler::response::{Detection, DetectionsPage, ErrorResponse, RedactionResult}; -use crate::handler::utility::{SseResponse, resolve_account_ref}; +use crate::handler::utility::resolve_account_ref; use crate::handler::{Error, ErrorKind, Result}; +use crate::response::SseResponse; use crate::service::{ CryptoService, DetectionJob, DetectionQueue, DetectionRef, DetectionStatusEvent, EngineService, EventEmitter, EventOrigin, RunBlobStore, ServiceState, WorkspaceEvent, resolve_policies, diff --git a/crates/nvisy-server/src/handler/notifications.rs b/crates/nvisy-server/src/handler/notifications.rs index 757170f3..744a5c8b 100644 --- a/crates/nvisy-server/src/handler/notifications.rs +++ b/crates/nvisy-server/src/handler/notifications.rs @@ -22,8 +22,8 @@ use crate::handler::request::{CursorPagination, NotificationPathParams}; use crate::handler::response::{ ErrorResponse, MarkedReadStatus, Notification, NotificationsPage, UnreadStatus, }; -use crate::handler::utility::SseResponse; use crate::handler::{Error, Result}; +use crate::response::SseResponse; use crate::service::{NotificationEmitter, ServiceState, UnreadCountEvent}; /// Tracing target for notification operations. diff --git a/crates/nvisy-server/src/handler/utility/mod.rs b/crates/nvisy-server/src/handler/utility/mod.rs index ba32cf2e..9e226803 100644 --- a/crates/nvisy-server/src/handler/utility/mod.rs +++ b/crates/nvisy-server/src/handler/utility/mod.rs @@ -4,10 +4,8 @@ mod accounts; mod custom_routes; mod download; mod file_hash; -mod sse_response; pub use accounts::{ActorFilter, build_password_user_inputs, resolve_account_ref, resolve_actor}; pub use custom_routes::CustomRoutes; pub use download::{DownloadResponseExt, attachment_headers}; pub use file_hash::FileHash; -pub use sse_response::SseResponse; diff --git a/crates/nvisy-server/src/response/auth/mod.rs b/crates/nvisy-server/src/response/auth/mod.rs new file mode 100644 index 00000000..b0d00e46 --- /dev/null +++ b/crates/nvisy-server/src/response/auth/mod.rs @@ -0,0 +1,9 @@ +//! Authentication response types: the outbound side of the auth flow. +//! +//! [`WebSession`] and [`ClearedSession`] emit the browser session and CSRF +//! cookies (set on sign-in, cleared on sign-out); [`CookieConfig`] is the +//! deployment cookie policy they apply. + +mod session; + +pub use session::{ClearedSession, CookieConfig, WebSession}; diff --git a/crates/nvisy-server/src/response/mod.rs b/crates/nvisy-server/src/response/mod.rs index 18dc7ffc..a4d68159 100644 --- a/crates/nvisy-server/src/response/mod.rs +++ b/crates/nvisy-server/src/response/mod.rs @@ -5,6 +5,8 @@ //! [`handler::response`](crate::handler::response); this module is for types //! whose job is response *behavior* — setting status, headers, or cookies. -mod session; +mod auth; +mod sse; -pub use session::{ClearedSession, CookieConfig, WebSession}; +pub use auth::{ClearedSession, CookieConfig, WebSession}; +pub use sse::SseResponse; From 0dea1ac917d7796d7cfa40e1d928c68692747bd6 Mon Sep 17 00:00:00 2001 From: Oleh Martsokha Date: Wed, 9 Sep 2026 23:41:35 +0200 Subject: [PATCH 08/13] Rewrite READMEs without em-dashes; note CLIENT_IP_SOURCE/COOKIE_SECURE Replace em-dashes across the top-level, docker, and file-service READMEs with context-appropriate punctuation (colons for label lists, commas or parentheses for parentheticals). Add a short deployment note to docker/README documenting the two topology-dependent settings, CLIENT_IP_SOURCE and COOKIE_SECURE, which default to the directly-exposed case and must be set behind a proxy. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_018bKk1YEG4tZ69jzYVQvQL8 --- README.md | 24 ++++++++++++------------ crates/nvisy-file-service/README.md | 4 ++-- docker/README.md | 26 ++++++++++++++++++-------- 3 files changed, 32 insertions(+), 22 deletions(-) diff --git a/README.md b/README.md index fc9edffe..5552e743 100644 --- a/README.md +++ b/README.md @@ -33,17 +33,17 @@ credential encryption. ## Features -- **Multimodal redaction** — detect and remove sensitive data across PDFs, office documents, images, and audio. -- **AI-powered detection** — LLM- and pattern-driven PII/entity recognition, governed by configurable redaction policies. -- **Reviewer edits** — suppress a false positive, retag a detection, or add one the analysis missed, then re-redact — as many times as needed. -- **Workspace isolation** — multi-tenant workspaces with HKDF-derived, per-workspace credential encryption. -- **Real-time collaboration** — WebSocket and NATS pub/sub for live status and document editing. -- **Interactive docs** — auto-generated OpenAPI served through a Scalar UI. +- **Multimodal redaction**: detect and remove sensitive data across PDFs, office documents, images, and audio. +- **AI-powered detection**: LLM- and pattern-driven PII/entity recognition, governed by configurable redaction policies. +- **Reviewer edits**: suppress a false positive, retag a detection, or add one the analysis missed, then re-redact, as many times as needed. +- **Workspace isolation**: multi-tenant workspaces with HKDF-derived, per-workspace credential encryption. +- **Real-time collaboration**: WebSocket and NATS pub/sub for live status and document editing. +- **Interactive docs**: auto-generated OpenAPI served through a Scalar UI. ## Requirements -- **Rust + Cargo** — 1.95+, Edition 2024 -- **PostgreSQL** 18+, **NATS** 2.10+ (JetStream), and an **S3-compatible blob store** (RustFS by default) — the dev compose file provides all three +- **Rust + Cargo**: 1.95+, Edition 2024 +- **PostgreSQL** 18+, **NATS** 2.10+ (JetStream), and an **S3-compatible blob store** (RustFS by default). The dev compose file provides all three. ## Quick start @@ -78,10 +78,10 @@ configuration. See [`docs/`](docs/) for the details: -- [Architecture](docs/ARCHITECTURE.md) — the crates, the detect/redact pipeline, and how they fit together. -- [Intelligence](docs/INTELLIGENCE.md) — detection capabilities and the redaction engine. -- [Providers](docs/PROVIDERS.md) — inference and object-store provider design. -- [Security](docs/SECURITY.md) — the encryption, authentication, and isolation model. +- [Architecture](docs/ARCHITECTURE.md): the crates, the detect/redact pipeline, and how they fit together. +- [Intelligence](docs/INTELLIGENCE.md): detection capabilities and the redaction engine. +- [Providers](docs/PROVIDERS.md): inference and object-store provider design. +- [Security](docs/SECURITY.md): the encryption, authentication, and isolation model. ## Contributing diff --git a/crates/nvisy-file-service/README.md b/crates/nvisy-file-service/README.md index 56bad579..56f3e278 100644 --- a/crates/nvisy-file-service/README.md +++ b/crates/nvisy-file-service/README.md @@ -6,8 +6,8 @@ OAuth-based cloud file-service providers for the Nvisy platform. ## Overview -Connects a workspace to a tenant's consumer file service — Google Drive, -Dropbox, OneDrive, and Box — behind a single `FileServiceClient`. It owns +Connects a workspace to a tenant's consumer file service (Google Drive, +Dropbox, OneDrive, and Box) behind a single `FileServiceClient`. It owns the OAuth2 authorization-code flow (with refresh), driven through a shared `reqwest` client, and the per-provider REST calls to list and stream files in and out. Sibling to `nvisy-object-store`, which covers object stores diff --git a/docker/README.md b/docker/README.md index 9e6b0a66..5d86821c 100644 --- a/docker/README.md +++ b/docker/README.md @@ -16,11 +16,11 @@ real-time events and persistent job queues for asynchronous processing. JetStream must be enabled with sufficient storage allocation: the default configuration uses 1 GB of memory store and 10 GB of file store. -**An S3-compatible object store** for first-party blobs — uploaded files, +**An S3-compatible object store** for first-party blobs: uploaded files, detection audits, redacted output, and avatars. The compose files use [RustFS](https://rustfs.com) (MinIO-compatible, Apache-2.0); AWS S3 or any S3-compatible service (MinIO, Cloudflare R2, …) works by pointing `S3_ENDPOINT` -at it. The configured `S3_BUCKET` must exist before the server starts — the +at it. The configured `S3_BUCKET` must exist before the server starts, so the compose files provision it with a one-shot init container. ## Quick Start @@ -59,8 +59,8 @@ docker compose up -d --build ``` The production compose file starts every service on a private bridge network. -The server waits for the PostgreSQL, NATS, and RustFS health checks to pass — -and for the bucket-provisioning init container to finish — before starting. +The server waits for the PostgreSQL, NATS, and RustFS health checks to pass, +and for the bucket-provisioning init container to finish, before starting. ## Services @@ -77,6 +77,16 @@ All configuration is provided through environment variables. See [`.env.example`](../.env.example) at the repository root for a complete reference with defaults and descriptions. +Two settings depend on the deployment topology and default to the +directly-exposed case: + +- `CLIENT_IP_SOURCE` decides where the caller's IP is read from for audit and + security records. It defaults to `ConnectInfo` (the TCP peer). Behind a reverse + proxy or load balancer that sets a forwarding header, set it to the matching + source (e.g. `RightmostXForwardedFor`), or the recorded IP is the proxy's. +- `COOKIE_SECURE` defaults to `true` (session cookies are HTTPS-only). Set it to + `false` only for local HTTP development. + ## Key Generation The server requires an Ed25519 keypair for JWT signing and a 32-byte key for @@ -117,14 +127,14 @@ queues. ## Encryption at Rest The server encrypts sensitive payloads at the application layer with -XChaCha20-Poly1305 under per-workspace keys before they reach storage — file +XChaCha20-Poly1305 under per-workspace keys before they reach storage: file bytes, redacted output, analyzed documents, webhook signing secrets, and policy/context definitions. The blob store only ever receives ciphertext; any server-side encryption it offers is redundant defense-in-depth. This protects those payloads even against a live read of the datastore. -For everything else on disk — NATS stream/consumer metadata and KV entries, -Postgres rows, backups — provision the data volumes on **encrypted storage**. +For everything else on disk (NATS stream/consumer metadata and KV entries, +Postgres rows, backups), provision the data volumes on **encrypted storage**. This adds no runtime overhead, keeps key management out of the datastores, and covers the whole volume. @@ -138,7 +148,7 @@ The persistent volumes to encrypt are `nats_data`, `postgres_data`, and volumes) on a LUKS-encrypted partition. Volume encryption guards against stolen disks, snapshots, and backups. It does -not protect against a compromised running host — which is why the sensitive +not protect against a compromised running host, which is why the sensitive payloads above are additionally encrypted at the application layer. ## Health Checks From d7bb80b1043f98a87d0098170e5874be1cedc80e Mon Sep 17 00:00:00 2001 From: Oleh Martsokha Date: Wed, 9 Sep 2026 23:41:48 +0200 Subject: [PATCH 09/13] Split Avatar into AvatarUpload (extract) and AvatarImage (response) The Avatar type was both a FromRequest extractor and an IntoResponse, spanning both directions. Split it per direction and name each for its content: extract/avatar_upload.rs holds AvatarUpload (the multipart upload), and response/avatar_image.rs holds AvatarImage (the served WebP with cache headers). AvatarUpload now carries Bytes rather than Vec, dropping the field.bytes() -> .to_vec() copy on upload; set_account_avatar/set_workspace_avatar and process_avatar take Bytes (image decode only needs &[u8], and the re-encode produces a fresh Vec regardless). The serve side keeps Vec since that is what the blob read and re-encode produce and Vec -> Body is already zero-copy. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_018bKk1YEG4tZ69jzYVQvQL8 --- crates/nvisy-server/src/extract/avatar.rs | 97 ------------------- .../nvisy-server/src/extract/avatar_upload.rs | 53 ++++++++++ crates/nvisy-server/src/extract/mod.rs | 4 +- crates/nvisy-server/src/handler/accounts.rs | 4 +- crates/nvisy-server/src/handler/avatars.rs | 11 ++- crates/nvisy-server/src/handler/workspaces.rs | 4 +- .../nvisy-server/src/response/avatar_image.rs | 58 +++++++++++ crates/nvisy-server/src/response/mod.rs | 2 + crates/nvisy-server/src/service/avatar.rs | 17 ++-- 9 files changed, 135 insertions(+), 115 deletions(-) delete mode 100644 crates/nvisy-server/src/extract/avatar.rs create mode 100644 crates/nvisy-server/src/extract/avatar_upload.rs create mode 100644 crates/nvisy-server/src/response/avatar_image.rs diff --git a/crates/nvisy-server/src/extract/avatar.rs b/crates/nvisy-server/src/extract/avatar.rs deleted file mode 100644 index 70d9cc6d..00000000 --- a/crates/nvisy-server/src/extract/avatar.rs +++ /dev/null @@ -1,97 +0,0 @@ -//! The [`Avatar`] type used by both ends of the avatar routes: extracted from an -//! upload request and returned as the served image. - -use aide::generate::GenContext; -use aide::openapi::{MediaType, Operation, Response as OpenApiResponse}; -use aide::{OperationInput, OperationOutput}; -use axum::body::Body; -use axum::extract::{FromRequest, Request}; -use axum::http::{HeaderValue, StatusCode, header}; -use axum::response::{IntoResponse, Response}; - -use crate::extract::Multipart; -use crate::handler::{Error, ErrorKind}; -use crate::service::AVATAR_CONTENT_TYPE; - -/// Raw avatar image bytes, shared by the upload and serve directions. -/// -/// As a request extractor it reads the first file field of a multipart upload -/// into memory. As a response it serves the bytes with the WebP content type and -/// an immutable cache header: the serve URL carries a content hash, so a given -/// URL always maps to the same bytes and may be cached indefinitely; a new -/// upload changes the URL rather than the contents at a URL. -#[must_use] -pub struct Avatar(pub Vec); - -impl FromRequest for Avatar -where - S: Send + Sync, -{ - type Rejection = Error<'static>; - - async fn from_request(req: Request, state: &S) -> Result { - let Multipart(mut multipart) = Multipart::from_request(req, state).await?; - - while let Some(field) = multipart.next_field().await.map_err(|err| { - ErrorKind::BadRequest - .with_message("Invalid multipart data") - .with_context(err.to_string()) - })? { - if field.file_name().is_none() { - continue; - } - let bytes = field.bytes().await.map_err(|err| { - ErrorKind::BadRequest - .with_message("Failed to read uploaded image") - .with_context(err.to_string()) - })?; - return Ok(Avatar(bytes.to_vec())); - } - - Err(ErrorKind::BadRequest.with_message("No image file in upload")) - } -} - -impl OperationInput for Avatar { - fn operation_input(ctx: &mut GenContext, operation: &mut Operation) { - Multipart::operation_input(ctx, operation); - } -} - -impl IntoResponse for Avatar { - fn into_response(self) -> Response { - ( - StatusCode::OK, - [ - ( - header::CONTENT_TYPE, - HeaderValue::from_static(AVATAR_CONTENT_TYPE), - ), - ( - header::CACHE_CONTROL, - HeaderValue::from_static("public, max-age=31536000, immutable"), - ), - ], - Body::from(self.0), - ) - .into_response() - } -} - -impl OperationOutput for Avatar { - type Inner = Self; - - fn operation_response( - _ctx: &mut GenContext, - _operation: &mut Operation, - ) -> Option { - let mut response = OpenApiResponse { - description: "The owner's avatar image.".to_owned(), - ..Default::default() - }; - response - .content - .insert(AVATAR_CONTENT_TYPE.to_owned(), MediaType::default()); - Some(response) - } -} diff --git a/crates/nvisy-server/src/extract/avatar_upload.rs b/crates/nvisy-server/src/extract/avatar_upload.rs new file mode 100644 index 00000000..535a73cb --- /dev/null +++ b/crates/nvisy-server/src/extract/avatar_upload.rs @@ -0,0 +1,53 @@ +//! The [`AvatarUpload`] extractor: the image bytes of a multipart avatar upload. + +use aide::OperationInput; +use aide::generate::GenContext; +use aide::openapi::Operation; +use axum::extract::{FromRequest, Request}; +use bytes::Bytes; + +use crate::extract::Multipart; +use crate::handler::{Error, ErrorKind}; + +/// The raw bytes of an uploaded avatar image, read from the first file field of a +/// multipart request. +/// +/// Holds [`Bytes`] rather than `Vec` so the multipart field's buffer is +/// carried through to image processing without a copy. +#[must_use] +pub struct AvatarUpload(pub Bytes); + +impl FromRequest for AvatarUpload +where + S: Send + Sync, +{ + type Rejection = Error<'static>; + + async fn from_request(req: Request, state: &S) -> Result { + let Multipart(mut multipart) = Multipart::from_request(req, state).await?; + + while let Some(field) = multipart.next_field().await.map_err(|err| { + ErrorKind::BadRequest + .with_message("Invalid multipart data") + .with_context(err.to_string()) + })? { + if field.file_name().is_none() { + continue; + } + let bytes = field.bytes().await.map_err(|err| { + ErrorKind::BadRequest + .with_message("Failed to read uploaded image") + .with_context(err.to_string()) + })?; + return Ok(AvatarUpload(bytes)); + } + + Err(ErrorKind::BadRequest.with_message("No image file in upload")) + } +} + +impl OperationInput for AvatarUpload { + fn operation_input(ctx: &mut GenContext, operation: &mut Operation) { + Multipart::operation_input(ctx, operation); + } +} diff --git a/crates/nvisy-server/src/extract/mod.rs b/crates/nvisy-server/src/extract/mod.rs index c894aa23..1bcca4ea 100644 --- a/crates/nvisy-server/src/extract/mod.rs +++ b/crates/nvisy-server/src/extract/mod.rs @@ -6,7 +6,7 @@ //! standard Axum counterparts while providing additional features. mod auth; -mod avatar; +mod avatar_upload; mod idempotency_key; mod reject; mod security_context; @@ -18,7 +18,7 @@ mod workspace_context; // Glob: the auth surface includes the permission markers (one per `Permission`, // generated by a macro) used as `Authorized

`, alongside the named types. pub use crate::extract::auth::*; -pub use crate::extract::avatar::Avatar; +pub use crate::extract::avatar_upload::AvatarUpload; pub use crate::extract::idempotency_key::IdempotencyKey; pub use crate::extract::reject::{Form, Json, Multipart, Path, Query}; pub use crate::extract::security_context::SecurityContext; diff --git a/crates/nvisy-server/src/handler/accounts.rs b/crates/nvisy-server/src/handler/accounts.rs index bbb67aa4..90b63747 100644 --- a/crates/nvisy-server/src/handler/accounts.rs +++ b/crates/nvisy-server/src/handler/accounts.rs @@ -15,7 +15,7 @@ use uuid::Uuid; use super::request::{AccountPathParams, UpdateAccount}; use super::response::{Account, ErrorResponse, PublicAccount}; -use crate::extract::{AuthState, Avatar, Json, Path, ValidateJson}; +use crate::extract::{AuthState, AvatarUpload, Json, Path, ValidateJson}; use crate::handler::{Error, ErrorKind, Result}; use crate::service::{AvatarService, MAX_AVATAR_UPLOAD_BYTES, ServiceState}; @@ -206,7 +206,7 @@ async fn upload_account_avatar( State(avatar): State, auth_state: AuthState, Path(path_params): Path, - Avatar(bytes): Avatar, + AvatarUpload(bytes): AvatarUpload, ) -> Result<(StatusCode, Json)> { tracing::debug!(target: TRACING_TARGET, "Uploading account avatar"); diff --git a/crates/nvisy-server/src/handler/avatars.rs b/crates/nvisy-server/src/handler/avatars.rs index 1e124969..49c16f88 100644 --- a/crates/nvisy-server/src/handler/avatars.rs +++ b/crates/nvisy-server/src/handler/avatars.rs @@ -15,9 +15,10 @@ use schemars::JsonSchema; use serde::{Deserialize, Serialize}; use uuid::Uuid; -use crate::extract::{Avatar, Json, Path}; +use crate::extract::{Json, Path}; use crate::handler::response::ErrorResponse; use crate::handler::{Error, Result}; +use crate::response::AvatarImage; use crate::service::{AvatarService, ServiceState}; /// Tracing target for public avatar serving. @@ -43,7 +44,7 @@ struct AvatarPathParams { async fn get_account_avatar( State(avatar): State, Path(params): Path, -) -> Result { +) -> Result { tracing::debug!(target: TRACING_TARGET, "Serving account avatar"); let bytes = avatar @@ -51,7 +52,7 @@ async fn get_account_avatar( .await? .ok_or_else(|| Error::not_found("avatar"))?; - Ok(Avatar(bytes)) + Ok(AvatarImage(bytes)) } /// Serves a workspace's avatar image. Public; 404 when the version is unknown. @@ -59,7 +60,7 @@ async fn get_account_avatar( async fn get_workspace_avatar( State(avatar): State, Path(params): Path, -) -> Result { +) -> Result { tracing::debug!(target: TRACING_TARGET, "Serving workspace avatar"); let bytes = avatar @@ -67,7 +68,7 @@ async fn get_workspace_avatar( .await? .ok_or_else(|| Error::not_found("avatar"))?; - Ok(Avatar(bytes)) + Ok(AvatarImage(bytes)) } fn get_avatar_docs(op: TransformOperation) -> TransformOperation { diff --git a/crates/nvisy-server/src/handler/workspaces.rs b/crates/nvisy-server/src/handler/workspaces.rs index 5ef512f0..6caf329a 100644 --- a/crates/nvisy-server/src/handler/workspaces.rs +++ b/crates/nvisy-server/src/handler/workspaces.rs @@ -17,7 +17,7 @@ use nvisy_postgres::{AsyncConnection, PgClient, PgConn}; use uuid::Uuid; use crate::extract::{ - AuthState, Authorized, Avatar, DeleteWorkspace, Json, Query, SecurityContext, + AuthState, Authorized, AvatarUpload, DeleteWorkspace, Json, Query, SecurityContext, UpdateWorkspace as UpdateWorkspacePerm, ValidateJson, ViewWorkspace, WorkspaceContext, }; use crate::handler::request::{ @@ -459,7 +459,7 @@ async fn find_workspace_creator(conn: &mut PgConn, slug: &str) -> Result, authz: Authorized, - Avatar(bytes): Avatar, + AvatarUpload(bytes): AvatarUpload, ) -> Result { tracing::debug!(target: TRACING_TARGET, "Uploading workspace avatar"); diff --git a/crates/nvisy-server/src/response/avatar_image.rs b/crates/nvisy-server/src/response/avatar_image.rs new file mode 100644 index 00000000..69e20cf7 --- /dev/null +++ b/crates/nvisy-server/src/response/avatar_image.rs @@ -0,0 +1,58 @@ +//! The [`AvatarImage`] response: a served avatar image. + +use aide::OperationOutput; +use aide::generate::GenContext; +use aide::openapi::{MediaType, Operation, Response as OpenApiResponse}; +use axum::body::Body; +use axum::http::{HeaderValue, StatusCode, header}; +use axum::response::{IntoResponse, Response}; + +use crate::service::AVATAR_CONTENT_TYPE; + +/// A stored avatar image, served with the WebP content type and an immutable +/// cache header. +/// +/// The serve URL carries a content hash, so a given URL always maps to the same +/// bytes and may be cached indefinitely; a new upload changes the URL rather than +/// the contents at a URL. The bytes come straight from the blob store, so this +/// holds a `Vec` (which becomes the response body without a copy). +#[must_use] +pub struct AvatarImage(pub Vec); + +impl IntoResponse for AvatarImage { + fn into_response(self) -> Response { + ( + StatusCode::OK, + [ + ( + header::CONTENT_TYPE, + HeaderValue::from_static(AVATAR_CONTENT_TYPE), + ), + ( + header::CACHE_CONTROL, + HeaderValue::from_static("public, max-age=31536000, immutable"), + ), + ], + Body::from(self.0), + ) + .into_response() + } +} + +impl OperationOutput for AvatarImage { + type Inner = Self; + + fn operation_response( + _ctx: &mut GenContext, + _operation: &mut Operation, + ) -> Option { + let mut response = OpenApiResponse { + description: "The owner's avatar image.".to_owned(), + ..Default::default() + }; + response + .content + .insert(AVATAR_CONTENT_TYPE.to_owned(), MediaType::default()); + Some(response) + } +} diff --git a/crates/nvisy-server/src/response/mod.rs b/crates/nvisy-server/src/response/mod.rs index a4d68159..c4349e0e 100644 --- a/crates/nvisy-server/src/response/mod.rs +++ b/crates/nvisy-server/src/response/mod.rs @@ -6,7 +6,9 @@ //! whose job is response *behavior* — setting status, headers, or cookies. mod auth; +mod avatar_image; mod sse; pub use auth::{ClearedSession, CookieConfig, WebSession}; +pub use avatar_image::AvatarImage; pub use sse::SseResponse; diff --git a/crates/nvisy-server/src/service/avatar.rs b/crates/nvisy-server/src/service/avatar.rs index ad4ab509..54335fb2 100644 --- a/crates/nvisy-server/src/service/avatar.rs +++ b/crates/nvisy-server/src/service/avatar.rs @@ -9,6 +9,7 @@ use std::io::Cursor; +use bytes::Bytes; use image::{ImageFormat, ImageReader}; use nvisy_postgres::model::{Account, UpdateAccount, UpdateWorkspace}; use nvisy_postgres::query::{AccountRepository, WorkspaceRepository}; @@ -55,7 +56,7 @@ impl AvatarService { /// object is written and the URL updated before the previous version is /// deleted, so a reader never sees a missing avatar; a crash between the /// update and the delete can leave the previous object orphaned (see #192). - pub async fn set_account_avatar(&self, account_id: Uuid, upload: Vec) -> Result { + pub async fn set_account_avatar(&self, account_id: Uuid, upload: Bytes) -> Result { let webp = process_avatar(upload).await?; let version = content_version(&webp); @@ -136,7 +137,7 @@ impl AvatarService { /// object is written and the URL updated before the previous version is /// deleted, so a reader never sees a missing avatar; a crash between the /// update and the delete can leave the previous object orphaned (see #192). - pub async fn set_workspace_avatar(&self, workspace_id: Uuid, upload: Vec) -> Result<()> { + pub async fn set_workspace_avatar(&self, workspace_id: Uuid, upload: Bytes) -> Result<()> { let webp = process_avatar(upload).await?; let version = content_version(&webp); @@ -256,7 +257,7 @@ fn avatar_version(avatar_url: Option<&str>) -> Option { /// source-dimension cap. The result is resized to fit within [`TARGET_DIMENSION`] /// on its longest side (never upscaled) and encoded as WebP. The CPU-bound /// decode/encode runs on a blocking thread so it does not stall the runtime. -async fn process_avatar(bytes: Vec) -> Result> { +async fn process_avatar(bytes: Bytes) -> Result> { if bytes.len() > MAX_AVATAR_UPLOAD_BYTES { return Err(ErrorKind::BadRequest.with_message("Avatar must be at most 2 MiB")); } @@ -326,11 +327,11 @@ mod tests { use super::*; /// Encodes a solid image of the given size in the given format for test input. - fn encode(width: u32, height: u32, format: ImageFormat) -> Vec { + fn encode(width: u32, height: u32, format: ImageFormat) -> Bytes { let img = DynamicImage::ImageRgba8(RgbaImage::new(width, height)); let mut out = Cursor::new(Vec::new()); img.write_to(&mut out, format).unwrap(); - out.into_inner() + Bytes::from(out.into_inner()) } #[tokio::test] @@ -364,13 +365,15 @@ mod tests { #[tokio::test] async fn rejects_non_image() { - let err = process_avatar(b"not an image".to_vec()).await.unwrap_err(); + let err = process_avatar(Bytes::from_static(b"not an image")) + .await + .unwrap_err(); assert!(err.to_string().to_lowercase().contains("image")); } #[tokio::test] async fn rejects_oversized_upload() { - let too_big = vec![0u8; MAX_AVATAR_UPLOAD_BYTES + 1]; + let too_big = Bytes::from(vec![0u8; MAX_AVATAR_UPLOAD_BYTES + 1]); assert!(process_avatar(too_big).await.is_err()); } From 38f213a17ca43079a77b235504cef1db4e3489cd Mon Sep 17 00:00:00 2001 From: Oleh Martsokha Date: Wed, 9 Sep 2026 23:41:59 +0200 Subject: [PATCH 10/13] Address PR review: JWT exp leeway, CSRF-protect logout - Set validation.leeway = 0 in JWT validation. jsonwebtoken 11 defaults to a 60s exp grace; the JWT expiry is documented as an independent absolute cap, so a token past its cap must not be accepted for up to a minute if the DB session check is ever bypassed. - Move logout from the public router to the authenticated one. Logout revokes the caller's session (a cookie-driven state change) but was mounted without require_authentication or csrf_protect. authentication now exposes public_routes (login/signup) and authenticated_routes (logout); the latter is merged into the private router so it sits behind the auth and CSRF layers. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_018bKk1YEG4tZ69jzYVQvQL8 --- .../nvisy-server/src/extract/auth/jwt_claims.rs | 5 +++++ .../nvisy-server/src/handler/authentication.rs | 17 +++++++++++++---- crates/nvisy-server/src/handler/mod.rs | 15 +++++++++------ 3 files changed, 27 insertions(+), 10 deletions(-) diff --git a/crates/nvisy-server/src/extract/auth/jwt_claims.rs b/crates/nvisy-server/src/extract/auth/jwt_claims.rs index ffe2827c..34ad90ef 100644 --- a/crates/nvisy-server/src/extract/auth/jwt_claims.rs +++ b/crates/nvisy-server/src/extract/auth/jwt_claims.rs @@ -204,6 +204,11 @@ where // Configure comprehensive JWT validation let mut validation = Validation::new(Algorithm::EdDSA); validation.validate_exp = true; + // No clock-skew grace on `exp`: the JWT expiry is documented as an + // independent absolute cap (a backstop if the DB session check is ever + // bypassed), and the default 60s leeway would let a token past its cap + // through for up to a minute. + validation.leeway = 0; validation.validate_nbf = false; // Not Before claim not used validation.validate_aud = true; validation.set_audience(&[Self::JWT_AUDIENCE]); diff --git a/crates/nvisy-server/src/handler/authentication.rs b/crates/nvisy-server/src/handler/authentication.rs index 569a63fc..f9002d4c 100644 --- a/crates/nvisy-server/src/handler/authentication.rs +++ b/crates/nvisy-server/src/handler/authentication.rs @@ -253,15 +253,24 @@ fn logout_docs(op: TransformOperation) -> TransformOperation { .response::<401, Json>() } -/// Returns a [`Router`] with all related routes. -/// -/// [`Router`]: axum::routing::Router -pub fn routes() -> ApiRouter { +/// Public authentication routes: login and signup, which a caller with no +/// session reaches before authenticating. +pub fn public_routes() -> ApiRouter { use aide::axum::routing::*; ApiRouter::new() .api_route("/auth/login/", post_with(login, login_docs)) .api_route("/auth/signup/", post_with(signup, signup_docs)) + .with_path_items(|item| item.tag("Authentication")) +} + +/// Authenticated authentication routes: logout, which revokes the caller's +/// session and so must sit behind the authentication and CSRF layers (it is a +/// cookie-driven state change). +pub fn authenticated_routes() -> ApiRouter { + use aide::axum::routing::*; + + ApiRouter::new() .api_route("/auth/logout/", post_with(logout, logout_docs)) .with_path_items(|item| item.tag("Authentication")) } diff --git a/crates/nvisy-server/src/handler/mod.rs b/crates/nvisy-server/src/handler/mod.rs index fe23f839..5fa193b5 100644 --- a/crates/nvisy-server/src/handler/mod.rs +++ b/crates/nvisy-server/src/handler/mod.rs @@ -99,18 +99,21 @@ fn private_routes(service_state: ServiceState) -> ApiRouter { // Account identity management and OIDC step-up re-auth. .merge(identities::routes()) .merge(auth_oidc::private_routes()) + // Logout revokes the caller's session (a cookie-driven state change), so + // it sits behind the authentication and CSRF layers. + .merge(authentication::authenticated_routes()) } /// Returns an [`ApiRouter`] with all built-in public routes. Downstream routes /// are merged separately by [`routes`]. fn public_routes() -> ApiRouter { ApiRouter::new() - // Authentication is always mounted: password login/signup/logout plus OIDC - // sign-in. The OIDC public routes (sign-in start + provider callback) need - // no session — the caller has none yet and the provider's browser redirect - // carries no Authorization header. The authenticated link route is in the - // private routes. - .merge(authentication::routes()) + // Public authentication: password login/signup plus OIDC sign-in. These + // need no session — the caller has none yet and the provider's browser + // redirect carries no Authorization header. Logout is authenticated (it + // revokes a session), so it is in the private routes; the authenticated + // OIDC link route is there too. + .merge(authentication::public_routes()) .merge(auth_oidc::public_routes()) .merge(monitors::routes()) // Avatar serving is public so images load directly in an `` tag; it From bbfa40ac9358860396e51f664b92c31552035445 Mon Sep 17 00:00:00 2001 From: Oleh Martsokha Date: Wed, 9 Sep 2026 23:57:51 +0200 Subject: [PATCH 11/13] Move download + redirect response behavior into crate::response Relocate the remaining response-behavior helpers into the root response module, keeping doc-only helpers out of it: - attachment_headers (builds real download response headers) -> response/download. Its sibling DownloadResponseExt only documents the raw-body response in the OpenAPI spec (no runtime response), so it moves back to handler/utility renamed DownloadDocs. - The two redirect_to_frontend helpers -> response/redirect: the OIDC one becomes RedirectResult::into_redirect(base) (a method on the outcome enum), and the cloud-file one becomes the free fn connection_result_redirect. Both leave auth_oidc/connection_oauth. Also fixes three doc bugs found reviewing auth_oidc: a resolve_account doc block that had drifted above link_oidc_identity, and two stale "fragment" references for the desktop app-token delivery, which rides in the URL query (the reauth proof, correctly, stays in the fragment). Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_018bKk1YEG4tZ69jzYVQvQL8 --- crates/nvisy-server/src/handler/activities.rs | 5 +- crates/nvisy-server/src/handler/auth_oidc.rs | 157 ++++-------------- .../src/handler/connection_oauth.rs | 30 +--- .../src/handler/detection_audits.rs | 3 +- crates/nvisy-server/src/handler/files.rs | 3 +- .../src/handler/utility/download.rs | 57 ------- .../src/handler/utility/download_docs.rs | 32 ++++ .../nvisy-server/src/handler/utility/mod.rs | 4 +- crates/nvisy-server/src/response/download.rs | 27 +++ crates/nvisy-server/src/response/mod.rs | 4 + crates/nvisy-server/src/response/redirect.rs | 139 ++++++++++++++++ 11 files changed, 242 insertions(+), 219 deletions(-) delete mode 100644 crates/nvisy-server/src/handler/utility/download.rs create mode 100644 crates/nvisy-server/src/handler/utility/download_docs.rs create mode 100644 crates/nvisy-server/src/response/download.rs create mode 100644 crates/nvisy-server/src/response/redirect.rs diff --git a/crates/nvisy-server/src/handler/activities.rs b/crates/nvisy-server/src/handler/activities.rs index d7c9f2a9..dde25f68 100644 --- a/crates/nvisy-server/src/handler/activities.rs +++ b/crates/nvisy-server/src/handler/activities.rs @@ -24,10 +24,9 @@ use crate::handler::request::{ MAX_EXPORT_ROWS, }; use crate::handler::response::{ActivitiesPage, Activity, ErrorResponse}; -use crate::handler::utility::{ - ActorFilter, DownloadResponseExt, attachment_headers, resolve_actor, -}; +use crate::handler::utility::{ActorFilter, DownloadDocs, resolve_actor}; use crate::handler::{Error, ErrorKind, Result, ServiceState}; +use crate::response::attachment_headers; /// Tracing target for activity export operations. const TRACING_TARGET: &str = "nvisy_server::handler::activities"; diff --git a/crates/nvisy-server/src/handler/auth_oidc.rs b/crates/nvisy-server/src/handler/auth_oidc.rs index 04a04331..00a9150f 100644 --- a/crates/nvisy-server/src/handler/auth_oidc.rs +++ b/crates/nvisy-server/src/handler/auth_oidc.rs @@ -15,7 +15,7 @@ //! sign-in, or auto-linking to an existing account on a verified email) and //! mint a session, delivered by the redirect target: a **web** origin gets an //! `HttpOnly` session cookie; a **desktop** deep-link scheme gets a long-lived -//! `app` token in the redirect's URL fragment (for the native app); +//! `app` token in the redirect's URL query (for the native app); //! - **link** — attach the verified provider identity to the authenticated //! account that started the flow (started under the account-identities //! resource; requires a step-up proof); @@ -41,7 +41,7 @@ use aide::axum::routing::{get_with, post_with}; use aide::transform::TransformOperation; use axum::extract::State; use axum::http::StatusCode; -use axum::response::{IntoResponse, Redirect, Response}; +use axum::response::{IntoResponse, Response}; use axum::routing::get; use nvisy_nats::NatsClient; use nvisy_nats::kv::{ @@ -61,7 +61,7 @@ use crate::extract::{AuthState, Json, Path, Query, SecurityContext, ValidateJson use crate::handler::request::{DesktopTokenRequest, IdentityPathParams, OidcCallbackQuery}; use crate::handler::response::{DesktopToken, ErrorResponse}; use crate::handler::{ErrorKind, Result}; -use crate::response::{CookieConfig, WebSession}; +use crate::response::{CookieConfig, RedirectResult, WebSession}; use crate::service::{ AuthIssuer, OidcAuthorization, OidcIdentity, OidcService, RedirectKind, ServiceState, }; @@ -456,7 +456,7 @@ async fn oidc_callback( // No flow means no trusted redirect target (an unknown/expired/replayed // state), so fall back to the in-page result. tracing::warn!(target: TRACING_TARGET, error = %err, "OIDC callback state invalid"); - return redirect_to_frontend(None, RedirectResult::Error); + return RedirectResult::Error.into_redirect(None); } }; let redirect_uri = flow.redirect_uri.clone(); @@ -468,7 +468,7 @@ async fn oidc_callback( } Err(err) => { tracing::warn!(target: TRACING_TARGET, error = %err, "OIDC callback failed"); - redirect_to_frontend(redirect_uri.as_deref(), RedirectResult::Error) + RedirectResult::Error.into_redirect(redirect_uri.as_deref()) } } } @@ -506,7 +506,7 @@ impl CallbackOutcome { Self::SignedIn { jwt } => { // Web sign-in delivers the session as an HttpOnly cookie (plus its // CSRF cookie) set on the success redirect — never in the URL. - let redirect = redirect_to_frontend(redirect_uri, RedirectResult::Success); + let redirect = RedirectResult::Success.into_redirect(redirect_uri); (WebSession::new(jwt, cookie).into_jar(), redirect).into_response() } Self::DesktopSignedIn { jwt } => { @@ -514,22 +514,18 @@ impl CallbackOutcome { // query (`?token=…`) — never a cookie the app's webview can't see. // The target is the allow-listed custom scheme, which has no server // hop, so the query is safe and matches the native OAuth convention. - redirect_to_frontend( - redirect_uri, - RedirectResult::Query { - name: "token", - value: &jwt, - }, - ) + RedirectResult::Query { + name: "token", + value: &jwt, + } + .into_redirect(redirect_uri) + } + Self::Linked => RedirectResult::Success.into_redirect(redirect_uri), + Self::Reauthed { proof } => RedirectResult::Fragment { + name: "reauthProof", + value: &proof, } - Self::Linked => redirect_to_frontend(redirect_uri, RedirectResult::Success), - Self::Reauthed { proof } => redirect_to_frontend( - redirect_uri, - RedirectResult::Fragment { - name: "reauthProof", - value: &proof, - }, - ), + .into_redirect(redirect_uri), } } } @@ -590,7 +586,7 @@ async fn run_flow( // The redirect target decides how the session is delivered. A desktop // deep-link scheme gets a long-lived `app` token in the callback's URL - // fragment; a web origin gets an HttpOnly session cookie. The target was + // query; a web origin gets an HttpOnly session cookie. The target was // already allow-listed at flow start; a `None` here means it is neither // kind (which `begin_flow` would have rejected), so default to the web // cookie path. @@ -665,18 +661,6 @@ async fn mint_reauth_proof(nats: &NatsClient, account_id: Uuid) -> Result } } +/// Resolves the account for a verified OIDC identity, in order of preference: +/// +/// 1. **Returning user** — an identity already exists for this `(provider, +/// subject)`; reuse its account. +/// 2. **Link to an existing account** — the provider asserts a *verified* email +/// that matches an account (e.g. one created by password signup); attach a new +/// OIDC identity to it, so the two sign-in methods share one account. +/// 3. **Provision** — otherwise create a new account and its OIDC identity. +/// +/// Linking requires a verified email: an unverified address could be one the +/// signer does not control, so linking on it would let an attacker attach their +/// provider identity to someone else's account. async fn resolve_account( conn: &mut PgConn, provider: IdentityProvider, @@ -930,99 +926,6 @@ fn truncate_on_char_boundary(value: &str, max: usize) -> &str { &value[..end] } -/// The outcome conveyed to the frontend by the callback redirect, and where its -/// value (if any) is placed on the redirect URL. -enum RedirectResult<'a> { - /// A plain success with no value (a completed link, or a web sign-in whose - /// session rides in cookies set on the same response). - Success, - /// A failure. - Error, - /// A value carried in the URL **fragment** (`#{name}=…`) — for a *web* target, - /// where a fragment is not sent to the server, not in `Referer`, and stays - /// client-side. Used for the step-up reauth proof (a bearer credential the web - /// frontend presents to a credential-adding action). - Fragment { name: &'a str, value: &'a str }, - /// A value carried in the URL **query** (`?{name}=…`) — for a *desktop* - /// custom-scheme deep-link, which has no server hop (so fragment vs query is - /// moot for leakage) and where the query is the RFC 8252 native convention. - /// Used for the desktop `app` token. - Query { name: &'a str, value: &'a str }, -} - -/// Returns the browser to the frontend with the callback outcome. -/// -/// The `signin=success|error` status always goes in the query string. A carried -/// value's placement depends on the target: [`Fragment`](RedirectResult::Fragment) -/// for a web target (the reauth proof — kept out of the query so it does not leak -/// via `Referer`/history), [`Query`](RedirectResult::Query) for a desktop -/// custom-scheme deep-link (the `app` token — no server hop, query is the native -/// convention). Web sign-in carries no value here: its session rides in cookies. -/// -/// `base` is only ever an allow-listed target (validated when the flow starts). -/// When no target is configured, or it somehow fails to parse, this renders a -/// minimal self-describing page instead of redirecting. -fn redirect_to_frontend(base: Option<&str>, result: RedirectResult<'_>) -> Response { - enum Placement<'a> { - None, - Fragment(&'a str, &'a str), - Query(&'a str, &'a str), - } - let (status, placement) = match result { - RedirectResult::Success => ("success", Placement::None), - RedirectResult::Error => ("error", Placement::None), - RedirectResult::Fragment { name, value } => ("success", Placement::Fragment(name, value)), - RedirectResult::Query { name, value } => ("success", Placement::Query(name, value)), - }; - let carries_value = !matches!(placement, Placement::None); - - // Build the redirect target through the URL parser so the query and fragment - // are assembled and encoded correctly, rather than by string concatenation - // that could mishandle an existing query or fragment on the base. - if let Some(base) = base - && let Ok(mut url) = url::Url::parse(base) - { - url.query_pairs_mut().append_pair("signin", status); - match placement { - Placement::None => url.set_fragment(None), - // A web bearer secret goes in the fragment, never the query, so it is - // not leaked via Referer, history, or logs. `Url` percent-encodes it. - Placement::Fragment(name, value) => { - url.set_fragment(Some(&format!("{name}={value}"))); - } - // A desktop deep-link value goes in the query (`query_pairs_mut` - // percent-encodes it). The custom scheme has no server hop, so this - // does not leak; it matches the native OAuth redirect convention. - Placement::Query(name, value) => { - url.query_pairs_mut().append_pair(name, value); - } - } - return Redirect::to(url.as_str()).into_response(); - } - - // No usable redirect target. A value-carrying outcome must NOT reach here: its - // target was allow-listed at flow start, so a missing/unparseable base now is a - // server-side invariant break — rendering the in-page page would silently - // discard the token (leaving a desktop app hung) instead of delivering it. Fail - // loudly rather than swallow it. - if carries_value { - tracing::error!( - target: TRACING_TARGET, - "callback reached the no-redirect fallback while carrying a token; \ - the redirect target should have been validated at flow start", - ); - return ErrorKind::InternalServerError - .with_message("Sign-in could not be completed") - .with_resource("authentication") - .into_response(); - } - - // A valueless success/error with no configured frontend: render a minimal - // in-page result. - let body = format!("Sign-in {status}. You can close this window."); - (StatusCode::OK, body).into_response() -} - /// Returns the public OIDC sign-in routes: sign-in start and the provider /// callback. /// diff --git a/crates/nvisy-server/src/handler/connection_oauth.rs b/crates/nvisy-server/src/handler/connection_oauth.rs index 9e517721..43e84e27 100644 --- a/crates/nvisy-server/src/handler/connection_oauth.rs +++ b/crates/nvisy-server/src/handler/connection_oauth.rs @@ -42,6 +42,7 @@ use crate::extract::{ use crate::handler::request::{OAuthCallbackQuery, OAuthStartPathParams, StartFileServiceOAuth}; use crate::handler::response::ErrorResponse; use crate::handler::{Error, ErrorKind, Result}; +use crate::response::connection_result_redirect; use crate::service::{ ConnectionConfig, ConnectionRef, CryptoService, EventEmitter, EventOrigin, FileServiceRedirect, ServiceState, WorkspaceEvent, @@ -177,7 +178,7 @@ async fn oauth_callback( ("error", None) } }; - redirect_to_frontend(redirect.0.as_deref(), status, workspace_slug.as_deref()) + connection_result_redirect(redirect.0.as_deref(), status, workspace_slug.as_deref()) } /// Runs the callback's work: consume the pending authorization, exchange the @@ -273,33 +274,6 @@ async fn complete_callback( }) } -/// Redirects the browser back to the frontend with the flow's outcome. -/// -/// A `{workspaceSlug}` placeholder in the configured base is substituted with -/// `workspace_slug` when known (i.e. on success), so a base like -/// `https://app/w/{workspaceSlug}/integrations` lands on the workspace's page. -/// The outcome is appended as a `connection=success|error` query. Falls back to a -/// self-describing data page when no frontend URL is configured. -fn redirect_to_frontend( - base: Option<&str>, - status: &str, - workspace_slug: Option<&str>, -) -> Redirect { - match base { - Some(base) => { - let base = match workspace_slug { - Some(slug) => base.replace("{workspaceSlug}", slug), - None => base.to_owned(), - }; - let separator = if base.contains('?') { '&' } else { '?' }; - Redirect::to(&format!("{base}{separator}connection={status}")) - } - None => Redirect::to(&format!( - "data:text/plain,cloud%20file%20connection%20{status}" - )), - } -} - /// Returns the authenticated cloud file OAuth routes: starting an /// authorization is workspace-scoped and requires `ManageConnections`. pub fn private_routes() -> ApiRouter { diff --git a/crates/nvisy-server/src/handler/detection_audits.rs b/crates/nvisy-server/src/handler/detection_audits.rs index 41782c0d..41ba9634 100644 --- a/crates/nvisy-server/src/handler/detection_audits.rs +++ b/crates/nvisy-server/src/handler/detection_audits.rs @@ -22,8 +22,9 @@ use super::detections::find_detection; use crate::extract::{Authorized, DownloadAudit, DownloadOriginalFiles, Json, Path, Query}; use crate::handler::request::{DetectionPathParams, ExportFormat, ExportQuery}; use crate::handler::response::ErrorResponse; -use crate::handler::utility::{DownloadResponseExt, attachment_headers}; +use crate::handler::utility::DownloadDocs; use crate::handler::{Error, ErrorKind, Result}; +use crate::response::attachment_headers; use crate::service::{EngineService, RunBlobStore, ServiceState}; /// Tracing target for detection audit operations. diff --git a/crates/nvisy-server/src/handler/files.rs b/crates/nvisy-server/src/handler/files.rs index d354322b..6c8986f0 100644 --- a/crates/nvisy-server/src/handler/files.rs +++ b/crates/nvisy-server/src/handler/files.rs @@ -31,9 +31,10 @@ use crate::handler::request::{ WorkspaceFilePathParams, }; use crate::handler::response::{self, ErrorResponse, File, Files, FilesPage}; -use crate::handler::utility::{DownloadResponseExt, attachment_headers, resolve_account_ref}; +use crate::handler::utility::{DownloadDocs, resolve_account_ref}; use crate::handler::{Error, ErrorKind, Result}; use crate::middleware::UploadConfig; +use crate::response::attachment_headers; use crate::service::{ CryptoService, EngineService, EventEmitter, EventOrigin, FileRef, HashingReader, LimitedReader, RunBlobStore, ServiceState, WorkspaceEvent, diff --git a/crates/nvisy-server/src/handler/utility/download.rs b/crates/nvisy-server/src/handler/utility/download.rs deleted file mode 100644 index 76d5c552..00000000 --- a/crates/nvisy-server/src/handler/utility/download.rs +++ /dev/null @@ -1,57 +0,0 @@ -//! Shared helpers for file-download (attachment) responses. - -use aide::openapi::MediaType; -use aide::transform::TransformOperation; -use axum::http::header::{CONTENT_DISPOSITION, CONTENT_LENGTH, CONTENT_TYPE}; -use axum::http::{HeaderMap, HeaderValue}; - -/// Builds the response headers for a downloadable attachment: a -/// `Content-Disposition: attachment` with `filename`, the `content_type`, and the -/// `content_length`. -/// -/// `filename` goes into a quoted header value verbatim, so a caller passing a -/// user-supplied name must strip control characters, `"`, and `\` first; a -/// server-generated name (a UUID, ISO dates, a fixed stem) needs no sanitizing. -/// A name that still fails to parse falls back to a bare `attachment`. -pub fn attachment_headers( - filename: &str, - content_type: HeaderValue, - content_length: u64, -) -> HeaderMap { - let mut headers = HeaderMap::new(); - let disposition = format!("attachment; filename=\"{filename}\"") - .parse() - .unwrap_or_else(|_| HeaderValue::from_static("attachment")); - headers.insert(CONTENT_DISPOSITION, disposition); - headers.insert(CONTENT_TYPE, content_type); - headers.insert(CONTENT_LENGTH, HeaderValue::from(content_length)); - headers -} - -/// Documents downloadable (raw-body) responses on an operation. -pub trait DownloadResponseExt<'a> { - /// Documents a `200` download response that carries a body under - /// `content_types`. - /// - /// A download handler returns raw bytes (`(StatusCode, HeaderMap, Body)`), - /// which aide cannot introspect, so without this the generated spec records an - /// empty `200` (`content: never`) and a client's low-level contract omits the - /// downloadable body. This declares each media type the endpoint can return, - /// so the body is present in the spec. The bodies are opaque (CSV, a zip, a - /// stream), so the media types carry no schema. - fn download_response(self, description: &str, content_types: &[&str]) -> Self; -} - -impl<'a> DownloadResponseExt<'a> for TransformOperation<'a> { - fn download_response(self, description: &str, content_types: &[&str]) -> Self { - self.response_with::<200, (), _>(|mut res| { - res.inner().description = description.to_owned(); - for content_type in content_types { - res.inner() - .content - .insert((*content_type).to_owned(), MediaType::default()); - } - res - }) - } -} diff --git a/crates/nvisy-server/src/handler/utility/download_docs.rs b/crates/nvisy-server/src/handler/utility/download_docs.rs new file mode 100644 index 00000000..83ca7e05 --- /dev/null +++ b/crates/nvisy-server/src/handler/utility/download_docs.rs @@ -0,0 +1,32 @@ +//! OpenAPI documentation helper for download (raw-body) responses. + +use aide::openapi::MediaType; +use aide::transform::TransformOperation; + +/// Documents downloadable (raw-body) responses on an operation. +pub trait DownloadDocs<'a> { + /// Documents a `200` download response that carries a body under + /// `content_types`. + /// + /// A download handler returns raw bytes (`(StatusCode, HeaderMap, Body)`), + /// which aide cannot introspect, so without this the generated spec records an + /// empty `200` (`content: never`) and a client's low-level contract omits the + /// downloadable body. This declares each media type the endpoint can return, + /// so the body is present in the spec. The bodies are opaque (CSV, a zip, a + /// stream), so the media types carry no schema. + fn download_response(self, description: &str, content_types: &[&str]) -> Self; +} + +impl<'a> DownloadDocs<'a> for TransformOperation<'a> { + fn download_response(self, description: &str, content_types: &[&str]) -> Self { + self.response_with::<200, (), _>(|mut res| { + res.inner().description = description.to_owned(); + for content_type in content_types { + res.inner() + .content + .insert((*content_type).to_owned(), MediaType::default()); + } + res + }) + } +} diff --git a/crates/nvisy-server/src/handler/utility/mod.rs b/crates/nvisy-server/src/handler/utility/mod.rs index 9e226803..803840be 100644 --- a/crates/nvisy-server/src/handler/utility/mod.rs +++ b/crates/nvisy-server/src/handler/utility/mod.rs @@ -2,10 +2,10 @@ mod accounts; mod custom_routes; -mod download; +mod download_docs; mod file_hash; pub use accounts::{ActorFilter, build_password_user_inputs, resolve_account_ref, resolve_actor}; pub use custom_routes::CustomRoutes; -pub use download::{DownloadResponseExt, attachment_headers}; +pub use download_docs::DownloadDocs; pub use file_hash::FileHash; diff --git a/crates/nvisy-server/src/response/download.rs b/crates/nvisy-server/src/response/download.rs new file mode 100644 index 00000000..80d67246 --- /dev/null +++ b/crates/nvisy-server/src/response/download.rs @@ -0,0 +1,27 @@ +//! Response headers for file-download (attachment) responses. + +use axum::http::header::{CONTENT_DISPOSITION, CONTENT_LENGTH, CONTENT_TYPE}; +use axum::http::{HeaderMap, HeaderValue}; + +/// Builds the response headers for a downloadable attachment: a +/// `Content-Disposition: attachment` with `filename`, the `content_type`, and the +/// `content_length`. +/// +/// `filename` goes into a quoted header value verbatim, so a caller passing a +/// user-supplied name must strip control characters, `"`, and `\` first; a +/// server-generated name (a UUID, ISO dates, a fixed stem) needs no sanitizing. +/// A name that still fails to parse falls back to a bare `attachment`. +pub fn attachment_headers( + filename: &str, + content_type: HeaderValue, + content_length: u64, +) -> HeaderMap { + let mut headers = HeaderMap::new(); + let disposition = format!("attachment; filename=\"{filename}\"") + .parse() + .unwrap_or_else(|_| HeaderValue::from_static("attachment")); + headers.insert(CONTENT_DISPOSITION, disposition); + headers.insert(CONTENT_TYPE, content_type); + headers.insert(CONTENT_LENGTH, HeaderValue::from(content_length)); + headers +} diff --git a/crates/nvisy-server/src/response/mod.rs b/crates/nvisy-server/src/response/mod.rs index c4349e0e..08e0dde4 100644 --- a/crates/nvisy-server/src/response/mod.rs +++ b/crates/nvisy-server/src/response/mod.rs @@ -7,8 +7,12 @@ mod auth; mod avatar_image; +mod download; +mod redirect; mod sse; pub use auth::{ClearedSession, CookieConfig, WebSession}; pub use avatar_image::AvatarImage; +pub use download::attachment_headers; +pub(crate) use redirect::{RedirectResult, connection_result_redirect}; pub use sse::SseResponse; diff --git a/crates/nvisy-server/src/response/redirect.rs b/crates/nvisy-server/src/response/redirect.rs new file mode 100644 index 00000000..520eb06e --- /dev/null +++ b/crates/nvisy-server/src/response/redirect.rs @@ -0,0 +1,139 @@ +//! Redirects that return the browser to the configured frontend at the end of a +//! browser-driven flow (OIDC sign-in/link/reauth, cloud-file OAuth). +//! +//! Each flow validates its redirect target when it starts, so the `base` here is +//! always an allow-listed URL; when none is configured, a minimal self-describing +//! fallback is rendered instead of redirecting. + +use axum::http::StatusCode; +use axum::response::{IntoResponse, Redirect, Response}; + +use crate::handler::ErrorKind; + +/// Tracing target for frontend-redirect construction. +const TRACING_TARGET: &str = "nvisy_server::response::redirect"; + +/// The outcome an OIDC callback conveys to the frontend, and where its value (if +/// any) is placed on the redirect URL. +pub(crate) enum RedirectResult<'a> { + /// A plain success with no value (a completed link, or a web sign-in whose + /// session rides in cookies set on the same response). + Success, + /// A failure. + Error, + /// A value carried in the URL **fragment** (`#{name}=…`) — for a *web* target, + /// where a fragment is not sent to the server, not in `Referer`, and stays + /// client-side. Used for the step-up reauth proof (a bearer credential the web + /// frontend presents to a credential-adding action). + Fragment { name: &'a str, value: &'a str }, + /// A value carried in the URL **query** (`?{name}=…`) — for a *desktop* + /// custom-scheme deep-link, which has no server hop (so fragment vs query is + /// moot for leakage) and where the query is the RFC 8252 native convention. + /// Used for the desktop `app` token. + Query { name: &'a str, value: &'a str }, +} + +impl RedirectResult<'_> { + /// Returns the browser to `base` (the frontend) carrying this outcome. + /// + /// The `signin=success|error` status always goes in the query string. A + /// carried value's placement depends on the target: + /// [`Fragment`](RedirectResult::Fragment) for a web target (the reauth proof, + /// kept out of the query so it does not leak via `Referer`/history), + /// [`Query`](RedirectResult::Query) for a desktop custom-scheme deep-link (the + /// `app` token, no server hop, query is the native convention). Web sign-in + /// carries no value here: its session rides in cookies. + /// + /// `base` is only ever an allow-listed target (validated when the flow + /// starts). When no target is configured, or it somehow fails to parse, this + /// renders a minimal self-describing page instead of redirecting. + pub(crate) fn into_redirect(self, base: Option<&str>) -> Response { + enum Placement<'a> { + None, + Fragment(&'a str, &'a str), + Query(&'a str, &'a str), + } + let (status, placement) = match self { + RedirectResult::Success => ("success", Placement::None), + RedirectResult::Error => ("error", Placement::None), + RedirectResult::Fragment { name, value } => { + ("success", Placement::Fragment(name, value)) + } + RedirectResult::Query { name, value } => ("success", Placement::Query(name, value)), + }; + let carries_value = !matches!(placement, Placement::None); + + // Build the redirect target through the URL parser so the query and + // fragment are assembled and encoded correctly, rather than by string + // concatenation that could mishandle an existing query or fragment. + if let Some(base) = base + && let Ok(mut url) = url::Url::parse(base) + { + url.query_pairs_mut().append_pair("signin", status); + match placement { + Placement::None => url.set_fragment(None), + // A web bearer secret goes in the fragment, never the query, so it + // is not leaked via Referer, history, or logs. `Url` encodes it. + Placement::Fragment(name, value) => { + url.set_fragment(Some(&format!("{name}={value}"))); + } + // A desktop deep-link value goes in the query (`query_pairs_mut` + // percent-encodes it). The custom scheme has no server hop, so this + // does not leak; it matches the native OAuth redirect convention. + Placement::Query(name, value) => { + url.query_pairs_mut().append_pair(name, value); + } + } + return Redirect::to(url.as_str()).into_response(); + } + + // No usable redirect target. A value-carrying outcome must NOT reach here: + // its target was allow-listed at flow start, so a missing/unparseable base + // now is a server-side invariant break — rendering the in-page page would + // silently discard the token (leaving a desktop app hung) instead of + // delivering it. Fail loudly rather than swallow it. + if carries_value { + tracing::error!( + target: TRACING_TARGET, + "callback reached the no-redirect fallback while carrying a token; \ + the redirect target should have been validated at flow start", + ); + return ErrorKind::InternalServerError + .with_message("Sign-in could not be completed") + .with_resource("authentication") + .into_response(); + } + + // A valueless success/error with no configured frontend: render a minimal + // in-page result. + let body = format!("Sign-in {status}. You can close this window."); + (StatusCode::OK, body).into_response() + } +} + +/// Returns the browser to the frontend after a cloud-file OAuth flow. +/// +/// A `{workspaceSlug}` placeholder in the configured base is substituted with +/// `workspace_slug` when known (i.e. on success), so a base like +/// `https://app/w/{workspaceSlug}/integrations` lands on the workspace's page. +/// The outcome is appended as a `connection=success|error` query. Falls back to a +/// self-describing data page when no frontend URL is configured. +pub(crate) fn connection_result_redirect( + base: Option<&str>, + status: &str, + workspace_slug: Option<&str>, +) -> Redirect { + match base { + Some(base) => { + let base = match workspace_slug { + Some(slug) => base.replace("{workspaceSlug}", slug), + None => base.to_owned(), + }; + let separator = if base.contains('?') { '&' } else { '?' }; + Redirect::to(&format!("{base}{separator}connection={status}")) + } + None => Redirect::to(&format!( + "data:text/plain,cloud%20file%20connection%20{status}" + )), + } +} From 52c3a578aa164cfad6b1f9c2f99d905d5869cee2 Mon Sep 17 00:00:00 2001 From: Oleh Martsokha Date: Thu, 10 Sep 2026 00:08:03 +0200 Subject: [PATCH 12/13] Extract OIDC account provisioning into an AccountProvisioner service MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move the OIDC account-resolution domain logic out of the auth_oidc handler into a stateless AccountProvisioner service (a unit struct resolved via State, like AuthIssuer): resolve() (return existing / link-to-existing-email / provision), link() (authenticated link), load_active(), and the private helpers (link_oidc_identity, derive_unique_username, truncate_on_char_boundary) plus the username-attempt cap. The logic is a verbatim move — behavior unchanged, and the security core (email-verified gating on link/provision, provider-slot conflict handling, unique-username derivation) was verified sound in review. auth_oidc.rs drops from 977 to 723 lines and is now cleanly the controller + flow-state layer; the callback and desktop-token handlers reach provisioning through DI. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_018bKk1YEG4tZ69jzYVQvQL8 --- crates/nvisy-server/src/handler/auth_oidc.rs | 311 ++--------------- .../src/service/account_provisioner.rs | 319 ++++++++++++++++++ crates/nvisy-server/src/service/mod.rs | 10 + 3 files changed, 359 insertions(+), 281 deletions(-) create mode 100644 crates/nvisy-server/src/service/account_provisioner.rs diff --git a/crates/nvisy-server/src/handler/auth_oidc.rs b/crates/nvisy-server/src/handler/auth_oidc.rs index 00a9150f..329cff46 100644 --- a/crates/nvisy-server/src/handler/auth_oidc.rs +++ b/crates/nvisy-server/src/handler/auth_oidc.rs @@ -48,12 +48,10 @@ use nvisy_nats::kv::{ OidcStateBucket as OidcStateKvBucket, OidcStateKey, ReauthProofBucket as ReauthProofKvBucket, ReauthProofKey, }; -use nvisy_postgres::model::{Account, NewAccount, NewAccountIdentity}; -use nvisy_postgres::query::{ - AccountApiTokenRepository, AccountIdentityRepository, AccountRepository, LinkIdentityOutcome, -}; -use nvisy_postgres::types::{ApiTokenType, HANDLE_MAX_LENGTH, Handle, IdentityProvider}; -use nvisy_postgres::{AsyncConnection, Error as PgError, PgClient, PgConn}; +use nvisy_postgres::PgClient; +use nvisy_postgres::model::Account; +use nvisy_postgres::query::{AccountApiTokenRepository, AccountIdentityRepository}; +use nvisy_postgres::types::{ApiTokenType, IdentityProvider}; use serde::{Deserialize, Serialize}; use uuid::Uuid; @@ -63,17 +61,12 @@ use crate::handler::response::{DesktopToken, ErrorResponse}; use crate::handler::{ErrorKind, Result}; use crate::response::{CookieConfig, RedirectResult, WebSession}; use crate::service::{ - AuthIssuer, OidcAuthorization, OidcIdentity, OidcService, RedirectKind, ServiceState, + AccountProvisioner, AuthIssuer, OidcAuthorization, OidcService, RedirectKind, ServiceState, }; /// Tracing target for OIDC sign-in operations. const TRACING_TARGET: &str = "nvisy_server::handler::auth_oidc"; -/// How many suffixed handles to try when deriving a unique username on -/// provisioning, before giving up. A collision past this many is implausible -/// (each is a distinct suffix), so exhausting it is a server-side failure. -const MAX_USERNAME_ATTEMPTS: u32 = 100; - /// What an in-flight OIDC flow is for. All three share the same authorize + /// callback machinery; only the callback's action differs. #[derive(Debug, Clone, Copy, Serialize, Deserialize)] @@ -319,6 +312,7 @@ async fn mint_desktop_token( State(pg_client): State, State(oidc): State, State(issuer): State, + State(provisioner): State, auth_state: AuthState, security: SecurityContext, ValidateJson(request): ValidateJson, @@ -350,7 +344,9 @@ async fn mint_desktop_token( .with_resource("session")); } - let account = load_active_account(&mut conn, auth_state.account_id).await?; + let account = provisioner + .load_active(&mut conn, auth_state.account_id) + .await?; gate_account_status(&account)?; let api_token = issuer @@ -441,6 +437,7 @@ async fn oidc_callback( State(nats): State, State(oidc): State, State(issuer): State, + State(provisioner): State, State(cookie): State, security: SecurityContext, Query(query): Query, @@ -461,7 +458,18 @@ async fn oidc_callback( }; let redirect_uri = flow.redirect_uri.clone(); - match run_flow(&pg_client, &oidc, &nats, &issuer, security, flow, query).await { + match run_flow( + &pg_client, + &oidc, + &nats, + &issuer, + &provisioner, + security, + flow, + query, + ) + .await + { Ok(outcome) => { tracing::info!(target: TRACING_TARGET, kind = outcome.kind(), "OIDC callback succeeded"); outcome.into_redirect(redirect_uri.as_deref(), cookie) @@ -553,11 +561,13 @@ async fn consume_flow(nats: &NatsClient, query: &OidcCallbackQuery) -> Result { - let account = resolve_account(&mut conn, flow.provider, identity).await?; + let account = provisioner + .resolve(&mut conn, flow.provider, identity) + .await?; gate_account_status(&account)?; // The redirect target decides how the session is delivered. A desktop @@ -611,7 +623,9 @@ async fn run_flow( } } OidcPurpose::Link { account_id } => { - link_account(&mut conn, account_id, flow.provider, identity).await?; + provisioner + .link(&mut conn, account_id, flow.provider, identity) + .await?; Ok(CallbackOutcome::Linked) } OidcPurpose::Reauth { account_id } => { @@ -661,271 +675,6 @@ async fn mint_reauth_proof(nats: &NatsClient, account_id: Uuid) -> Result Result<()> { - match conn.link_oidc_identity(identity).await? { - LinkIdentityOutcome::Linked | LinkIdentityOutcome::AlreadyLinked => Ok(()), - LinkIdentityOutcome::ProviderConflict => Err(ErrorKind::Conflict - .with_message("An account already uses a different provider account") - .with_resource("account_identity")), - } -} - -/// Resolves the account for a verified OIDC identity, in order of preference: -/// -/// 1. **Returning user** — an identity already exists for this `(provider, -/// subject)`; reuse its account. -/// 2. **Link to an existing account** — the provider asserts a *verified* email -/// that matches an account (e.g. one created by password signup); attach a new -/// OIDC identity to it, so the two sign-in methods share one account. -/// 3. **Provision** — otherwise create a new account and its OIDC identity. -/// -/// Linking requires a verified email: an unverified address could be one the -/// signer does not control, so linking on it would let an attacker attach their -/// provider identity to someone else's account. -async fn resolve_account( - conn: &mut PgConn, - provider: IdentityProvider, - identity: OidcIdentity, -) -> Result { - // 1. Returning user: an identity for this subject already exists. - if let Some(existing) = conn - .find_identity_by_subject(provider, &identity.subject) - .await? - && let Some(account) = conn.find_account_by_id(existing.account_id).await? - { - return Ok(account); - } - - // A new identity needs the provider-asserted email: to provision, it becomes - // the account's required primary address; to link, it is the match key. - let email = identity.email.ok_or_else(|| { - ErrorKind::BadRequest - .with_message("Sign-in provider did not return an email address") - .with_resource("account") - })?; - - // 2. An account already uses this email. - if let Some(account) = conn.find_account_by_email(&email).await? { - // Link only when the provider verified the email: an unverified address - // could be one the signer does not control, and linking on it would let - // them attach their provider identity to someone else's account. - if !identity.email_verified { - tracing::warn!( - target: TRACING_TARGET, - account_id = %account.id, - provider = ?provider, - "Refusing to link OIDC identity: provider did not verify the email", - ); - return Err(ErrorKind::Conflict - .with_message( - "An account already uses this email; sign in with your existing method \ - or verify the email with the provider first", - ) - .with_resource("account")); - } - - // The matched account may already have a *different* identity for this - // provider (a different subject). Only one identity per provider is - // allowed, so linking would trip the unique index; surface a clean - // conflict instead of a 500. - if conn - .find_account_identity(account.id, provider) - .await? - .is_some() - { - tracing::warn!( - target: TRACING_TARGET, - account_id = %account.id, - provider = ?provider, - "Refusing to link OIDC identity: account already has one for this provider", - ); - return Err(ErrorKind::Conflict - .with_message( - "An account already uses this email with a different provider account", - ) - .with_resource("account")); - } - - link_oidc_identity( - conn, - NewAccountIdentity::oidc(account.id, provider, identity.subject, Some(email)), - ) - .await?; - tracing::info!( - target: TRACING_TARGET, - account_id = %account.id, - provider = ?provider, - "Linked OIDC identity to existing account", - ); - return Ok(account); - } - - // 3. Provision a new account and its OIDC identity together, so an account - // never exists without a way to authenticate. - // - // Only provision on a verified email: the address becomes the new account's - // primary (and its future match key for step 2), so an unverified one could - // seed an account under an address the signer does not control. - if !identity.email_verified { - tracing::warn!( - target: TRACING_TARGET, - provider = ?provider, - "Refusing to provision account: provider did not verify the email", - ); - return Err(ErrorKind::BadRequest - .with_message( - "Sign-in provider did not verify your email address; verify it with the \ - provider and try again", - ) - .with_resource("account")); - } - - let username = derive_unique_username(conn, &email).await?; - let new_account = NewAccount { - username, - display_name: None, - email_address: email.clone(), - avatar_url: None, - timezone: None, - locale: None, - }; - - let account = conn - .transaction(async |conn| { - let account = conn.create_account(new_account).await?; - conn.create_account_identity(NewAccountIdentity::oidc( - account.id, - provider, - identity.subject, - Some(email), - )) - .await?; - Ok::<_, PgError>(account) - }) - .await?; - - tracing::info!( - target: TRACING_TARGET, - account_id = %account.id, - provider = ?provider, - "Provisioned account from OIDC sign-in", - ); - - Ok(account) -} - -/// Attaches a verified OIDC identity to an already-authenticated account (the -/// account that started an authenticated link flow). -/// -/// Idempotent for the same account: re-linking an identity already on this -/// account is a no-op. Refuses to move an identity already linked to a *different* -/// account (its provider subject is unique), so one provider login cannot be -/// hijacked onto another account. -async fn link_account( - conn: &mut PgConn, - account_id: Uuid, - provider: IdentityProvider, - identity: OidcIdentity, -) -> Result { - if let Some(existing) = conn - .find_identity_by_subject(provider, &identity.subject) - .await? - { - if existing.account_id == account_id { - // Already linked to this account: nothing to do. - return load_active_account(conn, account_id).await; - } - tracing::warn!( - target: TRACING_TARGET, - account_id = %account_id, - provider = ?provider, - "Refusing to link an identity already linked to another account", - ); - return Err(ErrorKind::Conflict - .with_message("This provider identity is already linked to another account") - .with_resource("account_identity")); - } - - link_oidc_identity( - conn, - NewAccountIdentity::oidc(account_id, provider, identity.subject, identity.email), - ) - .await?; - tracing::info!( - target: TRACING_TARGET, - account_id = %account_id, - provider = ?provider, - "Linked OIDC identity to the authenticated account", - ); - - load_active_account(conn, account_id).await -} - -/// Loads a live account by id, or a not-found error (e.g. the account was -/// deleted between starting a link flow and its callback). -async fn load_active_account(conn: &mut PgConn, account_id: Uuid) -> Result { - conn.find_account_by_id(account_id).await?.ok_or_else(|| { - ErrorKind::NotFound - .with_message("Account not found") - .with_resource("account") - }) -} - -/// Derives a unique username for a provisioned account from its email local -/// part, appending a numeric suffix on collision. -async fn derive_unique_username(conn: &mut PgConn, email: &str) -> Result { - let local_part = email.split('@').next().unwrap_or(email); - // A local part may not slugify to a valid handle (too short, no usable - // characters); fall back to a stable generated base so provisioning still - // succeeds. - let base = Handle::derive(local_part).unwrap_or_else(|| { - Handle::derive(&format!("user-{}", Uuid::now_v7().simple())) - .expect("a uuid-based handle is always valid") - }); - - if !conn.username_exists(&base).await? { - return Ok(base); - } - // Widest suffix this loop can append, so we reserve room for the largest - // `-{suffix}` up front. Without this, a `base` already at the length limit - // would have its suffix truncated straight back off, and every candidate - // would collapse to `base` and collide forever. - let widest_suffix = MAX_USERNAME_ATTEMPTS.to_string().len(); - let reserved = HANDLE_MAX_LENGTH.saturating_sub(1 + widest_suffix); - let stem = truncate_on_char_boundary(base.as_str(), reserved); - for suffix in 1..=MAX_USERNAME_ATTEMPTS { - // The stem already leaves room for the separator and suffix, so the - // re-derive validates the combined form without truncating the suffix away. - let candidate_text = format!("{stem}-{suffix}"); - if let Some(candidate) = Handle::derive(&candidate_text) - && !conn.username_exists(&candidate).await? - { - return Ok(candidate); - } - } - - Err(ErrorKind::InternalServerError - .with_message("Could not allocate a username for the new account") - .with_resource("account")) -} - -/// Truncates `value` to at most `max` bytes without splitting a UTF-8 character. -/// A derived [`Handle`] is ASCII, so `max` bytes equal `max` characters here. -fn truncate_on_char_boundary(value: &str, max: usize) -> &str { - if value.len() <= max { - return value; - } - let mut end = max; - while end > 0 && !value.is_char_boundary(end) { - end -= 1; - } - &value[..end] -} - /// Returns the public OIDC sign-in routes: sign-in start and the provider /// callback. /// diff --git a/crates/nvisy-server/src/service/account_provisioner.rs b/crates/nvisy-server/src/service/account_provisioner.rs new file mode 100644 index 00000000..b79b1605 --- /dev/null +++ b/crates/nvisy-server/src/service/account_provisioner.rs @@ -0,0 +1,319 @@ +//! Account resolution for OIDC sign-in: return the existing account, link the +//! verified identity to an account that already uses the email, or provision a +//! new one. +//! +//! This is the domain logic behind the OIDC callback — stateless, operating on a +//! connection and the verified provider identity — factored out of the handler so +//! the provisioning/linking rules live in one place and can be reasoned about +//! (and tested) independently of the HTTP flow. + +use nvisy_postgres::model::{Account, NewAccount, NewAccountIdentity}; +use nvisy_postgres::query::{AccountIdentityRepository, AccountRepository, LinkIdentityOutcome}; +use nvisy_postgres::types::{HANDLE_MAX_LENGTH, Handle, IdentityProvider}; +use nvisy_postgres::{AsyncConnection, Error as PgError, PgConn}; +use uuid::Uuid; + +use crate::handler::{ErrorKind, Result}; +use crate::service::OidcIdentity; + +/// Tracing target for account provisioning. +const TRACING_TARGET: &str = "nvisy_server::account_provisioner"; + +/// How many suffixed handles to try when deriving a unique username on +/// provisioning, before giving up. A collision past this many is implausible +/// (each is a distinct suffix), so exhausting it is a server-side failure. +const MAX_USERNAME_ATTEMPTS: u32 = 100; + +/// Resolves, links, and provisions accounts for verified OIDC identities. +/// +/// Stateless: every method takes the connection to act on. Resolved per request +/// from [`ServiceState`](crate::service::ServiceState). +#[derive(Clone, Copy, Default)] +pub struct AccountProvisioner; + +impl AccountProvisioner { + /// Resolves the account for a verified OIDC identity, in order of preference: + /// + /// 1. **Returning user** — an identity already exists for this `(provider, + /// subject)`; reuse its account. + /// 2. **Link to an existing account** — the provider asserts a *verified* + /// email that matches an account (e.g. one created by password signup); + /// attach a new OIDC identity to it, so the two sign-in methods share one + /// account. + /// 3. **Provision** — otherwise create a new account and its OIDC identity. + /// + /// Linking and provisioning both require a verified email: an unverified + /// address could be one the signer does not control, so acting on it would let + /// an attacker attach their provider identity to (or seed) someone else's + /// account. + /// + /// # Errors + /// + /// Rejects a missing email, an unverified email on a link/provision, or a + /// provider slot already taken on the matched account; propagates database + /// errors. + pub async fn resolve( + &self, + conn: &mut PgConn, + provider: IdentityProvider, + identity: OidcIdentity, + ) -> Result { + // 1. Returning user: an identity for this subject already exists. + if let Some(existing) = conn + .find_identity_by_subject(provider, &identity.subject) + .await? + && let Some(account) = conn.find_account_by_id(existing.account_id).await? + { + return Ok(account); + } + + // A new identity needs the provider-asserted email: to provision, it + // becomes the account's required primary address; to link, it is the match + // key. + let email = identity.email.ok_or_else(|| { + ErrorKind::BadRequest + .with_message("Sign-in provider did not return an email address") + .with_resource("account") + })?; + + // 2. An account already uses this email. + if let Some(account) = conn.find_account_by_email(&email).await? { + // Link only when the provider verified the email: an unverified address + // could be one the signer does not control, and linking on it would let + // them attach their provider identity to someone else's account. + if !identity.email_verified { + tracing::warn!( + target: TRACING_TARGET, + account_id = %account.id, + provider = ?provider, + "Refusing to link OIDC identity: provider did not verify the email", + ); + return Err(ErrorKind::Conflict + .with_message( + "An account already uses this email; sign in with your existing method \ + or verify the email with the provider first", + ) + .with_resource("account")); + } + + // The matched account may already have a *different* identity for this + // provider (a different subject). Only one identity per provider is + // allowed, so linking would trip the unique index; surface a clean + // conflict instead of a 500. + if conn + .find_account_identity(account.id, provider) + .await? + .is_some() + { + tracing::warn!( + target: TRACING_TARGET, + account_id = %account.id, + provider = ?provider, + "Refusing to link OIDC identity: account already has one for this provider", + ); + return Err(ErrorKind::Conflict + .with_message( + "An account already uses this email with a different provider account", + ) + .with_resource("account")); + } + + link_oidc_identity( + conn, + NewAccountIdentity::oidc(account.id, provider, identity.subject, Some(email)), + ) + .await?; + tracing::info!( + target: TRACING_TARGET, + account_id = %account.id, + provider = ?provider, + "Linked OIDC identity to existing account", + ); + return Ok(account); + } + + // 3. Provision a new account and its OIDC identity together, so an account + // never exists without a way to authenticate. + // + // Only provision on a verified email: the address becomes the new account's + // primary (and its future match key for step 2), so an unverified one could + // seed an account under an address the signer does not control. + if !identity.email_verified { + tracing::warn!( + target: TRACING_TARGET, + provider = ?provider, + "Refusing to provision account: provider did not verify the email", + ); + return Err(ErrorKind::BadRequest + .with_message( + "Sign-in provider did not verify your email address; verify it with the \ + provider and try again", + ) + .with_resource("account")); + } + + let username = derive_unique_username(conn, &email).await?; + let new_account = NewAccount { + username, + display_name: None, + email_address: email.clone(), + avatar_url: None, + timezone: None, + locale: None, + }; + + let account = conn + .transaction(async |conn| { + let account = conn.create_account(new_account).await?; + conn.create_account_identity(NewAccountIdentity::oidc( + account.id, + provider, + identity.subject, + Some(email), + )) + .await?; + Ok::<_, PgError>(account) + }) + .await?; + + tracing::info!( + target: TRACING_TARGET, + account_id = %account.id, + provider = ?provider, + "Provisioned account from OIDC sign-in", + ); + + Ok(account) + } + + /// Attaches a verified OIDC identity to an already-authenticated account (the + /// account that started an authenticated link flow). + /// + /// Idempotent for the same account: re-linking an identity already on this + /// account is a no-op. Refuses to move an identity already linked to a + /// *different* account (its provider subject is unique), so one provider login + /// cannot be hijacked onto another account. + /// + /// # Errors + /// + /// Returns a conflict if the identity is linked to a different account, a + /// not-found if the account no longer exists, or a database error. + pub async fn link( + &self, + conn: &mut PgConn, + account_id: Uuid, + provider: IdentityProvider, + identity: OidcIdentity, + ) -> Result { + if let Some(existing) = conn + .find_identity_by_subject(provider, &identity.subject) + .await? + { + if existing.account_id == account_id { + // Already linked to this account: nothing to do. + return self.load_active(conn, account_id).await; + } + tracing::warn!( + target: TRACING_TARGET, + account_id = %account_id, + provider = ?provider, + "Refusing to link an identity already linked to another account", + ); + return Err(ErrorKind::Conflict + .with_message("This provider identity is already linked to another account") + .with_resource("account_identity")); + } + + link_oidc_identity( + conn, + NewAccountIdentity::oidc(account_id, provider, identity.subject, identity.email), + ) + .await?; + tracing::info!( + target: TRACING_TARGET, + account_id = %account_id, + provider = ?provider, + "Linked OIDC identity to the authenticated account", + ); + + self.load_active(conn, account_id).await + } + + /// Loads a live account by id, or a not-found error (e.g. the account was + /// deleted between starting a link flow and its callback). + /// + /// # Errors + /// + /// Returns not-found if no account has the id, or a database error. + pub async fn load_active(&self, conn: &mut PgConn, account_id: Uuid) -> Result { + conn.find_account_by_id(account_id).await?.ok_or_else(|| { + ErrorKind::NotFound + .with_message("Account not found") + .with_resource("account") + }) + } +} + +/// Links an OIDC identity to an existing account, mapping the repository's +/// race-tolerant [`LinkIdentityOutcome`] to the handler result: a successful or +/// already-present link is `Ok`, and a provider slot already taken by a +/// *different* account is a clean 409 rather than a 500. +async fn link_oidc_identity(conn: &mut PgConn, identity: NewAccountIdentity) -> Result<()> { + match conn.link_oidc_identity(identity).await? { + LinkIdentityOutcome::Linked | LinkIdentityOutcome::AlreadyLinked => Ok(()), + LinkIdentityOutcome::ProviderConflict => Err(ErrorKind::Conflict + .with_message("An account already uses a different provider account") + .with_resource("account_identity")), + } +} + +/// Derives a unique username for a provisioned account from its email local +/// part, appending a numeric suffix on collision. +async fn derive_unique_username(conn: &mut PgConn, email: &str) -> Result { + let local_part = email.split('@').next().unwrap_or(email); + // A local part may not slugify to a valid handle (too short, no usable + // characters); fall back to a stable generated base so provisioning still + // succeeds. + let base = Handle::derive(local_part).unwrap_or_else(|| { + Handle::derive(&format!("user-{}", Uuid::now_v7().simple())) + .expect("a uuid-based handle is always valid") + }); + + if !conn.username_exists(&base).await? { + return Ok(base); + } + // Widest suffix this loop can append, so we reserve room for the largest + // `-{suffix}` up front. Without this, a `base` already at the length limit + // would have its suffix truncated straight back off, and every candidate + // would collapse to `base` and collide forever. + let widest_suffix = MAX_USERNAME_ATTEMPTS.to_string().len(); + let reserved = HANDLE_MAX_LENGTH.saturating_sub(1 + widest_suffix); + let stem = truncate_on_char_boundary(base.as_str(), reserved); + for suffix in 1..=MAX_USERNAME_ATTEMPTS { + // The stem already leaves room for the separator and suffix, so the + // re-derive validates the combined form without truncating the suffix away. + let candidate_text = format!("{stem}-{suffix}"); + if let Some(candidate) = Handle::derive(&candidate_text) + && !conn.username_exists(&candidate).await? + { + return Ok(candidate); + } + } + + Err(ErrorKind::InternalServerError + .with_message("Could not allocate a username for the new account") + .with_resource("account")) +} + +/// Truncates `value` to at most `max` bytes without splitting a UTF-8 character. +/// A derived [`Handle`] is ASCII, so `max` bytes equal `max` characters here. +fn truncate_on_char_boundary(value: &str, max: usize) -> &str { + if value.len() <= max { + return value; + } + let mut end = max; + while end > 0 && !value.is_char_boundary(end) { + end -= 1; + } + &value[..end] +} diff --git a/crates/nvisy-server/src/service/mod.rs b/crates/nvisy-server/src/service/mod.rs index 5cbce234..2c3f8b40 100644 --- a/crates/nvisy-server/src/service/mod.rs +++ b/crates/nvisy-server/src/service/mod.rs @@ -1,5 +1,6 @@ //! Application state and dependency injection. +mod account_provisioner; mod auth_issuer; mod avatar; mod chat; @@ -35,6 +36,7 @@ use tokio_util::sync::CancellationToken; use crate::middleware::UploadConfig; use crate::response::CookieConfig; +pub use crate::service::account_provisioner::AccountProvisioner; pub use crate::service::auth_issuer::AuthIssuer; pub use crate::service::avatar::{AVATAR_CONTENT_TYPE, AvatarService, MAX_AVATAR_UPLOAD_BYTES}; pub use crate::service::chat::{ChatService, TurnLocation}; @@ -396,3 +398,11 @@ impl axum::extract::FromRef for AuthIssuer { AuthIssuer::new(state.session_keys.clone(), state.user_agent_parser.clone()) } } + +// `AccountProvisioner` is stateless; it operates entirely on the connection +// passed to each method. +impl axum::extract::FromRef for AccountProvisioner { + fn from_ref(_state: &ServiceState) -> Self { + AccountProvisioner + } +} From 66378ad68f1cabba22a5bc463129c6d293eaeb42 Mon Sep 17 00:00:00 2001 From: Oleh Martsokha Date: Thu, 10 Sep 2026 00:59:01 +0200 Subject: [PATCH 13/13] Address PR review: browser-safe OAuth fallback, safe download filenames - connection_result_redirect: when no frontend URL is configured, render an inline text response instead of an HTTP redirect to a data: URL. Browsers block top-level navigation to data: URLs, so the callback showed nothing; this matches the OIDC redirect's inline fallback. Return type is now Response. - attachment_headers: handle the download filename safely inside the helper rather than relying on the caller. Escape " and \ in the quoted filename= (so a name cannot inject extra Content-Disposition params) and emit an RFC 6266 filename*=UTF-8'' for non-ASCII names, which previously failed HeaderValue parsing and dropped the name. Drops the now-redundant strip in files.rs; adds unit tests. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_018bKk1YEG4tZ69jzYVQvQL8 --- .../src/handler/connection_oauth.rs | 4 +- crates/nvisy-server/src/handler/files.rs | 17 +-- crates/nvisy-server/src/response/download.rs | 109 ++++++++++++++++-- crates/nvisy-server/src/response/redirect.rs | 17 +-- 4 files changed, 119 insertions(+), 28 deletions(-) diff --git a/crates/nvisy-server/src/handler/connection_oauth.rs b/crates/nvisy-server/src/handler/connection_oauth.rs index 43e84e27..e1bc387a 100644 --- a/crates/nvisy-server/src/handler/connection_oauth.rs +++ b/crates/nvisy-server/src/handler/connection_oauth.rs @@ -24,7 +24,7 @@ use aide::axum::routing::post_with; use aide::transform::TransformOperation; use axum::extract::State; use axum::http::StatusCode; -use axum::response::Redirect; +use axum::response::Response; use axum::routing::get; use nvisy_file_service::FileService; use nvisy_file_service::provider::{ConnectionSettings, FileServiceConfig, FileServiceProvider}; @@ -158,7 +158,7 @@ async fn oauth_callback( State(redirect): State, security: SecurityContext, Query(query): Query, -) -> Redirect { +) -> Response { tracing::debug!(target: TRACING_TARGET, "Completing cloud file OAuth"); // The callback is a top-level browser navigation, so its outcome is conveyed diff --git a/crates/nvisy-server/src/handler/files.rs b/crates/nvisy-server/src/handler/files.rs index 6c8986f0..64756401 100644 --- a/crates/nvisy-server/src/handler/files.rs +++ b/crates/nvisy-server/src/handler/files.rs @@ -616,18 +616,13 @@ async fn download_file( ErrorKind::NotFound.with_message("File content not found") })?; - // The display name is user-controlled, so strip characters that are invalid - // in a quoted header value to avoid header injection and a failed parse - // before it goes into the (server-trusting) attachment header. - let safe_name: String = file - .display_name - .chars() - .filter(|c| !c.is_control() && *c != '"' && *c != '\\') - .collect(); - // Content-length is the plaintext size from the record; storage holds the - // larger ciphertext, which the decrypting reader unwraps as it streams. + // `attachment_headers` handles the user-controlled name safely (escapes it, + // and carries a non-ASCII name via RFC 6266 `filename*`), so it is passed + // through as-is. Content-length is the plaintext size from the record; + // storage holds the larger ciphertext, which the decrypting reader unwraps as + // it streams. let headers = attachment_headers( - &safe_name, + &file.display_name, HeaderValue::from_static("application/octet-stream"), file.file_size_bytes as u64, ); diff --git a/crates/nvisy-server/src/response/download.rs b/crates/nvisy-server/src/response/download.rs index 80d67246..18b747d0 100644 --- a/crates/nvisy-server/src/response/download.rs +++ b/crates/nvisy-server/src/response/download.rs @@ -4,24 +4,117 @@ use axum::http::header::{CONTENT_DISPOSITION, CONTENT_LENGTH, CONTENT_TYPE}; use axum::http::{HeaderMap, HeaderValue}; /// Builds the response headers for a downloadable attachment: a -/// `Content-Disposition: attachment` with `filename`, the `content_type`, and the -/// `content_length`. +/// `Content-Disposition: attachment` naming `filename`, the `content_type`, and +/// the `content_length`. /// -/// `filename` goes into a quoted header value verbatim, so a caller passing a -/// user-supplied name must strip control characters, `"`, and `\` first; a -/// server-generated name (a UUID, ISO dates, a fixed stem) needs no sanitizing. -/// A name that still fails to parse falls back to a bare `attachment`. +/// The name is handled safely regardless of its contents — the caller does not +/// need to pre-sanitize it: +/// - A plain-ASCII name is quoted with `"` and `\` escaped, so it cannot inject +/// extra `Content-Disposition` parameters. +/// - A name with non-ASCII characters is additionally emitted as an RFC 6266 +/// `filename*=UTF-8''…` parameter (percent-encoded), so the name survives +/// rather than being dropped (a raw non-ASCII byte cannot go in a header value). +/// A plain `filename=` fallback (non-ASCII stripped) is kept for old clients. pub fn attachment_headers( filename: &str, content_type: HeaderValue, content_length: u64, ) -> HeaderMap { let mut headers = HeaderMap::new(); - let disposition = format!("attachment; filename=\"{filename}\"") - .parse() + let disposition = HeaderValue::from_str(&content_disposition(filename)) .unwrap_or_else(|_| HeaderValue::from_static("attachment")); headers.insert(CONTENT_DISPOSITION, disposition); headers.insert(CONTENT_TYPE, content_type); headers.insert(CONTENT_LENGTH, HeaderValue::from(content_length)); headers } + +/// Renders the `Content-Disposition` value for an attachment named `filename`. +/// +/// Always includes a quoted, escaped `filename=` (ASCII-only, for every client); +/// adds an RFC 6266 `filename*=UTF-8''…` when the name has non-ASCII characters, +/// so a modern client recovers the original name. +fn content_disposition(filename: &str) -> String { + // Quoted ASCII form: escape `"` and `\`, and drop control chars and any + // non-ASCII (the latter is carried by `filename*` below when present). + let ascii: String = filename + .chars() + .filter(|c| !c.is_control()) + .map(|c| match c { + '"' => "\\\"".to_owned(), + '\\' => "\\\\".to_owned(), + c if c.is_ascii() => c.to_string(), + _ => String::new(), + }) + .collect(); + + if filename.is_ascii() { + format!("attachment; filename=\"{ascii}\"") + } else { + format!( + "attachment; filename=\"{ascii}\"; filename*=UTF-8''{}", + percent_encode_rfc5987(filename) + ) + } +} + +/// Percent-encodes `value` for an RFC 5987 `ext-value` (used by RFC 6266 +/// `filename*`): unreserved characters pass through, everything else is +/// `%HH`-encoded from its UTF-8 bytes. +fn percent_encode_rfc5987(value: &str) -> String { + let mut out = String::with_capacity(value.len()); + for &byte in value.as_bytes() { + // RFC 5987 `attr-char`: ALPHA / DIGIT and a fixed set of symbols. + let unreserved = byte.is_ascii_alphanumeric() + || matches!( + byte, + b'!' | b'#' | b'$' | b'&' | b'+' | b'-' | b'.' | b'^' | b'_' | b'`' | b'|' | b'~' + ); + if unreserved { + out.push(byte as char); + } else { + out.push('%'); + out.push_str(&format!("{byte:02X}")); + } + } + out +} + +#[cfg(test)] +mod tests { + use super::content_disposition; + + #[test] + fn ascii_name_is_quoted() { + assert_eq!( + content_disposition("report.csv"), + r#"attachment; filename="report.csv""# + ); + } + + #[test] + fn quotes_and_backslashes_are_escaped() { + // A name that tries to inject a second parameter is neutralized: the `"` + // and `\` are escaped, so it stays inside the quoted value. + assert_eq!( + content_disposition(r#"a".pdf"#), + r#"attachment; filename="a\".pdf""# + ); + assert_eq!( + content_disposition(r"a\b.pdf"), + r#"attachment; filename="a\\b.pdf""# + ); + } + + #[test] + fn non_ascii_name_gets_a_filename_star() { + // The name survives via `filename*` (percent-encoded UTF-8); the quoted + // `filename=` keeps the ASCII remainder for old clients. + let out = content_disposition("résumé.pdf"); + assert!(out.contains("filename=\"rsum.pdf\""), "{out}"); + assert!( + out.contains("filename*=UTF-8''r%C3%A9sum%C3%A9.pdf"), + "{out}" + ); + } +} diff --git a/crates/nvisy-server/src/response/redirect.rs b/crates/nvisy-server/src/response/redirect.rs index 520eb06e..1a5fd37b 100644 --- a/crates/nvisy-server/src/response/redirect.rs +++ b/crates/nvisy-server/src/response/redirect.rs @@ -116,13 +116,15 @@ impl RedirectResult<'_> { /// A `{workspaceSlug}` placeholder in the configured base is substituted with /// `workspace_slug` when known (i.e. on success), so a base like /// `https://app/w/{workspaceSlug}/integrations` lands on the workspace's page. -/// The outcome is appended as a `connection=success|error` query. Falls back to a -/// self-describing data page when no frontend URL is configured. +/// The outcome is appended as a `connection=success|error` query. When no +/// frontend URL is configured, renders a minimal in-page result instead of a +/// redirect — a browser blocks a top-level navigation to a `data:` URL, so a +/// `data:` `Location` would show the user nothing. pub(crate) fn connection_result_redirect( base: Option<&str>, status: &str, workspace_slug: Option<&str>, -) -> Redirect { +) -> Response { match base { Some(base) => { let base = match workspace_slug { @@ -130,10 +132,11 @@ pub(crate) fn connection_result_redirect( None => base.to_owned(), }; let separator = if base.contains('?') { '&' } else { '?' }; - Redirect::to(&format!("{base}{separator}connection={status}")) + Redirect::to(&format!("{base}{separator}connection={status}")).into_response() + } + None => { + let body = format!("Cloud file connection {status}. You can close this window."); + (StatusCode::OK, body).into_response() } - None => Redirect::to(&format!( - "data:text/plain,cloud%20file%20connection%20{status}" - )), } }