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 | |
| 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 | } |