cheers_server/user_tokens.rs
1//! User API tokens (PATs) — the *metadata* half.
2//!
3//! A cheers API token is not a new credential kind. It is an ordinary
4//! `v4.public` MCP token minted by
5//! [`McpAuthority::mint_api_token`](crate::mcp_authority::McpAuthority::mint_api_token),
6//! carrying `auth_strength: "api-token"`, verified at the edge by the same
7//! [`PasetoV4PublicVerifier`](cheers_verify::PasetoV4PublicVerifier) and killed
8//! by the same [`RevocationWriter`](crate::revocation::RevocationWriter) /
9//! [`RevocationReader`](cheers_verify::RevocationReader) pair every other token
10//! goes through. There is no second verification path and no secret-hash
11//! lookup on the hot path.
12//!
13//! What *is* genuinely new is a row the user can look at: "you have a token
14//! called `ci-deploy`, minted on the 3rd, expiring in November, scoped to
15//! `cloud:read cloud:deploy`". That row is this module.
16//!
17//! ## No secret, and no hash of one, is stored
18//!
19//! [`UserTokenRecord`] has no `secret` and no `secret_hash` column, on
20//! purpose. Verification is by signature — cheers never looks the presented
21//! bytes up — so a stored hash would have no reader. A column with no reader
22//! that nonetheless has to be protected is a liability with no benefit: it
23//! widens what a database leak is worth and invites a future edge to "just
24//! check the hash", which is the parallel stack this design exists to avoid.
25//! The secret is returned by `POST /me/tokens` exactly once and then exists
26//! only in the holder's hands.
27//!
28//! ## `last_used_at`
29//!
30//! The column exists and is surfaced, but **cheers never writes it.** The only
31//! place a use could be observed is the verify edge, and an
32//! [`EdgeVerifier`](cheers_verify::EdgeVerifier) deliberately holds a
33//! `RevocationReader` and no writer at all — giving it a store write would
34//! undo the property that a compromised edge cannot mutate origin state.
35//! [`UserTokenStore::touch_last_used`] is the seam for a *resource server*
36//! (which is already doing per-call work and already holds writable state) to
37//! record the touch. Absent one, the field stays `None`, and that is an honest
38//! "cheers doesn't know" rather than a silently stale timestamp.
39
40use std::collections::HashMap;
41use std::sync::{Arc, Mutex};
42
43use async_trait::async_trait;
44use cheers_core::{Scope, StoreError, UserId};
45
46/// One user API token's metadata — everything except the secret.
47///
48/// `jti` is the token's own id: the value baked into the signed claim, the key
49/// the revocation set is keyed on, and the `{id}` in
50/// `DELETE /me/tokens/{id}`. One id, three places, no mapping table.
51#[derive(Debug, Clone, PartialEq, Eq)]
52#[non_exhaustive]
53pub struct UserTokenRecord {
54 /// The minted token's `jti` — also the public id of this row.
55 pub jti: String,
56 /// Owner. Every read path filters on this; a `jti` alone is never enough
57 /// to act on a row.
58 pub user_id: UserId,
59 /// Human label the user chose ("ci-deploy", "laptop"). Opaque to cheers.
60 pub name: String,
61 /// The scopes actually minted into the token — already intersected with
62 /// the caller's entitlement, so this is what the token *can do*, not what
63 /// was asked for.
64 pub scopes: Vec<Scope>,
65 /// The audience the token was minted for. Grants are keyed
66 /// `(principal, aud)`, so a token is never audience-portable.
67 pub aud: String,
68 pub created_at: i64,
69 /// Last observed use, when a resource server reports one. See the module
70 /// docs — cheers itself never writes this.
71 pub last_used_at: Option<i64>,
72 pub expires_at: i64,
73 /// Set by [`UserTokenStore::mark_revoked`]. The signed token is killed by
74 /// the revocation set; this is the half that makes the kill *visible* on
75 /// `GET /me/tokens`.
76 pub revoked: bool,
77}
78
79impl UserTokenRecord {
80 #[allow(clippy::too_many_arguments)]
81 pub fn new(
82 jti: impl Into<String>,
83 user_id: UserId,
84 name: impl Into<String>,
85 scopes: Vec<Scope>,
86 aud: impl Into<String>,
87 created_at: i64,
88 expires_at: i64,
89 ) -> Self {
90 Self {
91 jti: jti.into(),
92 user_id,
93 name: name.into(),
94 scopes,
95 aud: aud.into(),
96 created_at,
97 last_used_at: None,
98 expires_at,
99 revoked: false,
100 }
101 }
102
103 /// Live at `now` — neither revoked nor past its expiry.
104 pub fn is_live_at(&self, now: i64) -> bool {
105 !self.revoked && self.expires_at > now
106 }
107}
108
109/// Wire form of the `user_tokens.scopes` column: the scopes' wire strings,
110/// space-separated, in order.
111///
112/// Lives here rather than in each backend so `cheers-sqlx` and `cheers-turso`
113/// cannot drift — a row written by one family must be readable by the other,
114/// which is exactly what `cheers-turso/tests/flip.rs` exists to prove. Space
115/// is safe as a separator because [`Scope`]'s closed vocabulary contains no
116/// whitespace, and it matches the OAuth `scope` convention the claim itself
117/// uses.
118pub fn encode_scopes(scopes: &[Scope]) -> String {
119 scopes
120 .iter()
121 .map(|s| s.as_wire())
122 .collect::<Vec<_>>()
123 .join(" ")
124}
125
126/// Inverse of [`encode_scopes`].
127///
128/// A token outside the closed vocabulary is [`StoreError::Backend`], not a
129/// silent skip: a scope cheers cannot parse in a row it wrote itself means the
130/// table has drifted from the code, and quietly handing back a shorter list
131/// would present the user a token weaker than the one they hold.
132pub fn decode_scopes(raw: &str) -> Result<Vec<Scope>, StoreError> {
133 raw.split_whitespace()
134 .map(|s| {
135 s.parse::<Scope>()
136 .map_err(|e| StoreError::Backend(format!("user_tokens.scopes: {e}")))
137 })
138 .collect()
139}
140
141/// Persistence for the user-API-token metadata table.
142///
143/// Deliberately *not* part of the token's trust path: nothing here is
144/// consulted to decide whether a presented token is valid. An impl that
145/// returned garbage would corrupt the user's token list and nothing else.
146#[async_trait]
147pub trait UserTokenStore: Send + Sync {
148 /// Record a freshly minted token. The `jti` is unique by construction
149 /// (fresh per mint); an impl MAY reject a duplicate with
150 /// [`StoreError::Conflict`]-shaped semantics but is not required to check.
151 async fn insert(&self, record: &UserTokenRecord) -> Result<(), StoreError>;
152
153 /// Every **live** token the user holds at `now` — impls filter out revoked
154 /// and expired rows, the same contract
155 /// [`SessionDirectory`](https://docs.rs/cheers-axum) carries for sessions.
156 /// Order is unspecified.
157 async fn list_live_for_user(
158 &self,
159 user_id: &UserId,
160 now: i64,
161 ) -> Result<Vec<UserTokenRecord>, StoreError>;
162
163 /// One row by `jti`, revoked or expired rows included — the revoke path
164 /// needs to distinguish "not yours" from "already dead", and only the
165 /// caller of this decides which of those the client gets told.
166 async fn get(&self, jti: &str) -> Result<Option<UserTokenRecord>, StoreError>;
167
168 /// Flip `revoked`. Idempotent: revoking an already-revoked row is `Ok`.
169 /// A `jti` with no row is [`StoreError::NotFound`].
170 async fn mark_revoked(&self, jti: &str) -> Result<(), StoreError>;
171
172 /// Stamp `last_used_at`. Called by a resource server, never by cheers —
173 /// see the module docs. A `jti` with no row is [`StoreError::NotFound`].
174 async fn touch_last_used(&self, jti: &str, now: i64) -> Result<(), StoreError>;
175}
176
177/// In-memory [`UserTokenStore`] for tests and single-node bootstrapping.
178/// Cheap to `clone` — shares one backing map.
179#[derive(Default, Clone)]
180pub struct MemoryUserTokenStore {
181 inner: Arc<Mutex<HashMap<String, UserTokenRecord>>>,
182}
183
184impl MemoryUserTokenStore {
185 pub fn new() -> Self {
186 Self::default()
187 }
188
189 /// Row count, live and dead. Test affordance.
190 pub fn len(&self) -> usize {
191 self.inner.lock().expect("user token store poisoned").len()
192 }
193
194 pub fn is_empty(&self) -> bool {
195 self.len() == 0
196 }
197}
198
199#[async_trait]
200impl UserTokenStore for MemoryUserTokenStore {
201 async fn insert(&self, record: &UserTokenRecord) -> Result<(), StoreError> {
202 self.inner
203 .lock()
204 .expect("user token store poisoned")
205 .insert(record.jti.clone(), record.clone());
206 Ok(())
207 }
208
209 async fn list_live_for_user(
210 &self,
211 user_id: &UserId,
212 now: i64,
213 ) -> Result<Vec<UserTokenRecord>, StoreError> {
214 Ok(self
215 .inner
216 .lock()
217 .expect("user token store poisoned")
218 .values()
219 .filter(|r| &r.user_id == user_id && r.is_live_at(now))
220 .cloned()
221 .collect())
222 }
223
224 async fn get(&self, jti: &str) -> Result<Option<UserTokenRecord>, StoreError> {
225 Ok(self
226 .inner
227 .lock()
228 .expect("user token store poisoned")
229 .get(jti)
230 .cloned())
231 }
232
233 async fn mark_revoked(&self, jti: &str) -> Result<(), StoreError> {
234 let mut guard = self.inner.lock().expect("user token store poisoned");
235 match guard.get_mut(jti) {
236 Some(row) => {
237 row.revoked = true;
238 Ok(())
239 }
240 None => Err(StoreError::NotFound),
241 }
242 }
243
244 async fn touch_last_used(&self, jti: &str, now: i64) -> Result<(), StoreError> {
245 let mut guard = self.inner.lock().expect("user token store poisoned");
246 match guard.get_mut(jti) {
247 Some(row) => {
248 row.last_used_at = Some(now);
249 Ok(())
250 }
251 None => Err(StoreError::NotFound),
252 }
253 }
254}
255
256#[cfg(test)]
257mod tests {
258 use super::*;
259 use pollster::block_on;
260
261 fn rec(jti: &str, user: &str, expires_at: i64) -> UserTokenRecord {
262 UserTokenRecord::new(
263 jti,
264 UserId::new(user),
265 "ci",
266 vec![Scope::CloudRead],
267 "https://kamaji.example",
268 1_000,
269 expires_at,
270 )
271 }
272
273 #[test]
274 fn insert_then_list_returns_only_the_owner_s_live_rows() {
275 let store = MemoryUserTokenStore::new();
276 block_on(store.insert(&rec("j1", "alice", 9_000))).unwrap();
277 block_on(store.insert(&rec("j2", "bob", 9_000))).unwrap();
278
279 let alice = block_on(store.list_live_for_user(&UserId::new("alice"), 2_000)).unwrap();
280 assert_eq!(alice.len(), 1);
281 assert_eq!(alice[0].jti, "j1");
282 }
283
284 #[test]
285 fn expired_and_revoked_rows_drop_out_of_the_live_list_but_stay_gettable() {
286 let store = MemoryUserTokenStore::new();
287 block_on(store.insert(&rec("live", "alice", 9_000))).unwrap();
288 block_on(store.insert(&rec("expired", "alice", 1_500))).unwrap();
289 block_on(store.insert(&rec("revoked", "alice", 9_000))).unwrap();
290 block_on(store.mark_revoked("revoked")).unwrap();
291
292 let live = block_on(store.list_live_for_user(&UserId::new("alice"), 2_000)).unwrap();
293 assert_eq!(live.len(), 1);
294 assert_eq!(live[0].jti, "live");
295
296 // Revocation is not deletion — the row survives so the revoke path can
297 // tell "not yours" from "already dead".
298 assert!(block_on(store.get("revoked")).unwrap().unwrap().revoked);
299 assert!(block_on(store.get("expired")).unwrap().is_some());
300 }
301
302 #[test]
303 fn mark_revoked_is_idempotent_and_unknown_jti_is_not_found() {
304 let store = MemoryUserTokenStore::new();
305 block_on(store.insert(&rec("j1", "alice", 9_000))).unwrap();
306 block_on(store.mark_revoked("j1")).unwrap();
307 block_on(store.mark_revoked("j1")).unwrap();
308 match block_on(store.mark_revoked("nope")) {
309 Err(StoreError::NotFound) => {}
310 other => panic!("expected NotFound, got {other:?}"),
311 }
312 }
313
314 #[test]
315 fn scopes_round_trip_through_the_column_encoding() {
316 let scopes = vec![Scope::CloudRead, Scope::BoardWrite, Scope::CampAdmin];
317 let raw = encode_scopes(&scopes);
318 assert_eq!(raw, "cloud:read board:write camp:admin");
319 assert_eq!(decode_scopes(&raw).unwrap(), scopes);
320
321 // The empty list is a legal value and must survive the round trip —
322 // it is not the same thing as a NULL column.
323 assert_eq!(encode_scopes(&[]), "");
324 assert_eq!(decode_scopes("").unwrap(), Vec::<Scope>::new());
325 }
326
327 /// A scope cheers cannot parse in a row cheers wrote means the table has
328 /// drifted from the code. Loud, not a silently shorter list.
329 #[test]
330 fn decode_scopes_refuses_an_unknown_token() {
331 match decode_scopes("cloud:read cloud:teleport") {
332 Err(StoreError::Backend(msg)) => assert!(msg.contains("cloud:teleport"), "{msg}"),
333 other => panic!("expected Backend, got {other:?}"),
334 }
335 }
336
337 #[test]
338 fn touch_last_used_stamps_the_row() {
339 let store = MemoryUserTokenStore::new();
340 block_on(store.insert(&rec("j1", "alice", 9_000))).unwrap();
341 assert!(block_on(store.get("j1")).unwrap().unwrap().last_used_at.is_none());
342 block_on(store.touch_last_used("j1", 4_242)).unwrap();
343 assert_eq!(
344 block_on(store.get("j1")).unwrap().unwrap().last_used_at,
345 Some(4_242)
346 );
347 match block_on(store.touch_last_used("nope", 1)) {
348 Err(StoreError::NotFound) => {}
349 other => panic!("expected NotFound, got {other:?}"),
350 }
351 }
352}