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
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,14 @@ 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.2

### Fixed

- Password changes left every other HTTP session for the same user valid until the 24h TTL expired (`SessionStore` only exposed `destroy(session_id)`, so `POST /auth/password/change` updated `_credentials` and returned 200 with the attacker's session still live). Added `SessionStore::destroy_others_by_canonical_id(canonical_id, keep_session_id)` returning the JWTs of removed sessions so the caller can revoke their JTIs. `handle_password_change` now keeps the caller's session and destroys the rest; `handle_password_reset_submit` destroys all sessions for the target canonical_id (the user has no live session at reset time). Each destroyed session's JWT is decoded with `verify_jwt_ignore_expiry` and its `jti` added to `JtiRevocationStore`, matching the pattern already used by `handle_logout` so reissued MQTT tickets bound to those JWTs are rejected. The MQTT password-change path (`$DB/_auth/password/change`) intentionally still does not invalidate HTTP sessions — `AdminContext` has no reference to `SessionStore`; tracked as follow-up to issue #37.
- Test coverage: 3 unit tests for `destroy_others_by_canonical_id` covering keep-current-session, destroy-all (`None` keep), and unknown-user (empty result).
- Observability for security-sensitive silent failures along the new path: `destroy_others_by_canonical_id` now `warn!`s on a poisoned `SessionStore` lock (was a silent empty return); `revoke_jwt_jtis` `warn!`s when a destroyed session's JWT fails to decode or is missing the `jti` claim (was a silent skip); `handle_password_reset_submit` now `error!`s and returns 500 when the verified challenge record is missing `canonical_id` (was `.unwrap_or("")` → silent no-op on `destroy_others_by_canonical_id`).

## 2026-05-22 — mqdb-agent 0.8.1, mqdb-cluster 0.3.6, mqdb-cli 0.7.7

### Fixed
Expand Down
2 changes: 1 addition & 1 deletion Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 3 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -521,9 +521,11 @@ When `--http-bind` is set, the following HTTP endpoints are available:

All auth endpoints use cookie-based sessions. The `/auth/ticket` endpoint exchanges a valid session for a JWT that can be used to authenticate MQTT connections.

A successful `POST /auth/password/change` keeps the caller's session and destroys every other HTTP session for the same user; their JWTs' `jti` claims are added to the revocation store so any reissued MQTT ticket is rejected. `POST /auth/password/reset/submit` does the same for every session of the target user (no caller session at reset time).

### Password Change & Reset MQTT API

Password change and reset are also available over MQTT 5.0 request-response for JWT-authenticated users:
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.

| Topic | Payload | Description |
|-------|---------|-------------|
Expand Down
2 changes: 1 addition & 1 deletion crates/mqdb-agent/Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "mqdb-agent"
version = "0.8.1"
version = "0.8.2"
edition.workspace = true
license = "Apache-2.0"
authors.workspace = true
Expand Down
122 changes: 81 additions & 41 deletions crates/mqdb-agent/src/http/handlers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2298,6 +2298,67 @@ 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,
current_password: &str,
cors: Option<&str>,
) -> Result<(), HttpResponse> {
let identity = read_entity(&state.mqtt_client, "_identities", canonical_id).await;
let email_verified = identity
.as_ref()
.and_then(|i| i.get("email_verified"))
.and_then(serde_json::Value::as_bool)
.unwrap_or(false);
if !email_verified {
return Err(json_response_with_credentials(
403,
&json!({"error": "email must be verified before changing password"}),
cors,
));
}

let Some(cred) = read_entity(&state.mqtt_client, "_credentials", canonical_id).await else {
return Err(json_response_with_credentials(
404,
&json!({"error": "no credentials found (OAuth-only account)"}),
cors,
));
};

let Some(stored_hash) = cred.get("password_hash").and_then(|v| v.as_str()) else {
return Err(json_response_with_credentials(
500,
&json!({"error": "credential record is corrupt"}),
cors,
));
};

if !credentials::verify_password(stored_hash, current_password) {
return Err(json_response_with_credentials(
401,
&json!({"error": "incorrect current password"}),
cors,
));
}

Ok(())
}

