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 cheers_core::yah_scopes;
260 use pollster::block_on;
261
262 fn rec(jti: &str, user: &str, expires_at: i64) -> UserTokenRecord {
263 UserTokenRecord::new(
264 jti,
265 UserId::new(user),
266 "ci",
267 vec![yah_scopes::CLOUD_READ],
268 "https://kamaji.example",
269 1_000,
270 expires_at,
271 )
272 }
273
274 #[test]
275 fn insert_then_list_returns_only_the_owner_s_live_rows() {
276 let store = MemoryUserTokenStore::new();
277 block_on(store.insert(&rec("j1", "alice", 9_000))).unwrap();
278 block_on(store.insert(&rec("j2", "bob", 9_000))).unwrap();
279
280 let alice = block_on(store.list_live_for_user(&UserId::new("alice"), 2_000)).unwrap();
281 assert_eq!(alice.len(), 1);
282 assert_eq!(alice[0].jti, "j1");
283 }
284
285 #[test]
286 fn expired_and_revoked_rows_drop_out_of_the_live_list_but_stay_gettable() {
287 let store = MemoryUserTokenStore::new();
288 block_on(store.insert(&rec("live", "alice", 9_000))).unwrap();
289 block_on(store.insert(&rec("expired", "alice", 1_500))).unwrap();
290 block_on(store.insert(&rec("revoked", "alice", 9_000))).unwrap();
291 block_on(store.mark_revoked("revoked")).unwrap();
292
293 let live = block_on(store.list_live_for_user(&UserId::new("alice"), 2_000)).unwrap();
294 assert_eq!(live.len(), 1);
295 assert_eq!(live[0].jti, "live");
296
297 // Revocation is not deletion — the row survives so the revoke path can
298 // tell "not yours" from "already dead".
299 assert!(block_on(store.get("revoked")).unwrap().unwrap().revoked);
300 assert!(block_on(store.get("expired")).unwrap().is_some());
301 }
302
303 #[test]
304 fn mark_revoked_is_idempotent_and_unknown_jti_is_not_found() {
305 let store = MemoryUserTokenStore::new();
306 block_on(store.insert(&rec("j1", "alice", 9_000))).unwrap();
307 block_on(store.mark_revoked("j1")).unwrap();
308 block_on(store.mark_revoked("j1")).unwrap();
309 match block_on(store.mark_revoked("nope")) {
310 Err(StoreError::NotFound) => {}
311 other => panic!("expected NotFound, got {other:?}"),
312 }
313 }
314
315 #[test]
316 fn scopes_round_trip_through_the_column_encoding() {
317 let scopes = vec![yah_scopes::CLOUD_READ, yah_scopes::BOARD_WRITE, yah_scopes::CAMP_ADMIN];
318 let raw = encode_scopes(&scopes);
319 assert_eq!(raw, "cloud:read board:write camp:admin");
320 assert_eq!(decode_scopes(&raw).unwrap(), scopes);
321
322 // The empty list is a legal value and must survive the round trip —
323 // it is not the same thing as a NULL column.
324 assert_eq!(encode_scopes(&[]), "");
325 assert_eq!(decode_scopes("").unwrap(), Vec::<Scope>::new());
326 }
327
328 /// Storage decodes any well-formed scope (R731-F2: registry membership is
329 /// a grant/mint concern, not a storage one) but a malformed one means the
330 /// table has drifted from the code. Loud, not a silently shorter list.
331 #[test]
332 fn decode_scopes_accepts_undeclared_and_refuses_malformed() {
333 let ok = decode_scopes("cloud:read issues:triage").unwrap();
334 assert_eq!(ok[1].as_wire(), "issues:triage");
335 match decode_scopes("cloud:read cloud:Teleport") {
336 Err(StoreError::Backend(msg)) => assert!(msg.contains("cloud:Teleport"), "{msg}"),
337 other => panic!("expected Backend, got {other:?}"),
338 }
339 }
340
341 #[test]
342 fn touch_last_used_stamps_the_row() {
343 let store = MemoryUserTokenStore::new();
344 block_on(store.insert(&rec("j1", "alice", 9_000))).unwrap();
345 assert!(block_on(store.get("j1")).unwrap().unwrap().last_used_at.is_none());
346 block_on(store.touch_last_used("j1", 4_242)).unwrap();
347 assert_eq!(
348 block_on(store.get("j1")).unwrap().unwrap().last_used_at,
349 Some(4_242)
350 );
351 match block_on(store.touch_last_used("nope", 1)) {
352 Err(StoreError::NotFound) => {}
353 other => panic!("expected NotFound, got {other:?}"),
354 }
355 }
356}