cheers_server/revocation.rs
1//! The **write** side of the revocation split.
2//!
3//! Edge-verifiable auth (R019) keeps revocation's two sides physically apart.
4//! This crate holds the origin half:
5//!
6//! - [`RevocationWriter`] — the **cold path**, run at the origin on logout /
7//! device-revoke. Adds a token's `jti` to the set.
8//! - The **hot-path** reader (`cheers_verify::RevocationReader`) lives in
9//! `cheers-verify` — the verify-only edge holds it without the power to revoke
10//! anyone else's sessions.
11//!
12//! The set is **eventually consistent** by design: a `revoke` here propagates to
13//! edge readers asynchronously (KV replication / gossip), bounded by the access
14//! token TTL (see [`SessionPolicy`](crate::session::SessionPolicy)). Full
15//! contract on `cheers_verify::RevocationReader`.
16//!
17//! # Revoking a session vs. a whole login
18//!
19//! The set is keyed on `jti`, so [`RevocationWriter::revoke`] kills **one**
20//! access token immediately (edge-visible within the propagation window). The
21//! coarser flows compose it with the refresh cold path:
22//!
23//! - **Logout / single device-revoke:** call
24//! [`RefreshStore::revoke_chain`](crate::store::RefreshStore::revoke_chain) so
25//! the chain can mint no *further* access tokens, and `revoke(jti)` for the
26//! session's current access token. Any access tokens already issued on that
27//! chain die on their own within the access TTL.
28//! - **Account-wide revoke:** revoke every chain via the
29//! [`UserStore`](crate::store::UserStore) device list; same TTL bound applies.
30//!
31//! [`SessionAuthority`](crate::session::SessionAuthority) is where these calls
32//! are composed — it holds both the [`RevocationWriter`] and the
33//! [`RefreshStore`](crate::store::RefreshStore).
34
35use async_trait::async_trait;
36
37use cheers_core::StoreError;
38
39/// Origin-side revocation write. Held by
40/// [`SessionAuthority`](crate::session::SessionAuthority) on the cold path.
41#[async_trait]
42pub trait RevocationWriter: Send + Sync {
43 /// Add `jti` to the revocation set. **Idempotent** — revoking an
44 /// already-revoked `jti` is a no-op, not an error. Propagation to edge
45 /// `cheers_verify::RevocationReader`s is asynchronous (see the module-level
46 /// contract).
47 async fn revoke(&self, jti: &str) -> Result<(), StoreError>;
48}
49
50#[cfg(test)]
51mod tests {
52 use super::*;
53 use std::collections::HashSet;
54 use std::sync::Mutex;
55
56 #[derive(Default)]
57 struct MemRevocations(Mutex<HashSet<String>>);
58
59 #[async_trait]
60 impl RevocationWriter for MemRevocations {
61 async fn revoke(&self, jti: &str) -> Result<(), StoreError> {
62 self.0.lock().unwrap().insert(jti.to_owned());
63 Ok(())
64 }
65 }
66
67 #[test]
68 fn revoke_is_idempotent() {
69 let set = MemRevocations::default();
70 pollster::block_on(async {
71 set.revoke("tok-1").await.unwrap();
72 // Re-revoking is a no-op, not an error.
73 set.revoke("tok-1").await.unwrap();
74 assert!(set.0.lock().unwrap().contains("tok-1"));
75 });
76 }
77
78 #[test]
79 fn trait_is_dyn_compatible() {
80 fn _writer(_: &dyn RevocationWriter) {}
81 }
82}