1use std::sync::Arc;
23
24use axum::Router;
25use axum::extract::State;
26use axum::http::{HeaderMap, HeaderValue, StatusCode, header};
27use axum::response::Response;
28use axum::routing::get;
29use serde::{Deserialize, Serialize};
30
31use cheers_server::{RevocationPublisher, RevocationWriter};
32
33use crate::error::RouteError;
34use crate::jwks::strong_etag;
35
36pub const REVOCATION_SET_PATH: &str = "/.well-known/revocation-set.json";
38
39const CACHE_CONTROL_VALUE: &str = "no-cache";
40
41#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
43#[non_exhaustive]
44pub struct RevocationSetBody {
45 pub issuer: String,
46 pub epoch: u64,
47 pub set: String,
49}
50
51pub fn router<W>(publisher: Arc<RevocationPublisher<W>>) -> Router
53where
54 W: RevocationWriter + 'static,
55{
56 Router::new()
57 .route(REVOCATION_SET_PATH, get(revocation_set::<W>))
58 .with_state(publisher)
59}
60
61pub async fn revocation_set<W>(
63 State(publisher): State<Arc<RevocationPublisher<W>>>,
64 headers: HeaderMap,
65) -> Result<Response, RouteError>
66where
67 W: RevocationWriter,
68{
69 let signed = publisher.current(now_unix()).await?;
70 let body = RevocationSetBody {
71 issuer: publisher.issuer().to_owned(),
72 epoch: signed.epoch,
73 set: signed.token,
74 };
75 let body = serde_json::to_vec(&body).map_err(|e| RouteError::Store(e.to_string()))?;
76 let etag = strong_etag(&body);
77 let etag_value = HeaderValue::from_str(&etag).expect("etag bytes are ascii");
78 let cache_control = HeaderValue::from_static(CACHE_CONTROL_VALUE);
79
80 if headers
81 .get(header::IF_NONE_MATCH)
82 .is_some_and(|inm| inm.as_bytes() == etag.as_bytes())
83 {
84 return Ok(Response::builder()
85 .status(StatusCode::NOT_MODIFIED)
86 .header(header::CACHE_CONTROL, cache_control)
87 .header(header::ETAG, etag_value)
88 .body(axum::body::Body::empty())
89 .expect("304 response builds with static headers"));
90 }
91
92 Ok(Response::builder()
93 .status(StatusCode::OK)
94 .header(header::CONTENT_TYPE, "application/json")
95 .header(header::CACHE_CONTROL, cache_control)
96 .header(header::ETAG, etag_value)
97 .body(axum::body::Body::from(body))
98 .expect("revocation-set response builds with static headers"))
99}
100
101fn now_unix() -> i64 {
102 use std::time::{SystemTime, UNIX_EPOCH};
103 SystemTime::now()
104 .duration_since(UNIX_EPOCH)
105 .map(|d| d.as_secs() as i64)
106 .unwrap_or_default()
107}
108
109#[cfg(test)]
110mod tests {
111 use super::*;
112 use axum::body::Body;
113 use axum::http::Request;
114 use cheers_core::{DeviceId, PrincipalId, Revoked};
115 use cheers_server::{MemoryOwnershipStore, MemoryRevocationStore, OwnershipStore, PasetoV4SecretMinter};
116 use cheers_verify::{AdoptError, IssuerTrust, KeySetVerifier, ReplicatedRevocations, RevocationReader};
117 use http_body_util::BodyExt;
118 use tower::ServiceExt;
119
120 const ISS: &str = "https://cheers.test";
121 const KID: &str = "platform-1";
122
123 fn rig() -> (Router, MemoryRevocationStore, MemoryOwnershipStore, KeySetVerifier) {
124 let (minter, verifier) = PasetoV4SecretMinter::generate().unwrap();
125 let public: [u8; 32] = verifier.public_key().as_bytes().try_into().unwrap();
126 let store = MemoryRevocationStore::default();
127 let ownership = MemoryOwnershipStore::new();
128 let publisher = RevocationPublisher::new(store.clone(), Arc::new(ownership.clone()), minter, ISS, KID);
129 (
130 router(Arc::new(publisher)),
131 store,
132 ownership,
133 KeySetVerifier::from_issuer_key(KID, &public, ISS),
134 )
135 }
136
137 async fn get(app: &Router, if_none_match: Option<&str>) -> Response {
138 let mut req = Request::builder().method("GET").uri(REVOCATION_SET_PATH);
139 if let Some(etag) = if_none_match {
140 req = req.header(header::IF_NONE_MATCH, etag);
141 }
142 app.clone().oneshot(req.body(Body::empty()).unwrap()).await.unwrap()
143 }
144
145 async fn body(resp: Response) -> RevocationSetBody {
146 let bytes = resp.into_body().collect().await.unwrap().to_bytes();
147 serde_json::from_slice(&bytes).unwrap()
148 }
149
150 #[tokio::test]
151 async fn publishes_a_set_a_replica_adopts_and_queries() {
152 let (app, store, ownership, keys) = rig();
153 store.revoke(&Revoked::jti("j1", None)).await.unwrap();
154 store.revoke(&Revoked::device("phone", 10)).await.unwrap();
155 store
156 .revoke(&Revoked::membership("namespace", "ns-1", PrincipalId::user("alice"), 3))
157 .await
158 .unwrap();
159
160 let resp = get(&app, None).await;
161 assert_eq!(resp.status(), StatusCode::OK);
162 assert_eq!(resp.headers()[header::CONTENT_TYPE], "application/json");
163 assert_eq!(resp.headers()[header::CACHE_CONTROL], CACHE_CONTROL_VALUE);
164 assert!(resp.headers().contains_key(header::ETAG));
165 let published = body(resp).await;
166 assert_eq!(published.issuer, ISS);
167 assert_eq!(published.epoch, store.snapshot().await.unwrap().epoch);
168
169 let replica = ReplicatedRevocations::new(IssuerTrust::key_set(ISS, keys));
170 replica.adopt(&published.set).await.unwrap();
171 assert_eq!(replica.epoch(), Some(published.epoch));
172 assert!(replica.is_revoked("j1").await.unwrap());
173 assert!(replica.is_device_revoked(&DeviceId::new("phone"), 9).await.unwrap());
174 let key = ownership.revocation_key("namespace", "ns-1").await.unwrap();
176 assert!(replica
177 .is_membership_revoked(&key, "namespace", "ns-1", &PrincipalId::user("alice"), 2)
178 .await
179 .unwrap());
180 }
181
182 #[tokio::test]
183 async fn unchanged_set_revalidates_to_304_and_a_revoke_moves_the_etag() {
184 let (app, store, _, keys) = rig();
185 let first = get(&app, None).await;
186 let etag = first.headers()[header::ETAG].to_str().unwrap().to_owned();
187 let first = body(first).await;
188
189 let again = get(&app, Some(&etag)).await;
190 assert_eq!(again.status(), StatusCode::NOT_MODIFIED);
191 assert_eq!(again.headers()[header::ETAG], etag.as_str());
192
193 store.revoke(&Revoked::device("phone", 1)).await.unwrap();
194 let moved = get(&app, Some(&etag)).await;
195 assert_eq!(moved.status(), StatusCode::OK);
196 assert_ne!(moved.headers()[header::ETAG], etag.as_str());
197 let second = body(moved).await;
198 assert!(second.epoch > first.epoch);
199
200 let replica = ReplicatedRevocations::new(IssuerTrust::key_set(ISS, keys));
202 replica.adopt(&second.set).await.unwrap();
203 assert!(matches!(replica.adopt(&first.set).await, Err(AdoptError::Stale { .. })));
204 }
205}