Skip to main content

max / makenotwork

Add git personal-access-tokens for HTTPS clone and push Completes the git-over-HTTPS story: CLI `git clone/push https://...` of a private repo now works via a personal access token, alongside the existing SSH keys and the just-landed read-collaborator authz. Backend: - git_access_tokens table (migration 144): random mnw_-prefixed token shown once; only its SHA-256 hash is stored. Optional expiry; per-token can_push. - crypto::generate_git_token / git_token_hash. - A git HTTP principal resolver: session cookie OR Authorization: Basic <user>:<token> (git sends the token as the password). Wired into the upload-pack (clone) routes so token-authed CLI clones resolve the user for the owner/collaborator read check. - New git-receive-pack (push) route + service=git-receive-pack on info/refs, gated by authorize_push: a principal that's owner-or-push-collaborator AND, if token-authed, a token carrying push scope. The pushed pack is streamed into git's stdin (not buffered); same permit + kill-deadline as upload-pack; 2 GB body cap. UI/API: - Create/list/revoke endpoints + a token section in the git-access dashboard tab (name, optional expiry, push checkbox); the plaintext is shown once. Tests: token clone of a private repo (cookie-free, Basic auth), bad/revoked token → 404, read-only token → 403 on push advert, push token → receive-pack advert, plus create/revoke. +clear_cookies test helper.
Co-Authored-By
Claude Opus 4.8 <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-06-16 00:16 UTC
Signed with PGP, not checked
Commit: 6bd9e88c3b84ca0ea86ddcd08877998a25aa3281
Parent: 3474d6a
15 files changed, +656 insertions, -15 deletions
@@ -256,6 +256,10 @@
256 256 // past this deadline keeps slow/stuck clones from starving the budget. Generous
257 257 // enough for a large repo over a slow link.
258 258 pub const GIT_SMART_HTTP_TIMEOUT_SECS: u64 = 300;
259 + // Max size of a single git push (receive-pack) request body over HTTP. The pack
260 + // is streamed into git's stdin (not buffered), but this caps a single push so a
261 + // runaway upload can't fill the repo disk unbounded.
262 + pub const GIT_RECEIVE_PACK_MAX_BYTES: usize = 2 * 1024 * 1024 * 1024;
259 263
260 264 // -- Webhook security --
261 265 pub const WEBHOOK_TIMESTAMP_TOLERANCE_SECS: u64 = 300; // 5 minutes
@@ -136,6 +136,27 @@
136 136 crate::db::KeyCode::from_trusted(words.join("-"))
137 137 }
138 138
139 + /// Generate a git personal-access token. Returns `(plaintext, hash)`: the
140 + /// `mnw_`-prefixed plaintext is shown to the user exactly once and never
141 + /// stored; only the SHA-256 hex `hash` is persisted. The body is 32 CSPRNG
142 + /// bytes (~256 bits) rendered as hex so it's safe in a Basic-auth password / URL.
143 + pub fn generate_git_token() -> (String, String) {
144 + let mut bytes = [0u8; 32];
145 + rand::RngCore::fill_bytes(&mut rand::rng(), &mut bytes);
146 + let body: String = bytes.iter().map(|b| format!("{b:02x}")).collect();
147 + let plaintext = format!("mnw_{body}");
148 + let hash = git_token_hash(&plaintext);
149 + (plaintext, hash)
150 + }
151 +
152 + /// SHA-256 hex digest of a git token's plaintext. Used both when minting a
153 + /// token and when verifying one on a request, so the stored hash is never the
154 + /// plaintext and a DB read can't recover a usable credential.
155 + pub fn git_token_hash(token: &str) -> String {
156 + use sha2::{Digest, Sha256};
157 + Sha256::digest(token.as_bytes()).iter().map(|b| format!("{b:02x}")).collect()
158 + }
159 +
139 160 /// Compute the hex HMAC-SHA256 over `feed:{user_id}:{version}` with `secret`.
140 161 fn feed_signature(user_id: crate::db::UserId, version: i32, secret: &str) -> String {
141 162 use hmac::{Hmac, Mac};
@@ -163,6 +163,7 @@
163 163 InviteCodeId,
164 164 GitRepoId,
165 165 SshKeyId,
166 + GitAccessTokenId,
166 167 IssueId,
167 168 IssueCommentId,
168 169 IssueLabelId,
@@ -47,6 +47,7 @@
47 47 pub mod git_repos;
48 48 pub mod repo_collaborators;
49 49 pub mod ssh_keys;
50 + pub mod git_access_tokens;
50 51 pub mod issues;
51 52 pub(crate) mod reports;
52 53 pub(crate) mod fan_plus;
@@ -634,6 +634,15 @@
634 634 pub username: String,
635 635 }
636 636
637 + /// Git access-token list partial for HTMX updates. `new_token` carries a
638 + /// freshly-minted plaintext to show once (set only in the create response).
639 + #[derive(Template)]
640 + #[template(path = "partials/git_tokens_list.html")]
641 + pub struct GitTokensListTemplate {
642 + pub tokens: Vec<crate::routes::api::git_tokens::GitTokenView>,
643 + pub new_token: Option<String>,
644 + }
645 +
637 646 /// Sessions list partial, used by session revocation API responses (HTMX swap into `#sessions-list`).
638 647 #[derive(Template)]
639 648 #[template(path = "partials/tabs/user_sessions.html")]
@@ -55,6 +55,14 @@
55 55 self.bearer_token = None;
56 56 }
57 57
58 + /// Drop all stored cookies — simulates a fresh client with no session, e.g.
59 + /// a CLI `git` request that authenticates via a token rather than a browser
60 + /// cookie.
61 + #[allow(dead_code)]
62 + pub fn clear_cookies(&mut self) {
63 + self.cookies.clear();
64 + }
65 +
58 66 /// Access the current CSRF token (if any).
59 67 #[allow(dead_code)]
60 68 pub fn csrf_token(&self) -> Option<&str> {