Skip to main content

session_guard/
axum.rs

1//! axum 0.8 integration: a tower layer that authenticates requests, the [`AuthSession`]
2//! extractor, and helpers for routes outside the layer.
3//!
4//! The layer reads the TCP peer address from [`ConnectInfo<SocketAddr>`], so the app must be
5//! served with [`into_make_service_with_connect_info`](::axum::Router::into_make_service_with_connect_info).
6//! Without it every guarded request fails closed with 500.
7//!
8//! ```no_run
9//! use std::net::SocketAddr;
10//!
11//! use axum::{Router, routing::get};
12//! use session_guard::{AuthSession, Guard, MemoryStore};
13//! use uuid::Uuid;
14//!
15//! async fn me(session: AuthSession<Uuid>) -> String {
16//!     session.subject.to_string()
17//! }
18//!
19//! # async fn run() -> std::io::Result<()> {
20//! let guard = Guard::new(MemoryStore::new());
21//! let app = Router::new().route("/me", get(me)).layer(guard.layer());
22//!
23//! let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await?;
24//! axum::serve(listener, app.into_make_service_with_connect_info::<SocketAddr>()).await
25//! # }
26//! ```
27//!
28//! # Responses
29//!
30//! Every rejection is a 401 with `Content-Type: application/json`, `Cache-Control: no-store`
31//! and the body `{"error":"<code>"}`, where the code is [`Rejection::as_str`]. Rejections
32//! that end the session also delete the cookie; a revoked session additionally sends
33//! `Clear-Site-Data: "cookies"`. Guard errors (store failures) are a 500 with
34//! `{"error":"internal"}`; the error itself is only logged.
35
36use std::{
37    convert::Infallible,
38    fmt,
39    future::Future,
40    net::{IpAddr, SocketAddr},
41    pin::Pin,
42    sync::Arc,
43    task::{Context, Poll},
44    time::Duration,
45};
46
47use ::axum::{
48    body::Body,
49    extract::{ConnectInfo, FromRef, FromRequestParts, OptionalFromRequestParts},
50    response::{IntoResponse, IntoResponseParts, Response, ResponseParts},
51};
52use http::{Extensions, HeaderMap, HeaderValue, Request, StatusCode, header, request::Parts};
53use secrecy::{ExposeSecret, SecretString};
54use tower_layer::Layer;
55use tower_service::Service;
56use uuid::Uuid;
57
58use crate::{
59    AuthSession, Checks, ClientContext, Guard, GuardError, IssuedToken, NewSession, Outcome, Rejection, StepUpOutcome,
60    store::SessionStore, subject::Subject,
61};
62
63type Predicate = Arc<dyn Fn(&HeaderMap) -> bool + Send + Sync>;
64
65impl<St: SessionStore, S: Subject> Guard<St, S> {
66    /// A tower layer that authenticates every request through this guard.
67    ///
68    /// See [`GuardLayer`] for what it does per request and how to configure it.
69    ///
70    /// ```no_run
71    /// use std::{net::SocketAddr, time::Duration};
72    ///
73    /// use axum::{Router, http::HeaderMap, routing::post};
74    /// use session_guard::{AuthSession, Checks, Guard, MemoryStore};
75    /// use uuid::Uuid;
76    ///
77    /// async fn transfer(session: AuthSession<Uuid>) -> &'static str {
78    ///     "done"
79    /// }
80    ///
81    /// # async fn run() -> std::io::Result<()> {
82    /// let guard = Guard::new(MemoryStore::new());
83    /// let layer = guard
84    ///     .layer()
85    ///     .with(Checks::strict())
86    ///     .require_recent_auth(Duration::from_mins(15))
87    ///     .bypass_bearer_prefixes(["pat_", "svc_"])
88    ///     .bypass_when(|headers: &HeaderMap| headers.contains_key("x-internal-probe"));
89    /// let app = Router::new().route("/transfer", post(transfer)).layer(layer);
90    ///
91    /// let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await?;
92    /// axum::serve(listener, app.into_make_service_with_connect_info::<SocketAddr>()).await
93    /// # }
94    /// ```
95    pub fn layer(&self) -> GuardLayer<St, S> {
96        GuardLayer {
97            shared: Arc::new(Shared {
98                guard: self.clone(),
99                checks: None,
100                recent_auth: None,
101                bearer_prefixes: Vec::new(),
102                predicates: Vec::new(),
103            }),
104        }
105    }
106}
107
108/// Settings shared by a layer and every service it creates.
109struct Shared<St, S> {
110    guard: Guard<St, S>,
111    checks: Option<Checks>,
112    recent_auth: Option<Duration>,
113    bearer_prefixes: Vec<String>,
114    predicates: Vec<Predicate>,
115}
116
117impl<St, S> Clone for Shared<St, S> {
118    fn clone(&self) -> Self {
119        Self {
120            guard: self.guard.clone(),
121            checks: self.checks,
122            recent_auth: self.recent_auth,
123            bearer_prefixes: self.bearer_prefixes.clone(),
124            predicates: self.predicates.clone(),
125        }
126    }
127}
128
129impl<St, S> Shared<St, S> {
130    fn bypassed(&self, headers: &HeaderMap) -> bool {
131        bearer_token(headers).is_some_and(|token| self.bearer_prefixes.iter().any(|p| token.starts_with(p.as_str())))
132            || self.predicates.iter().any(|p| p(headers))
133    }
134}
135
136/// The token of an `Authorization: Bearer <token>` header (scheme case-insensitive).
137fn bearer_token(headers: &HeaderMap) -> Option<&str> {
138    let value = headers.get(header::AUTHORIZATION)?.to_str().ok()?.trim();
139    let (scheme, token) = value.split_once(' ')?;
140    scheme.eq_ignore_ascii_case("bearer").then(|| token.trim_start())
141}
142
143/// Tower layer that authenticates requests with a [`Guard`]. Create it with
144/// [`Guard::layer`].
145///
146/// Per request, the service:
147///
148/// 1. Passes the request to the inner service untouched when a bypass matches
149///    ([`bypass_bearer_prefixes`](Self::bypass_bearer_prefixes),
150///    [`bypass_when`](Self::bypass_when)): no authentication, no [`AuthSession`], no added
151///    headers.
152/// 2. Reads the peer address from [`ConnectInfo<SocketAddr>`]. When it is missing the request
153///    fails closed with 500, because the app was not served with
154///    `into_make_service_with_connect_info::<SocketAddr>()`.
155/// 3. Builds the [`ClientContext`] with [`Guard::client_context`] and authenticates the
156///    session cookie. A missing cookie is `not_authenticated`.
157/// 4. On success, inserts the [`AuthSession`] into the request extensions, calls the inner
158///    service, appends the rotated token's `Set-Cookie` when the token was rotated, and sets
159///    `Cache-Control: no-store`, **replacing** any `Cache-Control` the handler set:
160///    authenticated responses must never be cached by shared caches or the browser.
161/// 5. On rejection, answers with [`rejection_response`]; on a guard error, with 500
162///    `{"error":"internal"}`.
163///
164/// Works with both [`Router::layer`](::axum::Router::layer) and
165/// [`Router::route_layer`](::axum::Router::route_layer). Cheap to clone.
166pub struct GuardLayer<St, S> {
167    shared: Arc<Shared<St, S>>,
168}
169
170impl<St, S> Clone for GuardLayer<St, S> {
171    fn clone(&self) -> Self {
172        Self {
173            shared: Arc::clone(&self.shared),
174        }
175    }
176}
177
178impl<St, S> fmt::Debug for GuardLayer<St, S> {
179    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
180        f.debug_struct("GuardLayer")
181            .field("checks", &self.shared.checks)
182            .field("recent_auth", &self.shared.recent_auth)
183            .field("bearer_prefixes", &self.shared.bearer_prefixes)
184            .field("predicates", &self.shared.predicates.len())
185            .finish_non_exhaustive()
186    }
187}
188
189impl<St, S> GuardLayer<St, S> {
190    fn update(mut self, f: impl FnOnce(&mut Shared<St, S>)) -> Self {
191        f(Arc::make_mut(&mut self.shared));
192        self
193    }
194
195    /// Authenticates with `checks` through [`Guard::authenticate_with`] instead of the guard's
196    /// configured checks, e.g. [`Checks::strict`] for sensitive routes. The session's baseline
197    /// learns only the signals `checks` scored.
198    ///
199    /// Without this, the layer calls [`Guard::authenticate`] with the configured checks.
200    #[must_use]
201    pub fn with(self, checks: Checks) -> Self {
202        self.update(|s| s.checks = Some(checks))
203    }
204
205    /// Rejects authenticated requests whose subject last proved who they are more than
206    /// `max_age` ago with 401 `step_up_required` (see [`Guard::requires_reauth`]).
207    ///
208    /// The session is not changed and the cookie is kept; a token rotated by the same request
209    /// is still delivered. Complete the step-up with
210    /// [`Guard::complete_step_up`](crate::Guard::complete_step_up) to refresh
211    /// `authenticated_at`.
212    ///
213    /// The window must leave room for the retry after the step-up: with a zero `max_age` that
214    /// retry is already too late, so the route can never be reached. To confirm every single
215    /// action (deleting an account, say), verify the credential in that request's handler
216    /// instead, or use a short window such as two minutes.
217    #[must_use]
218    pub fn require_recent_auth(self, max_age: Duration) -> Self {
219        self.update(|s| s.recent_auth = Some(max_age))
220    }
221
222    /// Passes requests whose `Authorization: Bearer <token>` starts with one of `prefixes` to
223    /// the inner service untouched, for API keys handled by other middleware. Empty prefixes
224    /// are ignored. Calls add to the list.
225    #[must_use]
226    pub fn bypass_bearer_prefixes<I, P>(self, prefixes: I) -> Self
227    where
228        I: IntoIterator<Item = P>,
229        P: Into<String>,
230    {
231        let prefixes: Vec<String> = prefixes.into_iter().map(Into::into).filter(|p| !p.is_empty()).collect();
232        self.update(|s| s.bearer_prefixes.extend(prefixes))
233    }
234
235    /// Passes requests for which `predicate` returns `true` to the inner service untouched.
236    /// Calls add predicates; any match bypasses.
237    #[must_use]
238    pub fn bypass_when<F>(self, predicate: F) -> Self
239    where
240        F: Fn(&HeaderMap) -> bool + Send + Sync + 'static,
241    {
242        self.update(|s| s.predicates.push(Arc::new(predicate)))
243    }
244}
245
246impl<I, St, S> Layer<I> for GuardLayer<St, S> {
247    type Service = GuardService<I, St, S>;
248
249    fn layer(&self, inner: I) -> Self::Service {
250        GuardService {
251            inner,
252            shared: Arc::clone(&self.shared),
253        }
254    }
255}
256
257/// The service [`GuardLayer`] wraps around the inner service.
258pub struct GuardService<I, St, S> {
259    inner: I,
260    shared: Arc<Shared<St, S>>,
261}
262
263impl<I: Clone, St, S> Clone for GuardService<I, St, S> {
264    fn clone(&self) -> Self {
265        Self {
266            inner: self.inner.clone(),
267            shared: Arc::clone(&self.shared),
268        }
269    }
270}
271
272impl<I: fmt::Debug, St, S> fmt::Debug for GuardService<I, St, S> {
273    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
274        f.debug_struct("GuardService")
275            .field("inner", &self.inner)
276            .finish_non_exhaustive()
277    }
278}
279
280type BoxFuture<T> = Pin<Box<dyn Future<Output = T> + Send>>;
281
282impl<I, B, St, S> Service<Request<B>> for GuardService<I, St, S>
283where
284    I: Service<Request<B>, Response = Response> + Clone + Send + 'static,
285    I::Future: Send + 'static,
286    B: Send + 'static,
287    St: SessionStore,
288    S: Subject,
289{
290    type Response = Response;
291    type Error = I::Error;
292    type Future = BoxFuture<Result<Response, I::Error>>;
293
294    fn poll_ready(&mut self, cx: &mut Context<'_>) -> Poll<Result<(), Self::Error>> {
295        self.inner.poll_ready(cx)
296    }
297
298    fn call(&mut self, mut req: Request<B>) -> Self::Future {
299        // The clone may not be ready; keep the one `poll_ready` drove and leave the clone.
300        let clone = self.inner.clone();
301        let mut inner = std::mem::replace(&mut self.inner, clone);
302        let shared = Arc::clone(&self.shared);
303        Box::pin(async move {
304            if shared.bypassed(req.headers()) {
305                return inner.call(req).await;
306            }
307            let guard = &shared.guard;
308            let Some(peer) = peer_ip(req.extensions()) else {
309                return Ok(missing_connect_info());
310            };
311            let ctx = guard.client_context(peer, req.headers());
312            let Some(token) = guard.cookie_token(req.headers()) else {
313                return Ok(rejection_response(Rejection::NotAuthenticated, guard));
314            };
315            let outcome = match &shared.checks {
316                Some(checks) => guard.authenticate_with(token, &ctx, checks).await,
317                None => guard.authenticate(token, &ctx).await,
318            };
319            let auth = match outcome {
320                Ok(Outcome::Authenticated(auth)) => auth,
321                Ok(Outcome::Rejected(rejection)) => return Ok(rejection_response(rejection, guard)),
322                Err(error) => return Ok(guard_error(&error)),
323            };
324            let rotated = auth.rotated.as_ref().map(crate::IssuedToken::set_cookie);
325            if let Some(max_age) = shared.recent_auth
326                && guard.requires_reauth(&auth.session, max_age)
327            {
328                let mut response = rejection_response(Rejection::StepUpRequired, guard);
329                append_set_cookie(response.headers_mut(), rotated);
330                return Ok(response);
331            }
332            req.extensions_mut().insert(auth.session.clone());
333            let mut response = inner.call(req).await?;
334            let headers = response.headers_mut();
335            append_set_cookie(headers, rotated);
336            headers.insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store"));
337            Ok(response)
338        })
339    }
340}
341
342fn peer_ip(extensions: &Extensions) -> Option<IpAddr> {
343    extensions
344        .get::<ConnectInfo<SocketAddr>>()
345        .map(|ConnectInfo(addr)| addr.ip())
346}
347
348fn append_set_cookie(headers: &mut HeaderMap, set_cookie: Option<&str>) {
349    let Some(set_cookie) = set_cookie else { return };
350    if let Ok(value) = HeaderValue::from_str(set_cookie) {
351        headers.append(header::SET_COOKIE, value);
352    } else {
353        tracing::error!("session cookie is not a valid header value; not sent");
354    }
355}
356
357fn json_response(status: StatusCode, error: &str) -> Response {
358    let mut response = Response::new(Body::from(format!(r#"{{"error":"{error}"}}"#)));
359    *response.status_mut() = status;
360    let headers = response.headers_mut();
361    headers.insert(header::CONTENT_TYPE, HeaderValue::from_static("application/json"));
362    headers.insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store"));
363    response
364}
365
366fn internal_error() -> Response {
367    json_response(StatusCode::INTERNAL_SERVER_ERROR, "internal")
368}
369
370fn missing_connect_info() -> Response {
371    log_missing_connect_info();
372    internal_error()
373}
374
375fn log_missing_connect_info() {
376    tracing::error!(
377        "session-guard: no ConnectInfo<SocketAddr> in request extensions; serve the app with \
378         `into_make_service_with_connect_info::<SocketAddr>()`. Failing the request closed."
379    );
380}
381
382fn guard_error(error: &GuardError) -> Response {
383    tracing::error!(%error, "session-guard: authentication failed");
384    internal_error()
385}
386
387/// The 401 response for `rejection`, identical to the one the layer sends.
388///
389/// Use it where the app talks to the guard itself, e.g. when
390/// [`Guard::complete_step_up`](crate::Guard::complete_step_up) returns
391/// [`StepUpOutcome::Rejected`]. The body is
392/// `{"error":"<code>"}` with `Content-Type: application/json` and `Cache-Control: no-store`;
393/// `Set-Cookie` deletes the cookie when [`Rejection::clears_cookie`], and
394/// `Clear-Site-Data: "cookies"` is added when [`Rejection::clears_site_data`].
395///
396/// ```
397/// use session_guard::{Guard, MemoryStore, Rejection, axum::rejection_response};
398///
399/// let guard = Guard::new(MemoryStore::new());
400/// let response = rejection_response(Rejection::Revoked, &guard);
401/// assert_eq!(response.status(), 401);
402/// assert!(response.headers().contains_key("clear-site-data"));
403/// ```
404pub fn rejection_response<St, S>(rejection: Rejection, guard: &Guard<St, S>) -> Response
405where
406    St: SessionStore,
407    S: Subject,
408{
409    rejection_with(rejection, &guard.clear_cookie())
410}
411
412fn rejection_with(rejection: Rejection, clear_cookie: &str) -> Response {
413    let mut response = json_response(StatusCode::UNAUTHORIZED, rejection.as_str());
414    let headers = response.headers_mut();
415    if rejection.clears_cookie() {
416        append_set_cookie(headers, Some(clear_cookie));
417    }
418    if rejection.clears_site_data() {
419        headers.insert("clear-site-data", HeaderValue::from_static("\"cookies\""));
420    }
421    response
422}
423
424/// The [`ClientContext`] of a request, for handlers outside the layer (login, step-up,
425/// logout) that call the guard themselves.
426///
427/// Like the layer, it needs [`ConnectInfo<SocketAddr>`] and fails closed without it: the
428/// error is logged and converts into the same 500 `{"error":"internal"}` response.
429///
430/// ```no_run
431/// use std::net::SocketAddr;
432///
433/// use axum::{
434///     Router,
435///     extract::State,
436///     http::{StatusCode, header, request::Parts},
437///     response::{IntoResponse, Response},
438///     routing::post,
439/// };
440/// use session_guard::{Guard, MemoryStore, axum::client_context};
441/// use uuid::Uuid;
442///
443/// async fn login(State(guard): State<Guard<MemoryStore>>, parts: Parts) -> Response {
444///     let ctx = match client_context(&guard, &parts) {
445///         Ok(ctx) => ctx,
446///         Err(missing) => return missing.into_response(),
447///     };
448///     // Verify the credentials here; that, and rate limiting, is the app's job.
449///     let user = Uuid::new_v4();
450///     let previous = guard.cookie_token(&parts.headers);
451///     match guard.login(&user, &ctx, previous).await {
452///         Ok(session) => ([(header::SET_COOKIE, session.token.set_cookie())], "welcome").into_response(),
453///         Err(_) => StatusCode::INTERNAL_SERVER_ERROR.into_response(),
454///     }
455/// }
456///
457/// # async fn run() -> std::io::Result<()> {
458/// let guard = Guard::new(MemoryStore::new());
459/// let app = Router::new().route("/login", post(login)).with_state(guard);
460/// let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await?;
461/// axum::serve(listener, app.into_make_service_with_connect_info::<SocketAddr>()).await
462/// # }
463/// ```
464///
465/// # Errors
466///
467/// [`MissingConnectInfo`] when the request has no [`ConnectInfo<SocketAddr>`].
468pub fn client_context<St, S>(guard: &Guard<St, S>, parts: &Parts) -> Result<ClientContext, MissingConnectInfo>
469where
470    St: SessionStore,
471    S: Subject,
472{
473    let Some(peer) = peer_ip(&parts.extensions) else {
474        log_missing_connect_info();
475        return Err(MissingConnectInfo);
476    };
477    Ok(guard.client_context(peer, &parts.headers))
478}
479
480/// The request carries no [`ConnectInfo<SocketAddr>`]: the app was not served with
481/// `into_make_service_with_connect_info::<SocketAddr>()`. Responds with 500
482/// `{"error":"internal"}` and `Cache-Control: no-store`.
483#[derive(Clone, Copy, Debug, PartialEq, Eq)]
484pub struct MissingConnectInfo;
485
486impl fmt::Display for MissingConnectInfo {
487    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
488        f.write_str("no ConnectInfo<SocketAddr> in request extensions")
489    }
490}
491
492impl std::error::Error for MissingConnectInfo {}
493
494impl IntoResponse for MissingConnectInfo {
495    fn into_response(self) -> Response {
496        internal_error()
497    }
498}
499
500/// Extracts the session the [`GuardLayer`] authenticated. Without the layer (or for a
501/// bypassed request) it rejects with 401 `{"error":"not_authenticated"}`.
502///
503/// `S` must match the guard's subject type.
504impl<A, S> FromRequestParts<A> for AuthSession<S>
505where
506    A: Send + Sync,
507    S: Clone + Send + Sync + 'static,
508{
509    type Rejection = Response;
510
511    fn from_request_parts(parts: &mut Parts, _state: &A) -> impl Future<Output = Result<Self, Self::Rejection>> + Send {
512        std::future::ready(
513            parts
514                .extensions
515                .get::<AuthSession<S>>()
516                .cloned()
517                .ok_or_else(|| json_response(StatusCode::UNAUTHORIZED, Rejection::NotAuthenticated.as_str())),
518        )
519    }
520}
521
522/// `Option<AuthSession<S>>`: `None` when the layer did not authenticate the request, for
523/// handlers that serve both anonymous and signed-in clients.
524impl<A, S> OptionalFromRequestParts<A> for AuthSession<S>
525where
526    A: Send + Sync,
527    S: Clone + Send + Sync + 'static,
528{
529    type Rejection = std::convert::Infallible;
530
531    fn from_request_parts(
532        parts: &mut Parts,
533        _state: &A,
534    ) -> impl Future<Output = Result<Option<Self>, Self::Rejection>> + Send {
535        std::future::ready(Ok(parts.extensions.get::<AuthSession<S>>().cloned()))
536    }
537}
538
539/// Responses that set or delete the session cookie: append `Set-Cookie` and forbid caching.
540fn cookie_parts(mut parts: ResponseParts, set_cookie: &str) -> ResponseParts {
541    let headers = parts.headers_mut();
542    append_set_cookie(headers, Some(set_cookie));
543    headers.insert(header::CACHE_CONTROL, HeaderValue::from_static("no-store"));
544    parts
545}
546
547/// Sends the token: appends its `Set-Cookie` and `Cache-Control: no-store`.
548///
549/// ```
550/// # use axum::http::StatusCode;
551/// # use session_guard::IssuedToken;
552/// fn respond(token: IssuedToken) -> impl axum::response::IntoResponse {
553///     (token, StatusCode::NO_CONTENT)
554/// }
555/// ```
556impl IntoResponseParts for IssuedToken {
557    type Error = Infallible;
558
559    fn into_response_parts(self, parts: ResponseParts) -> Result<ResponseParts, Self::Error> {
560        Ok(cookie_parts(parts, self.set_cookie()))
561    }
562}
563
564/// Sends the session's first token, like [`IssuedToken`].
565impl IntoResponseParts for NewSession {
566    type Error = Infallible;
567
568    fn into_response_parts(self, parts: ResponseParts) -> Result<ResponseParts, Self::Error> {
569        self.token.into_response_parts(parts)
570    }
571}
572
573/// A 500 with `{"error":"internal"}` and `Cache-Control: no-store`. The error itself is only
574/// logged, so store details never reach the client.
575impl IntoResponse for GuardError {
576    fn into_response(self) -> Response {
577        guard_error(&self)
578    }
579}
580
581/// Login, logout and step-up for handlers: the guard, the request's [`ClientContext`] and the
582/// session cookie it carries, in one extractor.
583///
584/// It takes the guard from the router state (anything with [`FromRef`] for
585/// [`Guard<St, S>`](Guard), including the guard itself) and needs
586/// [`ConnectInfo<SocketAddr>`] like the layer; without it the request fails closed with
587/// [`MissingConnectInfo`]. It does not authenticate anything, so put step-up routes outside the
588/// [`GuardLayer`]: the layer would answer `step_up_required` before the handler runs.
589///
590/// Every result converts into a response: [`NewSession`] and [`IssuedToken`] set the cookie,
591/// [`LoggedOut`] deletes it, and [`GuardError`] and [`StepUpError`] become the matching error.
592/// Verifying credentials, and rate limiting the routes that do, stays with the app.
593///
594/// ```no_run
595/// use std::net::SocketAddr;
596///
597/// use axum::{Router, http::StatusCode, routing::post};
598/// use session_guard::{
599///     Guard, GuardError, IssuedToken, MemoryStore, NewSession,
600///     axum::{LoggedOut, Sessions, StepUpError},
601/// };
602/// use uuid::Uuid;
603///
604/// // Stands in for the app's own credential check.
605/// async fn verify() -> Uuid {
606///     Uuid::nil()
607/// }
608///
609/// async fn login(sessions: Sessions<MemoryStore>) -> Result<(NewSession, StatusCode), GuardError> {
610///     let user = verify().await;
611///     Ok((sessions.login(&user).await?, StatusCode::NO_CONTENT))
612/// }
613///
614/// async fn step_up(sessions: Sessions<MemoryStore>) -> Result<(IssuedToken, StatusCode), StepUpError> {
615///     let user = verify().await;
616///     Ok((sessions.complete_step_up(&user).await?, StatusCode::NO_CONTENT))
617/// }
618///
619/// async fn logout(sessions: Sessions<MemoryStore>) -> Result<(LoggedOut, StatusCode), GuardError> {
620///     Ok((sessions.logout().await?, StatusCode::NO_CONTENT))
621/// }
622///
623/// # async fn run() -> std::io::Result<()> {
624/// let guard = Guard::new(MemoryStore::new());
625/// let app = Router::new()
626///     .route("/login", post(login))
627///     .route("/step-up", post(step_up))
628///     .route("/logout", post(logout))
629///     .with_state(guard);
630/// let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await?;
631/// axum::serve(listener, app.into_make_service_with_connect_info::<SocketAddr>()).await
632/// # }
633/// ```
634pub struct Sessions<St, S = Uuid> {
635    guard: Guard<St, S>,
636    client: ClientContext,
637    token: Option<SecretString>,
638}
639
640impl<St, S> fmt::Debug for Sessions<St, S> {
641    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
642        f.debug_struct("Sessions")
643            .field("client", &self.client)
644            .field("token", &self.token.as_ref().map(|_| "<redacted>"))
645            .finish_non_exhaustive()
646    }
647}
648
649impl<St: SessionStore, S: Subject> Sessions<St, S> {
650    /// [`Guard::login`] for `subject`, revoking the session the request presented, if any.
651    /// Call it only after verifying the subject's credentials.
652    ///
653    /// # Errors
654    ///
655    /// As [`Guard::login`].
656    pub async fn login(&self, subject: &S) -> Result<NewSession, GuardError> {
657        self.guard.login(subject, &self.client, self.token()).await
658    }
659
660    /// [`Guard::logout`] for the presented session. The result deletes the cookie even when
661    /// there was no session to revoke.
662    ///
663    /// # Errors
664    ///
665    /// As [`Guard::logout`].
666    pub async fn logout(&self) -> Result<LoggedOut, GuardError> {
667        let revoked = match self.token() {
668            Some(token) => self.guard.logout(token, &self.client).await?,
669            None => false,
670        };
671        Ok(LoggedOut {
672            revoked,
673            clear_cookie: self.guard.clear_cookie(),
674        })
675    }
676
677    /// [`Guard::complete_step_up`] for the presented session, once the app has verified
678    /// `verified_subject`'s credentials again.
679    ///
680    /// # Errors
681    ///
682    /// A [`StepUpError`]: the subject does not own the session, there is no live session
683    /// (including no cookie at all), or the guard failed.
684    pub async fn complete_step_up(&self, verified_subject: &S) -> Result<IssuedToken, StepUpError> {
685        let failure = match self.token() {
686            None => StepUpFailure::Rejected(Rejection::NotAuthenticated),
687            Some(token) => match self.guard.complete_step_up(token, &self.client, verified_subject).await {
688                Ok(StepUpOutcome::Completed(token)) => return Ok(token),
689                Ok(StepUpOutcome::SubjectMismatch) => StepUpFailure::SubjectMismatch,
690                Ok(StepUpOutcome::Rejected(rejection)) => StepUpFailure::Rejected(rejection),
691                Err(error) => StepUpFailure::Guard(error),
692            },
693        };
694        Err(StepUpError {
695            failure,
696            clear_cookie: self.guard.clear_cookie(),
697        })
698    }
699
700    /// The guard, for anything else (listing or revoking sessions).
701    pub fn guard(&self) -> &Guard<St, S> {
702        &self.guard
703    }
704
705    /// The request's client context.
706    pub fn client(&self) -> &ClientContext {
707        &self.client
708    }
709
710    /// The session cookie's value, if the request carried one. Not validated.
711    pub fn token(&self) -> Option<&str> {
712        self.token.as_ref().map(ExposeSecret::expose_secret)
713    }
714}
715
716impl<T, St, S> FromRequestParts<T> for Sessions<St, S>
717where
718    T: Send + Sync,
719    Guard<St, S>: FromRef<T>,
720    St: SessionStore,
721    S: Subject,
722{
723    type Rejection = MissingConnectInfo;
724
725    fn from_request_parts(parts: &mut Parts, state: &T) -> impl Future<Output = Result<Self, Self::Rejection>> + Send {
726        let guard = Guard::from_ref(state);
727        let sessions = client_context(&guard, parts).map(|client| {
728            let token = guard
729                .cookie_token(&parts.headers)
730                .map(|token| SecretString::from(token.to_owned()));
731            Self { guard, client, token }
732        });
733        std::future::ready(sessions)
734    }
735}
736
737/// Result of [`Sessions::logout`]. As response parts it deletes the cookie and sets
738/// `Cache-Control: no-store`.
739#[derive(Clone, Debug)]
740pub struct LoggedOut {
741    revoked: bool,
742    clear_cookie: String,
743}
744
745impl LoggedOut {
746    /// Whether a session was revoked; `false` when the request had none.
747    pub fn revoked(&self) -> bool {
748        self.revoked
749    }
750}
751
752impl IntoResponseParts for LoggedOut {
753    type Error = Infallible;
754
755    fn into_response_parts(self, parts: ResponseParts) -> Result<ResponseParts, Self::Error> {
756        Ok(cookie_parts(parts, &self.clear_cookie))
757    }
758}
759
760/// Why [`Sessions::complete_step_up`] failed. As a response:
761///
762/// - [`StepUpFailure::SubjectMismatch`]: 403 `{"error":"subject_mismatch"}`, cookie kept.
763/// - [`StepUpFailure::Rejected`]: the layer's 401 for that rejection (see
764///   [`rejection_response`]).
765/// - [`StepUpFailure::Guard`]: 500 `{"error":"internal"}`, the error only logged.
766#[derive(Debug)]
767pub struct StepUpError {
768    failure: StepUpFailure,
769    clear_cookie: String,
770}
771
772/// The cause of a [`StepUpError`].
773#[derive(Debug)]
774#[non_exhaustive]
775pub enum StepUpFailure {
776    /// The verified subject does not own the session. Nothing changed.
777    SubjectMismatch,
778    /// There is no live session to step up.
779    Rejected(Rejection),
780    /// The guard failed.
781    Guard(GuardError),
782}
783
784impl StepUpError {
785    /// What went wrong.
786    pub fn failure(&self) -> &StepUpFailure {
787        &self.failure
788    }
789
790    /// What went wrong, by value.
791    pub fn into_failure(self) -> StepUpFailure {
792        self.failure
793    }
794}
795
796impl fmt::Display for StepUpError {
797    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
798        match &self.failure {
799            StepUpFailure::SubjectMismatch => f.write_str("verified subject does not own the session"),
800            StepUpFailure::Rejected(rejection) => write!(f, "step-up rejected: {rejection}"),
801            StepUpFailure::Guard(_) => f.write_str("step-up failed"),
802        }
803    }
804}
805
806impl std::error::Error for StepUpError {
807    fn source(&self) -> Option<&(dyn std::error::Error + 'static)> {
808        match &self.failure {
809            StepUpFailure::Guard(error) => Some(error),
810            _ => None,
811        }
812    }
813}
814
815impl IntoResponse for StepUpError {
816    fn into_response(self) -> Response {
817        match self.failure {
818            StepUpFailure::SubjectMismatch => json_response(StatusCode::FORBIDDEN, "subject_mismatch"),
819            StepUpFailure::Rejected(rejection) => rejection_with(rejection, &self.clear_cookie),
820            StepUpFailure::Guard(error) => guard_error(&error),
821        }
822    }
823}
824
825#[cfg(test)]
826mod tests {
827    use super::*;
828
829    fn headers(auth: &str) -> HeaderMap {
830        let mut h = HeaderMap::new();
831        h.insert(header::AUTHORIZATION, HeaderValue::from_str(auth).unwrap());
832        h
833    }
834
835    #[test]
836    fn bearer_parsing() {
837        assert_eq!(bearer_token(&headers("Bearer abc")), Some("abc"));
838        assert_eq!(bearer_token(&headers("BEARER  abc")), Some("abc"));
839        assert_eq!(bearer_token(&headers("Basic abc")), None);
840        assert_eq!(bearer_token(&headers("Bearer")), None);
841        assert_eq!(bearer_token(&HeaderMap::new()), None);
842    }
843
844    #[test]
845    fn json_body_is_exact() {
846        let response = json_response(StatusCode::UNAUTHORIZED, "session_expired");
847        assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
848        assert_eq!(response.headers()[header::CACHE_CONTROL], "no-store");
849    }
850}