git-ents.gitmain
⌘K
foforge
commit fc87cd2
feat: add device-flow auth with a persisted token store

Logins start at POST /auth/device, which mints a device_code and a human-readable XXXX-XXXX user_code. A client polls POST /auth/token until an operator approves the code at GET /auth/verify. For now the only approver is the admin holding ACCESS_TOKEN; the Principal enum already carries Github and Atproto variants so the OAuth backends slot in here next.

Issued tokens persist to .git-ents-tokens.json under the data dir, and the git endpoint now accepts any issued token (or the admin token), requiring auth whenever either an admin token or an issued token exists.

feat: serve /auth/device, /auth/token, and /auth/verify routes feat: add Principal with Admin, Github, and Atproto variants feat: persist issued tokens across restarts under the data dir feat: add --public-url to build login verification links refactor: gate git access via is_authorized over admin and issued tokens test: cover the device-flow round trip, reload, and credential checks 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 @@ -318,6 +318,18 @@ "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" @@ -326,7 +338,7 @@ dependencies = [ "cfg-if", "libc", - "r-efi", + "r-efi 6.0.0", ] [[package]] @@ -341,6 +353,9 @@ "axum-extra", "clap", "clap_mangen", + "getrandom 0.3.4", + "serde", + "serde_json", "tempfile", "tokio", ] @@ -556,6 +571,12 @@ "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" @@ -594,6 +615,7 @@ checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" dependencies = [ "serde_core", + "serde_derive", ] [[package]] @@ -725,7 +747,7 @@ checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom", + "getrandom 0.4.3", "once_cell", "rustix", "windows-sys", @@ -836,6 +858,15 @@ 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" @@ -851,6 +882,12 @@ "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 @@ -16,7 +16,10 @@ 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 = [
crates/git-ents-server/Cargo.toml @@ -10,6 +10,9 @@ 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 @@ -36,9 +36,7 @@ headers: HeaderMap, body: Bytes, ) -> Response { - if let Some(expected) = state.access_token.as_deref() - && !authorized(auth.as_ref(), expected) - { + if !is_authorized(&state, auth.as_ref()) { return unauthorized(); } @@ -255,6 +253,22 @@ .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 @@ -316,3 +330,34 @@ } 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,18 +1,21 @@ //! 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; +use std::sync::{Arc, Mutex}; use std::sync::atomic::{AtomicUsize, Ordering}; use axum::Router; -use axum::routing::get; +use axum::routing::{get, post}; use clap::{CommandFactory, Parser}; use tokio::sync::Notify; +use crate::auth::Auth; + #[derive(Parser)] #[command( name = "git-ents-server", @@ -31,20 +34,26 @@ #[arg(long, env = "GIT_PROJECT_ROOT", default_value = "/data/repos")] data_dir: PathBuf, - /// Bearer token required for git operations; when unset, auth is disabled. + /// 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 and the optional auth token. +/// Shared handler state: where repos live, the admin token, and issued tokens. #[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 { @@ -71,14 +80,23 @@ /// 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)), }; 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) .with_state(state);
crates/git-ents-server/src/auth.rs @@ -1,0 +1,405 @@ +//! 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)); + } +}