Skip to main content

cheers_verify/
revocation.rs

1//! The **read** side of the revocation split.
2//!
3//! Revocation has two physically distinct sides, and edge-verifiable auth (R019)
4//! depends on keeping them apart. This crate holds the edge half:
5//!
6//! - [`RevocationReader`] — the **hot path**, run at the edge on every request.
7//!   A point membership check (`is_revoked(jti)`) against a locally-replicated,
8//!   read-mostly set (CF KV / a Yubaba gossip replica).
9//! - The **cold path** writer (`RevocationWriter`) lives in `cheers-server` —
10//!   keeping it out of this crate means a verify-only edge consumer can check
11//!   revocation without holding the power to revoke anyone else's sessions, the
12//!   same capability-by-type discipline as the `TokenVerifier` / `TokenMinter`
13//!   split.
14//!
15//! # The consistency contract
16//!
17//! The set is **eventually consistent** by design. A `revoke` at the origin
18//! propagates to edge readers asynchronously (KV replication / gossip), so an
19//! edge reader may briefly answer `false` for a `jti` the origin has already
20//! revoked. That lag is bounded by the **access-token TTL**
21//! (`cheers_server::SessionPolicy`): a revoked-but-not-yet-propagated access
22//! token outlives its revocation by at most one short TTL, then expires on its
23//! own signature.
24//!
25//! This is sound because auth has no cross-session OLTP — every check validates
26//! *one* session, so the global hot path never needs a consistent view across
27//! sessions. Revocation membership is the *only* shared auth state the edge
28//! reads, and it tolerates lag.
29
30use async_trait::async_trait;
31
32use cheers_core::StoreError;
33
34/// Edge-side revocation check. The verify-only consumer depends on *this* — it
35/// can ask whether a `jti` is revoked but holds no power to revoke.
36///
37/// `async` + dyn-compatible (via [`async_trait`]) to match the rest of the store
38/// surface, so an edge can hold a `dyn RevocationReader` backed by CF KV.
39#[async_trait]
40pub trait RevocationReader: Send + Sync {
41    /// `true` if a session with this `jti` has been revoked.
42    ///
43    /// A point membership check against a read-mostly, eventually-consistent
44    /// replica. A `false` answer is not a *proof* of liveness — only that the
45    /// revocation, if any, has not yet propagated to this replica (bounded by
46    /// the access-token TTL). Cryptographic expiry is still enforced separately
47    /// by the [`TokenVerifier`](cheers_core::TokenVerifier).
48    async fn is_revoked(&self, jti: &str) -> Result<bool, StoreError>;
49}
50
51#[cfg(test)]
52mod tests {
53    use super::*;
54    use std::collections::HashSet;
55    use std::sync::Mutex;
56
57    #[derive(Default)]
58    struct MemRevocations(Mutex<HashSet<String>>);
59
60    #[async_trait]
61    impl RevocationReader for MemRevocations {
62        async fn is_revoked(&self, jti: &str) -> Result<bool, StoreError> {
63            Ok(self.0.lock().unwrap().contains(jti))
64        }
65    }
66
67    #[test]
68    fn unrevoked_jti_reads_false() {
69        let set = MemRevocations::default();
70        pollster::block_on(async {
71            assert!(!set.is_revoked("tok-1").await.unwrap());
72        });
73    }
74
75    #[test]
76    fn trait_is_dyn_compatible() {
77        fn _reader(_: &dyn RevocationReader) {}
78    }
79}