//! HTTP layer: routing, session handling and OpenAPI generation (aide). //! //! Authorization is carried by the extractor a handler asks for: //! - `AnonAppController` always succeeds; //! - `AppController` resolves the session and answers **401** without one, so //! a route cannot accidentally be left open. //! //! Handlers stay thin: extract the controller, call it, map `ControllerError` //! to a status code. No business logic here. use std::sync::Arc; use aide::{ OperationInput, OperationOutput, axum::{ApiRouter, routing::get_with}, generate::GenContext, openapi::{OpenApi, Operation, Response}, }; use axum::{ Extension, Json, Router, extract::FromRequestParts, http::{StatusCode, request::Parts}, }; use axum_login::{AuthManagerLayerBuilder, AuthSession, tower_sessions::SessionManagerLayer}; use indexmap::IndexMap; use tower_sessions::{Expiry, MemoryStore, cookie::SameSite, cookie::time::Duration}; use tracing::error; use crate::{ core::controller::{AnonAppController, AppController}, utils, }; mod auth; mod bikes; mod docs; mod helpers; pub fn get_router(aac: AnonAppController) -> Router { aide::generate::on_error(|err| error!("aide generated error: {err}")); aide::generate::extract_schemas(true); let config = utils::config::get(); // Sessions live in memory: everybody is logged out when the backend // restarts. Swap the store for a persistent one if that becomes a problem. let session_layer = SessionManagerLayer::new(MemoryStore::default()) // Over plain http in development the cookie cannot be `Secure` .with_secure(config.get_base_url().starts_with("https://")) // The provider sends the browser back with a top level navigation .with_same_site(SameSite::Lax) .with_expiry(Expiry::OnInactivity(Duration::minutes( config.get_session_lifetime(), ))); let auth_layer = AuthManagerLayerBuilder::new(aac.clone(), session_layer).build(); let mut api = OpenApi::default(); ApiRouter::new() .api_route( "/api/version", get_with( async || Json(env!("CARGO_PKG_VERSION").to_string()), |op| op.tag("misc").summary("Get app version"), ), ) // `auth` carries its own `/api/...` paths, so it is merged, not nested .merge(auth::routes()) .nest_api_service("/api/bikes", bikes::routes()) .nest_api_service("/api/docs", docs::routes()) .finish_api_with(&mut api, docs::api_docs_metadata) .layer(Extension(aac)) .layer(Extension(Arc::new(api))) .layer(auth_layer) } /// Lets a handler take an `AnonAppController`: always available. impl FromRequestParts for AnonAppController where S: Send + Sync, { type Rejection = StatusCode; async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result { parts .extensions .get::() .cloned() .ok_or(StatusCode::INTERNAL_SERVER_ERROR) } } /// Lets a handler take an `AppController`, which requires a session: asking for /// it *is* the authentication check. impl FromRequestParts for AppController where S: Send + Sync, { type Rejection = StatusCode; async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result { use axum::RequestPartsExt; let aac = parts .extensions .get::() .cloned() .ok_or(StatusCode::INTERNAL_SERVER_ERROR)?; let session = parts .extract::>() .await .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; let user = session.user.ok_or(StatusCode::UNAUTHORIZED)?; Ok(aac.auth(user)) } } // The controllers are not part of the request/response bodies, but asking for // an `AppController` documents the 401 and the cookie requirement. impl OperationOutput for AnonAppController { type Inner = Self; } impl OperationInput for AnonAppController {} impl OperationOutput for AppController { type Inner = Self; } impl OperationInput for AppController { fn inferred_early_responses( _: &mut GenContext, op: &mut Operation, ) -> Vec<(Option, Response)> { let mut session_cookie = IndexMap::new(); session_cookie.insert("session_cookie".to_owned(), Vec::new()); op.security = vec![session_cookie]; vec![( Some(401), Response { description: "Unauthenticated - a session is required".to_owned(), content: IndexMap::new(), ..Default::default() }, )] } }