purge.rs 24 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782
  1. //! Subject-scoped ephemeral-state purge for `user disable`.
  2. //!
  3. //! [`purge_subject`] deletes every Redis record whose `subject` field names
  4. //! the user (canonical email or user-id string): login sessions (plus their
  5. //! `:meta` sidecars), the lockout counters (the account key plus every
  6. //! per-IP composite key), authorization codes, device records
  7. //! (plus `:cnf`/`:poll` sidecars and `user_code` mappings), and token grants
  8. //! (cascade-revoked through [`grants::revoke`] so member token keys, the
  9. //! member set, and the barrier stay correct, plus standalone access/refresh
  10. //! records and `:act` sidecars).
  11. //!
  12. //! Records without a `subject` field are never touched: `par:`, `pending:`,
  13. //! `dpop_jti:`, `client_assertion_jti:`, `rotation:`, `totp_replay:`, and the
  14. //! `{rl}` rate-limit counters carry no subject, so they cannot name a user.
  15. //!
  16. //! Like the DCR `DELETE` revocation this scans the keyspaces with `SCAN MATCH
  17. //! <prefix>* COUNT 100` and filters on the raw `subject` hash field. `user
  18. //! disable` is a rare administrative call, so a bounded linear scan is
  19. //! proportionate; on Cluster topologies the scan covers the contacted node
  20. //! only (documented limit — Single and Sentinel scan fully).
  21. use std::collections::{HashMap, HashSet};
  22. use crate::db::user_repo::normalize_email;
  23. use crate::error::AppError;
  24. use crate::state::RedisConn;
  25. use crate::store::{Store, StoreError, grants, sha256_hex};
  26. /// How many records one subject purge removed, per key kind.
  27. #[derive(Debug, Clone, Default, PartialEq, Eq)]
  28. pub struct PurgedSubject {
  29. /// Login session records deleted (each also removed its `:meta` sidecar).
  30. pub sessions: u64,
  31. /// Lockout counters deleted (the account key plus every per-IP composite).
  32. pub lockouts: u64,
  33. /// Authorization-code records deleted.
  34. pub codes: u64,
  35. /// Device-code records deleted (each also removed `:cnf`/`:poll` sidecars).
  36. pub devices: u64,
  37. /// `user_code` mappings deleted.
  38. pub user_codes: u64,
  39. /// Grant families cascade-revoked.
  40. pub grants: u64,
  41. /// Standalone access-token records deleted (each also removed `:act`).
  42. pub access: u64,
  43. /// Standalone refresh-token records deleted.
  44. pub refresh: u64,
  45. }
  46. /// Delete every Redis record whose `subject` is `email` or `user_id`.
  47. ///
  48. /// `email` must be the canonical (lowercase) address from
  49. /// [`user_repo::find_by_email`](crate::db::user_repo::find_by_email); the
  50. /// lockout key is derived from it with the same normalization the login flow
  51. /// uses. Refresh-owned families cascade through [`grants::revoke`]; the
  52. /// grant's member `:act` sidecars are removed alongside because the revoke
  53. /// script deletes member keys only.
  54. ///
  55. /// # Errors
  56. ///
  57. /// Returns a fail-closed store failure when Redis is unavailable; callers
  58. /// (notably `user disable`) must leave the Postgres `disabled` flag untouched
  59. /// on error.
  60. pub async fn purge_subject(
  61. conn: &mut RedisConn,
  62. store: &Store,
  63. email: &str,
  64. user_id: &str,
  65. ) -> Result<PurgedSubject, AppError> {
  66. let mut summary = PurgedSubject::default();
  67. let session_prefix = store.session_key_prefix();
  68. for key in scan_keys(conn, &session_prefix).await? {
  69. if key.ends_with(":meta") {
  70. continue;
  71. }
  72. if is_subject(&read_fields(conn, &key).await?, email, user_id) {
  73. del_key(conn, &key).await?;
  74. del_key(conn, &format!("{key}:meta")).await?;
  75. summary.sessions += 1;
  76. }
  77. }
  78. let fail_key = store.login_fail_key(&sha256_hex(normalize_email(email).as_bytes()));
  79. let removed: i64 = redis::cmd("DEL")
  80. .arg(&fail_key)
  81. .query_async(conn)
  82. .await
  83. .map_err(store_mapped)?;
  84. if removed > 0 {
  85. summary.lockouts += 1;
  86. }
  87. let account_hash = sha256_hex(normalize_email(email).as_bytes());
  88. let composite_pattern = format!("{}*:{account_hash}", store.login_fail_ip_prefix());
  89. let mut cursor = "0".to_owned();
  90. loop {
  91. let (next, batch): (String, Vec<String>) = redis::cmd("SCAN")
  92. .arg(&cursor)
  93. .arg("MATCH")
  94. .arg(&composite_pattern)
  95. .arg("COUNT")
  96. .arg(100)
  97. .query_async(conn)
  98. .await
  99. .map_err(store_mapped)?;
  100. for key in &batch {
  101. del_key(conn, key).await?;
  102. summary.lockouts += 1;
  103. }
  104. if next == "0" {
  105. break;
  106. }
  107. cursor = next;
  108. }
  109. for key in scan_keys(conn, &store.code_key_prefix()).await? {
  110. if is_subject(&read_fields(conn, &key).await?, email, user_id) {
  111. del_key(conn, &key).await?;
  112. summary.codes += 1;
  113. }
  114. }
  115. let device_prefix = store.device_key_prefix();
  116. let mut device_hashes: HashSet<String> = HashSet::new();
  117. for key in scan_keys(conn, &device_prefix).await? {
  118. if key.ends_with(":cnf") || key.ends_with(":poll") {
  119. continue;
  120. }
  121. if is_subject(&read_fields(conn, &key).await?, email, user_id) {
  122. del_key(conn, &key).await?;
  123. del_key(conn, &format!("{key}:cnf")).await?;
  124. del_key(conn, &format!("{key}:poll")).await?;
  125. if let Some(hash) = key.strip_prefix(&device_prefix) {
  126. device_hashes.insert(hash.to_owned());
  127. }
  128. summary.devices += 1;
  129. }
  130. }
  131. if !device_hashes.is_empty() {
  132. for key in scan_keys(conn, &store.user_code_key_prefix()).await? {
  133. let target: Option<String> = redis::cmd("GET")
  134. .arg(&key)
  135. .query_async(conn)
  136. .await
  137. .map_err(store_mapped)?;
  138. if target.is_some_and(|hash| device_hashes.contains(&hash)) {
  139. del_key(conn, &key).await?;
  140. summary.user_codes += 1;
  141. }
  142. }
  143. }
  144. let grant_prefix = store.grant_key_prefix();
  145. let access_prefix = store.access_key_prefix();
  146. let mut grant_ids: Vec<String> = Vec::new();
  147. for key in scan_keys(conn, &grant_prefix).await? {
  148. if key.ends_with(":tokens") {
  149. continue;
  150. }
  151. if is_subject(&read_fields(conn, &key).await?, email, user_id)
  152. && let Some(id) = key.strip_prefix(&grant_prefix)
  153. {
  154. grant_ids.push(id.to_owned());
  155. }
  156. }
  157. for grant_id in &grant_ids {
  158. let members: Vec<String> = redis::cmd("SMEMBERS")
  159. .arg(store.grant_tokens_key(grant_id))
  160. .query_async(conn)
  161. .await
  162. .map_err(store_mapped)?;
  163. grants::revoke(conn, store, grant_id)
  164. .await
  165. .map_err(AppError::from)?;
  166. for member in &members {
  167. del_key(conn, &format!("{access_prefix}{member}:act")).await?;
  168. }
  169. summary.grants += 1;
  170. }
  171. for key in scan_keys(conn, &access_prefix).await? {
  172. if key.ends_with(":act") {
  173. continue;
  174. }
  175. if is_subject(&read_fields(conn, &key).await?, email, user_id) {
  176. del_key(conn, &key).await?;
  177. del_key(conn, &format!("{key}:act")).await?;
  178. summary.access += 1;
  179. }
  180. }
  181. for key in scan_keys(conn, &store.refresh_key_prefix()).await? {
  182. if is_subject(&read_fields(conn, &key).await?, email, user_id) {
  183. del_key(conn, &key).await?;
  184. summary.refresh += 1;
  185. }
  186. }
  187. Ok(summary)
  188. }
  189. /// Delete every login session (plus `:meta` sidecar) whose `subject` is
  190. /// `email` or `user_id`, except the session named by `keep_session_id`.
  191. ///
  192. /// The credential-change path (`change_password` / `confirm_totp` in
  193. /// `src/ui/login.rs`) calls this before the database update and before
  194. /// re-issuing the current session, so a password or TOTP change revokes the
  195. /// subject's other sessions while the session in hand survives. The sweep
  196. /// runs first so a Redis outage fails before any credential is written (the
  197. /// password stays unchanged); a later database failure then costs only
  198. /// re-authentication. Tokens, codes,
  199. /// and grants are deliberately untouched: ending the login sessions forces
  200. /// re-authentication, while live OAuth tokens keep working until they expire
  201. /// or the account is disabled (which runs the full [`purge_subject`]).
  202. ///
  203. /// # Errors
  204. ///
  205. /// Returns a fail-closed store failure when Redis is unavailable; callers
  206. /// must surface the error instead of reporting success with siblings intact.
  207. pub async fn purge_sibling_sessions(
  208. conn: &mut RedisConn,
  209. store: &Store,
  210. email: &str,
  211. user_id: &str,
  212. keep_session_id: &str,
  213. ) -> Result<u64, AppError> {
  214. let keep_key = store.session_key(keep_session_id);
  215. let mut revoked = 0;
  216. let session_prefix = store.session_key_prefix();
  217. for key in scan_keys(conn, &session_prefix).await? {
  218. if key.ends_with(":meta") || key == keep_key {
  219. continue;
  220. }
  221. if is_subject(&read_fields(conn, &key).await?, email, user_id) {
  222. del_key(conn, &key).await?;
  223. del_key(conn, &format!("{key}:meta")).await?;
  224. revoked += 1;
  225. }
  226. }
  227. Ok(revoked)
  228. }
  229. /// Whether a record's `subject` field names this user by email or id.
  230. fn is_subject(fields: &HashMap<String, String>, email: &str, user_id: &str) -> bool {
  231. fields
  232. .get("subject")
  233. .is_some_and(|subject| subject == email || subject == user_id)
  234. }
  235. /// List every key under `prefix` with a bounded `SCAN` loop.
  236. async fn scan_keys(conn: &mut RedisConn, prefix: &str) -> Result<Vec<String>, AppError> {
  237. let pattern = format!("{prefix}*");
  238. let mut cursor = "0".to_owned();
  239. let mut out = Vec::new();
  240. loop {
  241. let (next, batch): (String, Vec<String>) = redis::cmd("SCAN")
  242. .arg(&cursor)
  243. .arg("MATCH")
  244. .arg(&pattern)
  245. .arg("COUNT")
  246. .arg(100)
  247. .query_async(conn)
  248. .await
  249. .map_err(store_mapped)?;
  250. out.extend(batch);
  251. if next == "0" {
  252. break;
  253. }
  254. cursor = next;
  255. }
  256. Ok(out)
  257. }
  258. /// Read one record's hash fields; a missing key reads as empty.
  259. async fn read_fields(conn: &mut RedisConn, key: &str) -> Result<HashMap<String, String>, AppError> {
  260. redis::cmd("HGETALL")
  261. .arg(key)
  262. .query_async(conn)
  263. .await
  264. .map_err(store_mapped)
  265. }
  266. /// Delete one key.
  267. async fn del_key(conn: &mut RedisConn, key: &str) -> Result<(), AppError> {
  268. let _: () = redis::cmd("DEL")
  269. .arg(key)
  270. .query_async(conn)
  271. .await
  272. .map_err(store_mapped)?;
  273. Ok(())
  274. }
  275. /// Map a Redis failure onto the fail-closed store policy (transient outages
  276. /// render `503`, everything else is masked).
  277. fn store_mapped(error: redis::RedisError) -> AppError {
  278. AppError::from(StoreError::Redis(error))
  279. }
  280. #[cfg(test)]
  281. mod tests {
  282. use std::time::Duration;
  283. use super::{PurgedSubject, purge_sibling_sessions, purge_subject};
  284. use crate::{
  285. config::DurabilityMode,
  286. error::{AppError, OAuthErrorCode},
  287. state::RedisConn,
  288. store::Store,
  289. };
  290. #[derive(Debug, thiserror::Error)]
  291. enum TestError {
  292. #[error("redis error: {0}")]
  293. Redis(#[from] redis::RedisError),
  294. #[error("store error: {0}")]
  295. Store(#[from] crate::store::StoreError),
  296. #[error("app error: {0}")]
  297. App(#[from] crate::error::AppError),
  298. #[error("unexpected test value: {0}")]
  299. Unexpected(String),
  300. }
  301. type TestResult = Result<(), TestError>;
  302. /// Shared compose Redis plus a per-test uuid prefix pair.
  303. ///
  304. /// Unique prefixes are mandatory: `purge_subject` `SCAN`s its prefix, and a
  305. /// per-process counter collides across parallel nextest processes.
  306. async fn harness() -> Result<(RedisConn, Store), TestError> {
  307. let manager = crate::test_support::redis_manager()
  308. .await
  309. .map_err(|error| TestError::Unexpected(format!("shared redis: {error}")))?;
  310. let (state_prefix, rl_prefix) = crate::test_support::unique_prefixes();
  311. let store = Store::from_parts(state_prefix, rl_prefix, DurabilityMode::WaitAof);
  312. Ok((RedisConn::Single(manager), store))
  313. }
  314. async fn hset(conn: &mut RedisConn, key: &str, pairs: &[(&str, &str)]) -> TestResult {
  315. let mut cmd = redis::cmd("HSET");
  316. cmd.arg(key);
  317. for (field, value) in pairs {
  318. cmd.arg(field).arg(value);
  319. }
  320. cmd.query_async::<()>(conn).await?;
  321. redis::cmd("PEXPIRE")
  322. .arg(key)
  323. .arg(60_000)
  324. .query_async::<()>(conn)
  325. .await?;
  326. Ok(())
  327. }
  328. async fn set(conn: &mut RedisConn, key: &str, value: &str) -> TestResult {
  329. redis::cmd("SET")
  330. .arg(key)
  331. .arg(value)
  332. .arg("PX")
  333. .arg(60_000)
  334. .query_async::<()>(conn)
  335. .await?;
  336. Ok(())
  337. }
  338. async fn exists(conn: &mut RedisConn, key: &str) -> Result<bool, TestError> {
  339. let count: i64 = redis::cmd("EXISTS").arg(key).query_async(conn).await?;
  340. Ok(count > 0)
  341. }
  342. async fn require_gone(conn: &mut RedisConn, key: &str) -> TestResult {
  343. if exists(conn, key).await? {
  344. return Err(TestError::Unexpected(format!("key survived purge: {key}")));
  345. }
  346. Ok(())
  347. }
  348. async fn require_present(conn: &mut RedisConn, key: &str) -> TestResult {
  349. if !exists(conn, key).await? {
  350. return Err(TestError::Unexpected(format!("key missing: {key}")));
  351. }
  352. Ok(())
  353. }
  354. #[tokio::test]
  355. async fn purge_subject_deletes_all_subject_state() -> TestResult {
  356. let (mut conn, store) = harness().await?;
  357. let email = "alice@example.test";
  358. let user_id = uuid::Uuid::new_v4().to_string();
  359. let decoy = "bob@example.test";
  360. let session = "sess-alice";
  361. let session_key = store.session_key(session);
  362. hset(
  363. &mut conn,
  364. &session_key,
  365. &[
  366. ("subject", email),
  367. ("mfa_verified", "true"),
  368. ("restricted", "false"),
  369. ],
  370. )
  371. .await?;
  372. hset(
  373. &mut conn,
  374. &format!("{session_key}:meta"),
  375. &[
  376. ("auth_time", "1700000000"),
  377. ("csrf", "csrf-secret"),
  378. ("request_id", "req-1"),
  379. ],
  380. )
  381. .await?;
  382. let fail_key = store.login_fail_key(&crate::store::sha256_hex(email.as_bytes()));
  383. redis::cmd("INCR")
  384. .arg(&fail_key)
  385. .query_async::<()>(&mut conn)
  386. .await?;
  387. let code_key = store.code_key("codehashalice");
  388. hset(
  389. &mut conn,
  390. &code_key,
  391. &[
  392. ("client_id", "client-a"),
  393. ("redirect_uri", "https://client.example.test/cb"),
  394. ("scope", "orders:read"),
  395. (
  396. "code_challenge",
  397. "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM",
  398. ),
  399. ("subject", email),
  400. ("grant_id", "grant-code"),
  401. ("cnf", ""),
  402. ],
  403. )
  404. .await?;
  405. let device_hash = "devicehashalice";
  406. let device_key = store.device_key(device_hash);
  407. hset(
  408. &mut conn,
  409. &device_key,
  410. &[
  411. ("client_id", "client-a"),
  412. ("scope", "orders:read"),
  413. ("status", "approved"),
  414. ("subject", user_id.as_str()),
  415. ("grant_id", ""),
  416. ],
  417. )
  418. .await?;
  419. set(&mut conn, &format!("{device_key}:cnf"), "binding").await?;
  420. set(&mut conn, &format!("{device_key}:poll"), "1700000000").await?;
  421. let user_code_hash = "usercodehashalice";
  422. let user_code_key = store.user_code_key(user_code_hash);
  423. set(&mut conn, &user_code_key, device_hash).await?;
  424. let grant_id = "grant-alice";
  425. let grant_key = store.grant_key(grant_id);
  426. hset(
  427. &mut conn,
  428. &grant_key,
  429. &[
  430. ("grant_id", grant_id),
  431. ("client_id", "client-a"),
  432. ("subject", email),
  433. ("scope", "orders:read"),
  434. ],
  435. )
  436. .await?;
  437. let access_hash = "accesshashalice";
  438. let refresh_hash = "refreshhashalice";
  439. let access_key = store.access_key(access_hash);
  440. let refresh_key = store.refresh_key(refresh_hash);
  441. for (key, hash) in [(&access_key, access_hash), (&refresh_key, refresh_hash)] {
  442. hset(
  443. &mut conn,
  444. key,
  445. &[
  446. ("client_id", "client-a"),
  447. ("grant_id", grant_id),
  448. ("scope", "orders:read"),
  449. ("subject", email),
  450. ("cnf", ""),
  451. ],
  452. )
  453. .await?;
  454. redis::cmd("SADD")
  455. .arg(store.grant_tokens_key(grant_id))
  456. .arg(hash)
  457. .query_async::<()>(&mut conn)
  458. .await?;
  459. }
  460. set(&mut conn, &format!("{access_key}:act"), "jkt").await?;
  461. let lone_access_key = store.access_key("loneaccessalice");
  462. hset(
  463. &mut conn,
  464. &lone_access_key,
  465. &[
  466. ("client_id", "client-a"),
  467. ("grant_id", "orphan"),
  468. ("scope", "orders:read"),
  469. ("subject", email),
  470. ("cnf", ""),
  471. ],
  472. )
  473. .await?;
  474. set(&mut conn, &format!("{lone_access_key}:act"), "jkt").await?;
  475. let lone_refresh_key = store.refresh_key("lonerefreshalice");
  476. hset(
  477. &mut conn,
  478. &lone_refresh_key,
  479. &[
  480. ("client_id", "client-a"),
  481. ("grant_id", "orphan"),
  482. ("scope", "orders:read"),
  483. ("subject", email),
  484. ("cnf", ""),
  485. ],
  486. )
  487. .await?;
  488. let decoy_session_key = store.session_key("sess-bob");
  489. hset(
  490. &mut conn,
  491. &decoy_session_key,
  492. &[
  493. ("subject", decoy),
  494. ("mfa_verified", "true"),
  495. ("restricted", "false"),
  496. ],
  497. )
  498. .await?;
  499. let decoy_access_key = store.access_key("accesshashbob");
  500. hset(
  501. &mut conn,
  502. &decoy_access_key,
  503. &[
  504. ("client_id", "client-b"),
  505. ("grant_id", "grant-bob"),
  506. ("scope", "orders:read"),
  507. ("subject", decoy),
  508. ("cnf", ""),
  509. ],
  510. )
  511. .await?;
  512. let summary = purge_subject(&mut conn, &store, email, &user_id).await?;
  513. let expected = PurgedSubject {
  514. sessions: 1,
  515. lockouts: 1,
  516. codes: 1,
  517. devices: 1,
  518. user_codes: 1,
  519. grants: 1,
  520. access: 1,
  521. refresh: 1,
  522. };
  523. if summary != expected {
  524. return Err(TestError::Unexpected(format!(
  525. "wrong purge summary: {summary:?} (expected {expected:?})"
  526. )));
  527. }
  528. for key in [
  529. session_key.clone(),
  530. format!("{session_key}:meta"),
  531. fail_key,
  532. code_key,
  533. device_key.clone(),
  534. format!("{device_key}:cnf"),
  535. format!("{device_key}:poll"),
  536. user_code_key,
  537. grant_key,
  538. store.grant_tokens_key(grant_id),
  539. access_key.clone(),
  540. format!("{access_key}:act"),
  541. refresh_key,
  542. lone_access_key.clone(),
  543. format!("{lone_access_key}:act"),
  544. lone_refresh_key,
  545. ] {
  546. require_gone(&mut conn, &key).await?;
  547. }
  548. require_present(&mut conn, &decoy_session_key).await?;
  549. require_present(&mut conn, &decoy_access_key).await?;
  550. Ok(())
  551. }
  552. #[tokio::test]
  553. async fn purge_subject_with_no_matching_state_returns_zero() -> TestResult {
  554. let (mut conn, store) = harness().await?;
  555. let summary = purge_subject(&mut conn, &store, "nobody@example.test", "no-id").await?;
  556. if summary != PurgedSubject::default() {
  557. return Err(TestError::Unexpected(format!(
  558. "expected zero summary, got {summary:?}"
  559. )));
  560. }
  561. Ok(())
  562. }
  563. async fn dead_conn() -> Result<RedisConn, TestError> {
  564. let client = redis::Client::open("redis://127.0.0.1:1")?;
  565. let manager = redis::aio::ConnectionManager::new_lazy_with_config(
  566. client,
  567. redis::aio::ConnectionManagerConfig::new()
  568. .set_number_of_retries(0)
  569. .set_response_timeout(Some(Duration::from_millis(200)))
  570. .set_connection_timeout(Some(Duration::from_millis(200))),
  571. )?;
  572. Ok(RedisConn::Single(manager))
  573. }
  574. fn require_unavailable(error: AppError) -> TestResult {
  575. if error.code() != OAuthErrorCode::TemporarilyUnavailable {
  576. return Err(TestError::Unexpected(format!(
  577. "a store outage must be temporarily_unavailable, got {:?}",
  578. error.code()
  579. )));
  580. }
  581. if error.status().as_u16() != 503 {
  582. return Err(TestError::Unexpected(format!(
  583. "a store outage must render 503, got {}",
  584. error.status()
  585. )));
  586. }
  587. Ok(())
  588. }
  589. #[tokio::test]
  590. async fn purge_sibling_sessions_deletes_meta_sidecar_and_keeps_current() -> TestResult {
  591. let (mut conn, store) = harness().await?;
  592. let email = "sibling-meta@example.test";
  593. let user_id = uuid::Uuid::new_v4().to_string();
  594. let current_key = store.session_key("sess-current");
  595. hset(
  596. &mut conn,
  597. &current_key,
  598. &[("subject", email), ("mfa_verified", "true")],
  599. )
  600. .await?;
  601. hset(&mut conn, &format!("{current_key}:meta"), &[("csrf", "a")]).await?;
  602. let sibling_key = store.session_key("sess-sibling");
  603. hset(
  604. &mut conn,
  605. &sibling_key,
  606. &[("subject", email), ("mfa_verified", "true")],
  607. )
  608. .await?;
  609. hset(&mut conn, &format!("{sibling_key}:meta"), &[("csrf", "b")]).await?;
  610. let revoked =
  611. purge_sibling_sessions(&mut conn, &store, email, &user_id, "sess-current").await?;
  612. if revoked != 1 {
  613. return Err(TestError::Unexpected(format!(
  614. "exactly one sibling must be revoked, got {revoked}"
  615. )));
  616. }
  617. require_present(&mut conn, &current_key).await?;
  618. require_present(&mut conn, &format!("{current_key}:meta")).await?;
  619. require_gone(&mut conn, &sibling_key).await?;
  620. require_gone(&mut conn, &format!("{sibling_key}:meta")).await?;
  621. Ok(())
  622. }
  623. #[tokio::test]
  624. async fn purge_sibling_sessions_leaves_other_subjects_tokens_and_grants() -> TestResult {
  625. let (mut conn, store) = harness().await?;
  626. let email = "sibling-scope@example.test";
  627. let user_id = uuid::Uuid::new_v4().to_string();
  628. let other = "stranger@example.test";
  629. let victim_key = store.session_key("sess-victim");
  630. hset(&mut conn, &victim_key, &[("subject", email)]).await?;
  631. let other_key = store.session_key("sess-other");
  632. hset(&mut conn, &other_key, &[("subject", other)]).await?;
  633. hset(&mut conn, &format!("{other_key}:meta"), &[("csrf", "c")]).await?;
  634. let grant_id = "grant-survivor";
  635. hset(
  636. &mut conn,
  637. &store.grant_key(grant_id),
  638. &[("subject", email), ("scope", "orders:read")],
  639. )
  640. .await?;
  641. redis::cmd("SADD")
  642. .arg(store.grant_tokens_key(grant_id))
  643. .arg("memberhash")
  644. .query_async::<()>(&mut conn)
  645. .await?;
  646. let access_key = store.access_key("survivoraccess");
  647. hset(&mut conn, &access_key, &[("subject", email)]).await?;
  648. set(&mut conn, &format!("{access_key}:act"), "jkt").await?;
  649. let refresh_key = store.refresh_key("survivorrefresh");
  650. hset(&mut conn, &refresh_key, &[("subject", email)]).await?;
  651. let code_key = store.code_key("survivorcode");
  652. hset(&mut conn, &code_key, &[("subject", email)]).await?;
  653. let revoked = purge_sibling_sessions(&mut conn, &store, email, &user_id, "sess-victim").await?;
  654. if revoked != 0 {
  655. return Err(TestError::Unexpected(format!(
  656. "keeping the only own session must revoke nothing, got {revoked}"
  657. )));
  658. }
  659. for key in [
  660. victim_key,
  661. other_key.clone(),
  662. format!("{other_key}:meta"),
  663. store.grant_key(grant_id),
  664. store.grant_tokens_key(grant_id),
  665. access_key.clone(),
  666. format!("{access_key}:act"),
  667. refresh_key,
  668. code_key,
  669. ] {
  670. require_present(&mut conn, &key).await?;
  671. }
  672. Ok(())
  673. }
  674. #[tokio::test]
  675. async fn purge_sibling_sessions_matches_subject_by_user_id() -> TestResult {
  676. let (mut conn, store) = harness().await?;
  677. let email = "sibling-id@example.test";
  678. let user_id = uuid::Uuid::new_v4().to_string();
  679. let current_key = store.session_key("sess-id-current");
  680. hset(&mut conn, &current_key, &[("subject", user_id.as_str())]).await?;
  681. let sibling_key = store.session_key("sess-id-sibling");
  682. hset(&mut conn, &sibling_key, &[("subject", user_id.as_str())]).await?;
  683. let revoked =
  684. purge_sibling_sessions(&mut conn, &store, email, &user_id, "sess-id-current").await?;
  685. if revoked != 1 {
  686. return Err(TestError::Unexpected(format!(
  687. "a user-id-keyed sibling must be revoked, got {revoked}"
  688. )));
  689. }
  690. require_present(&mut conn, &current_key).await?;
  691. require_gone(&mut conn, &sibling_key).await?;
  692. Ok(())
  693. }
  694. #[tokio::test]
  695. async fn purge_subject_fails_closed_on_store_outage() -> TestResult {
  696. let mut conn = dead_conn().await?;
  697. let store = Store::from_parts(
  698. "purge-outage:{state}:".to_owned(),
  699. "oauth:{rl}:".to_owned(),
  700. DurabilityMode::WaitAof,
  701. );
  702. match purge_subject(&mut conn, &store, "outage@example.test", "outage-id").await {
  703. Err(error) => require_unavailable(error),
  704. Ok(_) => Err(TestError::Unexpected(
  705. "purge_subject on a dead store must fail, never report success".to_owned(),
  706. )),
  707. }
  708. }
  709. #[tokio::test]
  710. async fn purge_sibling_sessions_fails_closed_on_store_outage() -> TestResult {
  711. let mut conn = dead_conn().await?;
  712. let store = Store::from_parts(
  713. "purge-outage:{state}:".to_owned(),
  714. "oauth:{rl}:".to_owned(),
  715. DurabilityMode::WaitAof,
  716. );
  717. match purge_sibling_sessions(
  718. &mut conn,
  719. &store,
  720. "outage@example.test",
  721. "outage-id",
  722. "sess-keep",
  723. )
  724. .await
  725. {
  726. Err(error) => require_unavailable(error),
  727. Ok(_) => Err(TestError::Unexpected(
  728. "purge_sibling_sessions on a dead store must fail, never report success".to_owned(),
  729. )),
  730. }
  731. }
  732. }