Skip to main content

rustlavel_oauth_provider/
token.rs

1//! Access and refresh tokens, and the family that ties them together.
2//!
3//! # Why the tokens are opaque
4//!
5//! An access token here is 256 random bits, stored as a digest and looked up on
6//! every request. The obvious alternative is a signed JWT that the resource
7//! server verifies without asking anyone — faster, and stateless.
8//!
9//! It is also unrevocable. A JWT is valid because its signature says so, which
10//! means a token that has been stolen, or belongs to a user who was just
11//! deleted, or to a client whose access was withdrawn an hour ago, keeps
12//! working until it expires. The usual repair is a revocation list the resource
13//! server checks — at which point it is asking the server on every request
14//! anyway, and has traded a lookup for a lookup plus a signature.
15//!
16//! Revocation that actually revokes is worth a hash-map lookup, so tokens are
17//! opaque. That is also what makes [`crate::endpoints::revoke`] and
18//! [`crate::endpoints::introspect`] able to tell the truth.
19//!
20//! # Families
21//!
22//! Every token descended from one authorisation carries the same `family` id:
23//! the access and refresh tokens minted from a code, and everything a refresh
24//! rotates into afterwards. It exists so that one detection — a replayed
25//! authorisation code, a reused refresh token — can revoke the whole lineage
26//! rather than the single token that happened to be presented. Revoking only
27//! the presented token leaves the thief holding the other one.
28
29use crate::store::{StoreFuture, digest};
30use rustlavel_auth::{base64, random};
31use rustlavel_oauth::Scopes;
32use std::collections::HashMap;
33use std::sync::Mutex;
34
35/// An hour, which is Laravel Passport's default and a reasonable one: long
36/// enough that refreshing is rare, short enough that a leak has an end.
37pub const DEFAULT_ACCESS_TTL: u64 = 3600;
38
39/// Thirty days, again matching Passport.
40pub const DEFAULT_REFRESH_TTL: u64 = 30 * 24 * 3600;
41
42/// A 256-bit opaque token, base64url so it needs no escaping anywhere.
43pub fn generate() -> String {
44    base64::encode_url(&random::bytes(32))
45}
46
47/// An identifier for a record or a family. Not a secret; never presented.
48fn identifier() -> String {
49    random::hex(16)
50}
51
52/// One issued access token, as stored. The token itself is not here.
53#[derive(Clone)]
54pub struct AccessToken {
55    pub id: String,
56    hash: String,
57    pub client_id: String,
58    /// `None` for `client_credentials`: that grant has no user behind it, and a
59    /// resource server must be able to tell the two apart.
60    pub user_id: Option<String>,
61    pub scopes: Scopes,
62    pub family: String,
63    pub issued_at: u64,
64    pub expires_at: u64,
65    pub revoked: bool,
66}
67
68impl AccessToken {
69    pub fn issued(token: &str, now: u64, ttl: u64) -> AccessToken {
70        AccessToken {
71            id: identifier(),
72            hash: digest(token),
73            client_id: String::new(),
74            user_id: None,
75            scopes: Scopes::new(),
76            family: String::new(),
77            issued_at: now,
78            expires_at: now.saturating_add(ttl),
79            revoked: false,
80        }
81    }
82
83    pub fn for_client(mut self, client_id: impl Into<String>) -> AccessToken {
84        self.client_id = client_id.into();
85        self
86    }
87
88    /// `None` for `client_credentials`, which has no user behind it.
89    pub fn for_user(mut self, user_id: Option<&str>) -> AccessToken {
90        self.user_id = user_id.map(str::to_string);
91        self
92    }
93
94    pub fn granting(mut self, scopes: Scopes) -> AccessToken {
95        self.scopes = scopes;
96        self
97    }
98
99    pub fn in_family(mut self, family: impl Into<String>) -> AccessToken {
100        self.family = family.into();
101        self
102    }
103
104    /// Rebuild from a stored digest, for a [`TokenStore`] backed by a table.
105    ///
106    /// The plaintext is not needed and must not be available: a store reads
107    /// rows, and a row holds the digest. Every other field is public and is set
108    /// by the caller from the same row.
109    pub fn from_hash(id: impl Into<String>, hash: impl Into<String>) -> AccessToken {
110        AccessToken {
111            id: id.into(),
112            hash: hash.into(),
113            client_id: String::new(),
114            user_id: None,
115            scopes: Scopes::new(),
116            family: String::new(),
117            issued_at: 0,
118            expires_at: 0,
119            revoked: false,
120        }
121    }
122
123    pub fn hash(&self) -> &str {
124        &self.hash
125    }
126
127    /// Whether the token may be used right now.
128    pub fn is_live(&self, now: u64) -> bool {
129        !self.revoked && now < self.expires_at
130    }
131}
132
133impl std::fmt::Debug for AccessToken {
134    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
135        f.debug_struct("AccessToken")
136            .field("id", &self.id)
137            .field("hash", &"<redacted>")
138            .field("client_id", &self.client_id)
139            .field("user_id", &self.user_id)
140            .field("scopes", &self.scopes)
141            .field("family", &self.family)
142            .field("expires_at", &self.expires_at)
143            .field("revoked", &self.revoked)
144            .finish()
145    }
146}
147
148/// One issued refresh token, as stored.
149#[derive(Clone)]
150pub struct RefreshToken {
151    pub id: String,
152    hash: String,
153    pub client_id: String,
154    pub user_id: Option<String>,
155    pub scopes: Scopes,
156    pub family: String,
157    /// The access token minted alongside it, revoked when this one rotates so a
158    /// refresh does not leave the previous access token live for another hour.
159    pub access_token_id: String,
160    pub issued_at: u64,
161    pub expires_at: u64,
162    pub revoked: bool,
163    /// Set once this token has been exchanged. A rotated token presented again
164    /// is the signal that something has a copy of it.
165    pub rotated: bool,
166}
167
168impl RefreshToken {
169    pub fn issued(token: &str, now: u64, ttl: u64) -> RefreshToken {
170        RefreshToken {
171            id: identifier(),
172            hash: digest(token),
173            client_id: String::new(),
174            user_id: None,
175            scopes: Scopes::new(),
176            family: String::new(),
177            access_token_id: String::new(),
178            issued_at: now,
179            expires_at: now.saturating_add(ttl),
180            revoked: false,
181            rotated: false,
182        }
183    }
184
185    pub fn for_client(mut self, client_id: impl Into<String>) -> RefreshToken {
186        self.client_id = client_id.into();
187        self
188    }
189
190    pub fn for_user(mut self, user_id: Option<&str>) -> RefreshToken {
191        self.user_id = user_id.map(str::to_string);
192        self
193    }
194
195    pub fn granting(mut self, scopes: Scopes) -> RefreshToken {
196        self.scopes = scopes;
197        self
198    }
199
200    pub fn in_family(mut self, family: impl Into<String>) -> RefreshToken {
201        self.family = family.into();
202        self
203    }
204
205    /// The access token minted alongside this one, so a rotation can retire it.
206    pub fn alongside(mut self, access_token_id: impl Into<String>) -> RefreshToken {
207        self.access_token_id = access_token_id.into();
208        self
209    }
210
211    /// Rebuild from a stored digest. See [`AccessToken::from_hash`].
212    pub fn from_hash(id: impl Into<String>, hash: impl Into<String>) -> RefreshToken {
213        RefreshToken {
214            id: id.into(),
215            hash: hash.into(),
216            client_id: String::new(),
217            user_id: None,
218            scopes: Scopes::new(),
219            family: String::new(),
220            access_token_id: String::new(),
221            issued_at: 0,
222            expires_at: 0,
223            revoked: false,
224            rotated: false,
225        }
226    }
227
228    pub fn hash(&self) -> &str {
229        &self.hash
230    }
231
232    pub fn is_live(&self, now: u64) -> bool {
233        !self.revoked && !self.rotated && now < self.expires_at
234    }
235}
236
237impl std::fmt::Debug for RefreshToken {
238    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
239        f.debug_struct("RefreshToken")
240            .field("id", &self.id)
241            .field("hash", &"<redacted>")
242            .field("client_id", &self.client_id)
243            .field("user_id", &self.user_id)
244            .field("scopes", &self.scopes)
245            .field("family", &self.family)
246            .field("expires_at", &self.expires_at)
247            .field("revoked", &self.revoked)
248            .field("rotated", &self.rotated)
249            .finish()
250    }
251}
252
253/// Where issued tokens live.
254pub trait TokenStore: Send + Sync + 'static {
255    fn store_access<'a>(&'a self, token: AccessToken) -> StoreFuture<'a, ()>;
256    fn store_refresh<'a>(&'a self, token: RefreshToken) -> StoreFuture<'a, ()>;
257
258    /// Look a token up by its digest — the record whatever its state, so the
259    /// caller can tell "revoked" apart from "never existed" and act on it.
260    fn find_access<'a>(&'a self, hash: &'a str) -> StoreFuture<'a, Option<AccessToken>>;
261    fn find_refresh<'a>(&'a self, hash: &'a str) -> StoreFuture<'a, Option<RefreshToken>>;
262
263    /// Mark a refresh token rotated, returning whether *this* call did it.
264    ///
265    /// The return value is the whole point: two requests presenting the same
266    /// live refresh token must not both be told yes. Implementations must make
267    /// this a compare-and-set, not a read followed by a write — otherwise a
268    /// stolen refresh token racing the legitimate one is issued a second family
269    /// and nothing notices.
270    fn rotate<'a>(&'a self, id: &'a str) -> StoreFuture<'a, bool>;
271
272    fn revoke_access<'a>(&'a self, id: &'a str) -> StoreFuture<'a, ()>;
273    fn revoke_refresh<'a>(&'a self, id: &'a str) -> StoreFuture<'a, ()>;
274
275    /// Revoke everything descended from one authorisation, returning how many
276    /// records changed. This is what a detected replay or reuse calls.
277    fn revoke_family<'a>(&'a self, family: &'a str) -> StoreFuture<'a, usize>;
278
279    /// Drop records that are past their lifetime. Housekeeping.
280    fn purge<'a>(&'a self, now: u64) -> StoreFuture<'a, usize>;
281}
282
283/// Tokens held in this process's memory.
284///
285/// Wrong for production for the usual reason and one extra: a restart would
286/// empty the store, and an empty store answers "unknown token" to everything —
287/// which fails safe — but it also loses every revocation, and a revocation that
288/// a restart undoes is not a revocation.
289#[derive(Default)]
290pub struct MemoryTokenStore {
291    access: Mutex<HashMap<String, AccessToken>>,
292    refresh: Mutex<HashMap<String, RefreshToken>>,
293}
294
295impl MemoryTokenStore {
296    pub fn new() -> MemoryTokenStore {
297        MemoryTokenStore::default()
298    }
299
300    /// How many access tokens are usable right now. For tests.
301    pub fn live_access(&self, now: u64) -> usize {
302        self.access.lock().expect("token store lock poisoned").values().filter(|t| t.is_live(now)).count()
303    }
304
305    /// How many refresh tokens are usable right now. For tests.
306    pub fn live_refresh(&self, now: u64) -> usize {
307        self.refresh.lock().expect("token store lock poisoned").values().filter(|t| t.is_live(now)).count()
308    }
309}
310
311impl TokenStore for MemoryTokenStore {
312    fn store_access<'a>(&'a self, token: AccessToken) -> StoreFuture<'a, ()> {
313        Box::pin(async move {
314            self.access
315                .lock()
316                .expect("token store lock poisoned")
317                .insert(token.hash.clone(), token);
318            Ok(())
319        })
320    }
321
322    fn store_refresh<'a>(&'a self, token: RefreshToken) -> StoreFuture<'a, ()> {
323        Box::pin(async move {
324            self.refresh
325                .lock()
326                .expect("token store lock poisoned")
327                .insert(token.hash.clone(), token);
328            Ok(())
329        })
330    }
331
332    fn find_access<'a>(&'a self, hash: &'a str) -> StoreFuture<'a, Option<AccessToken>> {
333        Box::pin(async move {
334            Ok(self.access.lock().expect("token store lock poisoned").get(hash).cloned())
335        })
336    }
337
338    fn find_refresh<'a>(&'a self, hash: &'a str) -> StoreFuture<'a, Option<RefreshToken>> {
339        Box::pin(async move {
340            Ok(self.refresh.lock().expect("token store lock poisoned").get(hash).cloned())
341        })
342    }
343
344    fn rotate<'a>(&'a self, id: &'a str) -> StoreFuture<'a, bool> {
345        Box::pin(async move {
346            // One lock around the test and the set: this is the compare-and-set
347            // the trait requires.
348            let mut refresh = self.refresh.lock().expect("token store lock poisoned");
349            let Some(token) = refresh.values_mut().find(|token| token.id == id) else {
350                return Ok(false);
351            };
352            if token.rotated {
353                return Ok(false);
354            }
355            token.rotated = true;
356            Ok(true)
357        })
358    }
359
360    fn revoke_access<'a>(&'a self, id: &'a str) -> StoreFuture<'a, ()> {
361        Box::pin(async move {
362            let mut access = self.access.lock().expect("token store lock poisoned");
363            if let Some(token) = access.values_mut().find(|token| token.id == id) {
364                token.revoked = true;
365            }
366            Ok(())
367        })
368    }
369
370    fn revoke_refresh<'a>(&'a self, id: &'a str) -> StoreFuture<'a, ()> {
371        Box::pin(async move {
372            let mut refresh = self.refresh.lock().expect("token store lock poisoned");
373            if let Some(token) = refresh.values_mut().find(|token| token.id == id) {
374                token.revoked = true;
375            }
376            Ok(())
377        })
378    }
379
380    fn revoke_family<'a>(&'a self, family: &'a str) -> StoreFuture<'a, usize> {
381        Box::pin(async move {
382            let mut changed = 0;
383            let mut access = self.access.lock().expect("token store lock poisoned");
384            for token in access.values_mut().filter(|t| t.family == family && !t.revoked) {
385                token.revoked = true;
386                changed += 1;
387            }
388            drop(access);
389
390            let mut refresh = self.refresh.lock().expect("token store lock poisoned");
391            for token in refresh.values_mut().filter(|t| t.family == family && !t.revoked) {
392                token.revoked = true;
393                changed += 1;
394            }
395            Ok(changed)
396        })
397    }
398
399    fn purge<'a>(&'a self, now: u64) -> StoreFuture<'a, usize> {
400        Box::pin(async move {
401            let mut removed = 0;
402
403            let mut access = self.access.lock().expect("token store lock poisoned");
404            let before = access.len();
405            access.retain(|_, token| now < token.expires_at);
406            removed += before - access.len();
407            drop(access);
408
409            let mut refresh = self.refresh.lock().expect("token store lock poisoned");
410            let before = refresh.len();
411            refresh.retain(|_, token| now < token.expires_at);
412            removed += before - refresh.len();
413
414            Ok(removed)
415        })
416    }
417}
418
419#[cfg(test)]
420mod tests {
421    use super::*;
422
423    const NOW: u64 = 1_800_000_000;
424
425    fn access(token: &str, family: &str) -> AccessToken {
426        AccessToken::issued(token, NOW, DEFAULT_ACCESS_TTL)
427            .for_client("web")
428            .for_user(Some("7"))
429            .granting(Scopes::of(["read"]))
430            .in_family(family)
431    }
432
433    fn refresh(token: &str, family: &str, access_token_id: &str) -> RefreshToken {
434        RefreshToken::issued(token, NOW, DEFAULT_REFRESH_TTL)
435            .for_client("web")
436            .for_user(Some("7"))
437            .granting(Scopes::of(["read"]))
438            .in_family(family)
439            .alongside(access_token_id)
440    }
441
442    async fn populated() -> (MemoryTokenStore, AccessToken, RefreshToken) {
443        let store = MemoryTokenStore::new();
444        let at = access("at-1", "family-1");
445        let rt = refresh("rt-1", "family-1", &at.id);
446        store.store_access(at.clone()).await.expect("stored");
447        store.store_refresh(rt.clone()).await.expect("stored");
448        (store, at, rt)
449    }
450
451    #[test]
452    fn a_generated_token_is_256_bits_and_url_safe() {
453        let token = generate();
454
455        assert_eq!(token.len(), 43);
456        assert!(token.chars().all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_'));
457        assert_ne!(token, generate());
458    }
459
460    #[test]
461    fn debug_prints_no_digest_for_either_token() {
462        let at = access("at-1", "f");
463        let rt = refresh("rt-1", "f", &at.id);
464
465        assert!(!format!("{at:?}").contains(at.hash()));
466        assert!(!format!("{rt:?}").contains(rt.hash()));
467        assert!(format!("{at:?}").contains("redacted"));
468    }
469
470    #[tokio::test]
471    async fn a_token_is_found_by_its_digest_and_not_by_its_plaintext() {
472        let (store, _, _) = populated().await;
473
474        assert!(store.find_access(&digest("at-1")).await.expect("lookup").is_some());
475        assert!(store.find_access("at-1").await.expect("lookup").is_none());
476        assert!(store.find_refresh(&digest("rt-1")).await.expect("lookup").is_some());
477    }
478
479    #[tokio::test]
480    async fn rotation_succeeds_exactly_once() {
481        // The compare-and-set the trait promises. Without it, a stolen refresh
482        // token racing the real one is quietly issued its own family.
483        let (store, _, rt) = populated().await;
484
485        assert!(store.rotate(&rt.id).await.expect("rotate"));
486        assert!(!store.rotate(&rt.id).await.expect("rotate"), "the second must lose");
487        assert!(!store.rotate("no-such-id").await.expect("rotate"));
488    }
489
490    #[tokio::test]
491    async fn a_rotated_token_is_no_longer_live() {
492        let (store, _, rt) = populated().await;
493        store.rotate(&rt.id).await.expect("rotate");
494
495        let reloaded = store.find_refresh(&digest("rt-1")).await.expect("lookup").expect("found");
496        assert!(reloaded.rotated);
497        assert!(!reloaded.is_live(NOW));
498    }
499
500    #[tokio::test]
501    async fn revoking_a_family_takes_down_both_halves_of_it() {
502        let (store, _, _) = populated().await;
503        store.store_access(access("at-2", "family-2")).await.expect("stored");
504
505        assert_eq!(store.revoke_family("family-1").await.expect("revoked"), 2);
506        assert_eq!(store.live_access(NOW), 1, "the other family is untouched");
507        assert_eq!(store.live_refresh(NOW), 0);
508
509        // Idempotent: revoking again changes nothing, so a second detection of
510        // the same theft does not report a fresh revocation.
511        assert_eq!(store.revoke_family("family-1").await.expect("revoked"), 0);
512    }
513
514    #[tokio::test]
515    async fn revoking_one_token_leaves_its_sibling_alone() {
516        let (store, at, _) = populated().await;
517
518        store.revoke_access(&at.id).await.expect("revoked");
519
520        assert_eq!(store.live_access(NOW), 0);
521        assert_eq!(store.live_refresh(NOW), 1);
522    }
523
524    #[tokio::test]
525    async fn an_expired_token_is_not_live_and_is_eventually_purged() {
526        let (store, _, _) = populated().await;
527
528        assert_eq!(store.live_access(NOW + DEFAULT_ACCESS_TTL), 0);
529        assert_eq!(store.live_refresh(NOW + DEFAULT_ACCESS_TTL), 1, "refresh outlives access");
530
531        assert_eq!(store.purge(NOW + DEFAULT_ACCESS_TTL).await.expect("purged"), 1);
532        assert_eq!(store.purge(NOW + DEFAULT_REFRESH_TTL).await.expect("purged"), 1);
533    }
534}