Skip to main content

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}