From 62349d52bd251a84404e2a2935b2f04027249856 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fabr=C3=ADcio=20Bracht?= Date: Sat, 23 May 2026 14:57:41 -0700 Subject: [PATCH] invalidate http sessions on mqtt password change and reset --- CHANGELOG.md | 9 ++ Cargo.lock | 2 +- README.md | 2 +- crates/mqdb-agent/Cargo.toml | 2 +- crates/mqdb-agent/src/agent/handlers.rs | 25 ++++++ crates/mqdb-agent/src/agent/mod.rs | 10 +++ crates/mqdb-agent/src/agent/tasks.rs | 8 ++ crates/mqdb-agent/src/http/handlers.rs | 48 +++++------ crates/mqdb-agent/src/http/mod.rs | 2 +- crates/mqdb-agent/src/http/server.rs | 6 +- crates/mqdb-agent/src/http/session_store.rs | 93 +++++++++++++++++---- crates/mqdb-cli/src/commands/agent.rs | 2 + docs/testing/13-http-api.md | 8 +- 13 files changed, 162 insertions(+), 55 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1e1cc93..7745bff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,15 @@ All notable changes to this project will be documented in this file. Each entry lists the date and the crate versions that were released. +## 2026-05-23 — mqdb-agent 0.8.3 + +### Fixed + +- MQTT password change and reset (`$DB/_auth/password/change`, `$DB/_auth/password/reset/submit`) updated `_credentials` and returned success without invalidating any HTTP sessions for the same user, leaving every cookie-backed session live until its 24h TTL — the HTTP-only fix in 0.8.2 (#68) only closed the HTTP scope of #37. `SessionStore`, `JtiRevocationStore`, and the existing HTTP-session-invalidation step now reach both MQTT handlers via Option-wrapped `Arc` references threaded through `MqdbAgent` → `MessageContext` → `AdminContext` (Option because the cluster-agent code path does not run an HTTP server). After the MQTT credential write succeeds, `invalidate_http_sessions` calls `destroy_others_by_canonical_id(canonical_id, None)` (no live caller session at MQTT request time) and revokes the returned JTIs via the new `JtiRevocationStore::revoke_many`. +- Cleanup along the way: `Session`/`NewSession`/`SessionRef` now carry the `jti` directly (captured when the session's JWT is minted), so `destroy_others_by_canonical_id` returns JTIs rather than JWTs and `handle_logout` no longer decodes its own session's JWT to find the JTI. `mint_callback_jwt` returns `(jwt, jti)` so all 3 callers (callback, register, login) pass the JTI through to `SessionStore::create`; the dev-login session keeps an empty JTI and is filtered out of revocation results. `verify_jwt_ignore_expiry` is no longer called from the password-change/logout paths (only the refresh path still needs it). `HttpServerConfig` now owns the `Arc` and `Arc` so the same instances back both the HTTP server and the MQTT handler task — closing the architectural gap noted as the reason for partial scope in #68. +- Code-review nit from #68 addressed: `JtiRevocationStore::revoke` now `warn!`s when the `MAX_REVOKED_JTIS` cap is hit and the JTI is dropped (was silent). +- Test coverage: 2 new unit tests in `session_store.rs` (`destroy_others_skips_empty_jti_sessions`, `revoke_many_revokes_all_jtis`); the existing 3 destroy-others tests rewritten around JTIs instead of JWTs to match the new return type. + ## 2026-05-23 — mqdb-agent 0.8.2 ### Fixed diff --git a/Cargo.lock b/Cargo.lock index f10b63e..f11f767 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1349,7 +1349,7 @@ dependencies = [ [[package]] name = "mqdb-agent" -version = "0.8.2" +version = "0.8.3" dependencies = [ "arc-swap", "argon2", diff --git a/README.md b/README.md index b36749e..f0b5153 100644 --- a/README.md +++ b/README.md @@ -525,7 +525,7 @@ A successful `POST /auth/password/change` keeps the caller's session and destroy ### Password Change & Reset MQTT API -Password change and reset are also available over MQTT 5.0 request-response for JWT-authenticated users. The MQTT path currently updates `_credentials` but does not invalidate HTTP sessions for the same user — tracked in issue #69. +Password change and reset are also available over MQTT 5.0 request-response for JWT-authenticated users. The MQTT path destroys every HTTP session for the affected user and revokes their JTIs, matching the HTTP-path behavior. | Topic | Payload | Description | |-------|---------|-------------| diff --git a/crates/mqdb-agent/Cargo.toml b/crates/mqdb-agent/Cargo.toml index fdec550..7f8213a 100644 --- a/crates/mqdb-agent/Cargo.toml +++ b/crates/mqdb-agent/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "mqdb-agent" -version = "0.8.2" +version = "0.8.3" edition.workspace = true license = "Apache-2.0" authors.workspace = true diff --git a/crates/mqdb-agent/src/agent/handlers.rs b/crates/mqdb-agent/src/agent/handlers.rs index cdaa21b..01f6fea 100644 --- a/crates/mqdb-agent/src/agent/handlers.rs +++ b/crates/mqdb-agent/src/agent/handlers.rs @@ -61,6 +61,10 @@ pub(super) struct MessageContext<'a> { pub auth_rate_limiter: &'a RateLimiter, #[cfg(feature = "http-api")] pub identity_crypto: Option<&'a Arc>, + #[cfg(feature = "http-api")] + pub session_store: Option<&'a Arc>, + #[cfg(feature = "http-api")] + pub jti_revocation: Option<&'a Arc>, } #[allow(clippy::too_many_lines)] @@ -90,6 +94,10 @@ pub(super) async fn handle_message(ctx: &MessageContext<'_>, message: Message) { auth_rate_limiter: ctx.auth_rate_limiter, #[cfg(feature = "http-api")] identity_crypto: ctx.identity_crypto, + #[cfg(feature = "http-api")] + session_store: ctx.session_store, + #[cfg(feature = "http-api")] + jti_revocation: ctx.jti_revocation, }; handle_admin_operation(&admin_ctx, admin_op).await; return; @@ -277,6 +285,10 @@ struct AdminContext<'a> { auth_rate_limiter: &'a RateLimiter, #[cfg(feature = "http-api")] identity_crypto: Option<&'a Arc>, + #[cfg(feature = "http-api")] + session_store: Option<&'a Arc>, + #[cfg(feature = "http-api")] + jti_revocation: Option<&'a Arc>, } #[allow(clippy::too_many_lines)] @@ -1105,6 +1117,15 @@ async fn dispatch_vault_admin_mqtt( } } +#[cfg(feature = "http-api")] +fn invalidate_http_sessions(ctx: &AdminContext<'_>, canonical_id: &str) { + let (Some(sessions), Some(jtis)) = (ctx.session_store, ctx.jti_revocation) else { + return; + }; + let revoked = sessions.destroy_others_by_canonical_id(canonical_id, None); + jtis.revoke_many(&revoked); +} + #[cfg(feature = "http-api")] async fn handle_password_change_mqtt(ctx: &AdminContext<'_>, payload: &Value) -> Response { use serde_json::json; @@ -1209,6 +1230,8 @@ async fn handle_password_change_mqtt(ctx: &AdminContext<'_>, payload: &Value) -> return Response::error(mqdb_core::ErrorCode::Internal, "failed to update password"); } + invalidate_http_sessions(ctx, canonical_id); + Response::ok(json!({"status": "password changed"})) } @@ -1592,5 +1615,7 @@ async fn handle_password_reset_submit_mqtt(ctx: &AdminContext<'_>, payload: &Val ); } + invalidate_http_sessions(ctx, canonical_id); + Response::ok(json!({"status": "password_reset"})) } diff --git a/crates/mqdb-agent/src/agent/mod.rs b/crates/mqdb-agent/src/agent/mod.rs index 910517c..596b870 100644 --- a/crates/mqdb-agent/src/agent/mod.rs +++ b/crates/mqdb-agent/src/agent/mod.rs @@ -43,6 +43,10 @@ pub struct MqdbAgent { pub(super) auth_rate_limiter: Arc, #[cfg(feature = "http-api")] pub(super) identity_crypto: Option>, + #[cfg(feature = "http-api")] + pub(super) session_store: Option>, + #[cfg(feature = "http-api")] + pub(super) jti_revocation: Option>, pub(super) license_expires_at: Option, #[cfg(feature = "opentelemetry")] pub(super) telemetry_config: Option, @@ -75,6 +79,10 @@ impl MqdbAgent { auth_rate_limiter: Arc::new(RateLimiter::new(10)), #[cfg(feature = "http-api")] identity_crypto: None, + #[cfg(feature = "http-api")] + session_store: None, + #[cfg(feature = "http-api")] + jti_revocation: None, license_expires_at: None, #[cfg(feature = "opentelemetry")] telemetry_config: None, @@ -225,6 +233,8 @@ impl MqdbAgent { #[allow(clippy::missing_panics_doc)] pub fn with_http_config(mut self, config: crate::http::HttpServerConfig) -> Self { self.identity_crypto.clone_from(&config.identity_crypto); + self.session_store = Some(Arc::clone(&config.session_store)); + self.jti_revocation = Some(Arc::clone(&config.jti_revocation)); *self.http_config.lock().expect("http_config lock") = Some(config); self } diff --git a/crates/mqdb-agent/src/agent/tasks.rs b/crates/mqdb-agent/src/agent/tasks.rs index 110c06c..3adf4ec 100644 --- a/crates/mqdb-agent/src/agent/tasks.rs +++ b/crates/mqdb-agent/src/agent/tasks.rs @@ -61,6 +61,10 @@ impl MqdbAgent { let auth_rate_limiter = Arc::clone(&self.auth_rate_limiter); #[cfg(feature = "http-api")] let identity_crypto = self.identity_crypto.clone(); + #[cfg(feature = "http-api")] + let session_store = self.session_store.clone(); + #[cfg(feature = "http-api")] + let jti_revocation = self.jti_revocation.clone(); tokio::spawn(async move { tokio::time::sleep(Duration::from_millis(100)).await; @@ -131,6 +135,10 @@ impl MqdbAgent { auth_rate_limiter: &auth_rate_limiter, #[cfg(feature = "http-api")] identity_crypto: identity_crypto.as_ref(), + #[cfg(feature = "http-api")] + session_store: session_store.as_ref(), + #[cfg(feature = "http-api")] + jti_revocation: jti_revocation.as_ref(), }; handle_message(&ctx, message).await; } else { diff --git a/crates/mqdb-agent/src/http/handlers.rs b/crates/mqdb-agent/src/http/handlers.rs index 6fa99eb..06ffc73 100644 --- a/crates/mqdb-agent/src/http/handlers.rs +++ b/crates/mqdb-agent/src/http/handlers.rs @@ -53,14 +53,14 @@ pub struct ServerState { pub mqtt_client: Arc, pub db_access: Arc, pub frontend_redirect_uri: Option, - pub session_store: SessionStore, + pub session_store: Arc, pub ticket_expiry_secs: u64, pub cookie_secure: bool, pub cors_origin: Option, pub ticket_rate_limiter: RateLimiter, pub login_rate_limiter: RateLimiter, pub register_rate_limiter: RateLimiter, - pub jti_revocation: JtiRevocationStore, + pub jti_revocation: Arc, pub trust_proxy: bool, pub identity_crypto: Option>, pub ownership_config: Arc, @@ -266,10 +266,11 @@ pub async fn handle_callback(state: &ServerState, query: &str) -> HttpResponse { persist_oauth_tokens(state, &link_key, &canonical_id, refresh_token, &identity).await; } - let jwt = mint_callback_jwt(state, &canonical_id, &identity); + let (jwt, jti) = mint_callback_jwt(state, &canonical_id, &identity); let Some(session_id) = state.session_store.create(NewSession { jwt, + jti, canonical_id, provider: provider.to_string(), provider_sub: identity.provider_sub.clone(), @@ -621,18 +622,19 @@ fn mint_callback_jwt( state: &ServerState, canonical_id: &str, identity: &ProviderIdentity, -) -> String { +) -> (String, String) { let now = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .map_or(0, |d| d.as_secs()); + let jti = JtiRevocationStore::generate_jti(); let claims = json!({ "sub": canonical_id, "iss": state.jwt_config.issuer, "aud": state.jwt_config.audience, "exp": now + state.jwt_config.expiry_secs, "iat": now, - "jti": JtiRevocationStore::generate_jti(), + "jti": jti, "email": identity.email, "name": identity.name, "picture": identity.picture, @@ -640,7 +642,7 @@ fn mint_callback_jwt( "provider_sub": identity.provider_sub, }); - sign_jwt(&claims, &state.jwt_config) + (sign_jwt(&claims, &state.jwt_config), jti) } pub async fn handle_refresh(state: &ServerState, body: &[u8]) -> HttpResponse { @@ -957,10 +959,9 @@ pub fn handle_logout(state: &ServerState, headers: &HeaderMap) -> HttpResponse { if let Some(session_id) = parse_session_id(cookie_header) { if let Some(session) = state.session_store.get(session_id) - && let Some(payload) = verify_jwt_ignore_expiry(&session.jwt, &state.jwt_config) - && let Some(jti) = payload.get("jti").and_then(|v| v.as_str()) + && !session.jti.is_empty() { - state.jti_revocation.revoke(jti); + state.jti_revocation.revoke(&session.jti); } state.session_store.destroy(session_id); } @@ -1740,9 +1741,10 @@ pub async fn handle_register(state: &ServerState, body: &[u8], client_ip: &str) return json_response_with_credentials(500, &json!({"error": "registration failed"}), cors); } - let jwt = mint_callback_jwt(state, &canonical_id, &identity); + let (jwt, jti) = mint_callback_jwt(state, &canonical_id, &identity); let Some(session_id) = state.session_store.create(NewSession { jwt, + jti, canonical_id: canonical_id.clone(), provider: "email".to_string(), provider_sub, @@ -1867,10 +1869,11 @@ pub async fn handle_login(state: &ServerState, body: &[u8], client_ip: &str) -> picture: picture.clone(), email_verified: verified, }; - let jwt = mint_callback_jwt(state, canonical_id, &identity); + let (jwt, jti) = mint_callback_jwt(state, canonical_id, &identity); let Some(session_id) = state.session_store.create(NewSession { jwt, + jti, canonical_id: canonical_id.to_string(), provider: "email".to_string(), provider_sub: canonical_id.to_string(), @@ -2298,20 +2301,6 @@ pub async fn handle_verify_status(state: &ServerState, headers: &HeaderMap) -> H ) } -fn revoke_jwt_jtis(state: &ServerState, jwts: &[String]) { - for jwt in jwts { - let Some(payload) = verify_jwt_ignore_expiry(jwt, &state.jwt_config) else { - warn!("failed to decode destroyed session JWT; JTI not revoked"); - continue; - }; - let Some(jti) = payload.get("jti").and_then(|v| v.as_str()) else { - warn!("destroyed session JWT missing jti claim; not revoked"); - continue; - }; - state.jti_revocation.revoke(jti); - } -} - async fn verify_stored_password( state: &ServerState, canonical_id: &str, @@ -2442,10 +2431,10 @@ pub async fn handle_password_change( ); } - let revoked_jwts = state + let revoked_jtis = state .session_store .destroy_others_by_canonical_id(&canonical_id, Some(current_session_id)); - revoke_jwt_jtis(state, &revoked_jwts); + state.jti_revocation.revoke_many(&revoked_jtis); json_response_with_credentials(200, &json!({"status": "password changed"}), cors) } @@ -2798,10 +2787,10 @@ pub async fn handle_password_reset_submit( ) .await; - let revoked_jwts = state + let revoked_jtis = state .session_store .destroy_others_by_canonical_id(canonical_id, None); - revoke_jwt_jtis(state, &revoked_jwts); + state.jti_revocation.revoke_many(&revoked_jtis); json_response_with_credentials(200, &json!({"status": "password_reset"}), cors) } @@ -2845,6 +2834,7 @@ pub async fn handle_dev_login(state: &ServerState, body: &[u8]) -> HttpResponse let Some(session_id) = state.session_store.create(NewSession { jwt: String::new(), + jti: String::new(), canonical_id: canonical_id.clone(), provider: "dev".to_string(), provider_sub: "dev-local".to_string(), diff --git a/crates/mqdb-agent/src/http/mod.rs b/crates/mqdb-agent/src/http/mod.rs index 16bd117..67229d2 100644 --- a/crates/mqdb-agent/src/http/mod.rs +++ b/crates/mqdb-agent/src/http/mod.rs @@ -17,4 +17,4 @@ pub use jwt_signer::{JwtSigningAlgorithm, JwtSigningConfig}; pub use providers::google::GoogleProvider; pub use providers::{Provider, ProviderConfig, ProviderRegistry}; pub use server::{HttpServer, HttpServerConfig}; -pub use session_store::SessionStore; +pub use session_store::{JtiRevocationStore, SessionStore}; diff --git a/crates/mqdb-agent/src/http/server.rs b/crates/mqdb-agent/src/http/server.rs index 1c97b12..206a9ba 100644 --- a/crates/mqdb-agent/src/http/server.rs +++ b/crates/mqdb-agent/src/http/server.rs @@ -39,6 +39,8 @@ pub struct HttpServerConfig { pub vault_backend: Option>, pub auth_rate_limit: u32, pub email_auth: bool, + pub session_store: Arc, + pub jti_revocation: Arc, } pub struct HttpServer { @@ -79,14 +81,14 @@ impl HttpServer { mqtt_client: self.mqtt_client, db_access: self.config.db_access, frontend_redirect_uri: self.config.frontend_redirect_uri, - session_store: SessionStore::new(), + session_store: self.config.session_store, ticket_expiry_secs: self.config.ticket_expiry_secs, cookie_secure: self.config.cookie_secure, cors_origin: self.config.cors_origin, ticket_rate_limiter: RateLimiter::new(self.config.ticket_rate_limit), login_rate_limiter: RateLimiter::new(if no_rate_limit { u32::MAX } else { 10 }), register_rate_limiter: RateLimiter::new(if no_rate_limit { u32::MAX } else { 5 }), - jti_revocation: JtiRevocationStore::new(), + jti_revocation: self.config.jti_revocation, trust_proxy: self.config.trust_proxy, identity_crypto: self.config.identity_crypto, ownership_config: self.config.ownership_config, diff --git a/crates/mqdb-agent/src/http/session_store.rs b/crates/mqdb-agent/src/http/session_store.rs index d8cd9fa..e52b018 100644 --- a/crates/mqdb-agent/src/http/session_store.rs +++ b/crates/mqdb-agent/src/http/session_store.rs @@ -12,6 +12,7 @@ const SESSION_TTL_SECS: u64 = 86400; pub struct Session { pub jwt: String, + pub jti: String, pub canonical_id: String, pub provider: String, pub provider_sub: String, @@ -24,6 +25,7 @@ pub struct Session { pub struct NewSession { pub jwt: String, + pub jti: String, pub canonical_id: String, pub provider: String, pub provider_sub: String, @@ -60,6 +62,7 @@ impl SessionStore { let session_id = hex_encode(&bytes); let session = Session { jwt: new.jwt, + jti: new.jti, canonical_id: new.canonical_id, provider: new.provider, provider_sub: new.provider_sub, @@ -87,6 +90,7 @@ impl SessionStore { Some(SessionRef { jwt: session.jwt.clone(), + jti: session.jti.clone(), canonical_id: session.canonical_id.clone(), provider: session.provider.clone(), provider_sub: session.provider_sub.clone(), @@ -105,8 +109,9 @@ impl SessionStore { } /// Destroys every session whose `canonical_id` matches, except `keep_session_id` - /// (pass `None` to destroy all). Returns the JWTs of destroyed sessions so the - /// caller can decode and revoke their JTIs. + /// (pass `None` to destroy all). Returns the JTIs of destroyed sessions so the + /// caller can add them to the revocation store. Empty JTIs (e.g. dev-login + /// sessions with no real JWT) are filtered out. pub fn destroy_others_by_canonical_id( &self, canonical_id: &str, @@ -119,18 +124,20 @@ impl SessionStore { ); return Vec::new(); }; - let mut removed_jwts = Vec::new(); + let mut removed_jtis = Vec::new(); sessions.retain(|sid, session| { let same_user = session.canonical_id == canonical_id; let is_kept = keep_session_id.is_some_and(|keep| sid.as_str() == keep); if same_user && !is_kept { - removed_jwts.push(session.jwt.clone()); + if !session.jti.is_empty() { + removed_jtis.push(session.jti.clone()); + } false } else { true } }); - removed_jwts + removed_jtis } pub fn set_vault_unlocked(&self, session_id: &str, unlocked: bool) -> bool { @@ -164,6 +171,7 @@ impl SessionStore { pub struct SessionRef { pub jwt: String, + pub jti: String, pub canonical_id: String, pub provider: String, pub provider_sub: String, @@ -196,11 +204,23 @@ impl JtiRevocationStore { pub fn revoke(&self, jti: &str) { let Ok(mut store) = self.revoked.write() else { + warn!("jti revocation store lock poisoned; jti not revoked"); return; }; cleanup_revoked(&mut store); if store.len() < MAX_REVOKED_JTIS { store.insert(jti.to_string(), SystemTime::now()); + } else { + warn!( + cap = MAX_REVOKED_JTIS, + "jti revocation store full; jti dropped (revoked sessions may stay valid until natural expiry)" + ); + } + } + + pub fn revoke_many(&self, jtis: &[String]) { + for jti in jtis { + self.revoke(jti); } } @@ -264,6 +284,7 @@ mod tests { let session_id = store .create(NewSession { jwt: "jwt123".into(), + jti: "jti-abc".into(), canonical_id: "550e8400-e29b-41d4-a716-446655440000".into(), provider: "google".into(), provider_sub: "112233445566".into(), @@ -277,6 +298,7 @@ mod tests { let session = store.get(&session_id).expect("get should succeed"); assert_eq!(session.jwt, "jwt123"); + assert_eq!(session.jti, "jti-abc"); assert_eq!(session.canonical_id, "550e8400-e29b-41d4-a716-446655440000"); assert_eq!(session.provider, "google"); assert_eq!(session.provider_sub, "112233445566"); @@ -289,6 +311,7 @@ mod tests { let session_id = store .create(NewSession { jwt: "jwt".into(), + jti: "jti".into(), canonical_id: "canonical-1".into(), provider: "google".into(), provider_sub: "sub-1".into(), @@ -309,10 +332,11 @@ mod tests { assert!(store.get("nonexistent").is_none()); } - fn make_session(store: &SessionStore, canonical_id: &str, jwt: &str) -> String { + fn make_session(store: &SessionStore, canonical_id: &str, jti: &str) -> String { store .create(NewSession { - jwt: jwt.into(), + jwt: format!("jwt-for-{jti}"), + jti: jti.into(), canonical_id: canonical_id.into(), provider: "email".into(), provider_sub: canonical_id.into(), @@ -324,17 +348,17 @@ mod tests { } #[test] - fn destroy_others_keeps_target_session_and_returns_other_jwts() { + fn destroy_others_keeps_target_session_and_returns_other_jtis() { let store = SessionStore::new(); - let keep = make_session(&store, "user-a", "jwt-keep"); - let other_a = make_session(&store, "user-a", "jwt-a1"); - let other_b = make_session(&store, "user-a", "jwt-a2"); - let untouched = make_session(&store, "user-b", "jwt-b"); + let keep = make_session(&store, "user-a", "jti-keep"); + let other_a = make_session(&store, "user-a", "jti-a1"); + let other_b = make_session(&store, "user-a", "jti-a2"); + let untouched = make_session(&store, "user-b", "jti-b"); let removed = store.destroy_others_by_canonical_id("user-a", Some(&keep)); assert_eq!(removed.len(), 2); - assert!(removed.contains(&"jwt-a1".to_string())); - assert!(removed.contains(&"jwt-a2".to_string())); + assert!(removed.contains(&"jti-a1".to_string())); + assert!(removed.contains(&"jti-a2".to_string())); assert!(store.get(&keep).is_some()); assert!(store.get(&other_a).is_none()); @@ -345,9 +369,9 @@ mod tests { #[test] fn destroy_others_with_none_destroys_all_sessions_for_user() { let store = SessionStore::new(); - let a1 = make_session(&store, "user-a", "jwt-a1"); - let a2 = make_session(&store, "user-a", "jwt-a2"); - let b = make_session(&store, "user-b", "jwt-b"); + let a1 = make_session(&store, "user-a", "jti-a1"); + let a2 = make_session(&store, "user-a", "jti-a2"); + let b = make_session(&store, "user-b", "jti-b"); let removed = store.destroy_others_by_canonical_id("user-a", None); assert_eq!(removed.len(), 2); @@ -360,9 +384,42 @@ mod tests { #[test] fn destroy_others_with_unknown_user_returns_empty() { let store = SessionStore::new(); - make_session(&store, "user-a", "jwt-a"); + make_session(&store, "user-a", "jti-a"); let removed = store.destroy_others_by_canonical_id("user-x", None); assert!(removed.is_empty()); } + + #[test] + fn destroy_others_skips_empty_jti_sessions() { + let store = SessionStore::new(); + let dev_session = store + .create(NewSession { + jwt: String::new(), + jti: String::new(), + canonical_id: "user-a".into(), + provider: "dev".into(), + provider_sub: "dev-local".into(), + email: None, + name: None, + picture: None, + }) + .expect("create should succeed"); + let real_session = make_session(&store, "user-a", "jti-real"); + + let removed = store.destroy_others_by_canonical_id("user-a", None); + assert_eq!(removed, vec!["jti-real".to_string()]); + assert!(store.get(&dev_session).is_none()); + assert!(store.get(&real_session).is_none()); + } + + #[test] + fn revoke_many_revokes_all_jtis() { + let store = JtiRevocationStore::new(); + store.revoke_many(&["a".into(), "b".into(), "c".into()]); + assert!(store.is_revoked("a")); + assert!(store.is_revoked("b")); + assert!(store.is_revoked("c")); + assert!(!store.is_revoked("d")); + } } diff --git a/crates/mqdb-cli/src/commands/agent.rs b/crates/mqdb-cli/src/commands/agent.rs index 38ce68d..7185248 100644 --- a/crates/mqdb-cli/src/commands/agent.rs +++ b/crates/mqdb-cli/src/commands/agent.rs @@ -420,6 +420,8 @@ pub(crate) fn build_http_config( vault_backend: None, auth_rate_limit: if auth.no_rate_limit { u32::MAX } else { 5 }, email_auth: oauth.email_auth, + session_store: std::sync::Arc::new(mqdb_agent::http::SessionStore::new()), + jti_revocation: std::sync::Arc::new(mqdb_agent::http::JtiRevocationStore::new()), }) } diff --git a/docs/testing/13-http-api.md b/docs/testing/13-http-api.md index 39813a7..f741087 100644 --- a/docs/testing/13-http-api.md +++ b/docs/testing/13-http-api.md @@ -383,7 +383,9 @@ Expected: successful login with session cookie. - [ ] Caller's session cookie still works after the change (other sessions for the same user are destroyed, caller's is kept) - [ ] A second HTTP session for the same user logged in before the change returns 401 on any authenticated endpoint after the change - [ ] An MQTT ticket bound to a destroyed session's JWT is rejected (`jti` is in `JtiRevocationStore`) -- [ ] MQTT `$DB/_auth/password/change` works with same logic (note: MQTT path does NOT yet invalidate HTTP sessions — tracked in issue #69) +- [ ] MQTT `$DB/_auth/password/change` works with same logic +- [ ] An HTTP session for the same user logged in before an MQTT password change returns 401 on any authenticated endpoint after the change (MQTT path now destroys HTTP sessions too) +- [ ] An MQTT ticket bound to a destroyed session's JWT is rejected after an MQTT password change - [ ] Cluster mode rejects with "auth endpoints not supported in cluster mode" --- @@ -525,7 +527,9 @@ MQTT: shares the vault unlock rate limiter. - [ ] An MQTT ticket bound to a destroyed session's JWT is rejected after the reset - [ ] Rate limited after 3 attempts per minute (HTTP) - [ ] MQTT `$DB/_auth/password/reset/start` works with same logic -- [ ] MQTT `$DB/_auth/password/reset/submit` works with same logic (note: MQTT path does NOT yet invalidate HTTP sessions — tracked in issue #69) +- [ ] MQTT `$DB/_auth/password/reset/submit` works with same logic +- [ ] An HTTP session for the target user that existed before an MQTT reset submit returns 401 on any authenticated endpoint after the reset +- [ ] An MQTT ticket bound to a destroyed session's JWT is rejected after an MQTT reset submit - [ ] MQTT handler verifies email matches authenticated user's identity - [ ] Cluster mode rejects with "auth endpoints not supported in cluster mode"