Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 29 additions & 0 deletions crates/nvisy-postgres/src/query/account_api_token.rs
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,16 @@ pub trait AccountApiTokenRepository {
token_id: Uuid,
) -> impl Future<Output = PgResult<Option<AccountApiToken>>> + 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<Output = PgResult<bool>> + Send;

/// Updates an account API token.
fn update_account_api_token(
&mut self,
Expand Down Expand Up @@ -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<bool> {
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,
Expand Down
55 changes: 54 additions & 1 deletion crates/nvisy-server/src/extract/auth/auth_state.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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<T>) -> 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<T, S> FromRequestParts<S> for AuthState<T>
Expand Down