Skip to main content

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}