| | @@ -0,0 +1,422 @@ |
| 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 | + |
| 7 | +use std::time::SystemTime; |
| 8 | + |
| 9 | +use crate::domain::{ |
| 10 | + Actor, DomainError, PersonalAccessToken, TokenId, TokenSecret, repository::TokenRepository, |
| 11 | +}; |
| 12 | + |
| 13 | +use 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)] |
| 20 | +pub 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)] |
| 30 | +pub 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. |
| 42 | +pub 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. |
| 64 | +pub 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. |
| 90 | +pub 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. |
| 127 | +pub 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 | + |
| 137 | +fn not_found() -> super::error::Error { |
| 138 | + DomainError::NotFound { entity: "token" }.into() |
| 139 | +} |
| 140 | + |
| 141 | +#[cfg(test)] |
| 142 | +mod 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 | +} |