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}