diff --git a/src/api/middleware.rs b/src/api/middleware.rs index 11de4c4..777abe5 100644 --- a/src/api/middleware.rs +++ b/src/api/middleware.rs @@ -36,6 +36,16 @@ pub async fn extract_user_id_from_token( ) -> Result { 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) } diff --git a/src/core/session.rs b/src/core/session.rs index 542c603..f354bec 100644 --- a/src/core/session.rs +++ b/src/core/session.rs @@ -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, } /// 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, } /// 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()), }) } diff --git a/src/infra/db/sessions.rs b/src/infra/db/sessions.rs index 0a26f6e..92985a8 100644 --- a/src/infra/db/sessions.rs +++ b/src/infra/db/sessions.rs @@ -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 { + 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> {