git-ents.gitmain
⌘K
foforge
login.rs257 lines · 9.4 KB · rusthistorycomment on this file
1//! `GET /login`, `GET`/`POST /login/challenge/{code}`, `POST /logout`:
2//! the hosted sign-in surface (`roots.web-signin`), mounted only when the
3//! composition root injected [`AccessPolicy::SignInRequired`] — a local
4//! root has no sign-in surface at all, so under
5//! [`AccessPolicy::Trusted`] these routes do not exist and `/login` is a
6//! plain 404.
7//!
8//! The flow inverts a device-code login (`gh auth login`'s shape) because
9//! here the *CLI* holds the credential: the browser's `/login` page mints
10//! a short one-time code bound to its own session and displays the
11//! `git ents login` command to run; the CLI fetches the full challenge
12//! (`GET`, non-consuming), rebuilds the payload locally from the host the
13//! member addressed, signs it under [`crate::auth::LOGIN_NAMESPACE`], and
14//! posts the signature back (`POST`, consuming). The page refreshes
15//! itself until the session reads as signed in.
16//!
17//! The CLI endpoints speak `key=value` text lines, not HTML forms'
18//! escaping rules and not JSON — the same trivially-parseable shape the
19//! challenge payload itself uses, so neither side grows a parser.
20
21use std::sync::Arc;
22
23use axum::Form;
24use axum::extract::{Path, State};
25use axum::http::{StatusCode, header};
26use axum::response::{IntoResponse, Redirect, Response};
27use gix_object::{Find, Write};
28use maud::html;
29use serde::Deserialize;
30
31use crate::auth;
32use crate::session::{Session, SessionId, SessionMember};
33use crate::state::{AccessPolicy, AppState, Realm};
34
35/// The realm, or a 404 — these handlers are only ever routed under
36/// [`AccessPolicy::SignInRequired`], so a miss here is a wiring error,
37/// answered exactly as an unmounted route would be.
38fn realm<O>(state: &AppState<O>) -> Result<&Realm, Box<Response>> {
39 match &state.access {
40 AccessPolicy::SignInRequired(realm) => Ok(realm),
41 AccessPolicy::Trusted => Err(Box::new(StatusCode::NOT_FOUND.into_response())),
42 }
43}
44
45/// `GET /login`: the sign-in page. Signed out, it mints a challenge
46/// bound to this browser's session and shows the one command to run;
47/// the page refreshes itself every few seconds until the session reads
48/// as signed in — no script needed, and a challenge consumed by the CLI
49/// re-issues on the next refresh only if sign-in did not complete.
50// @relation(roots.web-signin, scope=function)
51pub async fn show<O>(
52 State(state): State<Arc<AppState<O>>>,
53 axum::Extension(session): axum::Extension<Session>,
54 axum::Extension(SessionId(session_id)): axum::Extension<SessionId>,
55) -> Response
56where
57 O: Find + Write + Send + 'static,
58{
59 let realm = match realm(&state) {
60 Ok(realm) => realm,
61 Err(response) => return *response,
62 };
63
64 let body = match &session.member {
65 Some(member) => html! {
66 div.readable {
67 p { "Signed in as " strong { (member.username) } "." }
68 p.muted {
69 "Edits you make here are authored as this member and "
70 "signed by the server's own key -- history reads "
71 em { (member.username) " via the web" } "."
72 }
73 form method="post" action="/logout" {
74 input type="hidden" name="csrf" value=(session.csrf);
75 button.btn type="submit" { "Sign out" }
76 }
77 }
78 },
79 None => {
80 let (code, _nonce) = realm.challenges.issue(&session_id);
81 // The code is eight ASCII base32 characters by construction;
82 // split_at is byte-indexed and cannot land inside a char.
83 let (head, tail) = code.split_at(4);
84 let display = format!("{head}-{tail}");
85 html! {
86 div.readable {
87 p {
88 "Prove control of an enrolled member key. Run this "
89 "on your own machine -- the key never leaves it:"
90 }
91 pre.login-code {
92 "git ents login https://" span.code { (realm.host) " " (display) }
93 }
94 p.muted {
95 "This page refreshes on its own; the code is "
96 "single-use and expires in ten minutes."
97 }
98 }
99 }
100 }
101 };
102
103 let markup = super::layout(
104 &super::RepoHeader::from_state(&state),
105 &super::identity_label(&state),
106 super::Tab::Account,
107 "Sign in",
108 body,
109 );
110 if session.member.is_some() {
111 markup.into_response()
112 } else {
113 // A refresh header, not a script: the page re-renders as signed
114 // in on the first refresh after the CLI completes the challenge.
115 ([(header::HeaderName::from_static("refresh"), "3")], markup).into_response()
116 }
117}
118
119/// `GET /login/challenge/{code}`: the CLI's fetch — the challenge's
120/// bound facts as `key=value` lines, without consuming it. The CLI MUST
121/// rebuild the payload from the host it addressed rather than trusting
122/// these lines (`roots.web-signin`); they exist so it can carry the
123/// nonce and confirm the host matches.
124// @relation(roots.web-signin, scope=function)
125pub async fn challenge<O>(
126 State(state): State<Arc<AppState<O>>>,
127 Path(code): Path<String>,
128) -> Response
129where
130 O: Find + Write + Send + 'static,
131{
132 let realm = match realm(&state) {
133 Ok(realm) => realm,
134 Err(response) => return *response,
135 };
136 match realm.challenges.peek(&code) {
137 Some(challenge) => (
138 [(header::CONTENT_TYPE, "text/plain; charset=utf-8")],
139 format!(
140 "host={}\ncode={}\nnonce={}\n",
141 realm.host,
142 auth::normalize_code(&code),
143 challenge.nonce
144 ),
145 )
146 .into_response(),
147 None => (
148 StatusCode::NOT_FOUND,
149 "unknown or expired code; reload the sign-in page for a fresh one\n",
150 )
151 .into_response(),
152 }
153}
154
155/// What the CLI posts back to complete a sign-in: the member's public
156/// key line and the armored SSHSIG over the locally-rebuilt payload.
157#[derive(Deserialize)]
158pub struct Completion {
159 /// The member's OpenSSH public key line.
160 pub public_key: String,
161 /// The armored SSHSIG PEM over [`crate::auth::challenge_payload`].
162 pub signature: String,
163}
164
165/// `POST /login/challenge/{code}`: consume the challenge, verify the
166/// signature over the server's own reconstruction of the payload, check
167/// the key names an enrolled *active* member, and mark the bound browser
168/// session signed in. Deliberately outside the session/CSRF discipline:
169/// this request carries no cookie and authenticates by signature alone
170/// (`roots.web-signin`).
171// @relation(roots.web-signin, scope=function)
172pub async fn complete<O>(
173 State(state): State<Arc<AppState<O>>>,
174 Path(code): Path<String>,
175 Form(completion): Form<Completion>,
176) -> Response
177where
178 O: Find + Write + Send + 'static,
179{
180 let realm = match realm(&state) {
181 Ok(realm) => realm,
182 Err(response) => return *response,
183 };
184 let Some(challenge) = realm.challenges.take(&code) else {
185 return (
186 StatusCode::NOT_FOUND,
187 "unknown or expired code; reload the sign-in page for a fresh one\n",
188 )
189 .into_response();
190 };
191
192 let code = auth::normalize_code(&code);
193 let payload = auth::challenge_payload(&realm.host, &code, &challenge.nonce);
194 let public_key = completion.public_key.trim();
195 if !auth::verify_login(public_key, payload.as_bytes(), &completion.signature) {
196 return (
197 StatusCode::UNAUTHORIZED,
198 "the signature did not verify against that key for this host and code\n",
199 )
200 .into_response();
201 }
202 let username = match auth::active_member_by_key(&state, public_key) {
203 Ok(Some(username)) => username,
204 Ok(None) => {
205 return (
206 StatusCode::UNAUTHORIZED,
207 "that key is not an enrolled, active member of this repository\n",
208 )
209 .into_response();
210 }
211 Err(error) => return error.into_response(),
212 };
213
214 let member = SessionMember {
215 username: username.clone(),
216 key: public_key.to_owned(),
217 };
218 if !state.sessions.authenticate(&challenge.session_id, member) {
219 return (
220 StatusCode::GONE,
221 "the browser session that requested this code no longer exists; reload the sign-in \
222 page\n",
223 )
224 .into_response();
225 }
226 (
227 [(header::CONTENT_TYPE, "text/plain; charset=utf-8")],
228 format!("member={username}\n"),
229 )
230 .into_response()
231}
232
233/// What the logout form posts: the session's CSRF token.
234#[derive(Deserialize)]
235pub struct LogoutForm {
236 /// The per-session CSRF token (`roots.web-session`).
237 pub csrf: String,
238}
239
240/// `POST /logout`: drop the session's signed-in member, keeping the
241/// session (and its CSRF token) itself. CSRF-checked like every other
242/// browser mutation — a cross-site form must not be able to sign the
243/// user out.
244// @relation(roots.web-signin, roots.web-session, scope=function)
245pub async fn logout<O>(
246 State(state): State<Arc<AppState<O>>>,
247 axum::Extension(session): axum::Extension<Session>,
248 axum::Extension(SessionId(session_id)): axum::Extension<SessionId>,
249 Form(form): Form<LogoutForm>,
250) -> crate::Result<impl IntoResponse>
251where
252 O: Find + Write + Send + 'static,
253{
254 super::require_csrf(&session, &form.csrf)?;
255 state.sessions.clear_member(&session_id);
256 Ok(Redirect::to("/"))
257}