steid

@jamesgill /

feat: push and clone private repositories with a token

Milestone 4b, and with it Milestone 4. `git push` works over HTTP for the owner,
and a private repository is clonable by someone holding a token.

The bug worth recording is that authorizing a push is not enough. http-backend
refuses receive-pack by default, so a correctly authorized push still came back
403 — git's refusal wearing the same status code as ours. It needs
`-c http.receivepack=true` before the subcommand, and that is set from a
GitRequest field the use case turns on only after its authorization check
passes. Git therefore stays a second refusal behind Steid's rather than being
switched on wholesale: if the rules are ever wrong, git still says no.

The uniform 401 from 0007 is what makes any of this reachable. A git client
offers a credential only after a 401, so 4a's 404-for-private made an
authenticated private clone impossible; extending the 401 to repositories that
do not exist is what stops it leaking which private names are real.

Issuing a token deliberately does not redirect, breaking the convention every
other form here follows. The secret exists only in that response, and surviving
a redirect means putting a live credential in a URL — history, logs, referrers.

Verified by issuing a token through the UI and using it: 201 refs pushed to a
public and a private repo, 201 commits cloned back from the private one,
anonymous clone of the public repo still unprompted, 401 for private and
nonexistent repos alike, a wrong token challenged rather than accepted, and
after revoking, clone and push both 401 while the public repo stays open.

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

13 files changed+548 −98

