Skip to main content

umbral_auth/
token.rs

1//! Opaque DB-backed bearer tokens.
2//!
3//! A user can hold any number of named tokens (laptop, CI, iOS, …).
4//! Each token is a long random string with a `umbral_` prefix; only
5//! its SHA-256 digest hits the database. Plaintext is shown ONCE at
6//! creation; lookups go plaintext → digest → row, so a database leak
7//! does not surrender live tokens.
8//!
9//! ## Lifecycle
10//!
11//! 1. [`AuthToken::create_for`] generates a token, persists the
12//!    digest, returns the model row + the plaintext key.
13//! 2. The caller stores the plaintext somewhere (cookie, response
14//!    body, env file). The plaintext is never recoverable from the
15//!    row alone.
16//! 3. Every request that authenticates via `Authorization: Bearer
17//!    <key>` runs through [`AuthToken::lookup`]: digest the bearer,
18//!    look up by the unique `key_hash` index, return the row.
19//! 4. [`AuthToken::revoke`] deletes the row; the next request with
20//!    that plaintext fails the lookup and the caller is treated as
21//!    anonymous.
22//!
23//! ## Why hash at rest
24//!
25//! Classic token tables store plaintext. The modern recommendation is to
26//! hash the token so a DB read leak (backup, SQL injection, exposed dump)
27//! doesn't hand
28//! the attacker live API keys. The lookup cost is one SHA-256 per
29//! request, which is microseconds. The only ergonomic loss is "show
30//! me my token again" — which is the security boundary working as
31//! intended.
32//!
33//! ## Custom user models
34//!
35//! `AuthToken` FKs against [`crate::AuthUser`]. Apps using a custom
36//! `UserModel` need their own token model + their own
37//! `Authentication` impl. The [`crate::BearerAuthentication`] class
38//! and this model are convenience defaults for the built-in user.
39
40use crate::AuthUser;
41use base64::Engine;
42use base64::engine::general_purpose::URL_SAFE_NO_PAD;
43use chrono::{DateTime, Utc};
44use rand::RngCore;
45use serde::{Deserialize, Serialize};
46use sha2::{Digest, Sha256};
47use umbral::orm::ForeignKey;
48
49/// The token prefix. Lets a developer eyeball that a 50-char string
50/// is an umbral bearer token (the same trick GitHub uses with `ghp_`).
51/// Also lets log scrubbers grep for accidentally-committed tokens.
52pub const TOKEN_PREFIX: &str = "umbral_";
53
54/// One row per active bearer token. A user can hold any number of
55/// these (one per device / per environment / per CI runner). The
56/// `name` column is the human label shown in admin / management
57/// listings; it has no functional role.
58///
59/// The `key_hash` column carries `base64(sha256(plaintext))` (43
60/// chars, URL-safe, no padding) under a UNIQUE index so a digest
61/// collision is forbidden. The plaintext lives only in memory at
62/// creation time and in whatever client storage the caller chose.
63///
64/// `last_used_at` is updated best-effort on every successful lookup
65/// (a failure to write does not fail the auth). Useful for "this
66/// token has not been used in 90 days, prune it" cleanup jobs.
67#[derive(Debug, Clone, sqlx::FromRow, Serialize, Deserialize, umbral::orm::Model)]
68pub struct AuthToken {
69    pub id: i64,
70    /// Owning user. FK against `auth_user.id`. `ON DELETE CASCADE`
71    /// — when a user row is deleted, every token they hold goes
72    /// with them. Otherwise revoking a user would leave orphan
73    /// tokens that no longer match any user via the auth lookup,
74    /// silently failing 401 instead of cleanly disappearing.
75    #[umbral(on_delete = "cascade")]
76    pub user_id: ForeignKey<AuthUser>,
77    /// `base64(sha256(plaintext))` — 43 chars, URL-safe, no pad. The
78    /// UNIQUE constraint protects against the (cryptographically
79    /// negligible) chance of two random keys hashing to the same
80    /// digest, and lets the lookup path stop at the first match.
81    #[umbral(max_length = 64, unique)]
82    pub key_hash: String,
83    /// Human label. Shown in admin listings and the management
84    /// CLI; never used for lookup. Defaults to "default" when the
85    /// caller does not name the token.
86    #[umbral(max_length = 80)]
87    pub name: String,
88    pub created_at: DateTime<Utc>,
89    /// Last time this token authenticated a request. NULL until
90    /// the first successful lookup.
91    pub last_used_at: Option<DateTime<Utc>>,
92}
93
94/// The plaintext key returned at creation time. Wraps the raw
95/// string so a caller sees the type and remembers this value is
96/// not recoverable from the database.
97#[derive(Debug, Clone)]
98pub struct PlaintextToken(pub String);
99
100impl std::fmt::Display for PlaintextToken {
101    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
102        f.write_str(&self.0)
103    }
104}
105
106/// Generate a random opaque bearer token. 32 bytes of OS-provided
107/// randomness, URL-safe base64 encoded, with the `umbral_` prefix.
108/// The final string is ~50 chars and contains no `=` padding so it
109/// drops straight into `Authorization: Bearer …` without escaping.
110fn generate_plaintext() -> String {
111    let mut buf = [0u8; 32];
112    rand::rngs::OsRng.fill_bytes(&mut buf);
113    format!("{TOKEN_PREFIX}{}", URL_SAFE_NO_PAD.encode(buf))
114}
115
116/// SHA-256 the plaintext and encode the digest in URL-safe base64.
117/// Public so a caller running `AuthToken::lookup` against a custom
118/// query can compute the storage form themselves.
119pub fn digest_token(plaintext: &str) -> String {
120    let mut hasher = Sha256::new();
121    hasher.update(plaintext.as_bytes());
122    URL_SAFE_NO_PAD.encode(hasher.finalize())
123}
124
125impl AuthToken {
126    /// Mint a new bearer token for `user`. Returns the persisted
127    /// row plus the plaintext key — the caller is responsible for
128    /// surfacing the plaintext to whoever needs it (response body,
129    /// admin "copy this" UI). The plaintext is not recoverable from
130    /// the row alone after this call returns.
131    ///
132    /// `name` is a free-form label shown in admin and management
133    /// listings. Pass `""` to default to `"default"`.
134    pub async fn create_for(
135        user: &AuthUser,
136        name: &str,
137    ) -> Result<(Self, PlaintextToken), crate::AuthError> {
138        let plaintext = generate_plaintext();
139        let key_hash = digest_token(&plaintext);
140        let label = if name.is_empty() { "default" } else { name };
141        let row = AuthToken::objects()
142            .create(AuthToken {
143                id: 0, // ignored; the ORM assigns
144                user_id: ForeignKey::new(user.id),
145                key_hash,
146                name: label.to_string(),
147                created_at: Utc::now(),
148                last_used_at: None,
149            })
150            .await?;
151        Ok((row, PlaintextToken(plaintext)))
152    }
153
154    /// Look up the token row a plaintext key resolves to. Returns
155    /// `Ok(None)` for an unrecognised token (the auth backend will
156    /// then treat the request as anonymous); `Err` only on a DB
157    /// failure.
158    pub async fn lookup(plaintext: &str) -> Result<Option<Self>, crate::AuthError> {
159        let key_hash = digest_token(plaintext);
160        let row = AuthToken::objects()
161            .filter(auth_token::KEY_HASH.eq(key_hash))
162            .first()
163            .await?;
164        Ok(row)
165    }
166
167    /// Revoke this token by deleting its row. The next request that
168    /// carries this plaintext fails [`AuthToken::lookup`] and is
169    /// treated as anonymous.
170    pub async fn revoke(&self) -> Result<(), crate::AuthError> {
171        AuthToken::objects()
172            .filter(auth_token::ID.eq(self.id))
173            .delete()
174            .await?;
175        Ok(())
176    }
177
178    /// Best-effort `last_used_at` bump. Called by
179    /// [`crate::BearerAuthentication`] after a successful lookup so
180    /// the column is fresh for cleanup jobs. Failures here are
181    /// swallowed — a stat update should never fail the request that
182    /// triggered it.
183    pub(crate) async fn touch_last_used(&self) {
184        let now = Utc::now();
185        let mut delta = serde_json::Map::new();
186        delta.insert("last_used_at".to_string(), serde_json::json!(now));
187        let _ = AuthToken::objects()
188            .filter(auth_token::ID.eq(self.id))
189            .update_values(delta)
190            .await;
191    }
192}
193
194#[cfg(test)]
195mod tests {
196    use super::*;
197
198    #[test]
199    fn generated_plaintext_has_prefix_and_decent_length() {
200        let t = generate_plaintext();
201        assert!(t.starts_with(TOKEN_PREFIX), "missing prefix: {t}");
202        // prefix (6) + base64(32 bytes, no padding) = 6 + 43 = 49
203        assert_eq!(t.len(), TOKEN_PREFIX.len() + 43, "unexpected length: {t}");
204    }
205
206    #[test]
207    fn generated_plaintext_is_unique_per_call() {
208        let a = generate_plaintext();
209        let b = generate_plaintext();
210        assert_ne!(
211            a, b,
212            "two consecutive tokens collided (statistically impossible)"
213        );
214    }
215
216    #[test]
217    fn digest_is_deterministic_and_unique() {
218        let a = digest_token("umbral_AAAAA");
219        let b = digest_token("umbral_AAAAA");
220        let c = digest_token("umbral_BBBBB");
221        assert_eq!(a, b, "digest is supposed to be deterministic");
222        assert_ne!(a, c, "different inputs must produce different digests");
223        // Base64 of 32 bytes, no padding -> 43 chars.
224        assert_eq!(a.len(), 43, "unexpected digest length: {a}");
225    }
226}