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}