Skip to main content

hennery_kernel/
auth.rs

1//! Request auth for operator routes (kernel spec §3.2, §3.3): the session
2//! cookie, behind the browser rules. It replaces the walking skeleton's
3//! development bearer token.
4
5use crate::auth_api::{error, internal, secure_cookies, with_cookie};
6use crate::operator::{Authenticated, Operator, session_cookie};
7use crate::secret::unix_now;
8use axum::Router;
9use axum::extract::{Request, State};
10use axum::http::{StatusCode, header};
11use axum::middleware::{self, Next};
12use axum::response::Response;
13use std::sync::Arc;
14use std::time::Duration;
15
16/// Put `router`'s routes behind the browser rules (outermost) and the
17/// session cookie. Only routes added to `router` before this call are
18/// covered: add every operator route first.
19pub fn operator_only<S: Clone + Send + Sync + 'static>(router: Router<S>, operator: Arc<Operator>) -> Router<S> {
20    router
21        .layer(middleware::from_fn_with_state(operator.clone(), require_operator))
22        .layer(middleware::from_fn_with_state(operator, crate::origin::browser_rules))
23}
24
25/// Axum middleware: the request must carry a live session cookie (401
26/// `unauthenticated` otherwise). The session goes into the request's
27/// extensions as `Authenticated`; when this request slid its expiry, the
28/// cookie is sent again so the browser's copy lives as long. Not when the
29/// handler set a cookie of its own, nor once the session has ended (a
30/// request that revokes its own session): the browser keeps the last
31/// `Set-Cookie`, and that must not be the dead token.
32pub async fn require_operator(State(operator): State<Arc<Operator>>, mut req: Request, next: Next) -> Response {
33    let (token, session) = match operator.authenticate_cookies(req.headers(), unix_now()) {
34        Ok(Some(found)) => found,
35        Ok(None) => return unauthenticated(),
36        Err(err) => return internal(err),
37    };
38    let slid = session.slid;
39    let session_id = session.session_id.clone();
40    req.extensions_mut().insert(session);
41    let response = next.run(req).await;
42    let resend = slid
43        && !response.headers().contains_key(header::SET_COOKIE)
44        && matches!(operator.session_expires_at(&session_id, unix_now()), Ok(Some(_)));
45    if resend {
46        with_cookie(response, &session_cookie(&token, secure_cookies(&operator)))
47    } else {
48        response
49    }
50}
51
52fn unauthenticated() -> Response {
53    error(StatusCode::UNAUTHORIZED, "unauthenticated", "sign in first")
54}
55
56/// Axum middleware, inside `require_operator`: the session's last password
57/// or passkey check must be within the last five minutes (kernel spec
58/// §3.4), or the answer is 403 `step_up_required` and the client asks
59/// again.
60pub async fn require_step_up(req: Request, next: Next) -> Response {
61    let fresh = req
62        .extensions()
63        .get::<Authenticated>()
64        .is_some_and(|session| session.stepped_up(unix_now()));
65    if !fresh {
66        return step_up_required();
67    }
68    next.run(req).await
69}
70
71/// 403 `step_up_required`: what `require_step_up` answers, for a handler
72/// that needs a fresh step-up for only some of what it does.
73pub fn step_up_required() -> Response {
74    error(
75        StatusCode::FORBIDDEN,
76        "step_up_required",
77        "confirm your password or a passkey again (POST /api/auth/step-up/password or /api/auth/step-up/passkey/start)",
78    )
79}
80
81/// The longest a stream waits before it re-checks its session, however far
82/// off the expiry is. Tokio's clock is monotonic and does not count time the
83/// machine is suspended, so a sleep until the expiry could wake long after
84/// it: a laptop asleep for a week would keep a stream past its session.
85pub const SESSION_RECHECK: Duration = Duration::from_secs(60 * 60);
86
87/// Resolves once `session` has ended: revoked, signed out, or expired. A
88/// stream a session holds open must not outlive it (3b decision 7), so
89/// long-lived responses end with this. It re-checks the session whenever
90/// the operator announces an ending, at its expiry (which may have slid
91/// since, through the session's other requests), and at least every
92/// `SESSION_RECHECK`, against the wall clock.
93pub async fn session_ended(operator: Arc<Operator>, session: Authenticated) {
94    let mut ends = operator.session_ends();
95    // Whatever ended before this subscription is checked here.
96    let Ok(Some(mut expires_at)) = operator.session_expires_at(&session.session_id, unix_now()) else {
97        return;
98    };
99    loop {
100        let left = Duration::from_secs(expires_at.saturating_sub(unix_now()).max(0) as u64);
101        tokio::select! {
102            changed = ends.changed() => {
103                if changed.is_err() {
104                    return;
105                }
106            }
107            () = tokio::time::sleep(left.min(SESSION_RECHECK)) => {}
108        }
109        match operator.session_expires_at(&session.session_id, unix_now()) {
110            Ok(Some(later)) => expires_at = later,
111            Ok(None) | Err(_) => return,
112        }
113    }
114}