@jpgilldev / steid

12.6 KBRaw
1//! Issuing, listing, revoking and authenticating personal access tokens.
2//!
3//! Tokens **authenticate**; they do not authorize. Presenting one says who the actor is,
4//! and every rule about what that actor may do stays where it already lives. See
5//! [0007](../../plans/decisions/0007-tokens-over-http-basic.md).
6
7use std::time::SystemTime;
8
9use crate::domain::{
10 Actor, DomainError, PersonalAccessToken, TokenId, TokenSecret, repository::TokenRepository,
11};
12
13use super::error::Result;
14
15/// A token, and the one and only chance to read it.
16///
17/// The secret is not stored anywhere — only its hash is — so a caller that drops this
18/// without showing it has issued a credential nobody will ever be able to use.
19#[derive(Debug)]
20pub struct IssuedToken {
21 pub token: PersonalAccessToken,
22 pub secret: TokenSecret,
23}
24
25/// A token as it appears in a listing.
26///
27/// Carries the prefix rather than anything presentable: a list has to name tokens
28/// without being able to show them.
29#[derive(Debug, Clone, PartialEq, Eq)]
30pub struct TokenSummary {
31 pub id: TokenId,
32 pub name: String,
33 pub prefix: String,
34 pub created_at: SystemTime,
35}
36
37/// Issues a token to the actor, for the actor.
38///
39/// There is no "issue a token for someone else": a credential that acts as a user can
40/// only be minted by that user. An anonymous caller is refused rather than being
41/// silently given nothing.
42pub async fn issue_token(
43 actor: &Actor,
44 name: &str,
45 now: SystemTime,
46 tokens: &impl TokenRepository,
47) -> Result<IssuedToken> {
48 let Some(user_id) = actor.user_id() else {
49 return Err(DomainError::Forbidden.into());
50 };
51
52 let secret = TokenSecret::generate();
53 let token = PersonalAccessToken::new(TokenId::generate(), user_id.clone(), name, &secret, now)?;
54
55 tokens.save(&token).await?;
56
57 Ok(IssuedToken { token, secret })
58}
59
60/// Every token the actor holds, newest first.
61///
62/// Empty for an anonymous caller rather than an error: there is nothing to hide and
63/// nothing to show.
64pub async fn list_tokens(
65 actor: &Actor,
66 tokens: &impl TokenRepository,
67) -> Result<Vec<TokenSummary>> {
68 let Some(user_id) = actor.user_id() else {
69 return Ok(Vec::new());
70 };
71
72 Ok(tokens
73 .list_by_user(user_id)
74 .await?
75 .into_iter()
76 .map(|token| TokenSummary {
77 id: token.id,
78 name: token.name,
79 prefix: token.prefix,
80 created_at: token.created_at,
81 })
82 .collect())
83}
84
85/// Revokes one of the actor's own tokens.
86///
87/// A token belonging to someone else answers `NotFound`, not `Forbidden` — the same
88/// reason an invisible repository is absent rather than refused. Telling a caller that
89/// a token id exists but is not theirs is a fact they have no business learning.
90pub async fn revoke_token(
91 actor: &Actor,
92 id: &TokenId,
93 tokens: &impl TokenRepository,
94) -> Result<()> {
95 let Some(user_id) = actor.user_id() else {
96 return Err(not_found());
97 };
98
99 // Found by listing rather than by id: the port has no `find_by_id`, and adding one
100 // would exist solely to be paired with an ownership check that this already makes
101 // impossible to forget.
102 let owned = tokens
103 .list_by_user(user_id)
104 .await?
105 .into_iter()
106 .any(|token| &token.id == id);
107
108 if !owned {
109 return Err(not_found());
110 }
111
112 tokens.delete(id).await?;
113
114 Ok(())
115}
116
117/// Resolves a presented token into the actor it authenticates.
118///
119/// Anything unrecognised resolves to [`Actor::Anonymous`], exactly as
120/// [`resolve_actor`](super::session::resolve_actor) does for sessions: authentication
121/// never fails open, and never errors merely because a credential is wrong.
122///
123/// Tokens do not expire. That is a deliberate absence rather than an oversight — a
124/// credential a person pastes into a machine and forgets is worth less if it stops
125/// working silently, and revocation is the control that matters. Recorded in
126/// `current.md` so the next session does not read it as a missing feature.
127pub async fn authenticate_token(presented: &str, tokens: &impl TokenRepository) -> Result<Actor> {
128 let presented = TokenSecret::from_presented(presented);
129
130 let Some(token) = tokens.find_by_hash(&presented.hash()).await? else {
131 return Ok(Actor::Anonymous);
132 };
133
134 Ok(Actor::User(token.user_id))
135}
136
137fn not_found() -> super::error::Error {
138 DomainError::NotFound { entity: "token" }.into()
139}
140
141#[cfg(test)]
142mod tests {
143 use std::time::Duration;
144
145 use super::*;
146 use crate::{domain::UserId, infrastructure::repository::InMemoryTokenRepo};
147
148 fn at(seconds: u64) -> SystemTime {
149 SystemTime::UNIX_EPOCH + Duration::from_secs(seconds)
150 }
151
152 struct Fixture {
153 tokens: InMemoryTokenRepo,
154 user: Actor,
155 other: Actor,
156 }
157
158 fn fixture() -> Fixture {
159 Fixture {
160 tokens: InMemoryTokenRepo::new(),
161 user: Actor::User(UserId::generate()),
162 other: Actor::User(UserId::generate()),
163 }
164 }
165
166 // --- issuing ------------------------------------------------------------------
167
168 #[tokio::test]
169 async fn issuing_returns_a_token_that_authenticates_its_owner() {
170 let f = fixture();
171
172 let issued = issue_token(&f.user, "laptop", at(1_000), &f.tokens)
173 .await
174 .expect("should issue");
175
176 let actor = authenticate_token(issued.secret.reveal(), &f.tokens)
177 .await
178 .expect("should authenticate");
179
180 assert_eq!(actor, f.user);
181 }
182
183 #[tokio::test]
184 async fn an_anonymous_caller_cannot_issue_a_token() {
185 // A credential that acts as a user has to be minted by one.
186 let f = fixture();
187
188 let error = issue_token(&Actor::Anonymous, "laptop", at(1_000), &f.tokens)
189 .await
190 .expect_err("should refuse");
191
192 assert!(matches!(
193 error,
194 super::super::Error::Domain(DomainError::Forbidden)
195 ));
196 assert!(
197 list_tokens(&f.user, &f.tokens)
198 .await
199 .expect("list")
200 .is_empty()
201 );
202 }
203
204 #[tokio::test]
205 async fn a_token_needs_a_name() {
206 let f = fixture();
207
208 assert!(
209 issue_token(&f.user, " ", at(1_000), &f.tokens)
210 .await
211 .is_err()
212 );
213 }
214
215 #[tokio::test]
216 async fn each_issued_token_is_different() {
217 let f = fixture();
218
219 let first = issue_token(&f.user, "one", at(1_000), &f.tokens)
220 .await
221 .expect("issue");
222 let second = issue_token(&f.user, "two", at(2_000), &f.tokens)
223 .await
224 .expect("issue");
225
226 assert_ne!(first.secret.reveal(), second.secret.reveal());
227 }
228
229 #[tokio::test]
230 async fn the_stored_token_is_not_the_secret() {
231 // The whole point of hashing: a dumped table holds nothing presentable.
232 let f = fixture();
233
234 let issued = issue_token(&f.user, "laptop", at(1_000), &f.tokens)
235 .await
236 .expect("issue");
237
238 assert_ne!(issued.token.token_hash.as_str(), issued.secret.reveal());
239 assert!(
240 !issued
241 .secret
242 .reveal()
243 .contains(issued.token.token_hash.as_str())
244 );
245 }
246
247 // --- authenticating -----------------------------------------------------------
248
249 #[tokio::test]
250 async fn an_unknown_token_is_anonymous_rather_than_an_error() {
251 let f = fixture();
252 issue_token(&f.user, "laptop", at(1_000), &f.tokens)
253 .await
254 .expect("issue");
255
256 for presented in ["", "hunter2", TokenSecret::generate().reveal()] {
257 assert_eq!(
258 authenticate_token(presented, &f.tokens)
259 .await
260 .expect("should not error"),
261 Actor::Anonymous,
262 "{presented:?} should not authenticate"
263 );
264 }
265 }
266
267 #[tokio::test]
268 async fn a_token_authenticates_only_the_user_it_was_issued_to() {
269 let f = fixture();
270 let mine = issue_token(&f.user, "mine", at(1_000), &f.tokens)
271 .await
272 .expect("issue");
273 let theirs = issue_token(&f.other, "theirs", at(1_000), &f.tokens)
274 .await
275 .expect("issue");
276
277 assert_eq!(
278 authenticate_token(mine.secret.reveal(), &f.tokens)
279 .await
280 .expect("authenticate"),
281 f.user
282 );
283 assert_eq!(
284 authenticate_token(theirs.secret.reveal(), &f.tokens)
285 .await
286 .expect("authenticate"),
287 f.other
288 );
289 }
290
291 // --- listing ------------------------------------------------------------------
292
293 #[tokio::test]
294 async fn a_listing_names_tokens_without_showing_them() {
295 let f = fixture();
296 let issued = issue_token(&f.user, "laptop", at(1_000), &f.tokens)
297 .await
298 .expect("issue");
299
300 let listed = list_tokens(&f.user, &f.tokens).await.expect("list");
301 let summary = listed.first().expect("one token");
302
303 assert_eq!(summary.name, "laptop");
304 assert_eq!(summary.prefix, issued.secret.display_prefix());
305 assert!(
306 !issued.secret.reveal().contains(&format!("{summary:?}")),
307 "a summary must not carry anything presentable"
308 );
309 }
310
311 #[tokio::test]
312 async fn a_listing_covers_only_the_actors_own_tokens() {
313 let f = fixture();
314 issue_token(&f.user, "mine", at(1_000), &f.tokens)
315 .await
316 .expect("issue");
317 issue_token(&f.other, "theirs", at(1_000), &f.tokens)
318 .await
319 .expect("issue");
320
321 let listed = list_tokens(&f.user, &f.tokens).await.expect("list");
322
323 assert_eq!(listed.len(), 1);
324 assert_eq!(listed[0].name, "mine");
325 }
326
327 #[tokio::test]
328 async fn an_anonymous_caller_lists_nothing() {
329 let f = fixture();
330 issue_token(&f.user, "mine", at(1_000), &f.tokens)
331 .await
332 .expect("issue");
333
334 assert!(
335 list_tokens(&Actor::Anonymous, &f.tokens)
336 .await
337 .expect("list")
338 .is_empty()
339 );
340 }
341
342 // --- revoking -----------------------------------------------------------------
343
344 #[tokio::test]
345 async fn revoking_stops_a_token_authenticating() {
346 let f = fixture();
347 let issued = issue_token(&f.user, "laptop", at(1_000), &f.tokens)
348 .await
349 .expect("issue");
350
351 revoke_token(&f.user, &issued.token.id, &f.tokens)
352 .await
353 .expect("should revoke");
354
355 assert_eq!(
356 authenticate_token(issued.secret.reveal(), &f.tokens)
357 .await
358 .expect("authenticate"),
359 Actor::Anonymous
360 );
361 }
362
363 #[tokio::test]
364 async fn someone_elses_token_cannot_be_revoked_and_still_works() {
365 // Not found rather than forbidden: that a token id exists but belongs to someone
366 // else is not a fact a caller should be able to learn.
367 let f = fixture();
368 let theirs = issue_token(&f.other, "theirs", at(1_000), &f.tokens)
369 .await
370 .expect("issue");
371
372 let error = revoke_token(&f.user, &theirs.token.id, &f.tokens)
373 .await
374 .expect_err("should refuse");
375
376 assert!(matches!(
377 error,
378 super::super::Error::Domain(DomainError::NotFound { entity: "token" })
379 ));
380 assert_eq!(
381 authenticate_token(theirs.secret.reveal(), &f.tokens)
382 .await
383 .expect("authenticate"),
384 f.other,
385 "the token should still work"
386 );
387 }
388
389 #[tokio::test]
390 async fn revoking_an_unknown_token_is_not_found() {
391 let f = fixture();
392
393 let error = revoke_token(&f.user, &TokenId::generate(), &f.tokens)
394 .await
395 .expect_err("should refuse");
396
397 assert!(matches!(
398 error,
399 super::super::Error::Domain(DomainError::NotFound { entity: "token" })
400 ));
401 }
402
403 #[tokio::test]
404 async fn an_anonymous_caller_cannot_revoke_anything() {
405 let f = fixture();
406 let issued = issue_token(&f.user, "laptop", at(1_000), &f.tokens)
407 .await
408 .expect("issue");
409
410 assert!(
411 revoke_token(&Actor::Anonymous, &issued.token.id, &f.tokens)
412 .await
413 .is_err()
414 );
415 assert_eq!(
416 authenticate_token(issued.secret.reveal(), &f.tokens)
417 .await
418 .expect("authenticate"),
419 f.user
420 );
421 }
422}