use axum::extract::{Request, State};
use axum::middleware::Next;
use axum::response::{IntoResponse, Response};
use axum_extra::extract::cookie::CookieJar;
use crate::auth::session::{self, SESSION_COOKIE_NAME};
use crate::error::AppError;
use crate::state::AuthState;
#[derive(Debug, Clone)]
pub struct SessionIdentity {
pub sub: String,
pub email: String,
}
const SIN_SESION: &str = "hace falta una sesión válida para esta ruta";
pub async fn gate(
State(auth): State<AuthState>,
jar: CookieJar,
mut request: Request,
next: Next,
) -> Response {
let identidad = match jar.get(SESSION_COOKIE_NAME) {
Some(cookie) => match session::verify(&auth.secret, cookie.value()) {
Ok(claims) => SessionIdentity {
sub: claims.sub,
email: claims.email,
},
Err(error) => return error.into_response(),
},
None => return AppError::Unauthorized(SIN_SESION.to_owned()).into_response(),
};
request.extensions_mut().insert(identidad);
next.run(request).await
}
#[cfg(test)]
mod tests {
use axum::Router;
use axum::extract::Extension;
use axum::http::StatusCode;
use super::SessionIdentity;
use crate::api::protected_router;
use crate::api::response::{ApiResponse, Data};
use crate::auth::session;
use crate::secret::SecretString;
use crate::test_support::{
EMAIL, OTRA_SESSION_SECRET, SUB, auth_oidc, body_json, body_text, cookie_de_sesion, get,
get_with_cookie, session_cookie_header,
};
const RUTA: &str = "/negocio";
async fn quien_soy(Extension(identidad): Extension<SessionIdentity>) -> ApiResponse {
ApiResponse::new(
StatusCode::OK,
"sesión válida",
Data::Some(serde_json::json!({
"sub": identidad.sub,
"email": identidad.email,
})),
)
}
async fn router() -> Router {
protected_router(
Router::new().route(RUTA, axum::routing::get(quien_soy)),
&auth_oidc().await,
)
}
#[tokio::test]
async fn una_peticion_protegida_sin_cookie_contesta_401_con_la_envoltura() {
let respuesta = get(router().await, RUTA).await;
assert_eq!(respuesta.status(), StatusCode::UNAUTHORIZED);
let texto = body_text(respuesta).await;
assert!(!texto.is_empty(), "el 401 llegó con el cuerpo vacío");
let cuerpo: serde_json::Value =
serde_json::from_str(&texto).expect("el 401 debe traer JSON en el cuerpo");
assert_eq!(cuerpo["status"], 401);
assert!(
cuerpo["message"]
.as_str()
.is_some_and(|mensaje| !mensaje.is_empty()),
"el 401 debe traer un mensaje: {texto}"
);
assert!(
cuerpo.get("data").is_none(),
"el error no debe traer la clave data: {texto}"
);
}
#[tokio::test]
async fn una_cookie_valida_deja_pasar_y_el_manejador_ve_la_identidad() {
let respuesta = get_with_cookie(router().await, RUTA, &cookie_de_sesion()).await;
assert_eq!(respuesta.status(), StatusCode::OK);
let cuerpo = body_json(respuesta).await;
assert_eq!(
cuerpo["data"]["sub"], SUB,
"el manejador no vio el usuario de la sesión: {cuerpo}"
);
assert_eq!(
cuerpo["data"]["email"], EMAIL,
"el manejador no vio el correo de la sesión: {cuerpo}"
);
assert!(
cuerpo.to_string().contains(SUB),
"la respuesta no trae la identidad: {cuerpo}"
);
}
#[tokio::test]
async fn una_cookie_firmada_con_otra_clave_no_se_acepta() {
let token = session::issue(
&SecretString::new(OTRA_SESSION_SECRET.to_owned()),
SUB.to_owned(),
EMAIL.to_owned(),
)
.expect("la emisión de la sesión de prueba no puede fallar");
let respuesta = get_with_cookie(router().await, RUTA, &session_cookie_header(&token)).await;
assert_eq!(respuesta.status(), StatusCode::UNAUTHORIZED);
let cuerpo = body_json(respuesta).await;
assert_eq!(cuerpo["status"], 401);
assert!(
cuerpo["message"]
.as_str()
.is_some_and(|mensaje| !mensaje.is_empty()),
"el 401 debe traer un mensaje: {cuerpo}"
);
}
}