Cargo.lock+1 −0View file
@@ -1889,6 +1889,7 @@ name = "steid"
18891889 version = "0.1.0"
18901890 dependencies = [
18911891 "argon2",
1892+ "base64 0.22.1",
18921893 "bytes",
18931894 "dotenvy",
18941895 "envy",
Cargo.toml+1 −0View file
@@ -5,6 +5,7 @@ edition = "2024"
55
66 [dependencies]
77 argon2 = "0.5.3"
8+base64 = "0.22"
89 bytes = "1"
910 dotenvy = "0.15.7"
1011 envy = "0.4.2"
plans/ROADMAP.md+2 −2View file
@@ -56,8 +56,8 @@ a baseline.
5656 | 2 | **Profile page** — `/{handle}` as the real profile | done |
5757 | 3 | **Repo model** — records + bare repos on disk | done |
5858 | 4a | **Clone over HTTP** — `git http-backend`, public repos, no auth | done |
59| 4b | **Push and tokens** — PATs over HTTP Basic, push, private clone | active |
60| 5 | **Repo browsing** — tree, blob, commit log | not started |
59+| 4b | **Push and tokens** — PATs over HTTP Basic, push, private clone | done |
60+| 5 | **Repo browsing** — tree, blob, commit log | active |
6161 | 6 | **Writing** — posts, markdown | not started |
6262 | 7 | **Identity, full** — multi-user, orgs, invites, registration policy | not started |
6363 | 8+ | Projects/showcases · issues & PRs · SSH transport · federation | not started |
plans/current.md+30 −54View file
@@ -4,63 +4,44 @@
44 > [progress.md](progress.md). If this file starts reading like a changelog, it has
55 > drifted — that's exactly what went wrong last time.
66
7## Active: Milestone 4b — Push and tokens
8
9**Goal:** `git push` works over HTTP for someone who may write, and a private repository
10is clonable by someone who may read it. Authentication is personal access tokens over
11HTTP Basic, per [0001](decisions/0001-git-over-http-not-ssh.md).
12
13**Out of scope:** SSH, token scopes beyond whatever the Open questions settle, OAuth,
14and anything to do with browsing a tree (Milestone 5).
15
16### Steps
17
18- [x] Domain: `PersonalAccessToken`, `TokenId`, `TokenHash`, and the repository port
19- [x] Infrastructure: in-memory + SQLite implementations, migration
20- [x] Application: `issue_token`, `list_tokens`, `revoke_token`
21- [x] Application: `authenticate_token` — resolves a Basic credential into an `Actor`
22- [ ] Web: HTTP Basic on the git routes, and the 401 challenge that makes a client
23 send credentials at all
24- [ ] Application: let `serve_git` authorize writes rather than refusing them
25- [ ] Web: token management UI under `/{handle}/settings`
26- [ ] Verify: push to a public repo, clone a private one, and check a revoked token
27 stops working
28
29### Done when
30
31A token issued through the UI lets `git push` succeed against a repository its owner may
32write, and lets `git clone` succeed against a private repository its owner may read.
33Revoking the token stops both. An anonymous clone of a public repository still works
34exactly as it does today.
35
36### Settled
37
38All three of this milestone's open decisions are answered in
39[0007](decisions/0007-tokens-over-http-basic.md): SHA-256 with a stored display prefix,
40no scopes, and a **uniform 401** on any git path not anonymously readable — including
41repositories that do not exist, so nothing distinguishes "private" from "absent".
42
43- **Revocation is a delete, not a flag.** A revoked row that lingers is a credential
44 that stops working only as long as every read remembers to check the flag.
45- **Tokens authenticate; they do not authorize.** A token widens who the actor is;
46 `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.
52- **`TokenRepository` looks up by hash**, because that is the lookup authentication
53 actually performs — a client presents a token, never an id.
7+## Active: Milestone 5 — Repo browsing
8+
9+**Goal:** a repository's contents are readable on the web — tree, blob, commit log — so
10+a visitor can look at code without cloning it. The first milestone where the profile
11+starts to look like a portfolio rather than a list of names.
12+
13+**Not planned yet.** Steps get laid out at the start of the milestone rather than
14+guessed at the end of the previous one. Two things are already known and should shape
15+that plan:
16+
17+- **Start with domain value objects** — `ObjectId`, `RefName`, `TreeEntry` — before any
18+ adapter. A query port returning `String`s is an anaemic pass-through that pushes
19+ validation into the page, which is what
20+ [0006](decisions/0006-git-binary-behind-narrow-ports.md) rejected in advance.
21+- **Measure fork/exec per page view first.** Milestone 3 paid it once per repository
22+ creation and Milestone 4 once per git request; browsing would pay it several times per
23+ page. This is the point where `gix` for the read path gets reconsidered, and 0006 says
24+ to make that call with a measurement rather than an intuition.
5425
5526 ### Open
5627
57Nothing open.
28+- **Whether writing (Milestone 6) should come first.** The ladder puts browsing next, but
29+ the vision is portfolio-first and writing is the more distinctive feature. Browsing is
30+ the more expected one. Worth a moment's thought before starting rather than after.
5831
5932 ### Carried over — small, unblocked
6033
6134 - **A client that disappears mid-request leaves the body-copy task waiting.** The copy
6235 into git's stdin runs in its own task and nothing cancels it if the connection drops.
6336 Bounded by the backend exiting and closing the pipe, but not by anything deliberate.
37+- **`REMOTE_USER` is not set on the backend**, so a push is recorded in the repository's
38+ reflog without naming who made it. Steid knows the actor by then; it simply is not
39+ passed through. Small, and worth doing before anything reads reflogs.
40+- **No rate limiting on token authentication.** A token is 256 bits so guessing is not
41+ the worry; unbounded hashing on an open endpoint is.
42+- **Tokens have no expiry and no last-used timestamp.** Both deliberate omissions for
43+ now — see [0007](decisions/0007-tokens-over-http-basic.md) — but a token list with no
44+ "last used" makes it hard to know which are safe to revoke.
6445 - **A subprocess per git request.** Unlike Milestone 3's once-per-creation, this is on a
6546 hot path and has not been measured. Milestone 5 is where that bill comes due.
6647 - **Streaming is by construction, not by measurement.** The response body is never
@@ -87,12 +68,7 @@ Nothing open.
8768
8869 Ordered. Pull from the top.
8970
901. **Milestone 5 — Repo browsing.** Tree, blob, commit log. **Start with domain value
91 objects** — `ObjectId`, `RefName`, `TreeEntry` — before any adapter. A query port
92 returning `String`s is an anaemic pass-through that pushes validation into the page.
93 Also the point to measure fork/exec cost per page view, and to reconsider `gix` for
94 the read path ([0006](decisions/0006-git-binary-behind-narrow-ports.md)).
952. **Milestone 6 — Writing.** Posts, markdown, `/{handle}/posts/{slug}`. Still open
71+1. **Milestone 6 — Writing.** Posts, markdown, `/{handle}/posts/{slug}`. Still open
9672 whether writing or projects/showcases is the better first portfolio feature.
9773
9874 ## Open questions
plans/progress.md+49 −1View file
@@ -2,7 +2,7 @@
22
33 ## This attempt (#3, Topcoat)
44
5190 tests. Active milestone in [current.md](current.md).
5+249 tests. Active milestone in [current.md](current.md).
66
77 ### Milestone 0 — Skeleton · done
88
@@ -284,6 +284,54 @@ throwaway server and cloning through it. Everything below is measured, not read.
284284 `http_body::Body` and Topcoat re-bodies it. That is what lets the pack stream without
285285 a framework-specific body type.
286286
287+### Milestone 4b — Push and tokens · done
288+
289+`git push` works over HTTP for the owner, and a private repository is clonable by
290+someone holding a token for it. `PersonalAccessToken` with both storage adapters,
291+`issue_token` / `list_tokens` / `revoke_token` / `authenticate_token`, HTTP Basic on the
292+git routes, and token management at `/{handle}/settings/tokens`. Decisions recorded in
293+[0007](decisions/0007-tokens-over-http-basic.md).
294+
295+**Verified end to end**, issuing the token through the UI rather than seeding one:
296+push of 201 refs to a public repo and to a private one; clone of the private repo
297+returning 201 commits; anonymous clone of the public repo still open with no prompt;
298+401 with `WWW-Authenticate: Basic` for a private repo, a **nonexistent** repo, an
299+unknown handle and a push advertisement alike; a wrong token challenged rather than
300+accepted; and after revoking, both clone and push answer 401 while the public repo stays
301+open. The token is shown once and never again on reload, the revoke button disappears
302+from the list, and the page is 403 for anyone else.
303+
304+#### Decisions worth remembering
305+
306+- **`http-backend` refuses `receive-pack` by default, and Steid authorizing the push is
307+ not enough.** The symptom is a 403 that looks like Steid's own refusal but is git's:
308+ `Service not enabled: 'receive-pack'`. It needs `-c http.receivepack=true` **before**
309+ the subcommand. That flag is set from a `GitRequest` field the use case turns on only
310+ after the authorization check passes, so git remains a second refusal behind Steid's
311+ rather than being switched on wholesale — if the rules are ever wrong, git still says
312+ no.
313+- **The uniform 401 is what makes authenticated cloning possible at all.** A git client
314+ offers a credential only after a 401, so the 4a behaviour of answering 404 for a
315+ private repository made an authenticated private clone unreachable. Extending the 401
316+ to repositories that do not exist is what keeps it from leaking which private names
317+ are real.
318+- **A bad credential falls through to anonymous rather than failing.** The caller then
319+ gets the same challenge as someone who presented nothing and can try again, which is
320+ also how a stale session cookie behaves.
321+- **Basic accepts the token in the password field, or in the username with no password.**
322+ Git puts it in the password; people paste it into the username. The alternative is an
323+ authentication failure with nothing to explain it.
324+- **Issuing a token deliberately does not redirect**, unlike every other form here. The
325+ secret exists only in that response, and surviving a redirect would mean putting a live
326+ credential in a URL — browser history, logs, referrers. Reloading issues a second
327+ token, which is harmless and visible in the list.
328+- **Revoking someone else's token is `NotFound`, not `Forbidden`.** That a token id
329+ exists but belongs to another user is not a fact worth confirming.
330+- **Tokens do not expire.** A credential pasted into a machine and forgotten is worth
331+ less if it stops working silently; revocation is the control that matters.
332+- **`in_memory.rs` keeps `mod tests` in the middle of the file**, like `sqlite.rs`.
333+ Appending an implementation to the end lands it inside a later impl block.
334+
287335 ---
288336
289337 ## Reference: what attempt #2 proved
src/application/git.rs+98 −20View file
@@ -10,7 +10,7 @@ use crate::domain::{
1010 };
1111
1212 use super::{
13 authz::is_org_member,
13+ authz::{is_org_member, is_org_owner},
1414 error::Result,
1515 port::{ByteStream, GitMethod, GitProtocolServer, GitRequest, GitResponse},
1616 };
@@ -124,10 +124,14 @@ pub struct GitClientHeaders {
124124 /// as [`view_repo`](super::repo::view_repo) does, and the caller must render them
125125 /// identically — a private repository has to be absent, not merely unclonable.
126126 ///
127/// Writes are refused outright: push arrives with personal access tokens in Milestone
128/// 4b. The refusal is a real authorization decision made here, not a reliance on `git
129/// http-backend` disabling `receive-pack` by default — a default that helpfully changes
130/// is not a permission check.
127+/// Reading a public repository is open to anyone. Reading a private one needs any
128+/// membership of the owning organisation — seeing is weaker than changing. **Writing
129+/// needs `Role::Owner`**, matching every other mutation in Steid: a member's read access
130+/// is not permission to rewrite someone's history.
131+///
132+/// The refusal is made here rather than left to `git http-backend` disabling
133+/// `receive-pack` by default. That default is configuration, and a default that
134+/// helpfully changes is not a permission check.
131135 #[allow(clippy::too_many_arguments)]
132136 pub async fn serve_git(
133137 handle: &OrgName,
@@ -156,7 +160,9 @@ pub async fn serve_git(
156160 return Ok(None);
157161 }
158162
159 if endpoint.service().operation() == GitOperation::Write {
163+ let writing = endpoint.service().operation() == GitOperation::Write;
164+
165+ if writing && !is_org_owner(&org, actor, memberships).await? {
160166 return Err(DomainError::Forbidden.into());
161167 }
162168
@@ -183,6 +189,9 @@ pub async fn serve_git(
183189 content_encoding: headers.content_encoding,
184190 content_length: headers.content_length,
185191 git_protocol: headers.git_protocol,
192+ // Past the gate above, so this is only ever true for a write that was
193+ // authorized. Left false for a read, which never needs it.
194+ allow_receive_pack: writing,
186195 body,
187196 })
188197 .await?;
@@ -459,29 +468,98 @@ mod tests {
459468 // --- writes ------------------------------------------------------------------
460469
461470 #[tokio::test]
462 async fn pushing_is_refused_for_everyone_including_the_owner() {
463 // Milestone 4a has no way to authenticate a push, so nobody may make one. This
464 // is an explicit refusal rather than a reliance on git's own default, which is
465 // configuration and could change under us.
471+ async fn receive_pack_is_enabled_only_for_an_authorized_write() {
472+ // The flag git keys off. A read must never turn it on, and a refused write must
473+ // never reach the backend at all — so the only true is a push that passed.
474+ let f = fixture().await;
475+
476+ f.serve(&Actor::Anonymous, "steid", advertise_clone())
477+ .await
478+ .expect("should serve")
479+ .expect("visible");
480+ assert!(!f.protocol.requests()[0].allow_receive_pack);
481+
482+ f.serve(&f.owner, "steid", advertise_push())
483+ .await
484+ .expect("should serve")
485+ .expect("allowed");
486+ assert!(f.protocol.requests()[1].allow_receive_pack);
487+ }
488+
489+ #[tokio::test]
490+ async fn the_owner_may_push() {
466491 let f = fixture().await;
467492
468493 for endpoint in [advertise_push(), GitEndpoint::Rpc(GitService::ReceivePack)] {
469 for actor in [&Actor::Anonymous, &f.stranger, &f.member, &f.owner] {
470 let error = f
471 .serve(actor, "steid", endpoint)
472 .await
473 .expect_err("push should be refused");
494+ f.serve(&f.owner, "steid", endpoint)
495+ .await
496+ .expect("should serve")
497+ .expect("should be allowed");
498+ }
499+
500+ assert_eq!(f.protocol.requests().len(), 2);
501+ }
502+
503+ #[tokio::test]
504+ async fn the_owner_may_push_to_a_private_repository() {
505+ let f = fixture().await;
506+
507+ f.serve(&f.owner, "secret", advertise_push())
508+ .await
509+ .expect("should serve")
510+ .expect("should be allowed");
511+ }
512+
513+ #[tokio::test]
514+ async fn a_member_who_is_not_the_owner_may_not_push() {
515+ // Read access is not permission to rewrite history. The same rule as creating a
516+ // repository, and the same rule attempt #2 shipped over SSH.
517+ let f = fixture().await;
474518
475 assert!(
476 matches!(error, super::super::Error::Domain(DomainError::Forbidden)),
477 "{actor:?} on {endpoint:?} should be forbidden, got {error:?}"
478 );
479 }
519+ for endpoint in [advertise_push(), GitEndpoint::Rpc(GitService::ReceivePack)] {
520+ let error = f
521+ .serve(&f.member, "steid", endpoint)
522+ .await
523+ .expect_err("push should be refused");
524+
525+ assert!(
526+ matches!(error, super::super::Error::Domain(DomainError::Forbidden)),
527+ "a member on {endpoint:?} should be forbidden, got {error:?}"
528+ );
480529 }
481530
482531 assert!(!f.protocol.was_called());
483532 }
484533
534+ #[tokio::test]
535+ async fn strangers_and_anonymous_callers_may_not_push() {
536+ let f = fixture().await;
537+
538+ for actor in [&Actor::Anonymous, &f.stranger] {
539+ let error = f
540+ .serve(actor, "steid", advertise_push())
541+ .await
542+ .expect_err("push should be refused");
543+
544+ assert!(
545+ matches!(error, super::super::Error::Domain(DomainError::Forbidden)),
546+ "{actor:?} should be forbidden"
547+ );
548+ }
549+
550+ assert!(!f.protocol.was_called());
551+ }
552+
553+ #[tokio::test]
554+ async fn a_refused_push_never_reaches_git() {
555+ // The ordering that matters: once pack data is moving, refusing is not an option.
556+ let f = fixture().await;
557+
558+ let _ = f.serve(&f.member, "steid", advertise_push()).await;
559+
560+ assert!(!f.protocol.was_called());
561+ }
562+
485563 #[tokio::test]
486564 async fn a_push_to_an_invisible_repository_is_absent_rather_than_forbidden() {
487565 // Order matters: existence is settled before permission. Answering "forbidden"
src/application/port.rs+9 −0View file
@@ -174,6 +174,14 @@ pub struct GitRequest {
174174 /// The client's `Git-Protocol`, carrying `version=2` for any modern client.
175175 /// Dropping it silently downgrades the exchange to v0 rather than failing.
176176 pub git_protocol: Option<String>,
177+ /// Whether the backend may run `receive-pack` at all.
178+ ///
179+ /// `git http-backend` refuses pushes unless `http.receivepack` says otherwise, and
180+ /// this is what sets it. The use case turns it on **only after** authorizing the
181+ /// write, so git stays a second refusal behind Steid's own rather than being
182+ /// switched off wholesale. If the authorization logic is ever wrong, this is what
183+ /// still says no.
184+ pub allow_receive_pack: bool,
177185 pub body: ByteStream,
178186 }
179187
@@ -200,6 +208,7 @@ impl std::fmt::Debug for GitRequest {
200208 .field("content_encoding", &self.content_encoding)
201209 .field("content_length", &self.content_length)
202210 .field("git_protocol", &self.git_protocol)
211+ .field("allow_receive_pack", &self.allow_receive_pack)
203212 .finish_non_exhaustive()
204213 }
205214 }
src/infrastructure/git.rs+13 −0View file
@@ -252,6 +252,15 @@ impl GitHttpBackend {
252252 impl GitProtocolServer for GitHttpBackend {
253253 async fn serve(&self, request: GitRequest) -> Result<GitResponse, GitProtocolError> {
254254 let mut command = git_command();
255+
256+ // Before the subcommand: `git -c … http-backend`. Pushes are refused by the
257+ // backend unless this says otherwise, and it is set only for a request the use
258+ // case already authorized — so a bug in Steid's rules meets git's refusal rather
259+ // than an open door.
260+ if request.allow_receive_pack {
261+ command.arg("-c").arg("http.receivepack=true");
262+ }
263+
255264 command
256265 .arg("http-backend")
257266 .env("GIT_PROJECT_ROOT", &self.data_dir)
@@ -406,6 +415,7 @@ pub struct RecordedGitRequest {
406415 pub query: String,
407416 pub git_protocol: Option<String>,
408417 pub content_encoding: Option<String>,
418+ pub allow_receive_pack: bool,
409419 }
410420
411421 /// A git protocol that records what it was asked and never runs git.
@@ -444,6 +454,7 @@ impl GitProtocolServer for InMemoryGitProtocol {
444454 query: request.query,
445455 git_protocol: request.git_protocol,
446456 content_encoding: request.content_encoding,
457+ allow_receive_pack: request.allow_receive_pack,
447458 });
448459
449460 Ok(GitResponse {
@@ -765,6 +776,7 @@ mod tests {
765776 content_encoding: None,
766777 content_length: None,
767778 git_protocol: None,
779+ allow_receive_pack: false,
768780 body: Box::pin(tokio::io::empty()),
769781 }
770782 }
@@ -875,6 +887,7 @@ mod tests {
875887 query: "service=git-upload-pack".to_owned(),
876888 git_protocol: None,
877889 content_encoding: None,
890+ allow_receive_pack: false,
878891 }]
879892 );
880893 assert!(protocol.was_called());
src/infrastructure/web/context.rs+6 −1View file
@@ -20,7 +20,8 @@ use crate::{
2020 infrastructure::{
2121 git::{DiskGitStorage, GitHttpBackend},
2222 repository::{
23 SqliteMembershipRepo, SqliteOrgRepo, SqliteRepoRepo, SqliteSessionRepo, SqliteUserRepo,
23+ SqliteMembershipRepo, SqliteOrgRepo, SqliteRepoRepo, SqliteSessionRepo,
24+ SqliteTokenRepo, SqliteUserRepo,
2425 },
2526 },
2627 };
@@ -61,6 +62,10 @@ pub fn storage(cx: &Cx) -> DiskGitStorage {
6162 DiskGitStorage::new(app_context::<AppConfig>(cx).data_dir.clone())
6263 }
6364
65+pub fn tokens(cx: &Cx) -> SqliteTokenRepo {
66+ SqliteTokenRepo::new(pool(cx).clone())
67+}
68+
6469 /// The git smart-HTTP protocol, rooted at the same data directory as [`storage`].
6570 pub fn protocol(cx: &Cx) -> GitHttpBackend {
6671 GitHttpBackend::new(app_context::<AppConfig>(cx).data_dir.clone())
src/infrastructure/web/git.rs+92 −20View file
@@ -12,6 +12,7 @@ use std::{
1212 task::{Context, Poll},
1313 };
1414
15+use base64::{Engine, engine::general_purpose::STANDARD};
1516 use bytes::Bytes;
1617 use futures_util::TryStreamExt;
1718 use http_body::Frame;
@@ -22,19 +23,23 @@ use topcoat::{
2223 Result,
2324 context::Cx,
2425 router::{
25 Body, Response,
26 error::{RouterErrorExt, bad_request, forbidden, not_found},
27 parse_query_params, path_param, route,
26+ Body, Response, StatusCode,
27+ error::{bad_request, forbidden, not_found},
28+ header::{AUTHORIZATION, WWW_AUTHENTICATE},
29+ headers, parse_query_params, path_param, route,
2830 },
2931 };
3032
3133 use crate::{
32 application::{Error, GitClientHeaders, GitEndpoint, GitService, port::ByteStream, serve_git},
33 domain::{DomainError, RepoName},
34+ application::{
35+ Error, GitClientHeaders, GitEndpoint, GitService, authenticate_token, port::ByteStream,
36+ serve_git,
37+ },
38+ domain::{Actor, DomainError, RepoName},
3439 };
3540
3641 use super::{
37 context::{current_actor, memberships, orgs, protocol, repos, server_error},
42+ context::{current_actor, memberships, orgs, protocol, repos, server_error, tokens},
3843 profile::handle_param,
3944 };
4045
@@ -67,7 +72,7 @@ fn repo_param(cx: &Cx) -> Result<RepoName> {
6772
6873 /// The four request headers that change what git does.
6974 fn client_headers(cx: &Cx) -> GitClientHeaders {
70 let headers = topcoat::router::headers(cx);
75+ let headers = headers(cx);
7176 let value = |name: &str| {
7277 headers
7378 .get(name)
@@ -83,14 +88,70 @@ fn client_headers(cx: &Cx) -> GitClientHeaders {
8388 }
8489 }
8590
86/// Runs one protocol request and turns the result into an HTTP response.
91+/// Who is making this git request.
92+///
93+/// A personal access token over HTTP Basic first, then the session cookie. Both are
94+/// supported because both happen: git presents a token, and a signed-in person clicking
95+/// a `.git` URL in a browser presents a cookie.
96+///
97+/// A credential that does not authenticate falls through to anonymous rather than
98+/// failing, matching how a bad session cookie is treated. The caller then gets the same
99+/// 401 challenge as someone who presented nothing, and can try again.
100+async fn git_actor(cx: &Cx) -> Result<Actor> {
101+ if let Some(presented) = basic_credential(cx) {
102+ let actor = authenticate_token(&presented, &tokens(cx))
103+ .await
104+ .map_err(server_error)?;
105+
106+ if actor.user_id().is_some() {
107+ return Ok(actor);
108+ }
109+ }
110+
111+ current_actor(cx).await
112+}
113+
114+/// The secret from an `Authorization: Basic` header.
115+///
116+/// Git puts the token in the password field, so that is preferred; a token pasted into
117+/// the username field with no password is accepted too, because people do that and the
118+/// alternative is an authentication failure nothing explains.
119+fn basic_credential(cx: &Cx) -> Option<String> {
120+ let header = headers(cx).get(AUTHORIZATION)?.to_str().ok()?;
121+ let encoded = header.strip_prefix("Basic ")?;
122+ let decoded = STANDARD.decode(encoded).ok()?;
123+ let decoded = String::from_utf8(decoded).ok()?;
124+
125+ let (user, password) = decoded.split_once(':')?;
126+
127+ if password.is_empty() {
128+ Some(user.to_owned())
129+ } else {
130+ Some(password.to_owned())
131+ }
132+}
133+
134+/// Asks for credentials.
87135 ///
88/// A repository that does not exist and one the viewer may not see are the same 404,
89/// deliberately — see [`serve_git`].
136+/// Sent for anything an anonymous caller may not have — including repositories that do
137+/// not exist — so that nothing in the response distinguishes "private" from "absent".
138+/// A git client only offers a credential after seeing this, so answering 404 instead
139+/// would make an authenticated private clone impossible. See
140+/// [0007](../../plans/decisions/0007-tokens-over-http-basic.md).
141+fn challenge() -> Result<Response<GitBody>> {
142+ Response::builder()
143+ .status(StatusCode::UNAUTHORIZED)
144+ .header(WWW_AUTHENTICATE, r#"Basic realm="steid""#)
145+ .body(GitBody::new(Box::pin(tokio::io::empty())))
146+ .map_err(server_error)
147+}
148+
149+/// Runs one protocol request and turns the result into an HTTP response.
90150 async fn serve(cx: &Cx, endpoint: GitEndpoint, body: ByteStream) -> Result<Response<GitBody>> {
91151 let handle = handle_param(cx)?;
92152 let name = repo_param(cx)?;
93 let actor = current_actor(cx).await?;
153+ let actor = git_actor(cx).await?;
154+ let anonymous = actor.user_id().is_none();
94155
95156 let served = serve_git(
96157 &handle,
@@ -104,13 +165,24 @@ async fn serve(cx: &Cx, endpoint: GitEndpoint, body: ByteStream) -> Result<Respo
104165 &repos(cx),
105166 &protocol(cx),
106167 )
107 .await
108 .map_err(|error| match error {
109 // Push, until Milestone 4b. Everything else the visitor cannot act on.
110 Error::Domain(DomainError::Forbidden) => forbidden().into(),
111 other => server_error(other),
112 })?
113 .ok_or_not_found()?;
168+ .await;
169+
170+ let served = match served {
171+ Ok(Some(served)) => served,
172+
173+ // Absent, or invisible to this caller — the two are the same answer by design.
174+ // Anonymous callers are asked for credentials instead, so that a private
175+ // repository and a missing one are indistinguishable from outside.
176+ Ok(None) if anonymous => return challenge(),
177+ Ok(None) => return Err(not_found().into()),
178+
179+ // Refused a write. Someone signed in is told so; someone anonymous is asked to
180+ // identify themselves first, because they may well be allowed once they do.
181+ Err(Error::Domain(DomainError::Forbidden)) if anonymous => return challenge(),
182+ Err(Error::Domain(DomainError::Forbidden)) => return Err(forbidden().into()),
183+
184+ Err(other) => return Err(server_error(other)),
185+ };
114186
115187 let mut response = Response::builder().status(served.status);
116188 for (name, value) in served.headers {
@@ -127,8 +199,8 @@ async fn serve(cx: &Cx, endpoint: GitEndpoint, body: ByteStream) -> Result<Respo
127199
128200 /// The ref advertisement that opens every exchange.
129201 ///
130/// The service names the operation, so `service=git-receive-pack` is a write and is
131/// refused here, before a client has been told a single ref exists.
202+/// The service names the operation, so `service=git-receive-pack` is a write, and is
203+/// authorized as one before a client has been told a single ref exists.
132204 #[route(GET "/{handle}/repos/{repo}/info/refs")]
133205 async fn info_refs(cx: &Cx) -> Result<Response<GitBody>> {
134206 let query =
src/infrastructure/web/mod.rs+1 −0View file
@@ -10,3 +10,4 @@ pub mod repo;
1010 pub mod session_cookie;
1111 pub mod settings;
1212 pub mod setup;
13+pub mod token;
src/infrastructure/web/settings.rs+16 −0View file
@@ -187,5 +187,21 @@ async fn settings_form(
187187 >"Back to profile"</a>
188188 </div>
189189 </form>
190+
191+ <section class="mt-10 border-t border-border pt-6">
192+ <h2 class="text-xs font-medium uppercase tracking-wider text-muted-foreground">
193+ "Access tokens"
194+ </h2>
195+ <p class="mt-2 text-sm text-muted-foreground">
196+ "Tokens are how git authenticates when you push or clone a private "
197+ "repository over HTTPS."
198+ </p>
199+ <p class="mt-3">
200+ <a
201+ href=(format!("/{handle}/settings/tokens"))
202+ class="text-sm font-medium hover:underline"
203+ >"Manage tokens →"</a>
204+ </p>
205+ </section>
190206 }
191207 }
src/infrastructure/web/token.rs+230 −0View file
@@ -0,0 +1,230 @@
1+//! Personal access tokens — `/{handle}/settings/tokens`.
2+//!
3+//! The one place a token is ever visible. Everything else in Steid holds only its hash,
4+//! so if this page does not show it, nobody can.
5+
6+use std::time::SystemTime;
7+
8+use serde::Deserialize;
9+use topcoat::{
10+ Result,
11+ context::Cx,
12+ router::{StatusCode, content::Form, error::forbidden, page, path_param},
13+ view::{attributes, component, view},
14+};
15+
16+use crate::{
17+ application::{Error, TokenSummary, issue_token, list_tokens, revoke_token},
18+ components::{
19+ badge::{BadgeVariant, badge},
20+ button::button,
21+ flash::{FlashKind, flash},
22+ input::input,
23+ label::label,
24+ },
25+ domain::{DomainError, TokenId},
26+};
27+
28+use super::{
29+ context::{current_actor, location, server_error, tokens},
30+ profile::profile_for,
31+};
32+
33+/// `{token}` from the path — a token's id, never the token itself.
34+#[path_param]
35+struct Token(str);
36+
37+#[derive(Debug, Deserialize)]
38+struct IssueForm {
39+ name: String,
40+}
41+
42+/// Only the person whose settings these are may see them.
43+///
44+/// The use cases scope every operation to the actor's own tokens regardless, so this
45+/// guard decides what is *shown*, not what is allowed.
46+async fn own_settings(cx: &Cx) -> Result<String> {
47+ let profile = profile_for(cx).await?;
48+
49+ if !profile.viewer_is_owner {
50+ return Err(forbidden().into());
51+ }
52+
53+ Ok(profile.handle.to_string())
54+}
55+
56+async fn listing(cx: &Cx) -> Result<Vec<TokenSummary>> {
57+ list_tokens(&current_actor(cx).await?, &tokens(cx))
58+ .await
59+ .map_err(server_error)
60+}
61+
62+#[page("/{handle}/settings/tokens")]
63+async fn tokens_page(cx: &Cx) -> Result {
64+ let handle = own_settings(cx).await?;
65+ let listed = listing(cx).await?;
66+
67+ view! {
68+ tokens_view(
69+ handle: handle.as_str(),
70+ tokens: &listed,
71+ issued: "",
72+ error: "",
73+ name: "",
74+ )
75+ }
76+}
77+
78+/// Issues a token and shows it, once.
79+///
80+/// **This deliberately does not redirect**, unlike every other form in Steid. The
81+/// secret exists only in this response: surviving a redirect would mean putting a live
82+/// credential in a URL, where it lands in browser history, logs and referrers. Reloading
83+/// re-submits, which issues a second token — harmless, and visible in the list.
84+#[page(POST "/{handle}/settings/tokens")]
85+async fn issue(cx: &Cx, Form(submitted): Form<IssueForm>) -> Result {
86+ let handle = own_settings(cx).await?;
87+
88+ let outcome = issue_token(
89+ &current_actor(cx).await?,
90+ &submitted.name,
91+ SystemTime::now(),
92+ &tokens(cx),
93+ )
94+ .await;
95+
96+ let (issued, error) = match outcome {
97+ Ok(issued) => (issued.secret.reveal().to_owned(), String::new()),
98+ Err(Error::Domain(DomainError::Validation { field, reason })) => {
99+ (String::new(), format!("That {field} is no good: {reason}."))
100+ }
101+ Err(Error::Domain(DomainError::Forbidden)) => return Err(forbidden().into()),
102+ Err(other) => return Err(server_error(std::io::Error::other(other.to_string()))),
103+ };
104+
105+ let listed = listing(cx).await?;
106+
107+ view! {
108+ tokens_view(
109+ handle: handle.as_str(),
110+ tokens: &listed,
111+ issued: issued.as_str(),
112+ error: error.as_str(),
113+ // Cleared on success so the field is ready for the next one, kept on failure
114+ // so a rejected name is not silently thrown away.
115+ name: if issued.is_empty() { submitted.name.as_str() } else { "" },
116+ )
117+ }
118+}
119+
120+/// Revokes a token, then redirects so a reload cannot repeat it.
121+#[page(POST "/{handle}/settings/tokens/{token}/revoke")]
122+async fn revoke(cx: &Cx) -> Result {
123+ let handle = own_settings(cx).await?;
124+ let id = TokenId::from_trusted(path_param::<Token>(cx));
125+
126+ match revoke_token(&current_actor(cx).await?, &id, &tokens(cx)).await {
127+ Ok(()) => {}
128+ // Someone else's token, or none at all. Both are "no such token of yours".
129+ Err(Error::Domain(DomainError::NotFound { .. })) => {}
130+ Err(other) => return Err(server_error(std::io::Error::other(other.to_string()))),
131+ }
132+
133+ view! {
134+ (StatusCode::SEE_OTHER)
135+ (location(&format!("/{handle}/settings/tokens"))?)
136+ }
137+}
138+
139+#[component]
140+async fn tokens_view(
141+ handle: &str,
142+ tokens: &[TokenSummary],
143+ issued: &str,
144+ error: &str,
145+ name: &str,
146+) -> Result {
147+ view! {
148+ <h1 class="text-xl font-semibold tracking-tight">"Access tokens"</h1>
149+ <p class="mt-1 text-sm text-muted-foreground">
150+ "Use a token as the password when git asks. Your username can be anything."
151+ </p>
152+
153+ if !issued.is_empty() {
154+ <div class="mt-6 space-y-2">
155+ flash(
156+ kind: FlashKind::Success,
157+ "Copy this now — it is not shown again."
158+ )
159+ <pre class="overflow-x-auto rounded-lg border border-border bg-muted px-4 py-3 font-mono text-sm">(issued)</pre>
160+ </div>
161+ }
162+
163+ if !error.is_empty() {
164+ <div class="mt-6">
165+ flash(kind: FlashKind::Error, (error))
166+ </div>
167+ }
168+
169+ <form method="post" action=(format!("/{handle}/settings/tokens")) class="mt-6 space-y-5">
170+ <div class="space-y-2">
171+ label(attrs: attributes! { for="name" }, "Token name")
172+ input(attrs: attributes! {
173+ id="name"
174+ name="name"
175+ type="text"
176+ value=(name)
177+ placeholder="laptop"
178+ required=(true)
179+ })
180+ <p class="text-xs text-muted-foreground">
181+ "So you can tell which one to revoke later."
182+ </p>
183+ </div>
184+
185+ button(attrs: attributes! { type="submit" }, "Create token")
186+ </form>
187+
188+ <section class="mt-10">
189+ <h2 class="text-xs font-medium uppercase tracking-wider text-muted-foreground">
190+ "Your tokens"
191+ </h2>
192+
193+ if tokens.is_empty() {
194+ <p class="mt-2 rounded-lg border border-border px-4 py-6 text-center text-sm text-muted-foreground">
195+ "No tokens yet."
196+ </p>
197+ } else {
198+ <ul class="mt-2 divide-y divide-border rounded-lg border border-border">
199+ for token in tokens {
200+ <li class="flex items-center justify-between gap-4 px-4 py-3">
201+ <div class="flex items-baseline gap-2">
202+ <span class="font-medium">(token.name.as_str())</span>
203+ badge(
204+ variant: BadgeVariant::Outline,
205+ "steid_pat_" (token.prefix.as_str()) "…"
206+ )
207+ </div>
208+ <form
209+ method="post"
210+ action=(format!("/{handle}/settings/tokens/{}/revoke", token.id))
211+ >
212+ button(
213+ attrs: attributes! { type="submit" },
214+ "Revoke"
215+ )
216+ </form>
217+ </li>
218+ }
219+ </ul>
220+ }
221+ </section>
222+
223+ <p class="mt-8">
224+ <a
225+ href=(format!("/{handle}/settings"))
226+ class="text-sm text-muted-foreground hover:text-foreground"
227+ >"Back to settings"</a>
228+ </p>
229+ }
230+}