mirror of
https://github.com/Such-Software/smirk-backend-core.git
synced 2026-10-17 07:21:32 +02:00
auth: make access tokens revocable
verify_access_token was a pure signature + expiry check, so an access token could not be revoked. Sign-out and logout revoked the DB session row, and an erasure request revoked all of them, but the bearer token kept working for the rest of its 24h life on every authenticated route. The refresh token has always carried a sid for exactly this; the access token, which actually guards the API, did not. Adds sid to AccessTokenClaims and checks it against the sessions table on each authenticated request (one indexed primary-key lookup). sid is Option so tokens minted before this deploy still verify rather than logging every user out mid-session; they simply remain unrevocable until they expire, which is the pre-existing behaviour.
This commit is contained in:
@@ -36,6 +36,16 @@ pub async fn extract_user_id_from_token(
|
||||
) -> Result<Uuid, AppError> {
|
||||
let token = bearer_token(headers)?;
|
||||
let info = state.sessions.verify_access_token(token)?;
|
||||
// Signature + expiry alone is not enough: it made access tokens
|
||||
// unrevocable, so sign-out and even a completed erasure request left the
|
||||
// bearer token working for the rest of its 24h life. Tokens minted before
|
||||
// `sid` shipped carry no session and keep the old behaviour until they
|
||||
// expire, rather than logging everyone out on deploy.
|
||||
if let Some(sid) = info.session_id {
|
||||
if !state.db.is_session_live(sid).await? {
|
||||
return Err(AppError::AuthError("Invalid or expired token".into()));
|
||||
}
|
||||
}
|
||||
Ok(info.user_id)
|
||||
}
|
||||
|
||||
|
||||
@@ -32,6 +32,18 @@ pub struct AccessTokenClaims {
|
||||
pub exp: i64,
|
||||
/// Client kind: extension | web | nostr.
|
||||
pub platform: String,
|
||||
/// Session ID, so an access token can be REVOKED.
|
||||
///
|
||||
/// Without it verification was a pure signature+expiry check, so sign-out and
|
||||
/// even a completed erasure request revoked only the DB session row while the
|
||||
/// bearer token kept working for the rest of its 24h life. The refresh token
|
||||
/// has always carried this; the access token is the one that guards every
|
||||
/// authenticated route.
|
||||
///
|
||||
/// `Option` so tokens minted before this shipped still verify (they simply
|
||||
/// cannot be revoked) rather than logging every user out on deploy.
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub sid: Option<String>,
|
||||
}
|
||||
|
||||
/// JWT claims for refresh tokens.
|
||||
@@ -52,6 +64,9 @@ pub struct RefreshTokenClaims {
|
||||
pub struct TokenInfo {
|
||||
pub user_id: Uuid,
|
||||
pub platform: String,
|
||||
/// Session this token belongs to, when the token carries one. `None` for
|
||||
/// tokens minted before `sid` shipped; those cannot be revoked early.
|
||||
pub session_id: Option<Uuid>,
|
||||
}
|
||||
|
||||
/// Session token pair.
|
||||
@@ -192,6 +207,7 @@ impl SessionManager {
|
||||
iat: now.timestamp(),
|
||||
exp: access_exp.timestamp(),
|
||||
platform: platform.to_string(),
|
||||
sid: Some(session_id.to_string()),
|
||||
};
|
||||
let access_token = encode(&header, &access_claims, &self.encoding_key)
|
||||
.map_err(|e| AppError::Internal(format!("Failed to create access token: {}", e)))?;
|
||||
@@ -223,6 +239,7 @@ impl SessionManager {
|
||||
Ok(TokenInfo {
|
||||
user_id,
|
||||
platform: data.claims.platform,
|
||||
session_id: data.claims.sid.as_deref().and_then(|s| Uuid::parse_str(s).ok()),
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -56,6 +56,23 @@ impl Database {
|
||||
.await?)
|
||||
}
|
||||
|
||||
/// Whether a session id is still live: exists, not revoked, not expired.
|
||||
///
|
||||
/// Used on EVERY authenticated request so an access token stops working the
|
||||
/// moment its session is revoked (sign-out, erasure, admin action) rather
|
||||
/// than at its 24h expiry. A single indexed lookup on the primary key.
|
||||
#[instrument(skip(self))]
|
||||
pub async fn is_session_live(&self, session_id: Uuid) -> Result<bool, AppError> {
|
||||
let live: Option<(bool,)> = sqlx::query_as(
|
||||
"SELECT true FROM sessions \
|
||||
WHERE id = $1 AND revoked_at IS NULL AND expires_at > NOW()",
|
||||
)
|
||||
.bind(session_id)
|
||||
.fetch_optional(self.pool())
|
||||
.await?;
|
||||
Ok(live.is_some())
|
||||
}
|
||||
|
||||
/// Update a session's `last_used_at` timestamp.
|
||||
#[instrument(skip(self))]
|
||||
pub async fn touch_session(&self, session_id: Uuid) -> Result<(), AppError> {
|
||||
|
||||
Reference in New Issue
Block a user