steid

@jamesgill /

feat: store personal access tokens

Both implementations of TokenRepository, and the migration.

Lookup is by hash because that is what authentication actually does — a client
presents the token, never its id — so that is the index the table carries. The
hash column is unique: two rows answering one credential would make which user a
token authenticates depend on row order, and there is a test pinning the
constraint rather than trusting it.

Revocation deletes the row. A flag would work only as long as every future read
remembered to check it.

The fake sorts newest-first exactly as the SQL does, with the id breaking ties
on a shared timestamp. A fake that lists in a different order lets a use case
pass in tests and surprise someone in production.

Placement note: in_memory.rs keeps `mod tests` in the middle of the file, so
appending lands inside a later impl block. CLAUDE.md warns about this for
sqlite.rs; it is true here too.

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

5 files changed+412 −10

migrations/20260828120000_create_tokens.sql+19 −0View file
@@ -0,0 +1,19 @@
1+-- Personal access tokens: the credential a git client presents over HTTP Basic.
2+--
3+-- Only the SHA-256 of a token is stored, so a dumped database contains nothing anyone
4+-- could present. `prefix` is the first eight characters kept in the clear, which is what
5+-- lets a management UI name a token it can no longer show.
6+--
7+-- Indexed by hash because that is the lookup authentication performs: a client presents
8+-- the token itself, never its id. Revocation deletes the row rather than flagging it, so
9+-- there is no flag a future read can forget to check.
10+create table tokens (
11+ id text primary key,
12+ user_id text not null references users (id) on delete cascade,
13+ name text not null,
14+ prefix text not null,
15+ token_hash text not null unique,
16+ created_at integer not null
17+);
18+
19+create index tokens_user_id on tokens (user_id);
plans/current.md+1 −1View file
@@ -16,7 +16,7 @@ and anything to do with browsing a tree (Milestone 5).
1616 ### Steps
1717
1818 - [x] Domain: `PersonalAccessToken`, `TokenId`, `TokenHash`, and the repository port
19- [ ] Infrastructure: in-memory + SQLite implementations, migration
19+- [x] Infrastructure: in-memory + SQLite implementations, migration
2020 - [ ] Application: `issue_token`, `list_tokens`, `revoke_token`
2121 - [ ] 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
src/infrastructure/repository/in_memory.rs+148 −3View file
@@ -10,11 +10,11 @@ use std::{
1010 };
1111
1212 use crate::domain::{
13 Email, Membership, OrgId, OrgName, Organization, RepoId, RepoName, Repository, Session,
14 SessionTokenHash, User, UserId,
13+ Email, Membership, OrgId, OrgName, Organization, PersonalAccessToken, RepoId, RepoName,
14+ Repository, Session, SessionTokenHash, TokenHash, TokenId, User, UserId,
1515 repository::{
1616 MembershipRepository, OrgRepository, RepoRepository, RepositoryResult, SessionRepository,
17 UserRepository,
17+ TokenRepository, UserRepository,
1818 },
1919 };
2020
@@ -119,6 +119,64 @@ impl MembershipRepository for InMemoryMembershipRepo {
119119 }
120120 }
121121
122+/// Personal access tokens held in memory.
123+#[derive(Debug, Default, Clone)]
124+pub struct InMemoryTokenRepo {
125+ tokens: Arc<Mutex<HashMap<String, PersonalAccessToken>>>,
126+}
127+
128+impl InMemoryTokenRepo {
129+ pub fn new() -> Self {
130+ Self::default()
131+ }
132+}
133+
134+impl TokenRepository for InMemoryTokenRepo {
135+ async fn find_by_hash(
136+ &self,
137+ hash: &TokenHash,
138+ ) -> RepositoryResult<Option<PersonalAccessToken>> {
139+ let tokens = self.tokens.lock().expect("lock poisoned");
140+
141+ Ok(tokens
142+ .values()
143+ .find(|token| token.token_hash.matches(hash))
144+ .cloned())
145+ }
146+
147+ async fn list_by_user(&self, user_id: &UserId) -> RepositoryResult<Vec<PersonalAccessToken>> {
148+ let tokens = self.tokens.lock().expect("lock poisoned");
149+
150+ let mut found: Vec<PersonalAccessToken> = tokens
151+ .values()
152+ .filter(|token| &token.user_id == user_id)
153+ .cloned()
154+ .collect();
155+
156+ // Newest first, with the id breaking ties: two tokens issued in the same second
157+ // would otherwise come back in whatever order the map happened to hold them.
158+ found.sort_by(|a, b| {
159+ b.created_at
160+ .cmp(&a.created_at)
161+ .then_with(|| a.id.as_str().cmp(b.id.as_str()))
162+ });
163+
164+ Ok(found)
165+ }
166+
167+ async fn save(&self, token: &PersonalAccessToken) -> RepositoryResult<()> {
168+ let mut tokens = self.tokens.lock().expect("lock poisoned");
169+ tokens.insert(token.id.as_str().to_owned(), token.clone());
170+ Ok(())
171+ }
172+
173+ async fn delete(&self, id: &TokenId) -> RepositoryResult<()> {
174+ let mut tokens = self.tokens.lock().expect("lock poisoned");
175+ tokens.remove(id.as_str());
176+ Ok(())
177+ }
178+}
179+
122180 #[cfg(test)]
123181 mod tests {
124182 use super::*;
@@ -297,6 +355,93 @@ mod tests {
297355 assert_eq!(found.len(), 2);
298356 assert!(found.iter().all(|m| m.user_id == user_id));
299357 }
358+ // --- InMemoryTokenRepo -------------------------------------------------------
359+
360+ use crate::domain::TokenSecret;
361+
362+ fn token_for(user: &UserId, name: &str, at: u64) -> (PersonalAccessToken, TokenSecret) {
363+ use std::time::Duration;
364+
365+ let secret = TokenSecret::generate();
366+ let token = PersonalAccessToken::new(
367+ TokenId::generate(),
368+ user.clone(),
369+ name,
370+ &secret,
371+ SystemTime::UNIX_EPOCH + Duration::from_secs(at),
372+ )
373+ .expect("valid token");
374+
375+ (token, secret)
376+ }
377+
378+ #[tokio::test]
379+ async fn the_fake_finds_a_token_by_its_hash() {
380+ let tokens = InMemoryTokenRepo::new();
381+ let user = UserId::generate();
382+ let (token, secret) = token_for(&user, "laptop", 1_000);
383+ tokens.save(&token).await.expect("save");
384+
385+ assert_eq!(
386+ tokens.find_by_hash(&secret.hash()).await.expect("lookup"),
387+ Some(token)
388+ );
389+ assert_eq!(
390+ tokens
391+ .find_by_hash(&TokenSecret::generate().hash())
392+ .await
393+ .expect("lookup"),
394+ None
395+ );
396+ }
397+
398+ #[tokio::test]
399+ async fn the_fake_lists_newest_first_for_one_user_only() {
400+ // Mirrors the SQLite ordering. A fake that lists in a different order lets a use
401+ // case pass here and surprise someone in production.
402+ let tokens = InMemoryTokenRepo::new();
403+ let user = UserId::generate();
404+ let other = UserId::generate();
405+
406+ for (owner, name, at) in [
407+ (&user, "old", 1_000),
408+ (&user, "new", 3_000),
409+ (&user, "middle", 2_000),
410+ (&other, "theirs", 4_000),
411+ ] {
412+ let (token, _) = token_for(owner, name, at);
413+ tokens.save(&token).await.expect("save");
414+ }
415+
416+ assert_eq!(
417+ tokens
418+ .list_by_user(&user)
419+ .await
420+ .expect("list")
421+ .iter()
422+ .map(|token| token.name.as_str())
423+ .collect::<Vec<_>>(),
424+ vec!["new", "middle", "old"]
425+ );
426+ }
427+
428+ #[tokio::test]
429+ async fn the_fake_forgets_a_deleted_token() {
430+ let tokens = InMemoryTokenRepo::new();
431+ let user = UserId::generate();
432+ let (token, secret) = token_for(&user, "laptop", 1_000);
433+ tokens.save(&token).await.expect("save");
434+
435+ tokens.delete(&token.id).await.expect("delete");
436+
437+ assert!(
438+ tokens
439+ .find_by_hash(&secret.hash())
440+ .await
441+ .expect("lookup")
442+ .is_none()
443+ );
444+ }
300445 }
301446
302447 #[derive(Debug, Default, Clone)]
src/infrastructure/repository/mod.rs+3 −2View file
@@ -3,8 +3,9 @@ pub mod sqlite;
33
44 pub use in_memory::{
55 InMemoryMembershipRepo, InMemoryOrgRepo, InMemoryRepoRepo, InMemorySessionRepo,
6 InMemoryUserRepo,
6+ InMemoryTokenRepo, InMemoryUserRepo,
77 };
88 pub use sqlite::{
9 SqliteMembershipRepo, SqliteOrgRepo, SqliteRepoRepo, SqliteSessionRepo, SqliteUserRepo,
9+ SqliteMembershipRepo, SqliteOrgRepo, SqliteRepoRepo, SqliteSessionRepo, SqliteTokenRepo,
10+ SqliteUserRepo,
1011 };
src/infrastructure/repository/sqlite.rs+241 −4View file
@@ -8,11 +8,12 @@ use std::time::{Duration, SystemTime};
88 use sqlx::{Row, SqlitePool, sqlite::SqliteRow};
99
1010 use crate::domain::{
11 Email, Membership, MembershipId, OrgId, OrgName, Organization, PasswordHash, RepoId, RepoName,
12 Repository, Role, Session, SessionTokenHash, User, UserId, Visibility,
11+ Email, Membership, MembershipId, OrgId, OrgName, Organization, PasswordHash,
12+ PersonalAccessToken, RepoId, RepoName, Repository, Role, Session, SessionTokenHash, TokenHash,
13+ TokenId, User, UserId, Visibility,
1314 repository::{
1415 MembershipRepository, OrgRepository, RepoRepository, RepositoryError, RepositoryResult,
15 SessionRepository, UserRepository,
16+ SessionRepository, TokenRepository, UserRepository,
1617 },
1718 };
1819
@@ -389,12 +390,95 @@ impl RepoRepository for SqliteRepoRepo {
389390 }
390391 }
391392
393+#[derive(Debug, Clone)]
394+pub struct SqliteTokenRepo {
395+ pool: SqlitePool,
396+}
397+
398+impl SqliteTokenRepo {
399+ pub fn new(pool: SqlitePool) -> Self {
400+ Self { pool }
401+ }
402+
403+ /// `from_trusted`, because these values were validated on the way in. Revalidating
404+ /// stored rows means a tightened rule turns old rows unreadable.
405+ fn hydrate(row: &SqliteRow) -> PersonalAccessToken {
406+ PersonalAccessToken::from_trusted(
407+ TokenId::from_trusted(row.get::<String, _>("id")),
408+ UserId::from_trusted(row.get::<String, _>("user_id")),
409+ row.get::<String, _>("name"),
410+ row.get::<String, _>("prefix"),
411+ TokenHash::from_trusted(row.get::<String, _>("token_hash")),
412+ from_unix(row.get::<i64, _>("created_at")),
413+ )
414+ }
415+}
416+
417+impl TokenRepository for SqliteTokenRepo {
418+ async fn find_by_hash(
419+ &self,
420+ hash: &TokenHash,
421+ ) -> RepositoryResult<Option<PersonalAccessToken>> {
422+ // Matched in SQL by the hash, which is safe to compare with `=`: it is a digest,
423+ // not the secret. The constant-time comparison guards the value a client
424+ // presents, and that never reaches the database.
425+ let row = sqlx::query("select * from tokens where token_hash = ?")
426+ .bind(hash.as_str())
427+ .fetch_optional(&self.pool)
428+ .await
429+ .map_err(backend)?;
430+
431+ Ok(row.as_ref().map(Self::hydrate))
432+ }
433+
434+ async fn list_by_user(&self, user_id: &UserId) -> RepositoryResult<Vec<PersonalAccessToken>> {
435+ let rows =
436+ sqlx::query("select * from tokens where user_id = ? order by created_at desc, id asc")
437+ .bind(user_id.as_str())
438+ .fetch_all(&self.pool)
439+ .await
440+ .map_err(backend)?;
441+
442+ Ok(rows.iter().map(Self::hydrate).collect())
443+ }
444+
445+ async fn save(&self, token: &PersonalAccessToken) -> RepositoryResult<()> {
446+ sqlx::query(
447+ "insert into tokens (id, user_id, name, prefix, token_hash, created_at)
448+ values (?, ?, ?, ?, ?, ?)
449+ on conflict (id) do update set
450+ name = excluded.name",
451+ )
452+ .bind(token.id.as_str())
453+ .bind(token.user_id.as_str())
454+ .bind(&token.name)
455+ .bind(&token.prefix)
456+ .bind(token.token_hash.as_str())
457+ .bind(to_unix(token.created_at))
458+ .execute(&self.pool)
459+ .await
460+ .map_err(backend)?;
461+
462+ Ok(())
463+ }
464+
465+ async fn delete(&self, id: &TokenId) -> RepositoryResult<()> {
466+ sqlx::query("delete from tokens where id = ?")
467+ .bind(id.as_str())
468+ .execute(&self.pool)
469+ .await
470+ .map_err(backend)?;
471+
472+ Ok(())
473+ }
474+}
475+
392476 #[cfg(test)]
393477 mod tests {
394478 use super::*;
395479 use crate::{
396480 application::{OwnerSpec, claim_instance, port::PasswordHasher},
397 domain::SetupToken,
481+ domain::{SetupToken, TokenSecret},
398482 infrastructure::{database::test_support::test_pool, password::StubHasher},
399483 };
400484
@@ -830,4 +914,157 @@ mod tests {
830914
831915 assert_eq!(found, None);
832916 }
917+
918+ // --- SqliteTokenRepo ---------------------------------------------------------
919+
920+ /// A pool plus a user to hang tokens off, since the foreign key runs that way.
921+ async fn token_fixture() -> (SqliteTokenRepo, User, User) {
922+ let pool = test_pool().await;
923+ let repos = Repos {
924+ users: SqliteUserRepo::new(pool.clone()),
925+ orgs: SqliteOrgRepo::new(pool.clone()),
926+ memberships: SqliteMembershipRepo::new(pool.clone()),
927+ };
928+
929+ let org = saved_org(&repos, "acme").await;
930+ let owner = saved_user(&repos, "owner@example.com", &org).await;
931+ let other = saved_user(&repos, "other@example.com", &org).await;
932+
933+ (SqliteTokenRepo::new(pool), owner, other)
934+ }
935+
936+ fn issued(user: &User, name: &str, at: u64) -> (PersonalAccessToken, TokenSecret) {
937+ let secret = TokenSecret::generate();
938+ let token = PersonalAccessToken::new(
939+ TokenId::generate(),
940+ user.id.clone(),
941+ name,
942+ &secret,
943+ SystemTime::UNIX_EPOCH + Duration::from_secs(at),
944+ )
945+ .expect("valid token");
946+
947+ (token, secret)
948+ }
949+
950+ #[tokio::test]
951+ async fn a_token_round_trips_through_its_hash() {
952+ let (tokens, owner, _) = token_fixture().await;
953+ let (token, secret) = issued(&owner, "laptop", 1_000);
954+ tokens.save(&token).await.expect("save");
955+
956+ let found = tokens
957+ .find_by_hash(&secret.hash())
958+ .await
959+ .expect("lookup")
960+ .expect("should be found");
961+
962+ assert_eq!(found, token);
963+ assert_eq!(found.name, "laptop");
964+ assert_eq!(found.prefix, secret.display_prefix());
965+ }
966+
967+ #[tokio::test]
968+ async fn an_unknown_hash_finds_nothing() {
969+ let (tokens, owner, _) = token_fixture().await;
970+ let (token, _) = issued(&owner, "laptop", 1_000);
971+ tokens.save(&token).await.expect("save");
972+
973+ let found = tokens
974+ .find_by_hash(&TokenSecret::generate().hash())
975+ .await
976+ .expect("lookup");
977+
978+ assert!(found.is_none());
979+ }
980+
981+ #[tokio::test]
982+ async fn tokens_are_listed_newest_first_and_only_the_users_own() {
983+ let (tokens, owner, other) = token_fixture().await;
984+
985+ for (user, name, at) in [
986+ (&owner, "old", 1_000),
987+ (&owner, "new", 3_000),
988+ (&owner, "middle", 2_000),
989+ (&other, "theirs", 4_000),
990+ ] {
991+ let (token, _) = issued(user, name, at);
992+ tokens.save(&token).await.expect("save");
993+ }
994+
995+ let listed = tokens.list_by_user(&owner.id).await.expect("list");
996+
997+ assert_eq!(
998+ listed
999+ .iter()
1000+ .map(|token| token.name.as_str())
1001+ .collect::<Vec<_>>(),
1002+ vec!["new", "middle", "old"]
1003+ );
1004+ }
1005+
1006+ #[tokio::test]
1007+ async fn deleting_a_token_stops_it_authenticating() {
1008+ // Revocation is a delete, so the credential is gone rather than flagged.
1009+ let (tokens, owner, _) = token_fixture().await;
1010+ let (token, secret) = issued(&owner, "laptop", 1_000);
1011+ tokens.save(&token).await.expect("save");
1012+
1013+ tokens.delete(&token.id).await.expect("delete");
1014+
1015+ assert!(
1016+ tokens
1017+ .find_by_hash(&secret.hash())
1018+ .await
1019+ .expect("lookup")
1020+ .is_none()
1021+ );
1022+ }
1023+
1024+ #[tokio::test]
1025+ async fn deleting_a_token_that_is_not_there_succeeds() {
1026+ let (tokens, _, _) = token_fixture().await;
1027+
1028+ tokens
1029+ .delete(&TokenId::generate())
1030+ .await
1031+ .expect("deleting nothing should not fail");
1032+ }
1033+
1034+ #[tokio::test]
1035+ async fn a_second_token_with_the_same_hash_is_refused() {
1036+ // The unique constraint. Two rows answering one credential would make which
1037+ // user a token authenticates depend on row order.
1038+ let (tokens, owner, other) = token_fixture().await;
1039+ let (first, secret) = issued(&owner, "laptop", 1_000);
1040+ tokens.save(&first).await.expect("save");
1041+
1042+ let clash = PersonalAccessToken::new(
1043+ TokenId::generate(),
1044+ other.id.clone(),
1045+ "clash",
1046+ &secret,
1047+ SystemTime::UNIX_EPOCH,
1048+ )
1049+ .expect("valid token");
1050+
1051+ assert!(tokens.save(&clash).await.is_err());
1052+ }
1053+
1054+ #[tokio::test]
1055+ async fn a_token_belonging_to_no_user_is_refused() {
1056+ // The foreign key. Without `foreign_keys(true)` per connection SQLite ignores it.
1057+ let (tokens, _, _) = token_fixture().await;
1058+ let secret = TokenSecret::generate();
1059+ let orphan = PersonalAccessToken::new(
1060+ TokenId::generate(),
1061+ UserId::generate(),
1062+ "orphan",
1063+ &secret,
1064+ SystemTime::UNIX_EPOCH,
1065+ )
1066+ .expect("valid token");
1067+
1068+ assert!(tokens.save(&orphan).await.is_err());
1069+ }
8331070 }