pub async fn handle_password_change(
state: &ServerState,
headers: &HeaderMap,
Expand All @@ -2309,7 +2370,7 @@ pub async fn handle_password_change(
return json_response_with_credentials(404, &json!({"error": "not found"}), cors);
}

let (_, session) = match require_session(state, headers) {
let (current_session_id, session) = match require_session(state, headers) {
Ok(r) => r,
Err(resp) => return *resp,
};
Expand Down Expand Up @@ -2354,42 +2415,8 @@ pub async fn handle_password_change(
);
}

let identity = read_entity(&state.mqtt_client, "_identities", &canonical_id).await;
let email_verified = identity
.as_ref()
.and_then(|i| i.get("email_verified"))
.and_then(serde_json::Value::as_bool)
.unwrap_or(false);
if !email_verified {
return json_response_with_credentials(
403,
&json!({"error": "email must be verified before changing password"}),
cors,
);
}

let Some(cred) = read_entity(&state.mqtt_client, "_credentials", &canonical_id).await else {
return json_response_with_credentials(
404,
&json!({"error": "no credentials found (OAuth-only account)"}),
cors,
);
};

let Some(stored_hash) = cred.get("password_hash").and_then(|v| v.as_str()) else {
return json_response_with_credentials(
500,
&json!({"error": "credential record is corrupt"}),
cors,
);
};

if !credentials::verify_password(stored_hash, current_password) {
return json_response_with_credentials(
401,
&json!({"error": "incorrect current password"}),
cors,
);
if let Err(resp) = verify_stored_password(state, &canonical_id, current_password, cors).await {
return resp;
}

let new_hash = match credentials::hash_password(new_password) {
Expand All @@ -2415,6 +2442,11 @@ pub async fn handle_password_change(
);
}

let revoked_jwts = state
.session_store
.destroy_others_by_canonical_id(&canonical_id, Some(current_session_id));
revoke_jwt_jtis(state, &revoked_jwts);

json_response_with_credentials(200, &json!({"status": "password changed"}), cors)
}

Expand Down Expand Up @@ -2715,10 +2747,13 @@ pub async fn handle_password_reset_submit(
)
.await;

let canonical_id = challenge
.get("canonical_id")
.and_then(|v| v.as_str())
.unwrap_or("");
let Some(canonical_id) = challenge.get("canonical_id").and_then(|v| v.as_str()) else {
error!(
challenge_id,
"password reset challenge missing canonical_id"
);
return json_response_with_credentials(500, &json!({"error": "internal error"}), cors);
};

let new_hash = match credentials::hash_password(new_password) {
Ok(h) => h,
Expand Down Expand Up @@ -2763,6 +2798,11 @@ pub async fn handle_password_reset_submit(
)
.await;

let revoked_jwts = state
.session_store
.destroy_others_by_canonical_id(canonical_id, None);
revoke_jwt_jtis(state, &revoked_jwts);

json_response_with_credentials(200, &json!({"status": "password_reset"}), cors)
}

Expand Down
87 changes: 87 additions & 0 deletions crates/mqdb-agent/src/http/session_store.rs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ use ring::rand::{SecureRandom, SystemRandom};
use std::collections::HashMap;
use std::sync::RwLock;
use std::time::{Duration, SystemTime};
use tracing::warn;

const SESSION_ID_BYTES: usize = 32;
const SESSION_TTL_SECS: u64 = 86400;
Expand Down Expand Up @@ -103,6 +104,35 @@ impl SessionStore {
sessions.remove(session_id).is_some()
}

/// 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.
pub fn destroy_others_by_canonical_id(
&self,
canonical_id: &str,
keep_session_id: Option<&str>,
) -> Vec<String> {
let Ok(mut sessions) = self.sessions.write() else {
warn!(
canonical_id,
"session store lock poisoned; password-change session invalidation skipped"
);
return Vec::new();
};
let mut removed_jwts = 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());
false
} else {
true
}
});
removed_jwts
}

pub fn set_vault_unlocked(&self, session_id: &str, unlocked: bool) -> bool {
let Ok(mut sessions) = self.sessions.write() else {
return false;
Expand Down Expand Up @@ -278,4 +308,61 @@ mod tests {
let store = SessionStore::new();
assert!(store.get("nonexistent").is_none());
}

fn make_session(store: &SessionStore, canonical_id: &str, jwt: &str) -> String {
store
.create(NewSession {
jwt: jwt.into(),
canonical_id: canonical_id.into(),
provider: "email".into(),
provider_sub: canonical_id.into(),
email: None,
name: None,
picture: None,
})
.expect("create should succeed")
}

#[test]
fn destroy_others_keeps_target_session_and_returns_other_jwts() {
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 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!(store.get(&keep).is_some());
assert!(store.get(&other_a).is_none());
assert!(store.get(&other_b).is_none());
assert!(store.get(&untouched).is_some());
}

#[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 removed = store.destroy_others_by_canonical_id("user-a", None);
assert_eq!(removed.len(), 2);

assert!(store.get(&a1).is_none());
assert!(store.get(&a2).is_none());
assert!(store.get(&b).is_some());
}

#[test]
fn destroy_others_with_unknown_user_returns_empty() {
let store = SessionStore::new();
make_session(&store, "user-a", "jwt-a");

let removed = store.destroy_others_by_canonical_id("user-x", None);
assert!(removed.is_empty());
}
}
9 changes: 7 additions & 2 deletions docs/testing/13-http-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -380,7 +380,10 @@ Expected: successful login with session cookie.
- [ ] Rate limited after 5 attempts per minute
- [ ] Login works with new password after change
- [ ] Login fails with old password after change
- [ ] MQTT `$DB/_auth/password/change` works with same logic
- [ ] 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)
- [ ] Cluster mode rejects with "auth endpoints not supported in cluster mode"

---
Expand Down Expand Up @@ -518,9 +521,11 @@ MQTT: shares the vault unlock rate limiter.
- [ ] `email_verified` set to `true` after successful reset
- [ ] Login works with new password after reset
- [ ] Login fails with old password after reset
- [ ] Any HTTP session for the target user that existed before the reset returns 401 on any authenticated endpoint after the reset
- [ ] 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
- [ ] MQTT `$DB/_auth/password/reset/submit` works with same logic (note: MQTT path does NOT yet invalidate HTTP sessions — tracked in issue #69)
- [ ] MQTT handler verifies email matches authenticated user's identity
- [ ] Cluster mode rejects with "auth endpoints not supported in cluster mode"

Expand Down
Loading