steid

@jamesgill /

feat: issue, list, revoke and authenticate tokens

Tokens authenticate; they do not authorize. authenticate_token resolves a
presented value into an Actor and nothing else — every rule about what that
actor may do stays where it already lives, so a token widens who is asking
without touching what the answer is.

It mirrors resolve_actor: anything unrecognised is Anonymous rather than an
error. Authentication never fails open and never errors merely because a
credential is wrong.

Two refusals are shaped deliberately. Only a user can mint a credential that
acts as that user, so an anonymous issue is Forbidden. Revoking a token that
belongs to someone else is NotFound rather than Forbidden — the same reasoning
as an invisible repository being absent: that a token id exists but is not
yours is not a fact worth confirming.

Tokens do not expire, which is a decision rather than an omission. A credential
pasted into a machine and forgotten is worth less if it stops working silently;
revocation is the control that matters. Recorded in current.md so it does not
read later as a gap.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01JZwc7URWKVhkAuRTWiDmjA
JamesPatrickGill authored 8 days agoparent4a0d227Browse filesc25b985a67a4cc80526c3f97a2c089c37fd777b5

3 files changed+433 −2

plans/current.md+7 −2View file
@@ -17,8 +17,8 @@ and anything to do with browsing a tree (Milestone 5).
1717
1818 - [x] Domain: `PersonalAccessToken`, `TokenId`, `TokenHash`, and the repository port
1919 - [x] Infrastructure: in-memory + SQLite implementations, migration
20- [ ] Application: `issue_token`, `list_tokens`, `revoke_token`
21- [ ] Application: `authenticate_token` — resolves a Basic credential into an `Actor`
20+- [x] Application: `issue_token`, `list_tokens`, `revoke_token`
21+- [x] Application: `authenticate_token` — resolves a Basic credential into an `Actor`
2222 - [ ] Web: HTTP Basic on the git routes, and the 401 challenge that makes a client
2323 send credentials at all
2424 - [ ] Application: let `serve_git` authorize writes rather than refusing them
@@ -44,6 +44,11 @@ repositories that do not exist, so nothing distinguishes "private" from "absent"
4444 that stops working only as long as every read remembers to check the flag.
4545 - **Tokens authenticate; they do not authorize.** A token widens who the actor is;
4646 `serve_git` still decides what that actor may do.
47+- **Tokens do not expire.** A deliberate absence, not an oversight: a credential pasted
48+ into a machine and forgotten is worth less if it stops working silently, and revocation
49+ is the control that matters. Written down so it does not read as a missing feature.
50+- **Revoking someone else's token is `NotFound`, not `Forbidden`** — that a token id
51+ exists but belongs to another user is not a fact a caller should be able to learn.
4752 - **`TokenRepository` looks up by hash**, because that is the lookup authentication
4853 actually performs — a client presents a token, never an id.
4954
src/application/mod.rs+4 −0View file
@@ -14,6 +14,7 @@ pub mod port;
1414 pub mod profile;
1515 pub mod repo;
1616 pub mod session;
17+pub mod token;
1718
1819 pub use claim::{OwnerSpec, claim_instance, is_claimed};
1920 pub use config::AppConfig;
@@ -24,3 +25,6 @@ pub use login::login;
2425 pub use profile::{PublicProfile, update_profile, view_profile};
2526 pub use repo::{NewRepo, RepoSummary, RepoView, create_repo, list_repos, view_repo};
2627 pub use session::{SESSION_LIFETIME, end_session, record_session, resolve_actor, sweep_expired};
28+pub use token::{
29+ IssuedToken, TokenSummary, authenticate_token, issue_token, list_tokens, revoke_token,
30+};
src/application/token.rs+422 −0View file
@@ -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+}