use axum::body::Body;
use axum::extract::rejection::QueryRejection;
use axum::extract::{Extension, Query, State};
use axum::http::{HeaderValue, StatusCode, header};
use axum::response::{IntoResponse, Response};
use axum_extra::extract::cookie::{Cookie, CookieJar};
use serde::Deserialize;
use crate::api::response::{ApiResponse, Data};
use crate::auth::middleware::SessionIdentity;
use crate::auth::session::SESSION_COOKIE_NAME;
use crate::auth::{oidc, session};
use crate::error::AppError;
use crate::state::AppState;
const INTENTO_INVALIDO: &str = "el intento de entrada no es válido";
const CALLBACK_INCOMPLETO: &str = "el callback no trae los parámetros del flujo";
const DESTINO_PANEL: &str = "/";
pub async fn login(State(state): State<AppState>) -> Response {
let Some(auth) = state.auth.as_ref() else {
return AppError::Other("el login se atendió sin estado de autenticación".to_owned())
.into_response();
};
let discovery = match oidc::discovery_cached(auth).await {
Ok(discovery) => discovery,
Err(error) => return error.into_response(),
};
let solicitud = oidc::build_authorization_url(auth.settings.as_ref(), &discovery);
auth.registrar_login(&solicitud.state, &solicitud.nonce)
.await;
redireccion_302(&solicitud.url)
}
fn redireccion_302(destino: &str) -> Response {
match HeaderValue::from_str(destino) {
Ok(location) => {
let mut respuesta = Response::new(Body::empty());
*respuesta.status_mut() = StatusCode::FOUND;
respuesta.headers_mut().insert(header::LOCATION, location);
respuesta
}
Err(error) => AppError::Other(format!(
"la URL de autorización no es una cabecera válida: {error}"
))
.into_response(),
}
}
pub async fn callback(
State(state): State<AppState>,
params: Result<Query<CallbackParams>, QueryRejection>,
) -> Response {
let Some(auth) = state.auth.as_ref() else {
return AppError::Other("el callback se atendió sin estado de autenticación".to_owned())
.into_response();
};
let Query(params) = match params {
Ok(params) => params,
Err(_) => {
tracing::debug!("la consulta del callback no pasa el extractor");
return AppError::InvalidInput(CALLBACK_INCOMPLETO.to_owned()).into_response();
}
};
if params.error.is_some() {
return AppError::Unauthorized(INTENTO_INVALIDO.to_owned()).into_response();
}
let Some(state_recibido) = params.state.as_deref() else {
return AppError::InvalidInput(CALLBACK_INCOMPLETO.to_owned()).into_response();
};
let Some(code) = params.code.as_deref() else {
return AppError::InvalidInput(CALLBACK_INCOMPLETO.to_owned()).into_response();
};
let pendiente = match auth.consumir_login(state_recibido).await {
Ok(pendiente) => pendiente,
Err(rechazo) => {
tracing::debug!(?rechazo, "callback con un state no utilizable");
return AppError::Unauthorized(INTENTO_INVALIDO.to_owned()).into_response();
}
};
let discovery = match oidc::discovery_cached(auth).await {
Ok(discovery) => discovery,
Err(error) => return error.into_response(),
};
let id_token = match oidc::canjear_codigo(auth.settings.as_ref(), &discovery, code).await {
Ok(id_token) => id_token,
Err(error) => return error.into_response(),
};
let identidad = match oidc::verificar_id_token(
auth.settings.as_ref(),
&discovery,
&id_token,
&pendiente.nonce,
)
.await
{
Ok(identidad) => identidad,
Err(error) => return error.into_response(),
};
let token = match session::issue(&auth.secret, identidad.sub, identidad.email) {
Ok(token) => token,
Err(error) => return error.into_response(),
};
let cookie = match session::session_cookie(token, &auth.settings.redirect_url) {
Ok(cookie) => cookie,
Err(error) => return error.into_response(),
};
redireccion_302_al_panel(cookie)
}
fn redireccion_302_al_panel(cookie: Cookie<'static>) -> Response {
let mut respuesta = redireccion_302(DESTINO_PANEL);
match HeaderValue::from_str(&cookie.to_string()) {
Ok(valor) => {
respuesta.headers_mut().insert(header::SET_COOKIE, valor);
respuesta
}
Err(error) => AppError::Other(format!(
"la cookie de sesión no es una cabecera válida: {error}"
))
.into_response(),
}
}
#[derive(Debug, Deserialize)]
pub struct CallbackParams {
code: Option<String>,
state: Option<String>,
error: Option<String>,
}
pub async fn logout(jar: CookieJar) -> (CookieJar, ApiResponse) {
let jar = jar.add(cookie_de_borrado());
(
jar,
ApiResponse::new(StatusCode::OK, "sesión cerrada", Data::None),
)
}
pub async fn session(Extension(identidad): Extension<SessionIdentity>) -> ApiResponse {
ApiResponse::new(
StatusCode::OK,
"sesión en vigor",
Data::Some(serde_json::json!({
"sub": identidad.sub,
"email": identidad.email,
})),
)
}
fn cookie_de_borrado() -> Cookie<'static> {
let mut cookie = Cookie::build((SESSION_COOKIE_NAME, "")).path("/").build();
cookie.make_removal();
cookie
}
#[cfg(test)]
mod tests {
use std::time::Instant;
use axum::http::{StatusCode, header};
use axum::response::IntoResponse;
use crate::api::api_router;
use crate::auth::session::SESSION_COOKIE_NAME;
use crate::error::AppError;
use crate::test_support::{
EMAIL, OIDC_CLIENT_ID, OIDC_CLIENT_SECRET, SUB, body_json, body_text, callback_url,
capturar_log, cookie_de_sesion, emisor_caido, estado_oidc_con_emisor, get, get_with_cookie,
header_text, login_state_y_nonce, parametros_de_consulta, post, post_with_cookie,
proveedor_falso::ACCESS_TOKEN, proveedor_falso::IdToken, proveedor_falso::ProveedorFalso,
router_oidc, router_oidc_con_emisor, session_cookie_header, token_de_set_cookie,
};
#[tokio::test]
async fn el_login_guarda_el_state_con_su_nonce_y_su_marca_de_tiempo() {
let proveedor = ProveedorFalso::servir().await;
let estado = estado_oidc_con_emisor(&proveedor.issuer).await;
let auth = estado
.auth
.clone()
.expect("en modo Oidc hay estado de autenticación");
let router = api_router(estado);
let antes = Instant::now();
let respuesta = get(router, "/api/v1/auth/login").await;
let despues = Instant::now();
assert_eq!(
respuesta.status(),
StatusCode::FOUND,
"el login debe redirigir"
);
let location = header_text(&respuesta, header::LOCATION).expect("el 302 trae Location");
let parametros = parametros_de_consulta(&location);
let state = parametros
.get("state")
.expect("la redirección debe traer state")
.clone();
let nonce = parametros
.get("nonce")
.expect("la redirección debe traer nonce")
.clone();
let logins = auth.logins.lock().await;
let entrada = logins
.get(&state)
.expect("el state emitido debe quedar guardado en el mapa");
assert_eq!(
entrada.nonce, nonce,
"el nonce guardado no es el emitido junto al state: {entrada:?}"
);
assert!(
entrada.issued_at >= antes && entrada.issued_at <= despues,
"la marca de tiempo guardada no es la del login: {entrada:?}"
);
assert!(
!entrada.expirado_en(Instant::now()),
"un state recién emitido no puede estar caducado: {entrada:?}"
);
}
#[tokio::test]
async fn el_cierre_borra_la_cookie() {
let respuesta = post_with_cookie(
router_oidc().await,
"/api/v1/auth/logout",
&cookie_de_sesion(),
)
.await;
assert_eq!(respuesta.status(), StatusCode::OK);
let cabecera = header_text(&respuesta, header::SET_COOKIE)
.expect("el logout debe traer una Set-Cookie de borrado");
assert!(
cabecera.starts_with(&format!("{SESSION_COOKIE_NAME}=;")),
"la cookie de borrado debe dejar el valor vacío: {cabecera}"
);
assert!(
cabecera.contains("Max-Age=0"),
"la cookie de borrado debe llevar Max-Age=0: {cabecera}"
);
assert!(
cabecera.contains("Path=/"),
"la cookie de borrado debe llevar el mismo Path que la de sesión: {cabecera}"
);
let cuerpo = body_json(respuesta).await;
assert_eq!(cuerpo["status"], 200);
assert!(
cuerpo["message"]
.as_str()
.is_some_and(|mensaje| !mensaje.is_empty()),
"el logout debe contestar la envoltura con message: {cuerpo}"
);
}
#[tokio::test]
async fn sin_sesion_el_cierre_no_se_atiende() {
let respuesta = post(router_oidc().await, "/api/v1/auth/logout").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}"
);
}
#[tokio::test]
async fn el_cierre_no_se_declara_como_get() {
let respuesta = get_with_cookie(
router_oidc().await,
"/api/v1/auth/logout",
&cookie_de_sesion(),
)
.await;
assert_eq!(respuesta.status(), StatusCode::METHOD_NOT_ALLOWED);
let cuerpo = body_json(respuesta).await;
assert_eq!(cuerpo["status"], 405);
assert!(
cuerpo["message"]
.as_str()
.is_some_and(|mensaje| !mensaje.is_empty()),
"el 405 debe traer un mensaje: {cuerpo}"
);
}
#[tokio::test]
async fn sin_sesion_no_se_aprende_que_metodos_hay() {
let respuesta = get(router_oidc().await, "/api/v1/auth/logout").await;
assert_ne!(
respuesta.status(),
StatusCode::METHOD_NOT_ALLOWED,
"sin sesión, el 405 revelaría qué métodos existen"
);
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);
}
#[tokio::test]
async fn con_sesion_se_contesta_la_identidad() {
let respuesta = get_with_cookie(
router_oidc().await,
"/api/v1/auth/session",
&cookie_de_sesion(),
)
.await;
assert_eq!(respuesta.status(), StatusCode::OK);
let cuerpo = body_json(respuesta).await;
assert_eq!(cuerpo["status"], 200);
assert_eq!(
cuerpo["data"]["sub"], SUB,
"la sesión debe contestar el sub: {cuerpo}"
);
assert_eq!(
cuerpo["data"]["email"], EMAIL,
"la sesión debe contestar el email: {cuerpo}"
);
assert!(
!cuerpo.to_string().contains("eyJ"),
"la sesión no debe devolver el JWT: {cuerpo}"
);
}
#[tokio::test]
async fn sin_sesion_el_401_es_la_respuesta() {
let respuesta = get(router_oidc().await, "/api/v1/auth/session").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}"
);
}
#[tokio::test]
async fn un_login_con_el_proveedor_caido_contesta_502() {
let router = router_oidc_con_emisor(&emisor_caido()).await;
let respuesta = get(router, "/api/v1/auth/login").await;
assert_eq!(
respuesta.status(),
StatusCode::BAD_GATEWAY,
"un proveedor caído debe dejar el login en un 502"
);
let texto = body_text(respuesta).await;
assert!(
!texto.contains(OIDC_CLIENT_SECRET),
"el 502 publicó el client_secret: {texto}"
);
let cuerpo: serde_json::Value =
serde_json::from_str(&texto).expect("el 502 debe traer JSON en el cuerpo");
assert_eq!(cuerpo["status"], 502);
let generico = body_json(
AppError::Other("una causa distinguible que no debe salir al cuerpo".to_owned())
.into_response(),
)
.await;
assert_eq!(
cuerpo["message"], generico["message"],
"el 502 debe traer el mismo mensaje genérico que un 500: {cuerpo}"
);
}
#[tokio::test]
async fn el_callback_correcto_contesta_302() {
let proveedor = ProveedorFalso::servir().await;
let router = router_oidc_con_emisor(&proveedor.issuer).await;
let (state, nonce) = login_state_y_nonce(router.clone()).await;
let claims = IdToken::valido(&proveedor.issuer, OIDC_CLIENT_ID, &nonce);
proveedor
.montar_token_endpoint(&proveedor.firmar(&claims))
.await;
let respuesta = get(router, &callback_url(&state, "codigo-de-autorizacion")).await;
assert_eq!(
respuesta.status(),
StatusCode::FOUND,
"el callback correcto debe volver al panel"
);
let location =
header_text(&respuesta, header::LOCATION).expect("el 302 debe traer Location");
assert!(
!location.contains("codigo-de-autorizacion"),
"la vuelta al panel no puede arrastrar el código de autorización: {location}"
);
assert!(
token_de_set_cookie(&respuesta).is_some(),
"el callback correcto debe emitir la cookie de sesión"
);
}
#[tokio::test]
async fn una_consulta_de_callback_duplicada_contesta_la_envoltura_json() {
let respuesta = get(router_oidc().await, "/api/v1/auth/callback?code=a&code=b").await;
assert_eq!(
respuesta.status(),
StatusCode::BAD_REQUEST,
"una consulta ilegible debe rechazarse con un 400"
);
let tipo = header_text(&respuesta, header::CONTENT_TYPE)
.expect("la respuesta debe traer content-type");
assert!(
tipo.starts_with("application/json"),
"el rechazo no puede volver con el text/plain del extractor: {tipo}"
);
assert!(
!tipo.contains("text/plain"),
"el rechazo volvió con text/plain en vez de la envoltura: {tipo}"
);
let texto = body_text(respuesta).await;
assert!(
!texto.contains("duplicate field"),
"el 400 publicó el detalle de serde: {texto}"
);
let cuerpo: serde_json::Value =
serde_json::from_str(&texto).expect("el 400 debe traer JSON en el cuerpo");
assert_eq!(cuerpo["status"], 400);
assert!(
cuerpo["message"]
.as_str()
.is_some_and(|mensaje| !mensaje.is_empty()),
"el 400 debe traer la envoltura con message: {cuerpo}"
);
assert!(
cuerpo.get("data").is_none(),
"el rechazo no debe traer la clave data: {cuerpo}"
);
}
#[tokio::test]
async fn un_callback_sin_parametros_contesta_400_envuelto_y_no_un_401() {
let respuesta = get(router_oidc().await, "/api/v1/auth/callback").await;
assert_ne!(
respuesta.status(),
StatusCode::UNAUTHORIZED,
"el callback sin parámetros no puede contestar 401"
);
assert_eq!(respuesta.status(), StatusCode::BAD_REQUEST);
let tipo = header_text(&respuesta, header::CONTENT_TYPE)
.expect("la respuesta debe traer content-type");
assert!(
tipo.starts_with("application/json"),
"el 400 debe llevar la envoltura JSON: {tipo}"
);
let cuerpo = body_json(respuesta).await;
assert_eq!(cuerpo["status"], 400);
assert!(
cuerpo["message"]
.as_str()
.is_some_and(|mensaje| !mensaje.is_empty()),
"el 400 debe traer la envoltura con message: {cuerpo}"
);
}
#[tokio::test]
async fn el_flujo_completo_no_deja_credenciales_en_el_log() {
let (buffer, _guard) = capturar_log();
let proveedor = ProveedorFalso::servir().await;
let router = router_oidc_con_emisor(&proveedor.issuer).await;
let rechazo = get(
router.clone(),
&callback_url("state-que-nadie-emitio", "codigo-inventado"),
)
.await;
assert_eq!(
rechazo.status(),
StatusCode::UNAUTHORIZED,
"un state que nadie emitió debe rechazarse"
);
let proveedor_caido = ProveedorFalso::servir().await;
let router_caido = router_oidc_con_emisor(&proveedor_caido.issuer).await;
let cuerpo_ajeno = format!(
"{{\"error\":\"invalid_grant\",\"client_secret\":\"{OIDC_CLIENT_SECRET}\",\
\"access_token\":\"{ACCESS_TOKEN}\"}}"
);
proveedor_caido
.montar_token_endpoint_con_error(502, &cuerpo_ajeno)
.await;
let (state_del_error, _nonce) = login_state_y_nonce(router_caido.clone()).await;
let codigo_del_error = "codigo-del-502-distinguible";
let caida = get(
router_caido,
&callback_url(&state_del_error, codigo_del_error),
)
.await;
assert_eq!(
caida.status(),
StatusCode::BAD_GATEWAY,
"un proveedor que contesta un error al canje debe dejar el callback en un 502"
);
assert!(
token_de_set_cookie(&caida).is_none(),
"un 502 no puede emitir cookie de sesión"
);
let texto_caida = body_text(caida).await;
assert!(
!texto_caida.contains(OIDC_CLIENT_SECRET),
"el 502 del callback publicó el client_secret en el cuerpo: {texto_caida}"
);
let (state, nonce) = login_state_y_nonce(router.clone()).await;
let id_token =
proveedor.firmar(&IdToken::valido(&proveedor.issuer, OIDC_CLIENT_ID, &nonce));
proveedor.montar_token_endpoint(&id_token).await;
let codigo = "codigo-de-autorizacion-distinguible";
let respuesta = get(router.clone(), &callback_url(&state, codigo)).await;
assert_eq!(
respuesta.status(),
StatusCode::FOUND,
"el flujo debe completarse para que el log sea el de un flujo real"
);
let jwt = token_de_set_cookie(&respuesta).expect("el callback debe emitir la sesión");
let sesion =
get_with_cookie(router, "/api/v1/auth/session", &session_cookie_header(&jwt)).await;
assert_eq!(sesion.status(), StatusCode::OK);
let log = buffer.texto();
assert!(
log.contains("callback con un state no utilizable"),
"la captura no está en pie: el rechazo del callback no quedó registrado: {log}"
);
assert!(
log.contains("un servicio de terceros no contestó")
&& log.contains(
"el proveedor de identidad contestó 502 Bad Gateway al canje del código"
),
"la captura no está en pie: la causa del 502 no quedó registrada: {log}"
);
for (nombre, credencial) in [
("client_secret", OIDC_CLIENT_SECRET),
("código de autorización", codigo),
(
"código de autorización del camino de error",
codigo_del_error,
),
("ID token", id_token.as_str()),
("token de acceso", ACCESS_TOKEN),
] {
assert!(
!log.contains(credencial),
"el log publicó el {nombre}: {log}"
);
}
let payload = id_token
.split('.')
.nth(1)
.expect("un JWT tiene tres partes");
assert!(
!log.contains(payload),
"el log publicó el payload del ID token: {log}"
);
}
}