purge.rs 25 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807
  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::{
  283. sync::atomic::{AtomicU64, Ordering},
  284. time::Duration,
  285. };
  286. use testcontainers::{
  287. GenericImage, ImageExt,
  288. core::{IntoContainerPort, WaitFor},
  289. runners::AsyncRunner,
  290. };
  291. use super::{PurgedSubject, purge_sibling_sessions, purge_subject};
  292. use crate::{
  293. config::DurabilityMode,
  294. error::{AppError, OAuthErrorCode},
  295. state::RedisConn,
  296. store::Store,
  297. test_guard::ContainerGuard,
  298. };
  299. #[derive(Debug, thiserror::Error)]
  300. enum TestError {
  301. #[error("container error: {0}")]
  302. Containers(#[from] testcontainers::TestcontainersError),
  303. #[error("redis error: {0}")]
  304. Redis(#[from] redis::RedisError),
  305. #[error("store error: {0}")]
  306. Store(#[from] crate::store::StoreError),
  307. #[error("app error: {0}")]
  308. App(#[from] crate::error::AppError),
  309. #[error("unexpected test value: {0}")]
  310. Unexpected(String),
  311. }
  312. type TestResult = Result<(), TestError>;
  313. static NONCE: AtomicU64 = AtomicU64::new(1);
  314. async fn harness() -> Result<(ContainerGuard, RedisConn, Store), TestError> {
  315. let n = NONCE.fetch_add(1, Ordering::Relaxed);
  316. let container = GenericImage::new("redis", "7.2-alpine")
  317. .with_exposed_port(6379.tcp())
  318. .with_wait_for(WaitFor::message_on_stdout("Ready to accept connections"))
  319. .with_cmd(["redis-server", "--appendonly", "yes"])
  320. .start()
  321. .await?;
  322. let guarded = ContainerGuard::new(container);
  323. let port = guarded.get_host_port_ipv4(6379).await?;
  324. let url = format!("redis://127.0.0.1:{port}");
  325. let client = redis::Client::open(url.as_str())?;
  326. let manager_config = redis::aio::ConnectionManagerConfig::new()
  327. .set_response_timeout(Some(Duration::from_secs(10)))
  328. .set_connection_timeout(Some(Duration::from_secs(5)));
  329. let manager = redis::aio::ConnectionManager::new_with_config(client, manager_config).await?;
  330. let store = Store::from_parts(
  331. format!("purge-test-{n}:{{state}}:"),
  332. "oauth:{rl}:".to_owned(),
  333. DurabilityMode::WaitAof,
  334. );
  335. Ok((guarded, RedisConn::Single(manager), store))
  336. }
  337. async fn hset(conn: &mut RedisConn, key: &str, pairs: &[(&str, &str)]) -> TestResult {
  338. let mut cmd = redis::cmd("HSET");
  339. cmd.arg(key);
  340. for (field, value) in pairs {
  341. cmd.arg(field).arg(value);
  342. }
  343. cmd.query_async::<()>(conn).await?;
  344. redis::cmd("PEXPIRE")
  345. .arg(key)
  346. .arg(60_000)
  347. .query_async::<()>(conn)
  348. .await?;
  349. Ok(())
  350. }
  351. async fn set(conn: &mut RedisConn, key: &str, value: &str) -> TestResult {
  352. redis::cmd("SET")
  353. .arg(key)
  354. .arg(value)
  355. .arg("PX")
  356. .arg(60_000)
  357. .query_async::<()>(conn)
  358. .await?;
  359. Ok(())
  360. }
  361. async fn exists(conn: &mut RedisConn, key: &str) -> Result<bool, TestError> {
  362. let count: i64 = redis::cmd("EXISTS").arg(key).query_async(conn).await?;
  363. Ok(count > 0)
  364. }
  365. async fn require_gone(conn: &mut RedisConn, key: &str) -> TestResult {
  366. if exists(conn, key).await? {
  367. return Err(TestError::Unexpected(format!("key survived purge: {key}")));
  368. }
  369. Ok(())
  370. }
  371. async fn require_present(conn: &mut RedisConn, key: &str) -> TestResult {
  372. if !exists(conn, key).await? {
  373. return Err(TestError::Unexpected(format!("key missing: {key}")));
  374. }
  375. Ok(())
  376. }
  377. #[tokio::test]
  378. async fn purge_subject_deletes_all_subject_state() -> TestResult {
  379. let (_guard, mut conn, store) = harness().await?;
  380. let email = "alice@example.test";
  381. let user_id = uuid::Uuid::new_v4().to_string();
  382. let decoy = "bob@example.test";
  383. let session = "sess-alice";
  384. let session_key = store.session_key(session);
  385. hset(
  386. &mut conn,
  387. &session_key,
  388. &[
  389. ("subject", email),
  390. ("mfa_verified", "true"),
  391. ("restricted", "false"),
  392. ],
  393. )
  394. .await?;
  395. hset(
  396. &mut conn,
  397. &format!("{session_key}:meta"),
  398. &[
  399. ("auth_time", "1700000000"),
  400. ("csrf", "csrf-secret"),
  401. ("request_id", "req-1"),
  402. ],
  403. )
  404. .await?;
  405. let fail_key = store.login_fail_key(&crate::store::sha256_hex(email.as_bytes()));
  406. redis::cmd("INCR")
  407. .arg(&fail_key)
  408. .query_async::<()>(&mut conn)
  409. .await?;
  410. let code_key = store.code_key("codehashalice");
  411. hset(
  412. &mut conn,
  413. &code_key,
  414. &[
  415. ("client_id", "client-a"),
  416. ("redirect_uri", "https://client.example.test/cb"),
  417. ("scope", "orders:read"),
  418. (
  419. "code_challenge",
  420. "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM",
  421. ),
  422. ("subject", email),
  423. ("grant_id", "grant-code"),
  424. ("cnf", ""),
  425. ],
  426. )
  427. .await?;
  428. let device_hash = "devicehashalice";
  429. let device_key = store.device_key(device_hash);
  430. hset(
  431. &mut conn,
  432. &device_key,
  433. &[
  434. ("client_id", "client-a"),
  435. ("scope", "orders:read"),
  436. ("status", "approved"),
  437. ("subject", user_id.as_str()),
  438. ("grant_id", ""),
  439. ],
  440. )
  441. .await?;
  442. set(&mut conn, &format!("{device_key}:cnf"), "binding").await?;
  443. set(&mut conn, &format!("{device_key}:poll"), "1700000000").await?;
  444. let user_code_hash = "usercodehashalice";
  445. let user_code_key = store.user_code_key(user_code_hash);
  446. set(&mut conn, &user_code_key, device_hash).await?;
  447. let grant_id = "grant-alice";
  448. let grant_key = store.grant_key(grant_id);
  449. hset(
  450. &mut conn,
  451. &grant_key,
  452. &[
  453. ("grant_id", grant_id),
  454. ("client_id", "client-a"),
  455. ("subject", email),
  456. ("scope", "orders:read"),
  457. ],
  458. )
  459. .await?;
  460. let access_hash = "accesshashalice";
  461. let refresh_hash = "refreshhashalice";
  462. let access_key = store.access_key(access_hash);
  463. let refresh_key = store.refresh_key(refresh_hash);
  464. for (key, hash) in [(&access_key, access_hash), (&refresh_key, refresh_hash)] {
  465. hset(
  466. &mut conn,
  467. key,
  468. &[
  469. ("client_id", "client-a"),
  470. ("grant_id", grant_id),
  471. ("scope", "orders:read"),
  472. ("subject", email),
  473. ("cnf", ""),
  474. ],
  475. )
  476. .await?;
  477. redis::cmd("SADD")
  478. .arg(store.grant_tokens_key(grant_id))
  479. .arg(hash)
  480. .query_async::<()>(&mut conn)
  481. .await?;
  482. }
  483. set(&mut conn, &format!("{access_key}:act"), "jkt").await?;
  484. let lone_access_key = store.access_key("loneaccessalice");
  485. hset(
  486. &mut conn,
  487. &lone_access_key,
  488. &[
  489. ("client_id", "client-a"),
  490. ("grant_id", "orphan"),
  491. ("scope", "orders:read"),
  492. ("subject", email),
  493. ("cnf", ""),
  494. ],
  495. )
  496. .await?;
  497. set(&mut conn, &format!("{lone_access_key}:act"), "jkt").await?;
  498. let lone_refresh_key = store.refresh_key("lonerefreshalice");
  499. hset(
  500. &mut conn,
  501. &lone_refresh_key,
  502. &[
  503. ("client_id", "client-a"),
  504. ("grant_id", "orphan"),
  505. ("scope", "orders:read"),
  506. ("subject", email),
  507. ("cnf", ""),
  508. ],
  509. )
  510. .await?;
  511. let decoy_session_key = store.session_key("sess-bob");
  512. hset(
  513. &mut conn,
  514. &decoy_session_key,
  515. &[
  516. ("subject", decoy),
  517. ("mfa_verified", "true"),
  518. ("restricted", "false"),
  519. ],
  520. )
  521. .await?;
  522. let decoy_access_key = store.access_key("accesshashbob");
  523. hset(
  524. &mut conn,
  525. &decoy_access_key,
  526. &[
  527. ("client_id", "client-b"),
  528. ("grant_id", "grant-bob"),
  529. ("scope", "orders:read"),
  530. ("subject", decoy),
  531. ("cnf", ""),
  532. ],
  533. )
  534. .await?;
  535. let summary = purge_subject(&mut conn, &store, email, &user_id).await?;
  536. let expected = PurgedSubject {
  537. sessions: 1,
  538. lockouts: 1,
  539. codes: 1,
  540. devices: 1,
  541. user_codes: 1,
  542. grants: 1,
  543. access: 1,
  544. refresh: 1,
  545. };
  546. if summary != expected {
  547. return Err(TestError::Unexpected(format!(
  548. "wrong purge summary: {summary:?} (expected {expected:?})"
  549. )));
  550. }
  551. for key in [
  552. session_key.clone(),
  553. format!("{session_key}:meta"),
  554. fail_key,
  555. code_key,
  556. device_key.clone(),
  557. format!("{device_key}:cnf"),
  558. format!("{device_key}:poll"),
  559. user_code_key,
  560. grant_key,
  561. store.grant_tokens_key(grant_id),
  562. access_key.clone(),
  563. format!("{access_key}:act"),
  564. refresh_key,
  565. lone_access_key.clone(),
  566. format!("{lone_access_key}:act"),
  567. lone_refresh_key,
  568. ] {
  569. require_gone(&mut conn, &key).await?;
  570. }
  571. require_present(&mut conn, &decoy_session_key).await?;
  572. require_present(&mut conn, &decoy_access_key).await?;
  573. Ok(())
  574. }
  575. #[tokio::test]
  576. async fn purge_subject_with_no_matching_state_returns_zero() -> TestResult {
  577. let (_guard, mut conn, store) = harness().await?;
  578. let summary = purge_subject(&mut conn, &store, "nobody@example.test", "no-id").await?;
  579. if summary != PurgedSubject::default() {
  580. return Err(TestError::Unexpected(format!(
  581. "expected zero summary, got {summary:?}"
  582. )));
  583. }
  584. Ok(())
  585. }
  586. async fn dead_conn() -> Result<RedisConn, TestError> {
  587. let client = redis::Client::open("redis://127.0.0.1:1")?;
  588. let manager = redis::aio::ConnectionManager::new_lazy_with_config(
  589. client,
  590. redis::aio::ConnectionManagerConfig::new()
  591. .set_number_of_retries(0)
  592. .set_response_timeout(Some(Duration::from_millis(200)))
  593. .set_connection_timeout(Some(Duration::from_millis(200))),
  594. )?;
  595. Ok(RedisConn::Single(manager))
  596. }
  597. fn require_unavailable(error: AppError) -> TestResult {
  598. if error.code() != OAuthErrorCode::TemporarilyUnavailable {
  599. return Err(TestError::Unexpected(format!(
  600. "a store outage must be temporarily_unavailable, got {:?}",
  601. error.code()
  602. )));
  603. }
  604. if error.status().as_u16() != 503 {
  605. return Err(TestError::Unexpected(format!(
  606. "a store outage must render 503, got {}",
  607. error.status()
  608. )));
  609. }
  610. Ok(())
  611. }
  612. #[tokio::test]
  613. async fn purge_sibling_sessions_deletes_meta_sidecar_and_keeps_current() -> TestResult {
  614. let (_guard, mut conn, store) = harness().await?;
  615. let email = "sibling-meta@example.test";
  616. let user_id = uuid::Uuid::new_v4().to_string();
  617. let current_key = store.session_key("sess-current");
  618. hset(
  619. &mut conn,
  620. &current_key,
  621. &[("subject", email), ("mfa_verified", "true")],
  622. )
  623. .await?;
  624. hset(&mut conn, &format!("{current_key}:meta"), &[("csrf", "a")]).await?;
  625. let sibling_key = store.session_key("sess-sibling");
  626. hset(
  627. &mut conn,
  628. &sibling_key,
  629. &[("subject", email), ("mfa_verified", "true")],
  630. )
  631. .await?;
  632. hset(&mut conn, &format!("{sibling_key}:meta"), &[("csrf", "b")]).await?;
  633. let revoked =
  634. purge_sibling_sessions(&mut conn, &store, email, &user_id, "sess-current").await?;
  635. if revoked != 1 {
  636. return Err(TestError::Unexpected(format!(
  637. "exactly one sibling must be revoked, got {revoked}"
  638. )));
  639. }
  640. require_present(&mut conn, &current_key).await?;
  641. require_present(&mut conn, &format!("{current_key}:meta")).await?;
  642. require_gone(&mut conn, &sibling_key).await?;
  643. require_gone(&mut conn, &format!("{sibling_key}:meta")).await?;
  644. Ok(())
  645. }
  646. #[tokio::test]
  647. async fn purge_sibling_sessions_leaves_other_subjects_tokens_and_grants() -> TestResult {
  648. let (_guard, mut conn, store) = harness().await?;
  649. let email = "sibling-scope@example.test";
  650. let user_id = uuid::Uuid::new_v4().to_string();
  651. let other = "stranger@example.test";
  652. let victim_key = store.session_key("sess-victim");
  653. hset(&mut conn, &victim_key, &[("subject", email)]).await?;
  654. let other_key = store.session_key("sess-other");
  655. hset(&mut conn, &other_key, &[("subject", other)]).await?;
  656. hset(&mut conn, &format!("{other_key}:meta"), &[("csrf", "c")]).await?;
  657. let grant_id = "grant-survivor";
  658. hset(
  659. &mut conn,
  660. &store.grant_key(grant_id),
  661. &[("subject", email), ("scope", "orders:read")],
  662. )
  663. .await?;
  664. redis::cmd("SADD")
  665. .arg(store.grant_tokens_key(grant_id))
  666. .arg("memberhash")
  667. .query_async::<()>(&mut conn)
  668. .await?;
  669. let access_key = store.access_key("survivoraccess");
  670. hset(&mut conn, &access_key, &[("subject", email)]).await?;
  671. set(&mut conn, &format!("{access_key}:act"), "jkt").await?;
  672. let refresh_key = store.refresh_key("survivorrefresh");
  673. hset(&mut conn, &refresh_key, &[("subject", email)]).await?;
  674. let code_key = store.code_key("survivorcode");
  675. hset(&mut conn, &code_key, &[("subject", email)]).await?;
  676. let revoked = purge_sibling_sessions(&mut conn, &store, email, &user_id, "sess-victim").await?;
  677. if revoked != 0 {
  678. return Err(TestError::Unexpected(format!(
  679. "keeping the only own session must revoke nothing, got {revoked}"
  680. )));
  681. }
  682. for key in [
  683. victim_key,
  684. other_key.clone(),
  685. format!("{other_key}:meta"),
  686. store.grant_key(grant_id),
  687. store.grant_tokens_key(grant_id),
  688. access_key.clone(),
  689. format!("{access_key}:act"),
  690. refresh_key,
  691. code_key,
  692. ] {
  693. require_present(&mut conn, &key).await?;
  694. }
  695. Ok(())
  696. }
  697. #[tokio::test]
  698. async fn purge_sibling_sessions_matches_subject_by_user_id() -> TestResult {
  699. let (_guard, mut conn, store) = harness().await?;
  700. let email = "sibling-id@example.test";
  701. let user_id = uuid::Uuid::new_v4().to_string();
  702. let current_key = store.session_key("sess-id-current");
  703. hset(&mut conn, &current_key, &[("subject", user_id.as_str())]).await?;
  704. let sibling_key = store.session_key("sess-id-sibling");
  705. hset(&mut conn, &sibling_key, &[("subject", user_id.as_str())]).await?;
  706. let revoked =
  707. purge_sibling_sessions(&mut conn, &store, email, &user_id, "sess-id-current").await?;
  708. if revoked != 1 {
  709. return Err(TestError::Unexpected(format!(
  710. "a user-id-keyed sibling must be revoked, got {revoked}"
  711. )));
  712. }
  713. require_present(&mut conn, &current_key).await?;
  714. require_gone(&mut conn, &sibling_key).await?;
  715. Ok(())
  716. }
  717. #[tokio::test]
  718. async fn purge_subject_fails_closed_on_store_outage() -> TestResult {
  719. let mut conn = dead_conn().await?;
  720. let store = Store::from_parts(
  721. "purge-outage:{state}:".to_owned(),
  722. "oauth:{rl}:".to_owned(),
  723. DurabilityMode::WaitAof,
  724. );
  725. match purge_subject(&mut conn, &store, "outage@example.test", "outage-id").await {
  726. Err(error) => require_unavailable(error),
  727. Ok(_) => Err(TestError::Unexpected(
  728. "purge_subject on a dead store must fail, never report success".to_owned(),
  729. )),
  730. }
  731. }
  732. #[tokio::test]
  733. async fn purge_sibling_sessions_fails_closed_on_store_outage() -> TestResult {
  734. let mut conn = dead_conn().await?;
  735. let store = Store::from_parts(
  736. "purge-outage:{state}:".to_owned(),
  737. "oauth:{rl}:".to_owned(),
  738. DurabilityMode::WaitAof,
  739. );
  740. match purge_sibling_sessions(
  741. &mut conn,
  742. &store,
  743. "outage@example.test",
  744. "outage-id",
  745. "sess-keep",
  746. )
  747. .await
  748. {
  749. Err(error) => require_unavailable(error),
  750. Ok(_) => Err(TestError::Unexpected(
  751. "purge_sibling_sessions on a dead store must fail, never report success".to_owned(),
  752. )),
  753. }
  754. }
  755. }