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}