Skip to main content

hennery_kernel/
auth_api.rs

1//! Operator auth over HTTP (kernel spec §3, §8): the one-time setup, login
2//! and logout, step-up, the signed-in sessions, and passkeys (plan 3c).
3
4use crate::json::ApiJson;
5use crate::operator::{Authenticated, Operator, PublicUrl, SetupOutcome, cleared_cookie, session_cookie};
6use crate::passkeys::{PasskeyRecord, Refused, Start};
7use crate::secret::{rfc3339, unix_now};
8use axum::extract::{DefaultBodyLimit, Extension, Path, State};
9use axum::http::{HeaderMap, HeaderValue, StatusCode, header};
10use axum::response::{IntoResponse, Response};
11use axum::routing::{delete, get, post};
12use axum::{Json, Router, middleware};
13use hennery_proto::rest::{
14    ApiError, AuthSessionItem, LoginRequest, PasskeyCeremony, PasskeyFinishRequest, PasskeyItem,
15    PasskeyRegisterRequest, SetupRequest, SetupResponse, StepUpRequest,
16};
17use std::sync::Arc;
18use std::time::{Duration, Instant};
19
20/// The largest request body the auth routes read: a password is at most
21/// 1024 bytes (`MAX_PASSWORD_BYTES`), and a passkey's answer, with no
22/// attestation asked for (plan 3c decision 4), about one, so anything much
23/// larger is refused with 413 before it is parsed.
24pub const MAX_BODY_BYTES: usize = 16 * 1024;
25
26/// The operator auth routes. Setup has its own `Origin` rule; the rest are
27/// browser routes (kernel spec §3.3). Serve with `ConnectInfo<SocketAddr>`:
28/// the address policy resolves the limiter key from that peer, never identity.
29pub fn router(operator: Arc<Operator>) -> Router {
30    let browser = Router::new()
31        .route("/api/auth/login", post(login))
32        .route("/api/auth/logout", post(logout))
33        .route("/api/auth/passkeys/login/start", post(passkey_login_start))
34        .route("/api/auth/passkeys/login/finish", post(passkey_login_finish))
35        .layer(middleware::from_fn_with_state(
36            operator.clone(),
37            crate::origin::browser_rules,
38        ));
39    let step_up_first = || middleware::from_fn(crate::auth::require_step_up);
40    let signed_in = crate::auth::operator_only(
41        Router::new()
42            .route("/api/auth/step-up/password", post(step_up))
43            .route("/api/auth/step-up/passkey/start", post(passkey_step_up_start))
44            .route("/api/auth/step-up/passkey/finish", post(passkey_step_up_finish))
45            .route("/api/auth/sessions", get(list_sessions))
46            .route(
47                "/api/auth/sessions/{id}",
48                delete(revoke_session).route_layer(step_up_first()),
49            )
50            .route("/api/auth/passkeys", get(list_passkeys))
51            .route(
52                "/api/auth/passkeys/{id}",
53                delete(remove_passkey).route_layer(step_up_first()),
54            )
55            .route(
56                "/api/auth/passkeys/register/start",
57                post(passkey_register_start).route_layer(step_up_first()),
58            )
59            .route(
60                "/api/auth/passkeys/register/finish",
61                post(passkey_register_finish).route_layer(step_up_first()),
62            ),
63        operator.clone(),
64    );
65    Router::new()
66        .route(
67            "/api/setup",
68            post(setup).layer(middleware::map_response(private_headers)),
69        )
70        .merge(browser)
71        .merge(signed_in)
72        .layer(DefaultBodyLimit::max(MAX_BODY_BYTES))
73        .with_state(operator)
74}
75
76/// Every `POST /api/setup` answer: never cached, and never a `Referer`
77/// onwards (kernel spec §3.1). The page itself is the web UI's (`web.rs`).
78async fn private_headers(mut response: Response) -> Response {
79    let headers = response.headers_mut();
80    headers.insert(header::REFERRER_POLICY, HeaderValue::from_static("no-referrer"));
81    headers.insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store"));
82    response
83}
84
85pub(crate) fn error(status: StatusCode, code: &str, message: impl Into<String>) -> Response {
86    (
87        status,
88        Json(ApiError {
89            code: code.into(),
90            message: message.into(),
91            session_id: None,
92        }),
93    )
94        .into_response()
95}
96
97pub(crate) fn internal(err: anyhow::Error) -> Response {
98    tracing::error!(error = %err, "internal error");
99    error(StatusCode::INTERNAL_SERVER_ERROR, "internal", "internal error")
100}
101
102/// The request's `User-Agent`, for the session list.
103pub(crate) fn user_agent(headers: &HeaderMap) -> String {
104    headers
105        .get(header::USER_AGENT)
106        .and_then(|v| v.to_str().ok())
107        .unwrap_or_default()
108        .to_string()
109}
110
111/// 429 with `Retry-After`, in whole seconds rounded up.
112pub(crate) fn rate_limited(retry_after: Duration, message: &str) -> Response {
113    let mut response = error(StatusCode::TOO_MANY_REQUESTS, "rate_limited", message);
114    let secs = retry_after.as_secs() + u64::from(retry_after.subsec_nanos() > 0);
115    response
116        .headers_mut()
117        .insert(header::RETRY_AFTER, HeaderValue::from(secs));
118    response
119}
120
121/// Whether the session cookie is `Secure`: as `public_url` says, and
122/// `Secure` when there is none yet.
123pub(crate) fn secure_cookies(operator: &Operator) -> bool {
124    operator.public_url().is_none_or(|u| u.is_https())
125}
126
127/// A response that sets `cookie`.
128pub(crate) fn with_cookie(mut response: Response, cookie: &str) -> Response {
129    if let Ok(value) = HeaderValue::from_str(cookie) {
130        response.headers_mut().append(header::SET_COOKIE, value);
131    }
132    response
133}
134
135/// `POST /api/setup`: 201, the owner signed in. There is no `public_url`
136/// yet to check `Origin` against, so it must be the origin of the
137/// `public_url` being stored: the browser that sets hennery up is the one
138/// that can use it afterwards. The token is what authenticates.
139async fn setup(
140    State(operator): State<Arc<Operator>>,
141    headers: HeaderMap,
142    ApiJson(req): ApiJson<SetupRequest>,
143) -> Response {
144    let public_url = match PublicUrl::parse(&req.public_url) {
145        Ok(url) => url,
146        Err(why) => return error(StatusCode::BAD_REQUEST, "invalid", why),
147    };
148    let origin = headers.get(header::ORIGIN).and_then(|v| v.to_str().ok());
149    if origin != Some(public_url.origin()) {
150        tracing::debug!(expected = %public_url.origin(), received = ?origin, "setup refused: origin_mismatch");
151        return error(
152            StatusCode::FORBIDDEN,
153            "origin_mismatch",
154            "Origin must be the public_url being set up",
155        );
156    }
157    let op = operator.clone();
158    let outcome = tokio::task::spawn_blocking(move || {
159        op.set_up_naming_hat(
160            &req.token,
161            &req.password,
162            &req.public_url,
163            req.default_hat_name.as_deref(),
164            unix_now(),
165        )
166    })
167    .await;
168    let phc = match outcome {
169        Ok(Ok(SetupOutcome::Done { owner_id, phc })) => {
170            tracing::info!(%owner_id, public_url = %public_url.origin(), "hennery set up");
171            phc
172        }
173        Ok(Ok(SetupOutcome::AlreadySetUp)) => {
174            return error(StatusCode::CONFLICT, "already_set_up", "hennery is set up already");
175        }
176        Ok(Ok(SetupOutcome::InvalidToken)) => {
177            return error(
178                StatusCode::UNAUTHORIZED,
179                "invalid_setup_token",
180                "the setup link is unknown, used or expired; restart the collector for a new one",
181            );
182        }
183        Ok(Ok(SetupOutcome::Invalid(why))) => return error(StatusCode::BAD_REQUEST, "invalid", why),
184        Ok(Err(err)) => return internal(err),
185        Err(err) => return internal(err.into()),
186    };
187    let token = match operator.open_session(&user_agent(&headers), &phc, unix_now()) {
188        Ok(Some(token)) => token,
189        Ok(None) => return internal(anyhow::anyhow!("the password changed right after setup")),
190        Err(err) => return internal(err),
191    };
192    let response = (
193        StatusCode::CREATED,
194        Json(SetupResponse {
195            public_url: public_url.origin().to_string(),
196        }),
197    )
198        .into_response();
199    with_cookie(response, &session_cookie(&token, public_url.is_https()))
200}
201
202/// `POST /api/auth/login`: 204 and a new session cookie. Every attempt
203/// counts against the client's address until one succeeds; an attempt
204/// that is not refused for that runs exactly one password check, right or
205/// wrong, so the answer takes as long either way (kernel spec §3.2).
206async fn login(
207    State(operator): State<Arc<Operator>>,
208    address: crate::client_address::ClientAddress,
209    headers: HeaderMap,
210    ApiJson(req): ApiJson<LoginRequest>,
211) -> Response {
212    if let Err(retry_after) = operator.login_limiter.attempt(address.effective, Instant::now()) {
213        return rate_limited(
214            retry_after,
215            "too many wrong passwords from this address; try again later",
216        );
217    }
218    let phc = match operator.check_password(req.password).await {
219        Ok(Some(phc)) => phc,
220        Ok(None) => return error(StatusCode::UNAUTHORIZED, "invalid_password", "wrong password"),
221        Err(err) => return internal(err),
222    };
223    operator.login_limiter.succeeded(address.effective);
224    // Bound to the password just checked: a reset since then wins.
225    match operator.open_session(&user_agent(&headers), &phc, unix_now()) {
226        Ok(Some(token)) => with_cookie(
227            StatusCode::NO_CONTENT.into_response(),
228            &session_cookie(&token, secure_cookies(&operator)),
229        ),
230        Ok(None) => error(StatusCode::UNAUTHORIZED, "invalid_password", "wrong password"),
231        Err(err) => internal(err),
232    }
233}
234
235/// `POST /api/auth/logout`: 204, the request's session (if any) ended and
236/// its cookie cleared.
237async fn logout(State(operator): State<Arc<Operator>>, headers: HeaderMap) -> Response {
238    match operator.authenticate_cookies(&headers, unix_now()) {
239        Ok(Some((_, session))) => {
240            if let Err(err) = operator.revoke_session(&session.session_id, unix_now()) {
241                return internal(err);
242            }
243        }
244        Ok(None) => {}
245        Err(err) => return internal(err),
246    }
247    with_cookie(
248        StatusCode::NO_CONTENT.into_response(),
249        &cleared_cookie(secure_cookies(&operator)),
250    )
251}
252
253/// `POST /api/auth/step-up/password`: 204, the session stepped up for five
254/// minutes (kernel spec §3.4). Rate limited like login, on a budget of its
255/// own (3b decision 10).
256async fn step_up(
257    State(operator): State<Arc<Operator>>,
258    address: crate::client_address::ClientAddress,
259    Extension(session): Extension<Authenticated>,
260    ApiJson(req): ApiJson<StepUpRequest>,
261) -> Response {
262    if let Err(retry_after) = operator.step_up_limiter.attempt(address.effective, Instant::now()) {
263        return rate_limited(
264            retry_after,
265            "too many wrong passwords from this address; try again later",
266        );
267    }
268    match operator.check_password(req.password).await {
269        Ok(Some(_)) => {}
270        Ok(None) => return error(StatusCode::UNAUTHORIZED, "invalid_password", "wrong password"),
271        Err(err) => return internal(err),
272    }
273    operator.step_up_limiter.succeeded(address.effective);
274    match operator.step_up(&session.session_id, unix_now()) {
275        Ok(true) => StatusCode::NO_CONTENT.into_response(),
276        Ok(false) => error(StatusCode::UNAUTHORIZED, "unauthenticated", "sign in first"),
277        Err(err) => internal(err),
278    }
279}
280
281/// `GET /api/auth/sessions`: every signed-in session, most recently used
282/// first, the request's own marked `current`.
283async fn list_sessions(
284    State(operator): State<Arc<Operator>>,
285    Extension(session): Extension<Authenticated>,
286) -> Response {
287    match operator.sessions(unix_now()) {
288        Ok(sessions) => {
289            let items: Vec<AuthSessionItem> = sessions
290                .into_iter()
291                .map(|s| AuthSessionItem {
292                    current: s.id == session.session_id,
293                    id: s.id,
294                    user_agent: s.user_agent,
295                    created_at: rfc3339(s.created_at),
296                    last_seen_at: rfc3339(s.last_seen_at),
297                    expires_at: rfc3339(s.expires_at),
298                })
299                .collect();
300            Json(items).into_response()
301        }
302        Err(err) => internal(err),
303    }
304}
305
306/// `DELETE /api/auth/sessions/{id}` (step-up): 204, that session signed
307/// out; 404 if there is none. Ending the request's own session also clears
308/// its cookie.
309async fn revoke_session(
310    State(operator): State<Arc<Operator>>,
311    Extension(session): Extension<Authenticated>,
312    Path(id): Path<String>,
313) -> Response {
314    match operator.revoke_session(&id, unix_now()) {
315        Ok(true) if id == session.session_id => with_cookie(
316            StatusCode::NO_CONTENT.into_response(),
317            &cleared_cookie(secure_cookies(&operator)),
318        ),
319        Ok(true) => StatusCode::NO_CONTENT.into_response(),
320        Ok(false) => error(StatusCode::NOT_FOUND, "not_found", "no such session"),
321        Err(err) => internal(err),
322    }
323}
324
325/// The answer to a ceremony's start (plan 3c decision 11): 200 and the
326/// options, or why it did not begin.
327fn started(outcome: anyhow::Result<Start>) -> Response {
328    match outcome {
329        Ok(Start::Begun { ceremony_id, options }) => Json(PasskeyCeremony { ceremony_id, options }).into_response(),
330        Ok(Start::Unavailable) => passkeys_unavailable(),
331        Ok(Start::NoPasskeys) => error(StatusCode::CONFLICT, "no_passkeys", "there is no passkey to use"),
332        Ok(Start::Invalid(why)) => error(StatusCode::BAD_REQUEST, "invalid", why),
333        Err(err) => internal(err),
334    }
335}
336
337fn passkeys_unavailable() -> Response {
338    error(
339        StatusCode::CONFLICT,
340        "passkeys_unavailable",
341        "passkeys need public_url to name a host, not an IP address",
342    )
343}
344
345/// The answer to a refused finish (plan 3c decision 11). Nothing changed.
346fn refused(why: Refused) -> Response {
347    match why {
348        Refused::Ceremony => error(
349            StatusCode::BAD_REQUEST,
350            "invalid_ceremony",
351            "the passkey ceremony is unknown, used or expired; start again",
352        ),
353        Refused::Credential(why) => {
354            // `?`, not `%`: the reason can quote the client's input, and
355            // `Debug` escapes it (3c review, O4).
356            tracing::debug!(?why, "passkey refused: the answer did not verify");
357            error(
358                StatusCode::UNAUTHORIZED,
359                "passkey_refused",
360                "the passkey was not accepted",
361            )
362        }
363        // Logged where they were caught: a counter that went back at `warn`
364        // with the passkey's id, a passkey changed since it was read at
365        // `debug` with its id. A passkey removed since its ceremony began,
366        // or not the owner's, has no row to name, and is not logged.
367        Refused::Passkey | Refused::CounterWentBack => error(
368            StatusCode::UNAUTHORIZED,
369            "passkey_refused",
370            "the passkey was not accepted",
371        ),
372        Refused::AlreadyRegistered => error(
373            StatusCode::CONFLICT,
374            "already_registered",
375            "this passkey is registered already",
376        ),
377        Refused::Unavailable => passkeys_unavailable(),
378    }
379}
380
381fn passkey_item(record: PasskeyRecord) -> PasskeyItem {
382    PasskeyItem {
383        id: record.id,
384        label: record.label,
385        created_at: rfc3339(record.created_at),
386        last_used_at: record.last_used_at.map(rfc3339),
387    }
388}
389
390/// `POST /api/auth/passkeys/login/start`: a login ceremony over the
391/// owner's passkeys. Every start counts against the client's address on
392/// the passkey budget until a login succeeds (plan 3c decision 8).
393async fn passkey_login_start(
394    State(operator): State<Arc<Operator>>,
395    address: crate::client_address::ClientAddress,
396) -> Response {
397    if let Err(retry_after) = operator.passkey_limiter.attempt(address.effective, Instant::now()) {
398        return rate_limited(
399            retry_after,
400            "too many passkey logins begun from this address; try again later",
401        );
402    }
403    started(operator.start_passkey_login(unix_now()))
404}
405
406/// `POST /api/auth/passkeys/login/finish`: 204 and a new session cookie,
407/// the session stepped up, as a password login's is.
408async fn passkey_login_finish(
409    State(operator): State<Arc<Operator>>,
410    address: crate::client_address::ClientAddress,
411    headers: HeaderMap,
412    ApiJson(req): ApiJson<PasskeyFinishRequest>,
413) -> Response {
414    match operator.finish_passkey_login(&req.ceremony_id, &req.credential, &user_agent(&headers), unix_now()) {
415        Ok(Ok(token)) => {
416            operator.passkey_limiter.succeeded(address.effective);
417            with_cookie(
418                StatusCode::NO_CONTENT.into_response(),
419                &session_cookie(&token, secure_cookies(&operator)),
420            )
421        }
422        Ok(Err(why)) => refused(why),
423        Err(err) => internal(err),
424    }
425}
426
427/// `POST /api/auth/step-up/passkey/start`: a step-up ceremony for the
428/// request's session (kernel spec §3.4).
429async fn passkey_step_up_start(
430    State(operator): State<Arc<Operator>>,
431    Extension(session): Extension<Authenticated>,
432) -> Response {
433    started(operator.start_passkey_step_up(&session.session_id, unix_now()))
434}
435
436/// `POST /api/auth/step-up/passkey/finish`: 204, the session stepped up
437/// for five minutes.
438async fn passkey_step_up_finish(
439    State(operator): State<Arc<Operator>>,
440    Extension(session): Extension<Authenticated>,
441    ApiJson(req): ApiJson<PasskeyFinishRequest>,
442) -> Response {
443    match operator.finish_passkey_step_up(&session.session_id, &req.ceremony_id, &req.credential, unix_now()) {
444        Ok(Ok(true)) => StatusCode::NO_CONTENT.into_response(),
445        Ok(Ok(false)) => error(StatusCode::UNAUTHORIZED, "unauthenticated", "sign in first"),
446        Ok(Err(why)) => refused(why),
447        Err(err) => internal(err),
448    }
449}
450
451/// `GET /api/auth/passkeys`: the owner's passkeys, oldest first.
452async fn list_passkeys(State(operator): State<Arc<Operator>>) -> Response {
453    match operator.passkeys() {
454        Ok(records) => Json(records.into_iter().map(passkey_item).collect::<Vec<_>>()).into_response(),
455        Err(err) => internal(err),
456    }
457}
458
459/// `DELETE /api/auth/passkeys/{id}` (step-up): 204, or 404 if there is no
460/// such passkey. The password stays (plan 3c decision 2).
461async fn remove_passkey(State(operator): State<Arc<Operator>>, Path(id): Path<String>) -> Response {
462    match operator.remove_passkey(&id) {
463        Ok(true) => StatusCode::NO_CONTENT.into_response(),
464        Ok(false) => error(StatusCode::NOT_FOUND, "not_found", "no such passkey"),
465        Err(err) => internal(err),
466    }
467}
468
469/// `POST /api/auth/passkeys/register/start` (step-up, plan 3c decision 7):
470/// a registration ceremony for the request's session.
471async fn passkey_register_start(
472    State(operator): State<Arc<Operator>>,
473    Extension(session): Extension<Authenticated>,
474    ApiJson(req): ApiJson<PasskeyRegisterRequest>,
475) -> Response {
476    started(operator.start_passkey_registration(&session.session_id, &req.label, unix_now()))
477}
478
479/// `POST /api/auth/passkeys/register/finish` (step-up): 201 and the new
480/// passkey.
481async fn passkey_register_finish(
482    State(operator): State<Arc<Operator>>,
483    Extension(session): Extension<Authenticated>,
484    ApiJson(req): ApiJson<PasskeyFinishRequest>,
485) -> Response {
486    match operator.finish_passkey_registration(&session.session_id, &req.ceremony_id, &req.credential, unix_now()) {
487        Ok(Ok(record)) => (StatusCode::CREATED, Json(passkey_item(record))).into_response(),
488        Ok(Err(why)) => refused(why),
489        Err(err) => internal(err),
490    }
491}