git-ents.gitmain
⌘K
foforge
commit a7fd9c3
feat!: serve git over smart-HTTP without authentication

Any git client can now clone, fetch, and push, and creates a repository by pushing to an unused name. The device-flow login, persisted token store, and bearer/Basic gating are dropped in favor of open access for the project’s experimental phase.

feat: remove device-flow auth and the persisted token store feat: serve all git operations without authentication fix: disable the request body limit so pushes over 2 MiB succeed refactor: surface git http-backend stderr in the server log refactor: drop the now-unused axum-extra, getrandom, and serde deps docs: replace mandatory auth with an open-access requirement Assisted-by: Claude:claude-opus-4-8

Joseph D. Carpinelli · 2 months ago

Reviews

No reviews of this commit yet — record a verdict below.

Start a review

verdict

Cargo.lock @@ -110,49 +110,12 @@ "tracing", ] -[[package]] -name = "axum-extra" -version = "0.12.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "be44683b41ccb9ab2d23a5230015c9c3c55be97a25e4428366de8873103f7970" -dependencies = [ - "axum", - "axum-core", - "bytes", - "futures-core", - "futures-util", - "headers", - "http", - "http-body", - "http-body-util", - "mime", - "pin-project-lite", - "tower-layer", - "tower-service", - "tracing", -] - -[[package]] -name = "base64" -version = "0.22.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" - [[package]] name = "bitflags" version = "2.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" -[[package]] -name = "block-buffer" -version = "0.10.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" -dependencies = [ - "generic-array", -] - [[package]] name = "bytes" version = "1.12.0" @@ -221,35 +184,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" -[[package]] -name = "cpufeatures" -version = "0.2.17" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" -dependencies = [ - "libc", -] - -[[package]] -name = "crypto-common" -version = "0.1.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" -dependencies = [ - "generic-array", - "typenum", -] - -[[package]] -name = "digest" -version = "0.10.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" -dependencies = [ - "block-buffer", - "crypto-common", -] - [[package]] name = "errno" version = "0.3.14" @@ -308,28 +242,6 @@ "slab", ] -[[package]] -name = "generic-array" -version = "0.14.7" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" -dependencies = [ - "typenum", - "version_check", -] - -[[package]] -name = "getrandom" -version = "0.3.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" -dependencies = [ - "cfg-if", - "libc", - "r-efi 5.3.0", - "wasip2", -] - [[package]] name = "getrandom" version = "0.4.3" @@ -338,7 +250,7 @@ dependencies = [ "cfg-if", "libc", - "r-efi 6.0.0", + "r-efi", ] [[package]] @@ -350,40 +262,12 @@ version = "0.0.0" dependencies = [ "axum", - "axum-extra", "clap", "clap_mangen", - "getrandom 0.3.4", - "serde", - "serde_json", "tempfile", "tokio", ] -[[package]] -name = "headers" -version = "0.4.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b3314d5adb5d94bcdf56771f2e50dbbc80bb4bdf88967526706205ac9eff24eb" -dependencies = [ - "base64", - "bytes", - "headers-core", - "http", - "httpdate", - "mime", - "sha1", -] - -[[package]] -name = "headers-core" -version = "0.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "54b4a22553d4242c49fddb9ba998a99962b5cc6f22cb5a3482bec22522403ce4" -dependencies = [ - "http", -] - [[package]] name = "heck" version = "0.5.0" @@ -571,12 +455,6 @@ "proc-macro2", ] -[[package]] -name = "r-efi" -version = "5.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" - [[package]] name = "r-efi" version = "6.0.0" @@ -615,7 +493,6 @@ checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" dependencies = [ "serde_core", - "serde_derive", ] [[package]] @@ -674,17 +551,6 @@ "serde", ] -[[package]] -name = "sha1" -version = "0.10.6" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" -dependencies = [ - "cfg-if", - "cpufeatures", - "digest", -] - [[package]] name = "signal-hook-registry" version = "1.4.8" @@ -747,7 +613,7 @@ checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.4.3", + "getrandom", "once_cell", "rustix", "windows-sys", @@ -828,12 +694,6 @@ "once_cell", ] -[[package]] -name = "typenum" -version = "1.20.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" - [[package]] name = "unicode-ident" version = "1.0.24" @@ -846,27 +706,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" -[[package]] -name = "version_check" -version = "0.9.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" - [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" -[[package]] -name = "wasip2" -version = "1.0.4+wasi-0.2.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" -dependencies = [ - "wit-bindgen", -] - [[package]] name = "windows-link" version = "0.2.1" @@ -882,12 +727,6 @@ "windows-link", ] -[[package]] -name = "wit-bindgen" -version = "0.57.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" - [[package]] name = "zmij" version = "1.0.21"
Cargo.toml @@ -13,13 +13,9 @@ [workspace.dependencies] axum = "0.8" -axum-extra = { version = "0.12", features = ["typed-header"] } clap = { version = "4.5.60", features = ["derive"] } clap_mangen = "0.2.31" -getrandom = "0.3" gix = { version = "0.83", features = ["no-default-features"] } -serde = { version = "1", features = ["derive"] } -serde_json = "1" tempfile = "3" thiserror = "2" tokio = { version = "1", features = [
PROMPT.adoc @@ -10,15 +10,14 @@ The `git-ents` project is a single-node git hosting service reached over HTTPS. A thin gateway delegates all git protocol work to git's own `git http-backend` CGI, which implements the entire smart-HTTP protocol. -Authentication is a bearer token carried in the HTTP `Authorization` header, exactly as GitHub authenticates HTTPS git operations: the username is ignored and the password is the token. +Access is open during this experimental phase: any client may clone, fetch, and push, and creates a repository by pushing to an unused name. TLS, the public IP, and the custom domain are provided by Fly.io's HTTP proxy, so the gateway holds no certificates and opens no privileged ports. == Design Principles -* Authentication is a token over HTTPS, nothing more. Git sends it as HTTP Basic auth; the gateway compares it against a configured secret. No custom auth protocol, no key exchange, no signing in the hot path. -* The token gates writes; reads may be gated too or left open, as chosen. This is the same model `git push https://...` already speaks, so stock git works with no client plugin. -* Client ergonomics are separable from server auth. Stock git caches the token in the OS keychain; a credential helper (the `gh auth setup-git` pattern) is an optional later convenience, not part of the auth design. -* Commit signing is provenance, not authentication. If wanted, it is an optional "verified" check, never the gate that admits a push. +* Access is open. Stock git clones and pushes with no token, no plugin, and no custom protocol; the gateway adds nothing to the hot path. Authentication will return as the project matures, but it is deliberately absent now. +* The gateway is a thin translator. It maps Axum requests to the `git http-backend` CGI and back, and otherwise stays out of git's way. +* Commit signing is provenance, not authentication. If wanted, it is an optional "verified" check, never a gate that admits a push. * Git objects are immutable and content-addressed; git serializes ref updates itself on a single node. No discovery, no consensus, no CI. == Repository Layout @@ -29,7 +28,7 @@ git-ents-server/ # the HTTPS gateway binary src/ main.rs # entrypoint, config, startup - http.rs # token gate + git-http-backend dispatch + http.rs # git-http-backend dispatch Dockerfile .config/ fly.toml @@ -37,25 +36,24 @@ == Phase 1: HTTPS Git Delegation -Goal: `git clone` and `git push` work over HTTPS against a persistent volume on Fly.io, authenticated by a bearer token, with the gateway delegating all protocol work to `git http-backend`. +Goal: `git clone` and `git push` work over HTTPS against a persistent volume on Fly.io, with the gateway delegating all protocol work to `git http-backend`. === Tasks -. Add a minimal synchronous HTTP server dependency (e.g. `tiny_http`); no async runtime is required. -. Implement `main.rs` to read configuration from the environment (the auth token `ACCESS_TOKEN`), bind the HTTP listener, and start the server. +. Build the gateway on Axum over a Tokio runtime so request bodies and CGI output stream without blocking. +. Implement `main.rs` to read configuration from the environment (the repository root), disable the default request body limit so large packfiles are accepted, bind the HTTP listener, and start the server. . Implement `http.rs` to, for every request: .. derive the repository path from the URL and reject any path containing `..` or escaping `/data/repos`; -.. require the bearer token for writes (and for reads, if reads are private): parse the `Authorization` header as HTTP Basic and compare the password field against `ACCESS_TOKEN`, returning `401` with a `WWW-Authenticate: Basic` challenge on mismatch; -.. auto-initialize the bare repo (with `http.receivepack=true`) if absent on a write; -.. invoke `git http-backend` as a CGI child: set `GIT_PROJECT_ROOT=/data/repos`, `GIT_HTTP_EXPORT_ALL=1`, `PATH_INFO`, `REQUEST_METHOD`, `QUERY_STRING`, and `CONTENT_TYPE`; pipe the request body to its stdin; and stream its CGI stdout (status + headers + body) back to the client. +.. auto-initialize the bare repo (with `http.receivepack=true`) if absent on a push; +.. invoke `git http-backend` as a CGI child: set `GIT_PROJECT_ROOT=/data/repos`, `GIT_HTTP_EXPORT_ALL=1`, `PATH_INFO`, `REQUEST_METHOD`, `QUERY_STRING`, and `CONTENT_TYPE`; pipe the request body to its stdin; and return its CGI stdout (status + headers + body) to the client. . Write a `Dockerfile` whose runtime image installs `git`, mounts the persistent volume at `/data`, and listens on the `internal_port` from `fly.toml`. . Confirm `fly.toml` keeps the `[http_service]` with `force_https` and the volume mount; no dedicated IP and no TCP service are needed. -. Deploy to Fly.io and verify that `git clone`, `git push`, and a re-clone round-trip all succeed when given the token, and are rejected without it. +. Deploy to Fly.io and verify that `git clone`, `git push`, and a re-clone round-trip all succeed. === Acceptance Criteria -* `git push https://x:<token>@<domain>/repo.git main` reaches `git http-backend` and updates the repo. -* A push or private read without a valid token is rejected with `401`. +* `git push https://<domain>/repo.git main` reaches `git http-backend` and updates the repo. +* A push to a previously unused name creates the repository. * Objects persist across process restart; the volume survives redeploy. * Cloning a repo created by a previous push returns the same objects. @@ -73,18 +71,3 @@ * Cloning and pushing over `https://<domain>/...` succeed end to end. * Fly issues and renews the certificate automatically; the gateway manages no certificates. - -== Phase 3: Credential-Helper Ergonomics (Optional) - -Goal: users authenticate once instead of pasting the token on every operation, following the `gh auth setup-git` pattern. - -=== Tasks - -. Add a `git-ents auth login` subcommand that accepts a token and stores it (OS keychain or `git credential store`). -. Register the binary as a git credential helper for the service host, so HTTPS git operations retrieve the token automatically. -. Document the one-time setup; confirm subsequent `git clone`/`push` need no interactive credential entry. - -=== Acceptance Criteria - -* After a single `git-ents auth login`, `git clone` and `git push` over HTTPS succeed without prompting. -* Stock git (with the OS keychain) still works for users who skip the helper.
docs/specification.adoc @@ -19,17 +19,17 @@ Repository state MUST be persisted in a bare Git repository on durable storage. -- -=== Authentication +=== Access -[role="requirement", id="auth.ssh"] -.SSH Authentication +[role="requirement", id="access.open"] +.Open Access -- -All client connections MUST be authenticated via SSH public key. -Password authentication and any other mechanism MUST NOT be offered. -Connections from unrecognized keys MUST be rejected. +The server MUST serve clone, fetch, and push to any client without authentication. +Any client MAY create a repository by pushing to a previously unused name. [IMPORTANT] -This requirement is deliberately unstable, and will change to allow other authentication methods as the project matures. +This requirement is deliberately unstable. +The current experimental phase prioritizes a frictionless Git remote; authentication will be reintroduced as the project matures. -- === Protocol
crates/git-ents-server/Cargo.toml @@ -7,12 +7,8 @@ [dependencies] axum = { workspace = true } -axum-extra = { workspace = true } clap = { workspace = true } clap_mangen = { workspace = true } -getrandom = { workspace = true } -serde = { workspace = true } -serde_json = { workspace = true } tokio = { workspace = true } [dev-dependencies]
crates/git-ents-server/src/http.rs @@ -10,11 +10,8 @@ use axum::body::{Body, Bytes}; use axum::extract::State; -use axum::http::{HeaderMap, HeaderValue, Method, StatusCode, Uri, header}; +use axum::http::{HeaderMap, Method, StatusCode, Uri}; use axum::response::{IntoResponse, Response}; -use axum_extra::TypedHeader; -use axum_extra::headers::Authorization; -use axum_extra::headers::authorization::Basic; use tokio::io::{AsyncReadExt, AsyncWriteExt}; use tokio::process::Command; @@ -30,16 +27,11 @@ /// Delegate a single request to `git http-backend` and reply with its output. pub async fn git( State(state): State<AppState>, - auth: Option<TypedHeader<Authorization<Basic>>>, method: Method, uri: Uri, headers: HeaderMap, body: Bytes, ) -> Response { - if !is_authorized(&state, auth.as_ref()) { - return unauthorized(); - } - let path_info = uri.path().to_owned(); let query_string = uri.query().unwrap_or_default().to_owned(); @@ -58,7 +50,11 @@ && !repo.exists() && let Err(e) = init_bare_repo(repo).await { - return (StatusCode::INTERNAL_SERVER_ERROR, format!("init failed: {e}")).into_response(); + return ( + StatusCode::INTERNAL_SERVER_ERROR, + format!("init failed: {e}"), + ) + .into_response(); } let content_type = header_value(&headers, "Content-Type"); @@ -73,7 +69,10 @@ .env("REQUEST_METHOD", method.as_str()) .stdin(Stdio::piped()) .stdout(Stdio::piped()) - .stderr(Stdio::null()); + // Surface backend diagnostics in the server's own logs rather than + // discarding them; git http-backend only writes here when something + // goes wrong. + .stderr(Stdio::inherit()); if let Some(value) = &content_type { cmd.env("CONTENT_TYPE", value); } @@ -84,7 +83,10 @@ let mut child = match cmd.spawn() { Ok(child) => child, Err(e) => { - return (StatusCode::INTERNAL_SERVER_ERROR, format!("spawn failed: {e}")) + return ( + StatusCode::INTERNAL_SERVER_ERROR, + format!("spawn failed: {e}"), + ) .into_response(); } }; @@ -110,14 +112,17 @@ let status = match child.wait().await { Ok(status) => status, Err(e) => { - return (StatusCode::INTERNAL_SERVER_ERROR, format!("backend failed: {e}")) + return ( + StatusCode::INTERNAL_SERVER_ERROR, + format!("backend failed: {e}"), + ) .into_response(); } }; // A fresh bare repo's `HEAD` points at its initial branch, which may not be // the branch the client just pushed; without a valid `HEAD`, clones check - // out nothing. Adopt a pushed branch so the repo stays clonable. + // out nothing. Adopt a pushed branch so the repo stays cloneable. if let Some(repo) = &push_repo && status.success() { @@ -253,40 +258,6 @@ .await; } -/// Whether a request may touch git: it carries the admin token, a token issued -/// by the device flow, or auth is unconfigured (no admin token, none issued). -fn is_authorized(state: &AppState, auth: Option<&TypedHeader<Authorization<Basic>>>) -> bool { - if let Some(expected) = state.access_token.as_deref() - && authorized(auth, expected) - { - return true; - } - let presented = auth.map(|TypedHeader(creds)| creds.password()); - let (store_empty, store_ok) = match state.auth.lock() { - Ok(guard) => (guard.is_empty(), presented.is_some_and(|t| guard.validate(t))), - Err(_) => return false, - }; - store_ok || (state.access_token.is_none() && store_empty) -} - -/// Check the request's HTTP Basic credentials against the expected token. -/// -/// As with GitHub's HTTPS git auth, the username is ignored and the password -/// field carries the bearer token. -fn authorized(auth: Option<&TypedHeader<Authorization<Basic>>>, expected: &str) -> bool { - auth.is_some_and(|TypedHeader(creds)| creds.password() == expected) -} - -/// A `401` carrying the Basic challenge git expects before retrying with creds. -fn unauthorized() -> Response { - let mut response = (StatusCode::UNAUTHORIZED, "unauthorized").into_response(); - response.headers_mut().insert( - header::WWW_AUTHENTICATE, - HeaderValue::from_static(r#"Basic realm="git-ents""#), - ); - response -} - fn header_value(headers: &HeaderMap, field: &str) -> Option<String> { headers .get(field) @@ -303,7 +274,9 @@ if needle.is_empty() || haystack.len() < needle.len() { return None; } - haystack.windows(needle.len()).position(|window| window == needle) + haystack + .windows(needle.len()) + .position(|window| window == needle) } fn trim_cr(line: &[u8]) -> &[u8] { @@ -330,34 +303,3 @@ } value } - -#[cfg(test)] -mod tests { - #![allow( - clippy::unwrap_used, - reason = "unit tests may unwrap freely" - )] - - use super::*; - - fn basic(user: &str, password: &str) -> TypedHeader<Authorization<Basic>> { - TypedHeader(Authorization::basic(user, password)) - } - - #[test] - fn rejects_missing_credentials() { - assert!(!authorized(None, "secret")); - } - - #[test] - fn rejects_wrong_password() { - let creds = basic("anyone", "nope"); - assert!(!authorized(Some(&creds), "secret")); - } - - #[test] - fn accepts_matching_password_regardless_of_username() { - let creds = basic("anyone", "secret"); - assert!(authorized(Some(&creds), "secret")); - } -}
crates/git-ents-server/src/main.rs @@ -1,21 +1,19 @@ //! Git Ents server — helpful guardians of your git trees. -mod auth; mod http; use std::net::SocketAddr; use std::path::PathBuf; use std::process::ExitCode; -use std::sync::{Arc, Mutex}; +use std::sync::Arc; use std::sync::atomic::{AtomicUsize, Ordering}; use axum::Router; -use axum::routing::{get, post}; +use axum::extract::DefaultBodyLimit; +use axum::routing::get; use clap::{CommandFactory, Parser}; use tokio::sync::Notify; -use crate::auth::Auth; - #[derive(Parser)] #[command( name = "git-ents-server", @@ -34,26 +32,15 @@ #[arg(long, env = "GIT_PROJECT_ROOT", default_value = "/data/repos")] data_dir: PathBuf, - /// Admin bearer token; approves logins and bootstraps git access. - #[arg(long, env = "ACCESS_TOKEN")] - access_token: Option<String>, - - /// Externally reachable base URL, used to build login verification links. - #[arg(long, env = "PUBLIC_URL")] - public_url: Option<String>, - /// Stop after handling this many requests. #[arg(long)] max_requests: Option<usize>, } -/// Shared handler state: where repos live, the admin token, and issued tokens. +/// Shared handler state: where the bare repositories live. #[derive(Clone)] pub(crate) struct AppState { pub(crate) data_dir: PathBuf, - pub(crate) access_token: Option<String>, - pub(crate) public_url: Arc<str>, - pub(crate) auth: Arc<Mutex<Auth>>, } fn main() -> ExitCode { @@ -80,24 +67,17 @@ /// Bind the listener and serve until shutdown. async fn serve(args: Args) -> ExitCode { - let public_url = args - .public_url - .unwrap_or_else(|| format!("http://localhost:{}", args.port)); - let auth = Auth::load(&args.data_dir); let state = AppState { data_dir: args.data_dir, - access_token: args.access_token, - public_url: Arc::from(public_url), - auth: Arc::new(Mutex::new(auth)), }; + // The git smart-HTTP protocol streams whole packfiles through the request + // body, so the default 2 MiB cap would reject any non-trivial push. let mut app = Router::new() .route("/", get(http::health)) .route("/healthz", get(http::health)) - .route("/auth/device", post(auth::device)) - .route("/auth/token", post(auth::token)) - .route("/auth/verify", get(auth::verify_page).post(auth::verify_submit)) .fallback(http::git) + .layer(DefaultBodyLimit::disable()) .with_state(state); // When `--max-requests` is set, count every response and signal shutdown @@ -131,8 +111,8 @@ } }; - let server = axum::serve(listener, app) - .with_graceful_shutdown(async move { shutdown.notified().await }); + let server = + axum::serve(listener, app).with_graceful_shutdown(async move { shutdown.notified().await }); if let Err(e) = server.await { eprintln!("error: {e}"); return ExitCode::FAILURE;
crates/git-ents-server/tests/server.rs @@ -78,10 +78,7 @@ // Clone it back and confirm the objects round-trip. let dst = tempfile::tempdir().unwrap(); let clone_path = dst.path().join("clone"); - run_git( - None, - &["clone", "-q", &url, clone_path.to_str().unwrap()], - ); + run_git(None, &["clone", "-q", &url, clone_path.to_str().unwrap()]); let cloned = rev_parse(&clone_path); child.kill().unwrap(); @@ -120,7 +117,9 @@ } fn rev_parse(dir: &Path) -> String { - let output = git_command(Some(dir), &["rev-parse", "HEAD"]).output().unwrap(); + let output = git_command(Some(dir), &["rev-parse", "HEAD"]) + .output() + .unwrap(); assert!(output.status.success()); String::from_utf8(output.stdout).unwrap().trim().to_owned() }
crates/git-ents-server/src/auth.rs @@ -1,405 +1,0 @@ -//! Device-flow authentication: issue and validate access tokens. -//! -//! A client starts a login with `POST /auth/device`, shows the returned -//! `user_code` to the operator, and polls `POST /auth/token` until the request -//! is approved. Approval happens out of band: someone opens `GET /auth/verify` -//! and confirms the code. For now the only approver is the admin holding -//! `ACCESS_TOKEN`; OAuth backends (GitHub, atproto) slot in at the approval -//! step later, each minting a token bound to a richer [`Principal`]. - -use std::collections::HashMap; -use std::fmt::Write as _; -use std::fs::File; -use std::path::{Path, PathBuf}; -use std::time::{Duration, SystemTime}; - -use axum::Json; -use axum::extract::{Form, State}; -use axum::http::StatusCode; -use axum::response::{Html, IntoResponse, Response}; -use serde::{Deserialize, Serialize}; - -use crate::AppState; - -/// How long a pending login (and its `user_code`) stays valid. -const CODE_TTL: Duration = Duration::from_secs(900); -/// Seconds a polling client should wait between `POST /auth/token` calls. -const POLL_INTERVAL_SECS: u64 = 5; -/// Crockford-style alphabet for `user_code`s: no `0/O/1/I` ambiguity. -const USER_CODE_ALPHABET: &[u8] = b"ABCDEFGHJKLMNPQRSTUVWXYZ23456789"; - -/// Who an issued token belongs to. Stage (a) only mints [`Principal::Admin`]; -/// the OAuth backends add the other variants. -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] -#[serde(tag = "kind", content = "id")] -pub(crate) enum Principal { - Admin, - Github(String), - Atproto(String), -} - -/// On-disk shape of the token store. -#[derive(Default, Serialize, Deserialize)] -struct TokenFile { - tokens: HashMap<String, Principal>, -} - -/// A login awaiting approval, keyed by its `device_code`. -struct PendingLogin { - user_code: String, - expires_at: SystemTime, - /// Set once approved; the token the client's next poll will receive. - token: Option<String>, -} - -/// Result of a client polling for its token. -enum PollOutcome { - Pending, - Approved(String), - Expired, - Unknown, -} - -/// Why an approval attempt failed. -#[derive(Debug)] -enum ApproveError { - UnknownCode, - Internal, -} - -/// Issued tokens (persisted) plus in-flight logins (in-memory). -pub(crate) struct Auth { - path: PathBuf, - tokens: HashMap<String, Principal>, - pending: HashMap<String, PendingLogin>, -} - -impl Auth { - /// Load previously issued tokens from `data_dir`, if any. - pub(crate) fn load(data_dir: &Path) -> Self { - let path = data_dir.join(".git-ents-tokens.json"); - let tokens = File::open(&path) - .ok() - .and_then(|file| serde_json::from_reader::<_, TokenFile>(file).ok()) - .map(|file| file.tokens) - .unwrap_or_default(); - Self { - path, - tokens, - pending: HashMap::new(), - } - } - - /// Whether no tokens have ever been issued (used to decide if the git - /// endpoint should require auth at all). - pub(crate) fn is_empty(&self) -> bool { - self.tokens.is_empty() - } - - /// Whether `token` is a currently issued access token. - pub(crate) fn validate(&self, token: &str) -> bool { - self.tokens.contains_key(token) - } - - /// Begin a login, returning `(device_code, user_code)`. - fn start(&mut self) -> Option<(String, String)> { - let device_code = random_hex()?; - let user_code = random_user_code()?; - let expires_at = SystemTime::now().checked_add(CODE_TTL)?; - self.pending.insert( - device_code.clone(), - PendingLogin { - user_code: user_code.clone(), - expires_at, - token: None, - }, - ); - Some((device_code, user_code)) - } - - /// Check on a login; consumes the pending entry once it resolves. - fn poll(&mut self, device_code: &str) -> PollOutcome { - match self.pending.get(device_code) { - None => PollOutcome::Unknown, - Some(pending) if SystemTime::now() >= pending.expires_at => { - self.pending.remove(device_code); - PollOutcome::Expired - } - Some(pending) => match pending.token.clone() { - Some(token) => { - self.pending.remove(device_code); - PollOutcome::Approved(token) - } - None => PollOutcome::Pending, - }, - } - } - - /// Approve the login carrying `user_code`, minting a token for `principal`. - fn approve(&mut self, user_code: &str, principal: Principal) -> Result<(), ApproveError> { - let wanted = normalize_user_code(user_code); - let now = SystemTime::now(); - let device_code = self - .pending - .iter() - .find(|(_, pending)| { - normalize_user_code(&pending.user_code) == wanted && now < pending.expires_at - }) - .map(|(code, _)| code.clone()) - .ok_or(ApproveError::UnknownCode)?; - - let token = random_hex().ok_or(ApproveError::Internal)?; - self.tokens.insert(token.clone(), principal); - if let Some(pending) = self.pending.get_mut(&device_code) { - pending.token = Some(token); - } - self.persist(); - Ok(()) - } - - /// Write the issued tokens to disk, logging (but not failing on) I/O errors. - fn persist(&self) { - if let Some(parent) = self.path.parent() { - let _created = std::fs::create_dir_all(parent); - } - match File::create(&self.path) { - Ok(file) => { - let snapshot = TokenFile { - tokens: self.tokens.clone(), - }; - if let Err(e) = serde_json::to_writer(file, &snapshot) { - eprintln!("error: failed to write token store: {e}"); - } - } - Err(e) => eprintln!("error: failed to open token store: {e}"), - } - } -} - -/// `POST /auth/device` — mint a `device_code`/`user_code` pair to display. -pub(crate) async fn device(State(state): State<AppState>) -> Response { - let started = match state.auth.lock() { - Ok(mut guard) => guard.start(), - Err(e) => return internal(&format!("auth lock poisoned: {e}")), - }; - let Some((device_code, user_code)) = started else { - return internal("failed to generate login codes"); - }; - Json(DeviceResponse { - device_code, - user_code, - verification_uri: format!("{}/auth/verify", state.public_url), - expires_in: CODE_TTL.as_secs(), - interval: POLL_INTERVAL_SECS, - }) - .into_response() -} - -/// `POST /auth/token` — a client polling for its access token. -pub(crate) async fn token( - State(state): State<AppState>, - Json(request): Json<TokenRequest>, -) -> Response { - let outcome = match state.auth.lock() { - Ok(mut guard) => guard.poll(&request.device_code), - Err(e) => return internal(&format!("auth lock poisoned: {e}")), - }; - match outcome { - PollOutcome::Approved(access_token) => { - Json(TokenResponse { access_token }).into_response() - } - PollOutcome::Pending => oauth_error(StatusCode::BAD_REQUEST, "authorization_pending"), - PollOutcome::Expired => oauth_error(StatusCode::BAD_REQUEST, "expired_token"), - PollOutcome::Unknown => oauth_error(StatusCode::BAD_REQUEST, "invalid_grant"), - } -} - -/// `GET /auth/verify` — the page where an operator confirms a `user_code`. -pub(crate) async fn verify_page() -> Html<&'static str> { - Html(VERIFY_FORM) -} - -/// `POST /auth/verify` — approve a login. Stage (a): admin token only. -pub(crate) async fn verify_submit( - State(state): State<AppState>, - Form(form): Form<VerifyForm>, -) -> Response { - let Some(admin) = state.access_token.as_deref() else { - return ( - StatusCode::FORBIDDEN, - Html("admin approval is not configured (no ACCESS_TOKEN set)"), - ) - .into_response(); - }; - if form.admin_token != admin { - return (StatusCode::UNAUTHORIZED, Html("invalid admin token")).into_response(); - } - - let result = match state.auth.lock() { - Ok(mut guard) => guard.approve(&form.user_code, Principal::Admin), - Err(e) => return internal(&format!("auth lock poisoned: {e}")), - }; - match result { - Ok(()) => Html(APPROVED_PAGE).into_response(), - Err(ApproveError::UnknownCode) => { - (StatusCode::BAD_REQUEST, Html("unknown or expired code")).into_response() - } - Err(ApproveError::Internal) => internal("failed to mint token"), - } -} - -/// `POST /auth/device` response. -#[derive(Serialize)] -struct DeviceResponse { - device_code: String, - user_code: String, - verification_uri: String, - expires_in: u64, - interval: u64, -} - -/// `POST /auth/token` request body. -#[derive(Deserialize)] -pub(crate) struct TokenRequest { - device_code: String, -} - -/// `POST /auth/token` success body. -#[derive(Serialize)] -struct TokenResponse { - access_token: String, -} - -/// `POST /auth/verify` form fields. -#[derive(Deserialize)] -pub(crate) struct VerifyForm { - user_code: String, - admin_token: String, -} - -/// 32 random bytes rendered as 64 hex chars; used for device codes and tokens. -fn random_hex() -> Option<String> { - let mut bytes = [0u8; 32]; - getrandom::fill(&mut bytes).ok()?; - let mut out = String::with_capacity(64); - for byte in bytes { - write!(out, "{byte:02x}").ok()?; - } - Some(out) -} - -/// A human-friendly `XXXX-XXXX` code drawn from [`USER_CODE_ALPHABET`]. -fn random_user_code() -> Option<String> { - let mut bytes = [0u8; 8]; - getrandom::fill(&mut bytes).ok()?; - let mut code = String::with_capacity(9); - for (index, byte) in bytes.iter().enumerate() { - if index == 4 { - code.push('-'); - } - let letter = USER_CODE_ALPHABET.get((byte & 0x1f) as usize).copied()?; - code.push(letter as char); - } - Some(code) -} - -/// Collapse a `user_code` to its comparable form: uppercase, alphanumerics only. -fn normalize_user_code(input: &str) -> String { - input - .chars() - .filter(char::is_ascii_alphanumeric) - .map(|c| c.to_ascii_uppercase()) - .collect() -} - -/// An OAuth-style `{ "error": "..." }` body with the given status. -fn oauth_error(status: StatusCode, code: &str) -> Response { - (status, Json(serde_json::json!({ "error": code }))).into_response() -} - -/// A `500` carrying a short message, logged server-side. -fn internal(message: &str) -> Response { - eprintln!("error: {message}"); - (StatusCode::INTERNAL_SERVER_ERROR, "internal error").into_response() -} - -const VERIFY_FORM: &str = r#"<!doctype html> -<title>git-ents — approve a login</title> -<h1>Approve a device login</h1> -<form method="post" action="/auth/verify"> - <p><label>Code: <input name="user_code" placeholder="XXXX-XXXX" autofocus></label></p> - <p><label>Admin token: <input name="admin_token" type="password"></label></p> - <p><button type="submit">Approve</button></p> -</form> -"#; - -const APPROVED_PAGE: &str = r#"<!doctype html> -<title>git-ents — approved</title> -<h1>Approved</h1> -<p>You can return to your terminal.</p> -"#; - -#[cfg(test)] -mod tests { - #![allow(clippy::unwrap_used, reason = "unit tests may unwrap freely")] - - use super::*; - - #[test] - fn device_flow_round_trip() { - let dir = tempfile::tempdir().unwrap(); - let mut auth = Auth::load(dir.path()); - assert!(auth.is_empty()); - - let (device_code, user_code) = auth.start().unwrap(); - assert!(matches!(auth.poll(&device_code), PollOutcome::Pending)); - - auth.approve(&user_code, Principal::Admin).unwrap(); - - let PollOutcome::Approved(token) = auth.poll(&device_code) else { - panic!("login should be approved"); - }; - assert!(auth.validate(&token)); - assert!(!auth.is_empty()); - // The pending entry is consumed once its token is collected. - assert!(matches!(auth.poll(&device_code), PollOutcome::Unknown)); - } - - #[test] - fn approval_accepts_dashless_lowercase_codes() { - let dir = tempfile::tempdir().unwrap(); - let mut auth = Auth::load(dir.path()); - let (_device, user_code) = auth.start().unwrap(); - - let messy = user_code.replace('-', "").to_lowercase(); - auth.approve(&messy, Principal::Admin).unwrap(); - } - - #[test] - fn approval_rejects_unknown_code() { - let dir = tempfile::tempdir().unwrap(); - let mut auth = Auth::load(dir.path()); - assert!(matches!( - auth.approve("ZZZZ-ZZZZ", Principal::Admin), - Err(ApproveError::UnknownCode) - )); - } - - #[test] - fn issued_tokens_survive_reload() { - let dir = tempfile::tempdir().unwrap(); - let token = { - let mut auth = Auth::load(dir.path()); - let (device_code, user_code) = auth.start().unwrap(); - auth.approve(&user_code, Principal::Github("alice".to_owned())) - .unwrap(); - match auth.poll(&device_code) { - PollOutcome::Approved(token) => token, - _ => panic!("login should be approved"), - } - }; - - let reloaded = Auth::load(dir.path()); - assert!(reloaded.validate(&token)); - } -}