Skip to main content

rustlavel_auth/
tokens.rs

1//! API tokens: `Authorization: Bearer <id>|<secret>`, in Sanctum's shape.
2//!
3//! A session is a browser mechanism. A mobile app, a command-line client or a
4//! single-page application talking to its own API has no cookie jar and no CSRF
5//! token to echo; it has one long-lived credential it was handed once. That is
6//! what this module issues.
7//!
8//! ```ignore
9//! let store = Arc::new(MemoryTokenStore::new());
10//!
11//! // Issued once, at the end of a login or from a settings page.
12//! let issued = NewToken::new(Identity::new("41"), "iPhone")
13//!     .scope("posts:read")
14//!     .scope("posts:write")
15//!     .expires_in(Duration::from_secs(90 * 86_400))
16//!     .issue(&*store)
17//!     .await?;
18//! println!("{}", issued.plain_text()); // the only time it exists in the clear
19//!
20//! router.group("/api", |r| {
21//!     r.middleware(RequireApiToken::shared(Arc::clone(&store)));
22//!     r.get("/me", |req: Request| async move {
23//!         format!("user {}", req.identity().unwrap().id())
24//!     });
25//!
26//!     r.group("/posts", |r| {
27//!         r.middleware(RequireScope::new("posts:write"));
28//!         r.post("", publish);
29//!     });
30//! });
31//! ```
32//!
33//! # Why the token looks like `<id>|<secret>`
34//!
35//! Only a hash of the secret is ever stored, so a token presented on a request
36//! cannot be looked up by equality. Carrying the record's id in front of the
37//! secret turns verification into one indexed primary-key fetch. Without it the
38//! only correct implementation is to load every token in the table and hash
39//! against each one — which is fine with ten tokens and a denial of service
40//! with a hundred thousand.
41//!
42//! The id is not a secret. It identifies a row; it opens nothing.
43//!
44//! # Where the tokens live
45//!
46//! This module deliberately does not depend on `rustlavel-db`. [`TokenStore`]
47//! is a trait, [`MemoryTokenStore`] is the implementation used by tests and by
48//! a development server, and a real application writes its own against its own
49//! `personal_access_tokens` table. Everything needed to persist a token — the
50//! digest included — is reachable through [`Token`]'s accessors.
51//!
52//! [`MemoryTokenStore`]: store::MemoryTokenStore
53//! [`TokenStore`]: store::TokenStore
54
55pub mod middleware;
56pub mod store;
57
58use crate::guard::Identity;
59use crate::{base64, constant_time_eq, random, unix_now};
60use rustlavel_core::{Error, Json};
61use rustlavel_http::response::{IntoResponse, Response};
62use rustlavel_http::Status;
63use sha2::{Digest, Sha256};
64use std::time::Duration;
65
66pub use middleware::{RequireApiToken, RequireScope, TokenExt, bearer};
67pub use store::{MemoryTokenStore, SharedTokenStore, TokenFuture, TokenStore};
68
69/// What separates the id from the secret, as Sanctum spells it.
70pub const SEPARATOR: char = '|';
71
72/// The scope that means "everything", as Sanctum spells it.
73pub const WILDCARD: &str = "*";
74
75/// How many random bytes go into a secret.
76///
77/// 256 bits, from the operating system's CSPRNG. There is no meaningful attack
78/// on a value this size that is not just "steal it from the client", which is a
79/// problem no length fixes.
80pub const SECRET_BYTES: usize = 32;
81
82/// The longest string this module will look at before rejecting it.
83///
84/// A token we minted is well under a hundred characters. Refusing anything
85/// longer means a client cannot make the server hash a megabyte per request by
86/// sending a megabyte-long `Authorization` header.
87pub const MAX_PRESENTED_LENGTH: usize = 512;
88
89/// The SHA-256 of a secret, as stored.
90///
91/// Not argon2, and that is on purpose. Argon2 is the right answer for
92/// passwords, which are short, low-entropy and chosen by people, so an attacker
93/// holding the hashes can guess them; the whole point of a KDF is to make each
94/// guess expensive. A secret here is 256 bits of CSPRNG output, so there is
95/// nothing to guess — offline search is already impossible — and the cost that
96/// protects a password buys nothing. What it does buy is a self-inflicted
97/// denial of service: argon2 is tuned to take tens of milliseconds and hundreds
98/// of megabytes, and an API verifies a token on *every* request.
99pub fn digest(secret: &str) -> [u8; 32] {
100    Sha256::digest(secret.as_bytes()).into()
101}
102
103/// Split a presented token into its id and its secret.
104///
105/// Every shape that is not exactly `<id>|<secret>`, with both halves present,
106/// is rejected here — including the empty string, a bare separator, and
107/// anything past [`MAX_PRESENTED_LENGTH`]. Nothing downstream has to wonder
108/// whether it was handed a well-formed token.
109pub fn split(presented: &str) -> Option<(&str, &str)> {
110    if presented.is_empty() || presented.len() > MAX_PRESENTED_LENGTH {
111        return None;
112    }
113
114    let (id, secret) = presented.split_once(SEPARATOR)?;
115    (!id.is_empty() && !secret.is_empty()).then_some((id, secret))
116}
117
118/// What a token is allowed to do.
119///
120/// A plain list of strings, compared exactly. It is deliberately not an enum
121/// and deliberately not tied to any other package: an application names its own
122/// scopes, and a framework that ships a fixed vocabulary is a framework you
123/// fight the first time you add a feature.
124#[derive(Debug, Clone, Default, PartialEq, Eq)]
125pub struct Scopes(Vec<String>);
126
127impl Scopes {
128    /// No scopes at all — a token that can do nothing.
129    pub fn new() -> Self {
130        Scopes::default()
131    }
132
133    /// The wildcard: everything this user can do.
134    ///
135    /// Sanctum's default, and the right one for a first-party mobile app that
136    /// is simply the user on another device.
137    pub fn any() -> Self {
138        Scopes(vec![WILDCARD.to_string()])
139    }
140
141    /// Add one scope, ignoring a repeat.
142    pub fn push(&mut self, scope: impl Into<String>) {
143        let scope = scope.into();
144        if !self.0.contains(&scope) {
145            self.0.push(scope);
146        }
147    }
148
149    /// Add one scope, for chaining: `Scopes::new().with("posts:read")`.
150    pub fn with(mut self, scope: impl Into<String>) -> Self {
151        self.push(scope);
152        self
153    }
154
155    /// Whether this list grants `scope`.
156    ///
157    /// The match is exact, apart from the wildcard. `posts:write` does not
158    /// grant `posts:writeextra` and does not grant `posts:write:all`: a prefix
159    /// match here would mean that adding a *narrower*-sounding scope later
160    /// silently hands it to every token that already held the shorter one.
161    pub fn can(&self, scope: &str) -> bool {
162        self.0.iter().any(|held| held == WILDCARD || held == scope)
163    }
164
165    pub fn len(&self) -> usize {
166        self.0.len()
167    }
168
169    pub fn is_empty(&self) -> bool {
170        self.0.is_empty()
171    }
172
173    pub fn iter(&self) -> std::slice::Iter<'_, String> {
174        self.0.iter()
175    }
176
177    pub fn as_slice(&self) -> &[String] {
178        &self.0
179    }
180}
181
182impl<S: Into<String>> FromIterator<S> for Scopes {
183    fn from_iter<I: IntoIterator<Item = S>>(iter: I) -> Self {
184        let mut scopes = Scopes::new();
185        for scope in iter {
186            scopes.push(scope);
187        }
188        scopes
189    }
190}
191
192impl std::fmt::Display for Scopes {
193    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
194        f.write_str(&self.0.join(" "))
195    }
196}
197
198/// One issued token, as the store keeps it: metadata plus a digest.
199///
200/// The secret is not in here and never was. A store hands these back, the
201/// middleware attaches one to the request, and a handler reads it with
202/// `req.api_token()`.
203#[derive(Clone)]
204pub struct Token {
205    id: String,
206    identity: Identity,
207    name: String,
208    digest: [u8; 32],
209    scopes: Scopes,
210    created_at: u64,
211    expires_at: Option<u64>,
212    last_used_at: Option<u64>,
213}
214
215impl Token {
216    /// Rebuild a token from a stored row.
217    ///
218    /// This is the constructor an application's own [`TokenStore`] uses when it
219    /// reads a record back out of its table. Issuing a *new* token goes through
220    /// [`NewToken`], which is the only place a secret is generated.
221    pub fn new(
222        id: impl Into<String>,
223        identity: Identity,
224        name: impl Into<String>,
225        digest: [u8; 32],
226        scopes: Scopes,
227    ) -> Self {
228        Token {
229            id: id.into(),
230            identity,
231            name: name.into(),
232            digest,
233            scopes,
234            created_at: unix_now(),
235            expires_at: None,
236            last_used_at: None,
237        }
238    }
239
240    /// Give the token a different id.
241    ///
242    /// For a store whose table allocates the key itself: `create` returns the
243    /// token it actually stored, and the plain-text credential is assembled
244    /// from that id.
245    pub fn with_id(mut self, id: impl Into<String>) -> Self {
246        self.id = id.into();
247        self
248    }
249
250    pub fn with_created_at(mut self, at: u64) -> Self {
251        self.created_at = at;
252        self
253    }
254
255    pub fn with_expires_at(mut self, at: Option<u64>) -> Self {
256        self.expires_at = at;
257        self
258    }
259
260    pub fn with_last_used_at(mut self, at: Option<u64>) -> Self {
261        self.last_used_at = at;
262        self
263    }
264
265    pub fn id(&self) -> &str {
266        &self.id
267    }
268
269    /// Who the token speaks for.
270    pub fn identity(&self) -> &Identity {
271        &self.identity
272    }
273
274    /// What the person called it — "iPhone", "CI deploy key".
275    ///
276    /// Purely so a revocation screen lists something a human recognises.
277    pub fn name(&self) -> &str {
278        &self.name
279    }
280
281    pub fn scopes(&self) -> &Scopes {
282        &self.scopes
283    }
284
285    /// The stored SHA-256 of the secret, for a store to persist.
286    ///
287    /// Safe to write to a database column; not safe to write to a log. It
288    /// proves nothing on its own, but it is the only value that verifies a
289    /// token, so anywhere it leaks is somewhere it can be replayed against.
290    pub fn digest(&self) -> &[u8; 32] {
291        &self.digest
292    }
293
294    pub fn created_at(&self) -> u64 {
295        self.created_at
296    }
297
298    pub fn expires_at(&self) -> Option<u64> {
299        self.expires_at
300    }
301
302    pub fn last_used_at(&self) -> Option<u64> {
303        self.last_used_at
304    }
305
306    pub fn set_last_used_at(&mut self, at: u64) {
307        self.last_used_at = Some(at);
308    }
309
310    /// Whether the token grants `scope`. See [`Scopes::can`].
311    pub fn can(&self, scope: &str) -> bool {
312        self.scopes.can(scope)
313    }
314
315    /// A token with no expiry never expires — that is what `None` means.
316    pub fn is_expired(&self) -> bool {
317        self.is_expired_at(unix_now())
318    }
319
320    pub fn is_expired_at(&self, now: u64) -> bool {
321        self.expires_at.is_some_and(|at| now >= at)
322    }
323
324    /// Whether `secret` is the one this token was issued with.
325    ///
326    /// Constant time: the digest is compared with [`constant_time_eq`], so how
327    /// long the check took says nothing about how many bytes were right.
328    pub fn matches_secret(&self, secret: &str) -> bool {
329        constant_time_eq(&self.digest, &digest(secret))
330    }
331}
332
333/// Redacted by hand rather than derived.
334///
335/// The digest is the one value that verifies a token. `{:?}` on a request's
336/// extensions, or on a store, is exactly the sort of thing that ends up in a
337/// log file, and a log file is exactly where it must not be.
338impl std::fmt::Debug for Token {
339    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
340        f.debug_struct("Token")
341            .field("id", &self.id)
342            .field("identity", &self.identity)
343            .field("name", &self.name)
344            .field("digest", &"<redacted>")
345            .field("scopes", &self.scopes)
346            .field("created_at", &self.created_at)
347            .field("expires_at", &self.expires_at)
348            .field("last_used_at", &self.last_used_at)
349            .finish()
350    }
351}
352
353/// A freshly issued token, and the one moment its secret exists in the clear.
354///
355/// Returned by [`NewToken::issue`] and by nothing else. Show
356/// [`plain_text`](Self::plain_text) to the person once; there is no way to
357/// recover it afterwards, because the store never had it.
358pub struct PlainTextToken {
359    token: Token,
360    plain_text: String,
361}
362
363impl PlainTextToken {
364    /// `<id>|<secret>` — what the client sends as its bearer credential.
365    pub fn plain_text(&self) -> &str {
366        &self.plain_text
367    }
368
369    /// The credential, consuming the wrapper.
370    pub fn into_plain_text(self) -> String {
371        self.plain_text
372    }
373
374    /// The stored record: id, name, scopes, expiry.
375    pub fn token(&self) -> &Token {
376        &self.token
377    }
378
379    pub fn into_token(self) -> Token {
380        self.token
381    }
382
383    pub fn into_parts(self) -> (Token, String) {
384        (self.token, self.plain_text)
385    }
386}
387
388impl std::fmt::Debug for PlainTextToken {
389    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
390        f.debug_struct("PlainTextToken")
391            .field("token", &self.token)
392            .field("plain_text", &"<redacted>")
393            .finish()
394    }
395}
396
397/// A token about to be issued: who it belongs to, what it may do, how long for.
398///
399/// ```ignore
400/// let issued = NewToken::new(Identity::new("41"), "iPhone")
401///     .scope("posts:read")
402///     .expires_in(Duration::from_secs(30 * 86_400))
403///     .issue(&*store)
404///     .await?;
405/// ```
406#[derive(Debug, Clone)]
407pub struct NewToken {
408    identity: Identity,
409    name: String,
410    scopes: Scopes,
411    expires_at: Option<u64>,
412}
413
414impl NewToken {
415    /// A token for `identity`, named the way its owner will recognise it.
416    ///
417    /// It starts with no scopes, so a token issued and never granted anything
418    /// can do nothing. Defaulting to the wildcard would mean a forgotten line
419    /// hands out full access; ask for [`any`](Self::any) when that is meant.
420    pub fn new(identity: Identity, name: impl Into<String>) -> Self {
421        NewToken { identity, name: name.into(), scopes: Scopes::new(), expires_at: None }
422    }
423
424    pub fn scope(mut self, scope: impl Into<String>) -> Self {
425        self.scopes.push(scope);
426        self
427    }
428
429    pub fn scopes(mut self, scopes: Scopes) -> Self {
430        self.scopes = scopes;
431        self
432    }
433
434    /// Grant the wildcard: everything its owner can do.
435    pub fn any(self) -> Self {
436        self.scopes(Scopes::any())
437    }
438
439    /// Expire at a unix timestamp in seconds.
440    pub fn expires_at(mut self, at: u64) -> Self {
441        self.expires_at = Some(at);
442        self
443    }
444
445    /// Expire `after` from now.
446    pub fn expires_in(self, after: Duration) -> Self {
447        self.expires_at(unix_now().saturating_add(after.as_secs()))
448    }
449
450    /// Mint the secret, store the digest, and hand back the credential.
451    ///
452    /// The id is generated here but the *stored* id is the one the store
453    /// returns, so a table with its own primary key can replace it and the
454    /// credential still points at the right row.
455    pub async fn issue(self, store: &dyn TokenStore) -> Result<PlainTextToken, Error> {
456        // URL-safe and unpadded, so the credential survives a query string, a
457        // header and a shell command line without escaping — and, crucially,
458        // contains no `|` of its own to confuse the split.
459        let secret = base64::encode_url(&random::bytes(SECRET_BYTES));
460
461        let id = random::hex(16);
462        let token = Token::new(id, self.identity, self.name, digest(&secret), self.scopes)
463            .with_expires_at(self.expires_at);
464
465        let stored = store.create(token).await?;
466        let plain_text = format!("{}{SEPARATOR}{secret}", stored.id());
467
468        Ok(PlainTextToken { token: stored, plain_text })
469    }
470}
471
472/// Why a presented token was not accepted.
473///
474/// An unknown id and a wrong secret are the same variant on purpose. They are
475/// indistinguishable to the caller, and keeping them apart in the type is how
476/// somebody eventually writes a message — or a log line, or a metric — that
477/// tells an attacker which of their guesses was half right.
478#[derive(Debug)]
479pub enum TokenError {
480    /// No `Authorization: Bearer` header at all.
481    Missing,
482    /// Present, but not shaped like `<id>|<secret>`.
483    Malformed,
484    /// No such token, or the wrong secret for it.
485    Invalid,
486    /// The right secret for a token that is past its expiry.
487    Expired,
488    /// The store itself failed. Ours, not the caller's.
489    Unavailable(Error),
490}
491
492impl TokenError {
493    /// What the client is told. Never mentions the token it presented.
494    pub fn message(&self) -> &'static str {
495        match self {
496            TokenError::Missing => "Unauthenticated.",
497            // One message for both, for the reason the enum's docs give.
498            TokenError::Malformed | TokenError::Invalid => "Invalid API token.",
499            // Safe to be specific: only somebody holding the real secret ever
500            // sees this, and "it expired" is the one thing they can act on.
501            TokenError::Expired => "This API token has expired.",
502            TokenError::Unavailable(_) => "Could not verify the API token.",
503        }
504    }
505
506    pub fn status(&self) -> Status {
507        match self {
508            TokenError::Unavailable(_) => Status(500),
509            _ => Status::UNAUTHORIZED,
510        }
511    }
512
513    /// The `WWW-Authenticate` challenge, per RFC 6750.
514    ///
515    /// A bare `Bearer` when nothing was presented — the client may simply not
516    /// know it needed to authenticate — and `error="invalid_token"` when
517    /// something was, which tells a correct client to stop retrying with the
518    /// credential it has and go get another one.
519    pub fn challenge(&self) -> &'static str {
520        match self {
521            TokenError::Missing => "Bearer",
522            _ => r#"Bearer error="invalid_token""#,
523        }
524    }
525}
526
527impl std::fmt::Display for TokenError {
528    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
529        f.write_str(self.message())
530    }
531}
532
533impl std::error::Error for TokenError {}
534
535impl IntoResponse for TokenError {
536    fn into_response(self) -> Response {
537        // A store that is down is a server fault, and must not be reported as
538        // "your credential is bad" — that would have every client throw away a
539        // perfectly good token because the database blinked.
540        if let TokenError::Unavailable(error) = self {
541            return error.into_response();
542        }
543
544        Response::new(self.status())
545            .with_header("www-authenticate", self.challenge())
546            .with_json(Json::object([("message", Json::from(self.message()))]))
547    }
548}
549
550/// Look a presented token up and check it.
551///
552/// The order matters. The secret is verified before the expiry, so somebody
553/// who does not hold the secret cannot learn whether a given id ever existed
554/// and when it lapsed; they get [`TokenError::Invalid`] either way.
555///
556/// `last_used_at` is stamped on success, the way Sanctum does, so a settings
557/// page can show "last used two hours ago" and an unused key is visibly safe to
558/// revoke.
559pub async fn authenticate(store: &dyn TokenStore, presented: &str) -> Result<Token, TokenError> {
560    let (id, secret) = split(presented).ok_or(TokenError::Malformed)?;
561
562    // The id is not a secret — it names a row, it opens nothing — so looking it
563    // up before the secret check leaks nothing worth having.
564    let found = store.find(id).await.map_err(TokenError::Unavailable)?;
565    let Some(mut token) = found else { return Err(TokenError::Invalid) };
566
567    if !token.matches_secret(secret) {
568        return Err(TokenError::Invalid);
569    }
570    if token.is_expired() {
571        return Err(TokenError::Expired);
572    }
573
574    let now = unix_now();
575    if let Err(error) = store.touch(token.id(), now).await {
576        // Bookkeeping. The request authenticated; failing it now because a
577        // timestamp could not be written would be a database hiccup turning
578        // into an outage.
579        rustlavel_core::warn!("could not stamp last_used_at on an API token: {error}");
580    }
581    token.set_last_used_at(now);
582
583    Ok(token)
584}
585
586#[cfg(test)]
587mod tests {
588    use super::*;
589
590    fn store() -> MemoryTokenStore {
591        MemoryTokenStore::new()
592    }
593
594    async fn issue(store: &MemoryTokenStore, id: &str, name: &str) -> PlainTextToken {
595        NewToken::new(Identity::new(id), name)
596            .any()
597            .issue(store)
598            .await
599            .expect("the memory store never fails")
600    }
601
602    #[tokio::test]
603    async fn an_issued_token_reads_as_its_id_and_a_secret() {
604        let store = store();
605        let issued = issue(&store, "41", "iPhone").await;
606
607        let (id, secret) = split(issued.plain_text()).expect("the credential should split");
608
609        assert_eq!(id, issued.token().id());
610        assert_eq!(issued.token().identity(), &Identity::new("41"));
611        assert_eq!(issued.token().name(), "iPhone");
612
613        // 32 bytes of base64url, unpadded.
614        assert_eq!(secret.len(), 43);
615        assert!(secret.chars().all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_'));
616    }
617
618    #[tokio::test]
619    async fn two_tokens_issued_together_share_nothing() {
620        let store = store();
621        let first = issue(&store, "41", "iPhone").await;
622        let second = issue(&store, "41", "iPad").await;
623
624        assert_ne!(first.token().id(), second.token().id());
625        assert_ne!(first.plain_text(), second.plain_text());
626    }
627
628    #[tokio::test]
629    async fn the_plain_text_secret_is_never_kept_by_the_store() {
630        let store = store();
631        let issued = issue(&store, "41", "iPhone").await;
632        let (_, secret) = split(issued.plain_text()).unwrap();
633
634        let stored = store.find(issued.token().id()).await.unwrap().expect("it was just created");
635
636        assert_ne!(stored.digest().as_slice(), secret.as_bytes());
637        assert_eq!(stored.digest(), &digest(secret), "only the digest is kept");
638        assert!(
639            !store.dump().contains(secret),
640            "the secret was found somewhere in the store's contents"
641        );
642    }
643
644    #[tokio::test]
645    async fn no_type_here_prints_a_secret_in_its_debug_output() {
646        let store = store();
647        let issued = issue(&store, "41", "iPhone").await;
648        let (_, secret) = split(issued.plain_text()).unwrap();
649
650        let printed = format!(
651            "{:?} {:?} {:?} {:?}",
652            issued,
653            issued.token(),
654            store,
655            RequireApiToken::new(MemoryTokenStore::new()).scope("posts:read")
656        );
657
658        assert!(!printed.contains(secret), "a secret escaped into {printed}");
659        assert!(!printed.contains(issued.plain_text()));
660        assert!(printed.contains("<redacted>"));
661
662        // The harmless metadata is still there, or the redaction has gone too
663        // far to be useful when debugging.
664        assert!(printed.contains("iPhone"));
665        assert!(printed.contains(issued.token().id()));
666    }
667
668    #[tokio::test]
669    async fn a_valid_token_authenticates() {
670        let store = store();
671        let issued = issue(&store, "41", "iPhone").await;
672
673        let token = authenticate(&store, issued.plain_text()).await.expect("it should be accepted");
674
675        assert_eq!(token.id(), issued.token().id());
676        assert_eq!(token.identity().id(), "41");
677    }
678
679    #[tokio::test]
680    async fn authenticating_stamps_last_used_at() {
681        let store = store();
682        let issued = issue(&store, "41", "iPhone").await;
683
684        assert_eq!(issued.token().last_used_at(), None, "a token starts unused");
685
686        let token = authenticate(&store, issued.plain_text()).await.unwrap();
687        assert!(token.last_used_at().is_some());
688
689        let stored = store.find(token.id()).await.unwrap().unwrap();
690        assert_eq!(stored.last_used_at(), token.last_used_at(), "and it was persisted");
691    }
692
693    #[tokio::test]
694    async fn an_expired_token_is_rejected() {
695        let store = store();
696        let issued = NewToken::new(Identity::new("41"), "expired")
697            .any()
698            .expires_in(Duration::ZERO)
699            .issue(&store)
700            .await
701            .unwrap();
702
703        assert!(issued.token().is_expired());
704        let outcome = authenticate(&store, issued.plain_text()).await;
705        assert!(matches!(outcome, Err(TokenError::Expired)));
706
707        // A token with no expiry is not accidentally expired.
708        let forever = issue(&store, "41", "forever").await;
709        assert_eq!(forever.token().expires_at(), None);
710        assert!(!forever.token().is_expired());
711        assert!(authenticate(&store, forever.plain_text()).await.is_ok());
712    }
713
714    #[tokio::test]
715    async fn a_known_id_with_the_wrong_secret_is_rejected() {
716        let store = store();
717        let issued = issue(&store, "41", "iPhone").await;
718        let id = issued.token().id();
719
720        // An *empty* secret is not in this list: it never gets as far as the
721        // digest, because `split` refuses the shape. It is covered below.
722        for guess in ["x", "not-the-secret", &"a".repeat(43)] {
723            let presented = format!("{id}{SEPARATOR}{guess}");
724            assert!(
725                matches!(authenticate(&store, &presented).await, Err(TokenError::Invalid)),
726                "the secret {guess:?} should not open token {id}"
727            );
728        }
729    }
730
731    #[tokio::test]
732    async fn one_users_token_id_cannot_be_paired_with_anothers_secret() {
733        let store = store();
734        let mine = issue(&store, "41", "mine").await;
735        let yours = issue(&store, "99", "yours").await;
736
737        let (_, my_secret) = split(mine.plain_text()).unwrap();
738        let stolen = format!("{}{SEPARATOR}{my_secret}", yours.token().id());
739
740        assert!(matches!(authenticate(&store, &stolen).await, Err(TokenError::Invalid)));
741
742        // And the real pairings still work, so the test is not passing by
743        // rejecting everything.
744        assert!(authenticate(&store, mine.plain_text()).await.is_ok());
745        assert!(authenticate(&store, yours.plain_text()).await.is_ok());
746    }
747
748    #[tokio::test]
749    async fn a_malformed_token_is_rejected_without_panicking() {
750        let store = store();
751        let huge = "a".repeat(100_000);
752        let long_id = format!("{}{SEPARATOR}secret", "a".repeat(MAX_PRESENTED_LENGTH));
753
754        for presented in [
755            "",
756            "no-pipe",
757            "|",
758            "a|",
759            "|b",
760            "||",
761            " ",
762            "\0",
763            "él|ok",
764            huge.as_str(),
765            long_id.as_str(),
766        ] {
767            let outcome = authenticate(&store, presented).await;
768            assert!(
769                matches!(outcome, Err(TokenError::Malformed) | Err(TokenError::Invalid)),
770                "{presented:?} was not rejected: {outcome:?}"
771            );
772        }
773    }
774
775    #[test]
776    fn splitting_accepts_only_the_two_part_shape() {
777        assert_eq!(split("7|secret"), Some(("7", "secret")));
778        // The secret keeps everything after the first separator, so a stray
779        // one cannot be used to truncate the comparison.
780        assert_eq!(split("7|a|b"), Some(("7", "a|b")));
781
782        assert_eq!(split(""), None);
783        assert_eq!(split("|"), None);
784        assert_eq!(split("a|"), None);
785        assert_eq!(split("|b"), None);
786        assert_eq!(split("no-pipe"), None);
787        assert_eq!(split(&"a".repeat(MAX_PRESENTED_LENGTH + 1)), None);
788    }
789
790    #[tokio::test]
791    async fn revoking_a_token_takes_effect_immediately() {
792        let store = store();
793        let issued = issue(&store, "41", "iPhone").await;
794
795        assert!(authenticate(&store, issued.plain_text()).await.is_ok());
796
797        store.delete(issued.token().id()).await.unwrap();
798
799        let outcome = authenticate(&store, issued.plain_text()).await;
800        assert!(matches!(outcome, Err(TokenError::Invalid)));
801    }
802
803    #[tokio::test]
804    async fn revoking_every_token_for_a_user_leaves_the_others_alone() {
805        let store = store();
806        let phone = issue(&store, "41", "iPhone").await;
807        let laptop = issue(&store, "41", "laptop").await;
808        let someone_else = issue(&store, "99", "theirs").await;
809
810        store.delete_for_identity(&Identity::new("41")).await.unwrap();
811
812        for revoked in [&phone, &laptop] {
813            assert!(matches!(
814                authenticate(&store, revoked.plain_text()).await,
815                Err(TokenError::Invalid)
816            ));
817        }
818        assert!(authenticate(&store, someone_else.plain_text()).await.is_ok());
819    }
820
821    #[test]
822    fn can_matches_a_scope_exactly_and_not_by_prefix() {
823        let scopes = Scopes::new().with("posts:write").with("orders:read");
824
825        assert!(scopes.can("posts:write"));
826        assert!(scopes.can("orders:read"));
827
828        assert!(!scopes.can("posts:writeextra"), "a longer scope must not be granted");
829        assert!(!scopes.can("posts:writ"), "nor a shorter one");
830        assert!(!scopes.can("posts:write:all"));
831        assert!(!scopes.can("posts:read"), "read and write are not the same permission");
832        assert!(!scopes.can("POSTS:WRITE"), "the comparison is case sensitive");
833        assert!(!scopes.can(""));
834    }
835
836    #[test]
837    fn the_wildcard_grants_everything_and_an_empty_list_grants_nothing() {
838        let everything = Scopes::any();
839        assert!(everything.can("posts:write"));
840        assert!(everything.can("anything at all"));
841        assert!(everything.can(WILDCARD));
842
843        let nothing = Scopes::new();
844        assert!(nothing.is_empty());
845        assert!(!nothing.can("posts:read"));
846        assert!(!nothing.can(WILDCARD), "the wildcard is not held, so it is not granted");
847    }
848
849    #[test]
850    fn scopes_are_collected_deduplicated_and_kept_in_order() {
851        let scopes: Scopes = ["posts:read", "posts:write", "posts:read"].into_iter().collect();
852
853        assert_eq!(scopes.len(), 2);
854        assert_eq!(scopes.as_slice(), ["posts:read", "posts:write"]);
855        assert_eq!(scopes.to_string(), "posts:read posts:write");
856        assert_eq!(scopes.iter().count(), 2);
857    }
858
859    #[tokio::test]
860    async fn a_tokens_scopes_answer_can() {
861        let store = store();
862        let issued = NewToken::new(Identity::new("41"), "reader")
863            .scope("posts:read")
864            .issue(&store)
865            .await
866            .unwrap();
867
868        assert!(issued.token().can("posts:read"));
869        assert!(!issued.token().can("posts:write"));
870        assert_eq!(issued.token().scopes().as_slice(), ["posts:read"]);
871    }
872
873    #[tokio::test]
874    async fn a_new_token_grants_nothing_until_it_is_asked_to() {
875        let store = store();
876        let issued = NewToken::new(Identity::new("41"), "bare").issue(&store).await.unwrap();
877
878        assert!(issued.token().scopes().is_empty());
879        assert!(!issued.token().can("posts:read"));
880        assert!(!issued.token().can(WILDCARD));
881    }
882
883    #[test]
884    fn a_rejection_says_nothing_about_the_token_that_was_presented() {
885        assert_eq!(TokenError::Missing.message(), "Unauthenticated.");
886        assert_eq!(
887            TokenError::Malformed.message(),
888            TokenError::Invalid.message(),
889            "an unknown id and a wrong secret must be indistinguishable"
890        );
891
892        assert_eq!(TokenError::Missing.challenge(), "Bearer");
893        assert!(TokenError::Invalid.challenge().starts_with("Bearer"));
894        assert!(TokenError::Invalid.challenge().contains("invalid_token"));
895
896        assert_eq!(TokenError::Invalid.status(), Status::UNAUTHORIZED);
897        assert_eq!(TokenError::Unavailable(Error::msg("down")).status(), Status(500));
898    }
899
900    #[test]
901    fn digests_differ_for_different_secrets_and_are_stable_for_the_same_one() {
902        assert_eq!(digest("secret"), digest("secret"));
903        assert_ne!(digest("secret"), digest("secreu"));
904        assert_eq!(digest("").len(), 32);
905    }
906
907    #[test]
908    fn a_token_rebuilt_from_a_row_keeps_every_field() {
909        let token = Token::new("7", Identity::new("41"), "CI", digest("s"), Scopes::any())
910            .with_created_at(1_000)
911            .with_expires_at(Some(2_000))
912            .with_last_used_at(Some(1_500));
913
914        assert_eq!(token.id(), "7");
915        assert_eq!(token.name(), "CI");
916        assert_eq!(token.created_at(), 1_000);
917        assert_eq!(token.expires_at(), Some(2_000));
918        assert_eq!(token.last_used_at(), Some(1_500));
919
920        assert!(!token.is_expired_at(1_999));
921        assert!(token.is_expired_at(2_000), "the expiry second is already past");
922        assert!(token.is_expired_at(2_001));
923
924        assert!(token.matches_secret("s"));
925        assert!(!token.matches_secret("t"));
926    }
927}