diff --git a/crates/nvisy-postgres/src/query/account_api_token.rs b/crates/nvisy-postgres/src/query/account_api_token.rs index 47300ce..7a0ceb2 100644 --- a/crates/nvisy-postgres/src/query/account_api_token.rs +++ b/crates/nvisy-postgres/src/query/account_api_token.rs @@ -28,6 +28,16 @@ pub trait AccountApiTokenRepository { token_id: Uuid, ) -> impl Future>> + Send; + /// Returns whether the token is still active: it exists, belongs to the + /// given account, and has not been revoked (soft-deleted). Used on the + /// authentication path so revoking a token takes effect immediately rather + /// than only when its JWT expires. + fn account_api_token_is_active( + &mut self, + token_id: Uuid, + account_id: Uuid, + ) -> impl Future> + Send; + /// Updates an account API token. fn update_account_api_token( &mut self, @@ -117,6 +127,25 @@ impl AccountApiTokenRepository for PgConnection { .map_err(PgError::from) } + async fn account_api_token_is_active( + &mut self, + token_id: Uuid, + account_id: Uuid, + ) -> PgResult { + use diesel::dsl::{exists, select}; + use schema::account_api_tokens::{self, dsl}; + + select(exists( + account_api_tokens::table + .filter(dsl::id.eq(token_id)) + .filter(dsl::account_id.eq(account_id)) + .filter(dsl::deleted_at.is_null()), + )) + .get_result(self) + .await + .map_err(PgError::from) + } + async fn update_account_api_token( &mut self, token_id: Uuid, diff --git a/crates/nvisy-server/src/extract/auth/auth_state.rs b/crates/nvisy-server/src/extract/auth/auth_state.rs index b602b64..01fc98b 100644 --- a/crates/nvisy-server/src/extract/auth/auth_state.rs +++ b/crates/nvisy-server/src/extract/auth/auth_state.rs @@ -13,7 +13,7 @@ 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::AccountRepository; +use nvisy_postgres::query::{AccountApiTokenRepository, AccountRepository}; use nvisy_postgres::{PgClient, PgConn}; use serde::Deserialize; @@ -156,6 +156,11 @@ where // Step 2: Ensure token claims match current account state Self::verify_privilege_consistency(&auth_claims, &account)?; + // Step 3: Ensure the token itself has not been revoked. The JWT's own + // expiry bounds its lifetime, but revocation must take effect + // immediately, so the backing token row is checked on every request. + Self::verify_token_active(&mut conn, &auth_claims).await?; + tracing::info!( target: TRACING_TARGET, account_id = %auth_claims.account_id, @@ -293,6 +298,54 @@ where Ok(()) } + + /// Verifies that the token backing this request has not been revoked. + /// + /// The bearer credential is a self-contained JWT, so a revoked (soft-deleted) + /// token would otherwise keep working until its `exp`. Checking the backing + /// `account_api_tokens` row by `jti` on every request makes revocation + /// immediate. The lookup is scoped to the token's own account, so a token + /// only authenticates while its row exists, is not deleted, and still belongs + /// to the claimed account. + /// + /// # Errors + /// + /// Returns [`ErrorKind::Unauthorized`] if the token has been revoked. + async fn verify_token_active(conn: &mut PgConn, auth_claims: &AuthClaims) -> Result<()> { + let is_active = conn + .account_api_token_is_active(auth_claims.token_id, auth_claims.account_id) + .await + .map_err(|db_error| { + tracing::error!( + target: TRACING_TARGET, + error = %db_error, + account_id = %auth_claims.account_id, + token_id = %auth_claims.token_id, + "database error occurred during token revocation check" + ); + + ErrorKind::InternalServerError + .with_message("Authentication verification encountered an error") + .with_context("Unable to validate the session token") + .with_resource("authentication") + })?; + + if !is_active { + tracing::warn!( + target: TRACING_TARGET, + account_id = %auth_claims.account_id, + token_id = %auth_claims.token_id, + "authentication failed: token has been revoked" + ); + + return Err(ErrorKind::Unauthorized + .with_message("Your session has been revoked") + .with_context("Please sign in again to continue") + .with_resource("authentication")); + } + + Ok(()) + } } impl FromRequestParts for AuthState