The pre-redo key-proof sign-in, automated (roots.web-signin): /login
mints a one-time code bound to the browser session; the CLI signs the
host+code+nonce payload under the git-ents-login SSHSIG namespace and
posts it back; verification is pure-Rust ssh_key against the enrolled
member list, re-checked on every mutation so revocation bites
mid-session. AccessPolicy::{Trusted, SignInRequired(Realm)} is injected
by the composition root: under Trusted the login routes are unrouted and
the local surface is byte-identical, down to the policy-driven Secure
cookie attribute. Mutations are attributed to the signed-in member via
the two-arg receive_identity! (receive.attributed-author).
Assisted-by: Claude:claude-fable-5
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
crates/cli/ents-web/src/identity.rs
@@ -99,8 +99,20 @@
#[macro_export]
macro_rules! receive_identity {
($identity:expr) => {
+ $crate::receive_identity!($identity, None)
+ };
+ // The attributed form (`receive.attributed-author`,
+ // `roots.web-signing`): `$author` is an
+ // `Option<gix::actor::Signature>` naming the signed-in member --
+ // `pages::member_author(&session)` at every mutation call site -- so
+ // hosted history reads "member via the web" while the committer and
+ // signature stay the injected identity. Under a `Trusted` policy the
+ // session never holds a member, the option is `None`, and the commit
+ // is byte-identical to the single-argument form's.
+ ($identity:expr, $author:expr) => {
ents_receive::Identity {
actor: $identity.actor(),
+ author: $author,
sign: &|payload| $identity.sign(payload),
}
};
crates/cli/ents-web/src/lib.rs
@@ -108,6 +108,7 @@
pub(crate) mod asciidoc;
pub(crate) mod assets;
+pub mod auth;
pub(crate) mod editor;
pub mod error;
pub mod identity;
crates/cli/ents-web/src/router.rs
@@ -26,7 +26,7 @@
use crate::assets;
use crate::pages;
use crate::session;
-use crate::state::AppState;
+use crate::state::{AccessPolicy, AppState};
/// Build the full route table, wrapped in the session middleware
/// (`roots.web-session`).
@@ -38,7 +38,22 @@
where
O: Find + Write + Send + 'static,
{
+ // The sign-in surface exists only where the injected policy demands
+ // it (`roots.web-signin`): under `Trusted` — the local root — these
+ // routes are not merely inert, they are unrouted, so `/login` is a
+ // plain 404 and the local surface is byte-identical to before.
+ let sign_in = match state.access {
+ AccessPolicy::SignInRequired(_) => Router::new()
+ .route("/login", get(pages::login::show::<O>))
+ .route(
+ "/login/challenge/{code}",
+ get(pages::login::challenge::<O>).post(pages::login::complete::<O>),
+ )
+ .route("/logout", post(pages::login::logout::<O>)),
+ AccessPolicy::Trusted => Router::new(),
+ };
Router::new()
+ .merge(sign_in)
.route("/", get(pages::dashboard::show::<O>))
.route("/members", get(pages::members::list::<O>))
.route("/members/{username}", get(pages::members::show::<O>))
@@ -92,6 +107,13 @@
.route("/inbox", get(pages::inbox::list::<O>))
.route("/style.css", get(style))
.route("/ents.js", get(script))
+ // Layer order: axum runs the last-added layer first, so the
+ // session middleware (added below) resolves the session before
+ // the auth middleware consults it.
+ .layer(middleware::from_fn_with_state(
+ Arc::clone(&state),
+ auth_middleware::<O>,
+ ))
.layer(middleware::from_fn_with_state(
Arc::clone(&state),
session_middleware::<O>,
@@ -99,6 +121,78 @@
.with_state(state)
}
+/// The access-policy middleware (`roots.web-signin`): under
+/// [`AccessPolicy::Trusted`] every request passes untouched — the local
+/// root's behavior is byte-identical to before this middleware existed.
+/// Under [`AccessPolicy::SignInRequired`], a state-changing request (every
+/// mutation in this crate is a `POST`) requires a session signed in as a
+/// member who is *still* enrolled and active — re-checked here on every
+/// mutation, so a revocation takes effect mid-session, not at the next
+/// sign-in. `/login` and `/logout` are exempt: the sign-in surface itself
+/// authenticates by signature, and logout only clears session state.
+// @relation(roots.web-signin, scope=function)
+async fn auth_middleware<O>(
+ State(state): State<Arc<AppState<O>>>,
+ request: Request,
+ next: Next,
+) -> Response
+where
+ O: Find + Write + Send + 'static,
+{
+ let AccessPolicy::SignInRequired(_) = &state.access else {
+ return next.run(request).await;
+ };
+ let path = request.uri().path();
+ if request.method() != axum::http::Method::POST
+ || path.starts_with("/login")
+ || path == "/logout"
+ {
+ return next.run(request).await;
+ }
+
+ let member = request
+ .extensions()
+ .get::<session::Session>()
+ .and_then(|session| session.member.clone());
+ let enrolled = match &member {
+ Some(member) => crate::auth::active_member_by_key(&state, &member.key)
+ .ok()
+ .flatten()
+ .is_some_and(|username| username == member.username),
+ None => false,
+ };
+ if enrolled {
+ return next.run(request).await;
+ }
+
+ // A signed-in member who no longer verifies against the live member
+ // list is signed out, not just refused (`roots.web-signin`).
+ if member.is_some()
+ && let Some(id) = request
+ .headers()
+ .get(header::COOKIE)
+ .and_then(|value| value.to_str().ok())
+ .and_then(session::session_id_from_cookie_header)
+ {
+ state.sessions.clear_member(id);
+ }
+
+ let wants_html = request
+ .headers()
+ .get(header::ACCEPT)
+ .and_then(|value| value.to_str().ok())
+ .is_some_and(|accept| accept.contains("text/html"));
+ if wants_html {
+ axum::response::Redirect::to("/login").into_response()
+ } else {
+ (
+ axum::http::StatusCode::UNAUTHORIZED,
+ "sign in first: this deployment requires an authenticated member for mutations\n",
+ )
+ .into_response()
+ }
+}
+
/// The session middleware (`roots.web-session`): recognize an existing
/// session cookie, or mint a fresh one and set it on the response. Every
/// handler reads the resolved [`session::Session`] via `Extension`.
@@ -134,9 +228,15 @@
}
};
request.extensions_mut().insert(session);
+ request
+ .extensions_mut()
+ .insert(session::SessionId(id.clone()));
let mut response = next.run(request).await;
- if is_new && let Ok(value) = HeaderValue::from_str(&session::set_cookie_header(&id)) {
+ // `Secure` is policy-driven: hosted (sign-in-required) serving is
+ // HTTPS-only, local plain-HTTP loopback must not lose its cookie.
+ let secure = matches!(state.access, AccessPolicy::SignInRequired(_));
+ if is_new && let Ok(value) = HeaderValue::from_str(&session::set_cookie_header(&id, secure)) {
response.headers_mut().append(header::SET_COOKIE, value);
}
response
crates/cli/ents-web/src/session.rs
@@ -21,17 +21,41 @@
/// request carries its CSRF token in.
pub const CSRF_FIELD: &str = "csrf";
-/// One held session: nothing but the CSRF token it was issued.
-/// `roots.web-session` requires no more than this -- there is no login
-/// step in this phase (see `ents-web`'s crate doc for the scoping this
-/// leaves for a future account/login system), so a session's only job is
-/// letting this server recognize "the same browser that fetched the form
-/// is the one submitting it," which a bare CSRF token already proves.
-// @relation(roots.web-session, scope=file)
+/// The member a session proved control of a key for
+/// (`roots.web-signin`): nothing but the username and the public key —
+/// no secret is ever transmitted or stored.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct SessionMember {
+ /// The enrolled member's username, as resolved at sign-in.
+ pub username: String,
+ /// The member's OpenSSH public key line, re-checked against the live
+ /// member list at every mutation (`roots.web-signin`).
+ pub key: String,
+}
+
+/// The current request's session id, inserted alongside [`Session`] by
+/// the session middleware — a login page needs it to bind a challenge to
+/// exactly this browser (`roots.web-signin`), and nothing else does: the
+/// id is the cookie's secret, so handlers receive it in this deliberate
+/// newtype rather than a bare `String` any format call could leak.
+#[derive(Debug, Clone)]
+pub struct SessionId(pub String);
+
+/// One held session: the CSRF token it was issued, plus — once the
+/// browser completes a sign-in (`roots.web-signin`) — the member it
+/// authenticated as. Under a `Trusted` access policy
+/// ([`crate::state::AccessPolicy`], the local root) `member` stays
+/// `None` for every session and grants nothing either way: the injected
+/// identity is the whole story there, so a session's only job is letting
+/// this server recognize "the same browser that fetched the form is the
+/// one submitting it," which the CSRF token proves.
+// @relation(roots.web-session, roots.web-signin, scope=file)
#[derive(Debug, Clone)]
pub struct Session {
/// The token a state-changing request must echo back.
pub csrf: String,
+ /// The member this session signed in as, if any.
+ pub member: Option<SessionMember>,
}
/// Server-memory-only session storage (`roots.web-session`).
@@ -66,6 +90,7 @@
let id = random_token();
let session = Session {
csrf: random_token(),
+ member: None,
};
#[expect(
clippy::unwrap_used,
@@ -85,6 +110,33 @@
#[expect(clippy::unwrap_used, reason = "see Self::create's identical reasoning")]
self.sessions.lock().unwrap().get(id).cloned()
}
+
+ /// Mark `id`'s session as signed in as `member`
+ /// (`roots.web-signin`), returning whether the session existed — a
+ /// consumed challenge naming a session this store has never held (a
+ /// restart between page load and sign-in) authenticates nothing.
+ pub fn authenticate(&self, id: &str, member: SessionMember) -> bool {
+ #[expect(clippy::unwrap_used, reason = "see Self::create's identical reasoning")]
+ let mut sessions = self.sessions.lock().unwrap();
+ match sessions.get_mut(id) {
+ Some(session) => {
+ session.member = Some(member);
+ true
+ }
+ None => false,
+ }
+ }
+
+ /// Drop `id`'s signed-in member, keeping the session itself — the
+ /// logout action, and the auth middleware's response to a member
+ /// revoked mid-session (`roots.web-signin`).
+ pub fn clear_member(&self, id: &str) {
+ #[expect(clippy::unwrap_used, reason = "see Self::create's identical reasoning")]
+ let mut sessions = self.sessions.lock().unwrap();
+ if let Some(session) = sessions.get_mut(id) {
+ session.member = None;
+ }
+ }
}
/// A random, URL-safe token: 32 hex characters from 16 random bytes.
@@ -115,9 +167,16 @@
/// replacement for it: `SameSite=Strict` alone would already block a
/// cross-site POST, but a network intermediary or a future relaxation of
/// that attribute must not silently remove the protection).
+///
+/// `secure` adds the `Secure` attribute — set by the hosted deployment,
+/// which only ever serves behind HTTPS, and never by local plain-HTTP
+/// loopback serving, where the attribute would make the browser drop the
+/// cookie entirely. Policy-driven, not deployment-sniffed: the caller
+/// passes what its [`crate::state::AccessPolicy`] implies.
#[must_use]
-pub fn set_cookie_header(id: &str) -> String {
- format!("{COOKIE_NAME}={id}; Path=/; HttpOnly; SameSite=Strict")
+pub fn set_cookie_header(id: &str, secure: bool) -> String {
+ let secure = if secure { "; Secure" } else { "" };
+ format!("{COOKIE_NAME}={id}; Path=/; HttpOnly; SameSite=Strict{secure}")
}
#[cfg(test)]
@@ -144,7 +203,7 @@
#[rstest]
// @relation(roots.web-session, scope=function, role=Verifies)
fn cookie_header_round_trips_the_session_id() {
- let header = set_cookie_header("abc123");
+ let header = set_cookie_header("abc123", false);
assert!(header.contains("HttpOnly"));
let raw_cookie = header.split(';').next().expect("at least one segment");
assert_eq!(session_id_from_cookie_header(raw_cookie), Some("abc123"));
crates/cli/ents-web/src/state.rs
@@ -30,9 +30,39 @@
use ents_receive::{EventSink, Mode};
use gix_ref_store::RefStore;
+use crate::auth::ChallengeStore;
use crate::identity::SigningIdentity;
use crate::session::SessionStore;
+/// Who may mutate through this deployment's web UI — injected by the
+/// composition root, exactly as the signing identity is
+/// (`roots.web-agnostic`): this crate branches on the injected policy's
+/// value, never on where it is running (`arch.no-hosted-branch`'s
+/// spirit).
+// @relation(roots.web-signin, scope=type)
+pub enum AccessPolicy {
+ /// Every session mutates as the injected identity — the local root
+ /// (`roots.local`), where the operator's own key *is* the identity
+ /// and no sign-in surface exists (`roots.web-signin`).
+ Trusted,
+ /// Anonymous sessions browse read-only; a mutation requires a
+ /// session signed in as an enrolled, active member
+ /// (`roots.web-signin`) — the hosted root.
+ SignInRequired(Realm),
+}
+
+/// What a sign-in-required deployment knows about itself: the canonical
+/// external host bound into every challenge payload
+/// ([`crate::auth::challenge_payload`]), and the outstanding challenges.
+pub struct Realm {
+ /// The host a member addresses this deployment as, e.g.
+ /// `git.ents.cloud` — a signature is bound to it, so one minted for
+ /// this realm verifies nowhere else (`roots.web-signin`).
+ pub host: String,
+ /// Outstanding sign-in challenges, memory-only like the sessions.
+ pub challenges: ChallengeStore,
+}
+
/// Everything a page handler needs: the four composition-root seams, the
/// gate policy in force, the repository's working-tree path (comment
/// anchoring resolves paths against it), and the in-memory session store
@@ -67,6 +97,10 @@
pub path: PathBuf,
/// In-memory web sessions (`roots.web-session`).
pub sessions: SessionStore,
+ /// Who may mutate here (`roots.web-signin`): [`AccessPolicy::Trusted`]
+ /// unless the composition root said otherwise via
+ /// [`AppState::with_access`].
+ pub access: AccessPolicy,
}
impl<O> AppState<O> {
@@ -89,9 +123,22 @@
identity,
path,
sessions: SessionStore::default(),
+ access: AccessPolicy::Trusted,
}
}
+ /// Replace the default [`AccessPolicy::Trusted`] — the hosted
+ /// composition root's one extra wiring step
+ /// (`roots.single-node-hosted`, `roots.web-signin`). A consuming
+ /// builder rather than a constructor parameter so every existing
+ /// `new` caller (every `Trusted` deployment and test fixture) stays
+ /// untouched.
+ #[must_use]
+ pub fn with_access(mut self, access: AccessPolicy) -> Self {
+ self.access = access;
+ self
+ }
+
/// Lock the object store for the duration of one request.
///
/// Poisoning recovers rather than propagating (mirrors
crates/cli/ents-web/tests/router.rs
@@ -2830,3 +2830,429 @@
assert_eq!(response.status(), StatusCode::OK, "GET {path}");
}
}
+
+// ---------------------------------------------------------------------
+// roots.web-signin: the hosted sign-in surface, driven exactly as the
+// CLI and a browser would drive it, still with no socket anywhere.
+// ---------------------------------------------------------------------
+
+/// Sign `payload` under the *login* namespace with seed `seed`'s key --
+/// the same deterministic key `Keypair::from_seed(seed)` wraps, rebuilt
+/// here because `Keypair::sign` deliberately signs only git's own commit
+/// namespace.
+fn login_sign(seed: u8, payload: &[u8]) -> String {
+ use ssh_key::private::{Ed25519Keypair, KeypairData};
+ use ssh_key::{HashAlg, LineEnding, PrivateKey};
+ let pair = Ed25519Keypair::from_seed(&[seed; 32]);
+ let key = PrivateKey::new(KeypairData::from(pair), "test").expect("well-formed");
+ key.sign(ents_web::auth::LOGIN_NAMESPACE, HashAlg::Sha512, payload)
+ .expect("signing is infallible")
+ .to_pem(LineEnding::LF)
+ .expect("renders")
+}
+
+/// Percent-encode a form value (the armored signature carries newlines,
+/// `+`, `/`, and `=`, every one of which is significant to a form body).
+fn urlencode(value: &str) -> String {
+ value
+ .bytes()
+ .map(|b| match b {
+ b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
+ char::from(b).to_string()
+ }
+ _ => format!("%{b:02X}"),
+ })
+ .collect()
+}
+
+/// A sign-in-required state over `refs`/`objects`, host `ents.test` --
+/// the hosted composition root's shape (`roots.single-node-hosted`),
+/// minus the real repo.
+fn build_signin_state(
+ identity: FixtureIdentity,
+ refs: MemRefStore,
+ objects: ObjectStore,
+) -> Arc<AppState<ObjectStore>> {
+ Arc::new(
+ AppState::new(
+ Box::new(refs),
+ objects,
+ Box::new(NullEventSink),
+ Mode::Advisory,
+ Box::new(identity),
+ std::env::temp_dir(),
+ )
+ .with_access(ents_web::state::AccessPolicy::SignInRequired(
+ ents_web::state::Realm {
+ host: "ents.test".to_owned(),
+ challenges: ents_web::auth::ChallengeStore::default(),
+ },
+ )),
+ )
+}
+
+/// GET /login with `cookie`, returning the fresh code the page displays.
+async fn fetch_login_code(router: &axum::Router, cookie: &str) -> String {
+ let response = router
+ .clone()
+ .oneshot(
+ Request::get("/login")
+ .header(header::COOKIE, cookie)
+ .body(Body::empty())
+ .expect("request"),
+ )
+ .await
+ .expect("in-process call");
+ assert_eq!(response.status(), StatusCode::OK);
+ let body = String::from_utf8(
+ response
+ .into_body()
+ .collect()
+ .await
+ .expect("body")
+ .to_bytes()
+ .to_vec(),
+ )
+ .expect("utf8");
+ let after = body
+ .split("ents.test ")
+ .nth(1)
+ .expect("the page displays the login command");
+ after.chars().take(9).collect()
+}
+
+/// Complete a challenge for `code` as seed `seed`'s key, returning the
+/// response.
+async fn complete_challenge(
+ router: &axum::Router,
+ code: &str,
+ seed: u8,
+ host: &str,
+) -> axum::response::Response {
+ let challenge = router
+ .clone()
+ .oneshot(
+ Request::get(format!("/login/challenge/{code}"))
+ .body(Body::empty())
+ .expect("request"),
+ )
+ .await
+ .expect("in-process call");
+ assert_eq!(challenge.status(), StatusCode::OK, "challenge fetch");
+ let text = String::from_utf8(
+ challenge
+ .into_body()
+ .collect()
+ .await
+ .expect("body")
+ .to_bytes()
+ .to_vec(),
+ )
+ .expect("utf8");
+ let nonce = text
+ .lines()
+ .find_map(|line| line.strip_prefix("nonce="))
+ .expect("a nonce line");
+
+ let normalized = ents_web::auth::normalize_code(code);
+ let payload = ents_web::auth::challenge_payload(host, &normalized, nonce);
+ let signature = login_sign(seed, payload.as_bytes());
+ let public_key = Keypair::from_seed(seed).public_openssh();
+ let form = format!(
+ "public_key={}&signature={}",
+ urlencode(&public_key),
+ urlencode(&signature)
+ );
+ router
+ .clone()
+ .oneshot(
+ Request::post(format!("/login/challenge/{code}"))
+ .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
+ .body(Body::from(form))
+ .expect("request"),
+ )
+ .await
+ .expect("in-process call")
+}
+
+/// Rewrite `username`'s member record as revoked, directly against
+/// `state`'s own stores -- the commit is staged via a scratch ref store
+/// (`write_meta_entity` wants a concrete `MemRefStore`) and applied to
+/// the live one through the trait's own CAS transaction.
+fn revoke_member(state: &AppState<ObjectStore>, username: &str, key: &Keypair, seconds: i64) {
+ let scratch = MemRefStore::default();
+ let name = ents_model::namespace::member_ref(&MemberId::new(username)).expect("valid");
+ let mut member = ents_model::Member::new(
+ MemberId::new(username),
+ key.public_openssh(),
+ Provenance::AdminRegistered,
+ );
+ member.state = ents_model::MemberState::Revoked;
+ let oid = write_meta_entity(
+ &scratch,
+ &*state.objects(),
+ name.clone(),
+ &member,
+ Some(&Keypair::from_seed(9)),
+ seconds,
+ );
+ state
+ .refs
+ .transaction(&[gix_ref_store::RefEdit {
+ name,
+ expected: gix_ref_store::Expected::Any,
+ new: Some(oid),
+ }])
+ .expect("applies");
+}
+
+#[tokio::test]
+// @relation(roots.web-signin, scope=function, role=Verifies)
+async fn the_login_surface_is_unrouted_under_trusted() {
+ let state = build_state(FixtureIdentity {
+ name: "local-user",
+ key: Keypair::from_seed(1),
+ });
+ let router = ents_web::router(Arc::clone(&state));
+ for path in ["/login", "/login/challenge/ABCD2345"] {
+ let response = router
+ .clone()
+ .oneshot(Request::get(path).body(Body::empty()).expect("request"))
+ .await
+ .expect("in-process call");
+ assert_eq!(response.status(), StatusCode::NOT_FOUND, "GET {path}");
+ }
+}
+
+#[tokio::test]
+// @relation(roots.web-signin, scope=function, role=Verifies)
+async fn the_cli_challenge_flow_signs_the_browser_session_in() {
+ let refs = MemRefStore::default();
+ let objects = ObjectStore::default();
+ let joey = Keypair::from_seed(7);
+ enroll_member(
+ &refs,
+ &objects,
+ "joey",
+ &joey,
+ Provenance::AdminRegistered,
+ 100,
+ );
+ let state = build_signin_state(
+ FixtureIdentity {
+ name: "server",
+ key: Keypair::from_seed(9),
+ },
+ refs,
+ objects,
+ );
+ let router = ents_web::router(Arc::clone(&state));
+
+ let (cookie, _csrf) = session_cookie_and_csrf(&router, &state, "/").await;
+ let code = fetch_login_code(&router, &cookie).await;
+
+ let response = complete_challenge(&router, &code, 7, "ents.test").await;
+ assert_eq!(response.status(), StatusCode::OK, "sign-in completes");
+
+ // The browser's next look at /login reads as signed in.
+ let session_id = cookie
+ .split(';')
+ .next()
+ .expect("segment")
+ .split_once('=')
+ .expect("name=value")
+ .1
+ .to_owned();
+ let member = state
+ .sessions
+ .get(&session_id)
+ .expect("held")
+ .member
+ .expect("signed in");
+ assert_eq!(member.username, "joey");
+
+ // And a second post of the same code finds it consumed -- posted
+ // directly, since the challenge fetch itself now correctly 404s.
+ let replay = router
+ .clone()
+ .oneshot(
+ Request::post(format!("/login/challenge/{code}"))
+ .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
+ .body(Body::from("public_key=x&signature=y"))
+ .expect("request"),
+ )
+ .await
+ .expect("in-process call");
+ assert_eq!(replay.status(), StatusCode::NOT_FOUND, "single-use");
+}
+
+#[tokio::test]
+// @relation(roots.web-signin, scope=function, role=Verifies)
+async fn a_wrong_host_signature_or_foreign_key_is_refused() {
+ let refs = MemRefStore::default();
+ let objects = ObjectStore::default();
+ let joey = Keypair::from_seed(7);
+ enroll_member(
+ &refs,
+ &objects,
+ "joey",
+ &joey,
+ Provenance::AdminRegistered,
+ 100,
+ );
+ let state = build_signin_state(
+ FixtureIdentity {
+ name: "server",
+ key: Keypair::from_seed(9),
+ },
+ refs,
+ objects,
+ );
+ let router = ents_web::router(Arc::clone(&state));
+ let (cookie, _csrf) = session_cookie_and_csrf(&router, &state, "/").await;
+
+ // A signature over another deployment's host does not verify here.
+ let code = fetch_login_code(&router, &cookie).await;
+ let response = complete_challenge(&router, &code, 7, "evil.example").await;
+ assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
+
+ // An unenrolled key's valid signature is refused as not a member.
+ let code = fetch_login_code(&router, &cookie).await;
+ let response = complete_challenge(&router, &code, 3, "ents.test").await;
+ assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
+
+ // A revoked member's key is refused the same way.
+ revoke_member(&state, "joey", &joey, 200);
+ let code = fetch_login_code(&router, &cookie).await;
+ let response = complete_challenge(&router, &code, 7, "ents.test").await;
+ assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
+}
+
+#[tokio::test]
+// @relation(roots.web-signin, roots.web-session, scope=function, role=Verifies)
+async fn mutations_require_a_live_signed_in_member_and_csrf_still_gates() {
+ let refs = MemRefStore::default();
+ let objects = ObjectStore::default();
+ let joey = Keypair::from_seed(7);
+ enroll_member(
+ &refs,
+ &objects,
+ "joey",
+ &joey,
+ Provenance::AdminRegistered,
+ 100,
+ );
+ let state = build_signin_state(
+ FixtureIdentity {
+ name: "server",
+ key: Keypair::from_seed(9),
+ },
+ refs,
+ objects,
+ );
+ let router = ents_web::router(Arc::clone(&state));
+ let (cookie, csrf) = session_cookie_and_csrf(&router, &state, "/").await;
+
+ let post_account = |form: String, with_cookie: bool, accept_html: bool| {
+ let mut request = Request::post("/account")
+ .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded");
+ if with_cookie {
+ request = request.header(header::COOKIE, cookie.clone());
+ }
+ if accept_html {
+ request = request.header(header::ACCEPT, "text/html");
+ }
+ router
+ .clone()
+ .oneshot(request.body(Body::from(form)).expect("request"))
+ };
+
+ // Anonymous: a browser-shaped POST redirects to /login, a bare one
+ // gets 401.
+ let form = format!("member=joey&login=j@ents.test&csrf={csrf}");
+ let response = post_account(form.clone(), true, true).await.expect("call");
+ assert_eq!(response.status(), StatusCode::SEE_OTHER);
+ assert_eq!(
+ response.headers().get(header::LOCATION).expect("location"),
+ "/login"
+ );
+ let response = post_account(form.clone(), true, false).await.expect("call");
+ assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
+
+ // Sign in, then the same POST passes the middleware -- and CSRF is
+ // still enforced on top of it.
+ let code = fetch_login_code(&router, &cookie).await;
+ let signed_in = complete_challenge(&router, &code, 7, "ents.test").await;
+ assert_eq!(signed_in.status(), StatusCode::OK);
+ let response = post_account(form.clone(), true, true).await.expect("call");
+ assert_eq!(response.status(), StatusCode::SEE_OTHER, "mutation lands");
+ assert_eq!(
+ response.headers().get(header::LOCATION).expect("location"),
+ "/account"
+ );
+
+ // The landed commit is authored by the member and committed by the
+ // server identity -- "joey via the web"
+ // (receive.attributed-author, roots.web-signing).
+ {
+ use gix_object::Find as _;
+ let account_ref: gix::refs::FullName = ents_model::namespace::ACCOUNT_REF
+ .try_into()
+ .expect("valid");
+ let tip = state
+ .refs
+ .get(account_ref.as_ref())
+ .expect("readable")
+ .expect("written");
+ let mut buf = Vec::new();
+ let objects = state.objects();
+ let data = objects
+ .try_find(&tip, &mut buf)
+ .expect("readable")
+ .expect("present");
+ let commit = gix_object::CommitRef::from_bytes(data.data, tip.kind()).expect("parses");
+ assert_eq!(
+ commit.author().expect("author").name,
+ "joey",
+ "authored by the signed-in member"
+ );
+ assert_eq!(
+ commit.committer().expect("committer").name,
+ "server",
+ "committed by the server identity"
+ );
+ }
+ let bad_csrf = "member=joey&login=j@ents.test&csrf=not-the-token".to_owned();
+ let response = post_account(bad_csrf, true, true).await.expect("call");
+ assert_ne!(
+ response.status(),
+ StatusCode::SEE_OTHER,
+ "a signed-in session still fails a wrong csrf token"
+ );
+
+ // Revoke joey mid-session: the next mutation is refused and the
+ // session is signed out, not just bounced.
+ revoke_member(&state, "joey", &joey, 300);
+ let response = post_account(form, true, true).await.expect("call");
+ assert_eq!(response.status(), StatusCode::SEE_OTHER);
+ assert_eq!(
+ response.headers().get(header::LOCATION).expect("location"),
+ "/login"
+ );
+ let session_id = cookie
+ .split(';')
+ .next()
+ .expect("segment")
+ .split_once('=')
+ .expect("name=value")
+ .1;
+ assert!(
+ state
+ .sessions
+ .get(session_id)
+ .expect("held")
+ .member
+ .is_none(),
+ "a revoked member is signed out, not left holding a dead session"
+ );
+}
crates/cli/ents-web/src/pages/members.rs
@@ -184,7 +184,7 @@
/// omitting it (`roots.web-agnostic`: a reader surfaces a marker, never an
/// error or a silent gap, for one entity written by a schema this build no
/// longer speaks).
-fn read_all<O: Find>(
+pub(crate) fn read_all<O: Find>(
state: &AppState<O>,
) -> Result<Vec<(String, std::result::Result<Member, String>)>> {
let mut out = Vec::new();
crates/cli/ents-web/src/pages/mod.rs
@@ -30,6 +30,7 @@
pub mod files;
pub mod inbox;
pub mod issues;
+pub mod login;
pub mod members;
pub mod meta;
pub mod redactions;
@@ -442,6 +443,29 @@
///
/// [`Error::BadCsrf`] if `submitted` does not match.
// @relation(roots.web-session, scope=function)
+/// The author signature an attributed mutation carries
+/// (`receive.attributed-author`): the session's signed-in member, stamped
+/// with the current time, or `None` when the session holds no member --
+/// every `Trusted` deployment, and a hosted request that somehow reached a
+/// mutation anonymously (the auth middleware refuses those first). The
+/// synthetic email domain is reserved (RFC 2606): a member record carries
+/// no email of its own.
+// @relation(receive.attributed-author, scope=function)
+pub(crate) fn member_author(session: &Session) -> Option<gix::actor::Signature> {
+ let member = session.member.as_ref()?;
+ let seconds = std::time::SystemTime::now()
+ .duration_since(std::time::UNIX_EPOCH)
+ .unwrap_or_default()
+ .as_secs()
+ .try_into()
+ .unwrap_or_default();
+ Some(gix::actor::Signature {
+ name: member.username.clone().into(),
+ email: format!("{}@members.invalid", member.username).into(),
+ time: gix::date::Time { seconds, offset: 0 },
+ })
+}
+
pub(crate) fn require_csrf(session: &Session, submitted: &str) -> Result<()> {
if submitted == session.csrf {
Ok(())
crates/cli/ents-web/src/auth.rs
@@ -1,0 +1,340 @@
+//! Hosted web sign-in (`roots.web-signin`): prove control of an enrolled,
+//! active member key by signing a server-issued one-time challenge.
+//!
+//! The protocol is the pre-redo forge's key-proof sign-in with the paste
+//! step replaced by `git ents login`: the `/login` page mints a short code
+//! bound to the browser's own session, the CLI fetches the full challenge,
+//! signs it under [`LOGIN_NAMESPACE`] — deliberately distinct from git's
+//! `git` commit namespace, so a sign-in signature can never double as a
+//! push signature or vice versa — and posts the signature back. The
+//! server verifies it in pure Rust against the member's stored OpenSSH
+//! public key, the same `ssh_key` technique `ents_gate::signature` uses
+//! for commit signatures.
+//!
+//! Replay is bound off at every joint: the signed payload names the
+//! serving host, the code, and the nonce ([`challenge_payload`]), so a
+//! signature minted for one deployment or one browser session verifies
+//! nowhere else; a challenge is single-use ([`ChallengeStore::take`]) and
+//! expires after [`CHALLENGE_TTL`], measured on the monotonic clock.
+// @relation(roots.web-signin, scope=file)
+
+use std::collections::HashMap;
+use std::sync::Mutex;
+use std::time::{Duration, Instant};
+
+use ents_model::MemberState;
+use gix_object::Find;
+use ssh_key::{PublicKey, SshSig};
+
+use crate::state::AppState;
+
+/// The SSHSIG namespace a sign-in signature is made under — never `git`,
+/// so a login signature and a push signature are unexchangeable
+/// (`roots.web-signin`).
+pub const LOGIN_NAMESPACE: &str = "git-ents-login";
+
+/// How long an issued sign-in challenge stays valid. Measured with
+/// [`Instant`], so a wall-clock step never extends or shortens a
+/// challenge's life (a suspended machine's paused monotonic clock can
+/// honor one slightly longer than this in wall time — accepted).
+pub const CHALLENGE_TTL: Duration = Duration::from_secs(600);
+
+/// The exact bytes both sides sign and verify: version-tagged, binding
+/// the serving host, the browser code, and the one-time nonce. The CLI
+/// MUST rebuild this locally from the host the member addressed rather
+/// than signing server-supplied bytes (`roots.web-signin`).
+#[must_use]
+pub fn challenge_payload(host: &str, code: &str, nonce: &str) -> String {
+ format!("git-ents-login-v1\nhost={host}\ncode={code}\nnonce={nonce}\n")
+}
+
+/// Normalize a user-facing code: the `/login` page displays `XXXX-XXXX`
+/// and a human may retype it in either case, with or without the dash.
+#[must_use]
+pub fn normalize_code(code: &str) -> String {
+ code.chars()
+ .filter(|c| *c != '-')
+ .map(|c| c.to_ascii_uppercase())
+ .collect()
+}
+
+/// One outstanding sign-in challenge: which browser session it will
+/// authenticate, the nonce the signature must cover, and when it was
+/// issued.
+#[derive(Debug, Clone)]
+pub struct Challenge {
+ /// The session id the `/login` page bound this challenge to — the
+ /// only session [`ChallengeStore::take`]'s caller may authenticate.
+ pub session_id: String,
+ /// The one-time nonce bound into [`challenge_payload`].
+ pub nonce: String,
+ /// When this challenge was issued, for [`CHALLENGE_TTL`].
+ issued: Instant,
+}
+
+/// Outstanding sign-in challenges, keyed by their user-facing code —
+/// memory-only, like [`crate::session::SessionStore`], and pruned of
+/// expired entries on every issue.
+#[derive(Default)]
+pub struct ChallengeStore {
+ table: Mutex<HashMap<String, Challenge>>,
+}
+
+impl ChallengeStore {
+ /// Mint a fresh challenge bound to `session_id`, returning its
+ /// `(code, nonce)`. Re-issuing for the same session replaces that
+ /// session's outstanding challenge, so one browser holds at most one
+ /// live code.
+ #[must_use]
+ pub fn issue(&self, session_id: &str) -> (String, String) {
+ let code = random_code();
+ let nonce = random_nonce();
+ let challenge = Challenge {
+ session_id: session_id.to_owned(),
+ nonce: nonce.clone(),
+ issued: Instant::now(),
+ };
+ let mut table = self.lock();
+ let now = Instant::now();
+ table.retain(|_code, held| {
+ now.duration_since(held.issued) < CHALLENGE_TTL && held.session_id != session_id
+ });
+ table.insert(code.clone(), challenge);
+ (code, nonce)
+ }
+
+ /// Read `code`'s live challenge without consuming it — the CLI's
+ /// initial fetch. Expired entries read as absent.
+ #[must_use]
+ pub fn peek(&self, code: &str) -> Option<Challenge> {
+ let code = normalize_code(code);
+ let table = self.lock();
+ let held = table.get(&code)?;
+ (Instant::now().duration_since(held.issued) < CHALLENGE_TTL).then(|| held.clone())
+ }
+
+ /// Consume `code`, returning its challenge iff it was live and
+ /// unexpired — single-use by construction: a second take of the same
+ /// code is `None` whatever the first returned.
+ #[must_use]
+ pub fn take(&self, code: &str) -> Option<Challenge> {
+ let code = normalize_code(code);
+ let held = self.lock().remove(&code)?;
+ (Instant::now().duration_since(held.issued) < CHALLENGE_TTL).then_some(held)
+ }
+
+ /// Age `code`'s challenge by `by`, as if it had been issued that much
+ /// earlier — tests cannot construct an [`Instant`] in the past any
+ /// other way.
+ #[cfg(test)]
+ fn backdate(&self, code: &str, by: Duration) {
+ if let Some(held) = self.lock().get_mut(&normalize_code(code))
+ && let Some(issued) = held.issued.checked_sub(by)
+ {
+ held.issued = issued;
+ }
+ }
+
+ fn lock(&self) -> std::sync::MutexGuard<'_, HashMap<String, Challenge>> {
+ self.table
+ .lock()
+ .unwrap_or_else(std::sync::PoisonError::into_inner)
+ }
+}
+
+/// Whether `signature` (an armored SSHSIG) over `payload` verifies
+/// against `public_key` (an OpenSSH single-line key, as stored on an
+/// [`ents_model::Member`]) under [`LOGIN_NAMESPACE`]. Any malformed key,
+/// malformed signature, wrong namespace, or failed cryptographic check is
+/// `false` — mirrors `ents_gate::signature`'s identical posture for
+/// commit signatures.
+#[must_use]
+pub fn verify_login(public_key: &str, payload: &[u8], signature: &str) -> bool {
+ let Ok(key) = PublicKey::from_openssh(public_key) else {
+ return false;
+ };
+ let Ok(sig) = SshSig::from_pem(signature) else {
+ return false;
+ };
+ key.verify(LOGIN_NAMESPACE, payload, &sig).is_ok()
+}
+
+/// Resolve `pubkey` to the enrolled, *active* member that stores it, if
+/// any — the sign-in completion's membership check, and the auth
+/// middleware's per-mutation re-check (`roots.web-signin`: a session
+/// whose member is no longer enrolled and active is refused at the time
+/// of the mutation, not only at sign-in).
+///
+/// # Errors
+///
+/// Propagates a ref-store read failure; an individual member ref this
+/// build cannot decode is skipped, exactly as the members page skips it.
+pub(crate) fn active_member_by_key<O: Find>(
+ state: &AppState<O>,
+ pubkey: &str,
+) -> crate::Result<Option<String>> {
+ for (username, member) in crate::pages::members::read_all(state)? {
+ if let Ok(member) = member
+ && member.key == pubkey
+ && member.state == MemberState::Active
+ {
+ return Ok(Some(username));
+ }
+ }
+ Ok(None)
+}
+
+/// A user-facing code: eight characters of Crockford-style base32 (no
+/// `I`, `L`, `O`, `U`, no lowercase), ~40 bits — plenty for a single-use
+/// secret that lives ten minutes, and short enough to retype.
+fn random_code() -> String {
+ const ALPHABET: &[u8] = b"0123456789ABCDEFGHJKMNPQRSTVWXYZ";
+ let mut bytes = [0u8; 8];
+ fill_random(&mut bytes);
+ bytes
+ .iter()
+ .map(|b| {
+ // The low five bits index exactly the 32-entry alphabet, so
+ // the draw is uniform and the index cannot overrun.
+ let index = usize::from(*b & 0x1f);
+ #[expect(
+ clippy::indexing_slicing,
+ reason = "a five-bit index cannot overrun the 32-entry alphabet"
+ )]
+ char::from(ALPHABET[index])
+ })
+ .collect()
+}
+
+/// A nonce: 32 hex characters from 16 random bytes, the same shape as a
+/// session id.
+fn random_nonce() -> String {
+ let mut bytes = [0u8; 16];
+ fill_random(&mut bytes);
+ bytes.iter().map(|b| format!("{b:02x}")).collect()
+}
+
+fn fill_random(bytes: &mut [u8]) {
+ #[expect(
+ clippy::expect_used,
+ reason = "getrandom only fails when the platform has no randomness source at all, which \
+ every target this crate ships to provides"
+ )]
+ getrandom::fill(bytes).expect("platform randomness source is available");
+}
+
+#[cfg(test)]
+mod tests {
+ #![allow(clippy::expect_used, reason = "unit test")]
+
+ use rstest::rstest;
+ use ssh_key::private::{Ed25519Keypair, KeypairData};
+ use ssh_key::{HashAlg, LineEnding, PrivateKey};
+
+ use super::*;
+
+ fn keypair(seed: u8) -> PrivateKey {
+ let pair = Ed25519Keypair::from_seed(&[seed; 32]);
+ PrivateKey::new(KeypairData::from(pair), "test").expect("well-formed")
+ }
+
+ fn sign_in_namespace(key: &PrivateKey, namespace: &str, payload: &[u8]) -> String {
+ key.sign(namespace, HashAlg::Sha512, payload)
+ .expect("signing is infallible for a loaded key")
+ .to_pem(LineEnding::LF)
+ .expect("an SSHSIG always renders as PEM")
+ }
+
+ fn public_line(key: &PrivateKey) -> String {
+ key.public_key().to_openssh().expect("renders")
+ }
+
+ #[rstest]
+ // @relation(roots.web-signin, scope=function, role=Verifies)
+ fn payload_is_the_exact_versioned_bytes_both_sides_build() {
+ assert_eq!(
+ challenge_payload("git.ents.cloud", "ABCD2345", "0f" /* nonce */),
+ "git-ents-login-v1\nhost=git.ents.cloud\ncode=ABCD2345\nnonce=0f\n"
+ );
+ }
+
+ #[rstest]
+ // @relation(roots.web-signin, scope=function, role=Verifies)
+ fn a_challenge_is_single_use() {
+ let store = ChallengeStore::default();
+ let (code, nonce) = store.issue("session-1");
+ let taken = store.take(&code).expect("first take");
+ assert_eq!(taken.session_id, "session-1");
+ assert_eq!(taken.nonce, nonce);
+ assert!(store.take(&code).is_none(), "second take must fail");
+ }
+
+ #[rstest]
+ // @relation(roots.web-signin, scope=function, role=Verifies)
+ fn peek_reads_without_consuming_and_codes_normalize() {
+ let store = ChallengeStore::default();
+ let (code, nonce) = store.issue("session-1");
+ let (head, tail) = code.split_at(4);
+ let dashed = format!("{head}-{}", tail.to_lowercase());
+ assert_eq!(store.peek(&dashed).expect("live").nonce, nonce);
+ assert!(store.take(&dashed).is_some(), "peek must not consume");
+ }
+
+ #[rstest]
+ // @relation(roots.web-signin, scope=function, role=Verifies)
+ fn an_expired_challenge_reads_as_absent() {
+ let store = ChallengeStore::default();
+ let (code, _nonce) = store.issue("session-1");
+ store.backdate(&code, CHALLENGE_TTL.saturating_add(Duration::from_secs(1)));
+ assert!(store.peek(&code).is_none());
+ assert!(store.take(&code).is_none());
+ }
+
+ #[rstest]
+ // @relation(roots.web-signin, scope=function, role=Verifies)
+ fn reissuing_replaces_the_sessions_outstanding_challenge() {
+ let store = ChallengeStore::default();
+ let (first, _) = store.issue("session-1");
+ let (second, _) = store.issue("session-1");
+ assert!(store.take(&first).is_none(), "replaced by the re-issue");
+ assert!(store.take(&second).is_some());
+ }
+
+ #[rstest]
+ // @relation(roots.web-signin, scope=function, role=Verifies)
+ fn verify_accepts_only_the_login_namespace_over_the_exact_payload() {
+ let key = keypair(1);
+ let payload = challenge_payload("git.ents.cloud", "ABCD2345", "0011");
+ let good = sign_in_namespace(&key, LOGIN_NAMESPACE, payload.as_bytes());
+ assert!(verify_login(&public_line(&key), payload.as_bytes(), &good));
+
+ // A real signature under git's own commit namespace over the very
+ // same bytes must not double as a login (`roots.web-signin`).
+ let push_shaped = sign_in_namespace(&key, "git", payload.as_bytes());
+ assert!(!verify_login(
+ &public_line(&key),
+ payload.as_bytes(),
+ &push_shaped
+ ));
+
+ // Any drift in the signed payload — another host, another code,
+ // another nonce — fails verification.
+ let other = challenge_payload("evil.example", "ABCD2345", "0011");
+ assert!(!verify_login(&public_line(&key), other.as_bytes(), &good));
+
+ // Another member's key does not verify this member's signature.
+ assert!(!verify_login(
+ &public_line(&keypair(2)),
+ payload.as_bytes(),
+ &good
+ ));
+
+ // Garbage inputs are false, never a panic.
+ assert!(!verify_login("not a key", payload.as_bytes(), &good));
+ assert!(!verify_login(
+ &public_line(&key),
+ payload.as_bytes(),
+ "not a signature"
+ ));
+ }
+}
crates/cli/ents-web/src/pages/login.rs
@@ -1,0 +1,257 @@
+//! `GET /login`, `GET`/`POST /login/challenge/{code}`, `POST /logout`:
+//! the hosted sign-in surface (`roots.web-signin`), mounted only when the
+//! composition root injected [`AccessPolicy::SignInRequired`] — a local
+//! root has no sign-in surface at all, so under
+//! [`AccessPolicy::Trusted`] these routes do not exist and `/login` is a
+//! plain 404.
+//!
+//! The flow inverts a device-code login (`gh auth login`'s shape) because
+//! here the *CLI* holds the credential: the browser's `/login` page mints
+//! a short one-time code bound to its own session and displays the
+//! `git ents login` command to run; the CLI fetches the full challenge
+//! (`GET`, non-consuming), rebuilds the payload locally from the host the
+//! member addressed, signs it under [`crate::auth::LOGIN_NAMESPACE`], and
+//! posts the signature back (`POST`, consuming). The page refreshes
+//! itself until the session reads as signed in.
+//!
+//! The CLI endpoints speak `key=value` text lines, not HTML forms'
+//! escaping rules and not JSON — the same trivially-parseable shape the
+//! challenge payload itself uses, so neither side grows a parser.
+
+use std::sync::Arc;
+
+use axum::Form;
+use axum::extract::{Path, State};
+use axum::http::{StatusCode, header};
+use axum::response::{IntoResponse, Redirect, Response};
+use gix_object::{Find, Write};
+use maud::html;
+use serde::Deserialize;
+
+use crate::auth;
+use crate::session::{Session, SessionId, SessionMember};
+use crate::state::{AccessPolicy, AppState, Realm};
+
+/// The realm, or a 404 — these handlers are only ever routed under
+/// [`AccessPolicy::SignInRequired`], so a miss here is a wiring error,
+/// answered exactly as an unmounted route would be.
+fn realm<O>(state: &AppState<O>) -> Result<&Realm, Box<Response>> {
+ match &state.access {
+ AccessPolicy::SignInRequired(realm) => Ok(realm),
+ AccessPolicy::Trusted => Err(Box::new(StatusCode::NOT_FOUND.into_response())),
+ }
+}
+
+/// `GET /login`: the sign-in page. Signed out, it mints a challenge
+/// bound to this browser's session and shows the one command to run;
+/// the page refreshes itself every few seconds until the session reads
+/// as signed in — no script needed, and a challenge consumed by the CLI
+/// re-issues on the next refresh only if sign-in did not complete.
+// @relation(roots.web-signin, scope=function)
+pub async fn show<O>(
+ State(state): State<Arc<AppState<O>>>,
+ axum::Extension(session): axum::Extension<Session>,
+ axum::Extension(SessionId(session_id)): axum::Extension<SessionId>,
+) -> Response
+where
+ O: Find + Write + Send + 'static,
+{
+ let realm = match realm(&state) {
+ Ok(realm) => realm,
+ Err(response) => return *response,
+ };
+
+ let body = match &session.member {
+ Some(member) => html! {
+ div.readable {
+ p { "Signed in as " strong { (member.username) } "." }
+ p.muted {
+ "Edits you make here are authored as this member and "
+ "signed by the server's own key -- history reads "
+ em { (member.username) " via the web" } "."
+ }
+ form method="post" action="/logout" {
+ input type="hidden" name="csrf" value=(session.csrf);
+ button.btn type="submit" { "Sign out" }
+ }
+ }
+ },
+ None => {
+ let (code, _nonce) = realm.challenges.issue(&session_id);
+ // The code is eight ASCII base32 characters by construction;
+ // split_at is byte-indexed and cannot land inside a char.
+ let (head, tail) = code.split_at(4);
+ let display = format!("{head}-{tail}");
+ html! {
+ div.readable {
+ p {
+ "Prove control of an enrolled member key. Run this "
+ "on your own machine -- the key never leaves it:"
+ }
+ pre.signin-cmd {
+ "git ents login https://" (realm.host) " " (display)
+ }
+ p.muted {
+ "This page refreshes on its own; the code is "
+ "single-use and expires in ten minutes."
+ }
+ }
+ }
+ }
+ };
+
+ let markup = super::layout(
+ &super::RepoHeader::from_state(&state),
+ &super::identity_label(&state),
+ super::Tab::Account,
+ "Sign in",
+ body,
+ );
+ if session.member.is_some() {
+ markup.into_response()
+ } else {
+ // A refresh header, not a script: the page re-renders as signed
+ // in on the first refresh after the CLI completes the challenge.
+ ([(header::HeaderName::from_static("refresh"), "3")], markup).into_response()
+ }
+}
+
+/// `GET /login/challenge/{code}`: the CLI's fetch — the challenge's
+/// bound facts as `key=value` lines, without consuming it. The CLI MUST
+/// rebuild the payload from the host it addressed rather than trusting
+/// these lines (`roots.web-signin`); they exist so it can carry the
+/// nonce and confirm the host matches.
+// @relation(roots.web-signin, scope=function)
+pub async fn challenge<O>(
+ State(state): State<Arc<AppState<O>>>,
+ Path(code): Path<String>,
+) -> Response
+where
+ O: Find + Write + Send + 'static,
+{
+ let realm = match realm(&state) {
+ Ok(realm) => realm,
+ Err(response) => return *response,
+ };
+ match realm.challenges.peek(&code) {
+ Some(challenge) => (
+ [(header::CONTENT_TYPE, "text/plain; charset=utf-8")],
+ format!(
+ "host={}\ncode={}\nnonce={}\n",
+ realm.host,
+ auth::normalize_code(&code),
+ challenge.nonce
+ ),
+ )
+ .into_response(),
+ None => (
+ StatusCode::NOT_FOUND,
+ "unknown or expired code; reload the sign-in page for a fresh one\n",
+ )
+ .into_response(),
+ }
+}
+
+/// What the CLI posts back to complete a sign-in: the member's public
+/// key line and the armored SSHSIG over the locally-rebuilt payload.
+#[derive(Deserialize)]
+pub struct Completion {
+ /// The member's OpenSSH public key line.
+ pub public_key: String,
+ /// The armored SSHSIG PEM over [`crate::auth::challenge_payload`].
+ pub signature: String,
+}
+
+/// `POST /login/challenge/{code}`: consume the challenge, verify the
+/// signature over the server's own reconstruction of the payload, check
+/// the key names an enrolled *active* member, and mark the bound browser
+/// session signed in. Deliberately outside the session/CSRF discipline:
+/// this request carries no cookie and authenticates by signature alone
+/// (`roots.web-signin`).
+// @relation(roots.web-signin, scope=function)
+pub async fn complete<O>(
+ State(state): State<Arc<AppState<O>>>,
+ Path(code): Path<String>,
+ Form(completion): Form<Completion>,
+) -> Response
+where
+ O: Find + Write + Send + 'static,
+{
+ let realm = match realm(&state) {
+ Ok(realm) => realm,
+ Err(response) => return *response,
+ };
+ let Some(challenge) = realm.challenges.take(&code) else {
+ return (
+ StatusCode::NOT_FOUND,
+ "unknown or expired code; reload the sign-in page for a fresh one\n",
+ )
+ .into_response();
+ };
+
+ let code = auth::normalize_code(&code);
+ let payload = auth::challenge_payload(&realm.host, &code, &challenge.nonce);
+ let public_key = completion.public_key.trim();
+ if !auth::verify_login(public_key, payload.as_bytes(), &completion.signature) {
+ return (
+ StatusCode::UNAUTHORIZED,
+ "the signature did not verify against that key for this host and code\n",
+ )
+ .into_response();
+ }
+ let username = match auth::active_member_by_key(&state, public_key) {
+ Ok(Some(username)) => username,
+ Ok(None) => {
+ return (
+ StatusCode::UNAUTHORIZED,
+ "that key is not an enrolled, active member of this repository\n",
+ )
+ .into_response();
+ }
+ Err(error) => return error.into_response(),
+ };
+
+ let member = SessionMember {
+ username: username.clone(),
+ key: public_key.to_owned(),
+ };
+ if !state.sessions.authenticate(&challenge.session_id, member) {
+ return (
+ StatusCode::GONE,
+ "the browser session that requested this code no longer exists; reload the sign-in \
+ page\n",
+ )
+ .into_response();
+ }
+ (
+ [(header::CONTENT_TYPE, "text/plain; charset=utf-8")],
+ format!("member={username}\n"),
+ )
+ .into_response()
+}
+
+/// What the logout form posts: the session's CSRF token.
+#[derive(Deserialize)]
+pub struct LogoutForm {
+ /// The per-session CSRF token (`roots.web-session`).
+ pub csrf: String,
+}
+
+/// `POST /logout`: drop the session's signed-in member, keeping the
+/// session (and its CSRF token) itself. CSRF-checked like every other
+/// browser mutation — a cross-site form must not be able to sign the
+/// user out.
+// @relation(roots.web-signin, roots.web-session, scope=function)
+pub async fn logout<O>(
+ State(state): State<Arc<AppState<O>>>,
+ axum::Extension(session): axum::Extension<Session>,
+ axum::Extension(SessionId(session_id)): axum::Extension<SessionId>,
+ Form(form): Form<LogoutForm>,
+) -> crate::Result<impl IntoResponse>
+where
+ O: Find + Write + Send + 'static,
+{
+ super::require_csrf(&session, &form.csrf)?;
+ state.sessions.clear_member(&session_id);
+ Ok(Redirect::to("/"))
+}