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 /// How stale `last_used_at` must be before a bump actually writes.
179 ///
180 /// gaps4 #15: without this, every authenticated bearer request issued a
181 /// `last_used_at` UPDATE — a write on every read, and the hottest write in
182 /// a token-auth API. The column exists so cleanup jobs can prune stale
183 /// tokens; minute-granularity is ample for that, and coalescing turns
184 /// "a write per request" into "at most one write per minute per token".
185 const TOUCH_COALESCE: chrono::Duration = chrono::Duration::seconds(60);
186
187 /// Best-effort `last_used_at` bump. Called by
188 /// [`crate::BearerAuthentication`] after a successful lookup so
189 /// the column is fresh for cleanup jobs. Failures here are
190 /// swallowed — a stat update should never fail the request that
191 /// triggered it.
192 ///
193 /// Coalesced (gaps4 #15): the write is skipped when the stored value is
194 /// already within [`Self::TOUCH_COALESCE`] of now, so a busy token isn't
195 /// re-written on every request.
196 pub(crate) async fn touch_last_used(&self) {
197 let now = Utc::now();
198 if let Some(prev) = self.last_used_at {
199 if now.signed_duration_since(prev) < Self::TOUCH_COALESCE {
200 return;
201 }
202 }
203 let mut delta = serde_json::Map::new();
204 delta.insert("last_used_at".to_string(), serde_json::json!(now));
205 let _ = AuthToken::objects()
206 .filter(auth_token::ID.eq(self.id))
207 .update_values(delta)
208 .await;
209 }
210}
211
212#[cfg(test)]
213mod tests {
214 use super::*;
215
216 #[test]
217 fn generated_plaintext_has_prefix_and_decent_length() {
218 let t = generate_plaintext();
219 assert!(t.starts_with(TOKEN_PREFIX), "missing prefix: {t}");
220 // prefix (6) + base64(32 bytes, no padding) = 6 + 43 = 49
221 assert_eq!(t.len(), TOKEN_PREFIX.len() + 43, "unexpected length: {t}");
222 }
223
224 #[test]
225 fn generated_plaintext_is_unique_per_call() {
226 let a = generate_plaintext();
227 let b = generate_plaintext();
228 assert_ne!(
229 a, b,
230 "two consecutive tokens collided (statistically impossible)"
231 );
232 }
233
234 #[test]
235 fn digest_is_deterministic_and_unique() {
236 let a = digest_token("umbral_AAAAA");
237 let b = digest_token("umbral_AAAAA");
238 let c = digest_token("umbral_BBBBB");
239 assert_eq!(a, b, "digest is supposed to be deterministic");
240 assert_ne!(a, c, "different inputs must produce different digests");
241 // Base64 of 32 bytes, no padding -> 43 chars.
242 assert_eq!(a.len(), 43, "unexpected digest length: {a}");
243 }
244}