use axum_extra::extract::cookie::{Cookie, SameSite};
use jsonwebtoken::{
Algorithm, DecodingKey, EncodingKey, Header, Validation, decode, encode, get_current_timestamp,
};
use serde::{Deserialize, Serialize};
use crate::error::AppError;
use crate::secret::SecretString;
pub const SESSION_COOKIE_NAME: &str = "kesto_session";
pub const SESSION_LIFETIME: std::time::Duration = std::time::Duration::from_secs(8 * 60 * 60);
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Claims {
pub sub: String,
pub email: String,
pub iat: u64,
pub exp: u64,
}
pub fn issue(secret: &SecretString, sub: String, email: String) -> Result<String, AppError> {
let emitido = get_current_timestamp();
let claims = Claims {
sub,
email,
iat: emitido,
exp: emitido + SESSION_LIFETIME.as_secs(),
};
let cabecera = Header::new(Algorithm::HS256);
let clave = EncodingKey::from_secret(secret.expose().as_bytes());
encode(&cabecera, &claims, &clave).map_err(|error| {
tracing::error!(%error, "no se pudo firmar el JWT de sesión");
AppError::Other("no se pudo emitir la sesión".to_owned())
})
}
pub fn verify(secret: &SecretString, token: &str) -> Result<Claims, AppError> {
let mut validacion = Validation::new(Algorithm::HS256);
validacion.validate_aud = false;
validacion.leeway = 0;
let clave = DecodingKey::from_secret(secret.expose().as_bytes());
decode::<Claims>(token, &clave, &validacion)
.map(|datos| datos.claims)
.map_err(|error| {
tracing::debug!(%error, "cookie de sesión rechazada");
AppError::Unauthorized("la cookie de sesión no es válida".to_owned())
})
}
pub fn session_cookie(token: String, redirect_url: &str) -> Result<Cookie<'static>, AppError> {
let max_age = SESSION_LIFETIME.try_into().map_err(|_| {
AppError::Other("la vida de la sesión no cabe en el Max-Age de la cookie".to_owned())
})?;
Ok(Cookie::build((SESSION_COOKIE_NAME, token))
.http_only(true)
.same_site(SameSite::Strict)
.path("/")
.secure(requires_secure(redirect_url))
.max_age(max_age)
.build())
}
fn requires_secure(redirect_url: &str) -> bool {
redirect_url
.split_once("://")
.is_some_and(|(esquema, _)| esquema.eq_ignore_ascii_case("https"))
}
#[cfg(test)]
mod tests {
use axum_extra::extract::cookie::SameSite;
use super::{Claims, SESSION_COOKIE_NAME, SESSION_LIFETIME, issue, session_cookie, verify};
use crate::config::{AuthMode, Config};
use crate::error::AppError;
use crate::secret::SecretString;
use crate::test_support::lookup_from;
const MASTER_KEY: &str = "clave-maestra-distinguible-000000000000000000000000000000000000";
const SECRET: &str = "clave-de-firma-distinguible-000000000000000000000000000000000000";
const OTHER_SECRET: &str = "otra-clave-de-firma-000000000000000000000000000000000000000000";
const OIDC_ISSUER: &str = "https://idp.example.test/realms/kesto";
const OIDC_CLIENT_ID: &str = "kesto-panel";
const OIDC_CLIENT_SECRET: &str = "secreto-de-cliente-oidc-distinguible";
const REDIRECT_HTTP: &str = "http://localhost:8080/api/v1/auth/callback";
const REDIRECT_HTTPS: &str = "https://kesto.example.test/api/v1/auth/callback";
const SUB: &str = "usuario-distinguible-1";
const EMAIL: &str = "operador-distinguible@example.test";
const ID_TOKEN_DEL_PROVEEDOR: &str =
"eyJhbGciOiJSUzI1NiJ9.ID-TOKEN-DEL-PROVEEDOR.firma-del-proveedor";
fn secret() -> SecretString {
SecretString::new(SECRET.to_owned())
}
fn otro_secreto() -> SecretString {
SecretString::new(OTHER_SECRET.to_owned())
}
fn sesion() -> String {
issue(&secret(), SUB.to_owned(), EMAIL.to_owned()).expect("la emisión no puede fallar")
}
fn redirect_configurado(redirect_url: &str) -> String {
let lookup = lookup_from(&[
("KESTO_MASTER_KEY", MASTER_KEY),
("KESTO_SECRET", SECRET),
("OIDC_ISSUER_URL", OIDC_ISSUER),
("OIDC_CLIENT_ID", OIDC_CLIENT_ID),
("OIDC_CLIENT_SECRET", OIDC_CLIENT_SECRET),
("OIDC_REDIRECT_URL", redirect_url),
]);
let config = Config::from_lookup(&lookup).expect("la configuración OIDC es válida");
match &config.auth {
AuthMode::Oidc(settings) => settings.redirect_url.clone(),
AuthMode::Disabled => panic!("las tres variables OIDC deben encender la autenticación"),
}
}
fn manipular(token: &str) -> String {
let mut caracteres: Vec<char> = token.chars().collect();
let posicion = caracteres.len() / 2;
if let Some(caracter) = caracteres.get_mut(posicion) {
*caracter = if *caracter == 'a' { 'b' } else { 'a' };
}
caracteres.into_iter().collect()
}
fn firmar_con_caducidad(exp: u64) -> String {
let claims = Claims {
sub: SUB.to_owned(),
email: EMAIL.to_owned(),
iat: exp - SESSION_LIFETIME.as_secs(),
exp,
};
jsonwebtoken::encode(
&jsonwebtoken::Header::new(jsonwebtoken::Algorithm::HS256),
&claims,
&jsonwebtoken::EncodingKey::from_secret(SECRET.as_bytes()),
)
.expect("el token de prueba debe poder firmarse")
}
fn claves_del_payload(token: &str) -> Vec<String> {
let claims: serde_json::Value = jsonwebtoken::dangerous::insecure_decode_claims(token)
.expect("el payload debe poder leerse como JSON");
let mut claves: Vec<String> = claims
.as_object()
.expect("los claims deben ser un objeto JSON")
.keys()
.cloned()
.collect();
claves.sort();
claves
}
#[test]
fn los_atributos_de_la_cookie() {
let cookie = session_cookie(sesion(), REDIRECT_HTTP).expect("la cookie se construye");
assert_eq!(
cookie.name(),
SESSION_COOKIE_NAME,
"el logout tendrá que borrar exactamente esta cookie"
);
assert_eq!(
cookie.http_only(),
Some(true),
"la cookie debe ser HttpOnly"
);
assert_eq!(
cookie.same_site(),
Some(SameSite::Strict),
"la cookie debe ser SameSite=Strict"
);
assert_eq!(
cookie.path(),
Some("/"),
"la cookie debe valer para todo el panel"
);
assert!(
cookie.max_age().is_some(),
"la cookie debe llevar Max-Age, o el navegador la trataría de sesión"
);
let cabecera = cookie.to_string();
for atributo in ["HttpOnly", "SameSite=Strict", "Path=/", "Max-Age="] {
assert!(
cabecera.contains(atributo),
"la Set-Cookie no trae {atributo}: {cabecera}"
);
}
}
#[test]
fn el_secure_depende_del_esquema_de_retorno() {
let con_http = session_cookie(sesion(), &redirect_configurado(REDIRECT_HTTP))
.expect("la cookie se construye");
assert!(
!con_http.to_string().contains("Secure"),
"con retorno http la cookie no puede ir Secure, o no viajaría: {con_http}"
);
let con_https = session_cookie(sesion(), &redirect_configurado(REDIRECT_HTTPS))
.expect("la cookie se construye");
assert_eq!(
con_https.secure(),
Some(true),
"con retorno https la cookie debe ir Secure"
);
assert!(
con_https.to_string().contains("Secure"),
"con retorno https la Set-Cookie debe declarar Secure: {con_https}"
);
}
#[test]
fn los_claims_de_la_sesion_son_los_cuatro_sin_roles() {
let token = sesion();
let claves = claves_del_payload(&token);
assert_eq!(
claves,
["email", "exp", "iat", "sub"],
"los claims de la sesión deben ser exactamente los cuatro: {token}"
);
for rol in ["role", "roles", "rol"] {
assert!(
!claves.iter().any(|clave| clave == rol),
"la sesión lleva el claim de rol {rol}: {token}"
);
}
}
#[test]
fn la_vida_de_la_sesion_es_una_constante_de_ocho_horas() {
assert_eq!(
SESSION_LIFETIME.as_secs(),
8 * 60 * 60,
"la vida de la sesión debe ser de 8 horas"
);
let token = sesion();
let claims = verify(&secret(), &token).expect("el JWT emitido debe verificarse");
let cookie = session_cookie(token, REDIRECT_HTTP).expect("la cookie se construye");
let max_age = cookie
.max_age()
.map(|duracion| duracion.whole_seconds())
.expect("la cookie debe llevar Max-Age");
assert_eq!(
max_age,
SESSION_LIFETIME.as_secs() as i64,
"el Max-Age de la cookie y la constante de vida deben coincidir"
);
assert_eq!(
claims.exp - claims.iat,
SESSION_LIFETIME.as_secs(),
"el exp del JWT y la constante de vida deben coincidir"
);
}
#[test]
fn una_cookie_firmada_con_otra_clave_no_se_acepta() {
let error = verify(&otro_secreto(), &sesion())
.expect_err("una clave distinta de KESTO_SECRET no puede validar la sesión");
assert!(
matches!(error, AppError::Unauthorized(_)),
"el rechazo debe ser un 401 tipado: {error}"
);
}
#[test]
fn una_cookie_caducada_no_se_acepta() {
let error = verify(
&secret(),
&firmar_con_caducidad(jsonwebtoken::get_current_timestamp() - 60 * 60),
)
.expect_err("una sesión caducada no se acepta");
assert!(
matches!(error, AppError::Unauthorized(_)),
"el rechazo debe ser un 401 tipado: {error}"
);
}
#[test]
fn una_cookie_con_el_exp_apenas_pasado_no_se_acepta() {
let error = verify(
&secret(),
&firmar_con_caducidad(jsonwebtoken::get_current_timestamp() - 30),
)
.expect_err("una sesión caducada hace 30 segundos no se acepta");
assert!(
matches!(error, AppError::Unauthorized(_)),
"el rechazo debe ser un 401 tipado: {error}"
);
}
#[test]
fn una_cookie_manipulada_no_se_acepta() {
let error = verify(&secret(), &manipular(&sesion()))
.expect_err("alterar el contenido sin volver a firmarlo no puede colar");
assert!(
matches!(error, AppError::Unauthorized(_)),
"el rechazo debe ser un 401 tipado: {error}"
);
}
#[test]
fn el_payload_de_la_cookie_son_exactamente_los_cuatro_claims() {
let token = sesion();
let cookie = session_cookie(token.clone(), REDIRECT_HTTP).expect("la cookie se construye");
assert_eq!(
cookie.value(),
token,
"la cookie debe llevar únicamente el JWT de sesión"
);
assert_eq!(
claves_del_payload(&token),
["email", "exp", "iat", "sub"],
"la sesión no puede llevar claims del proveedor: {token}"
);
assert!(
verify(&secret(), ID_TOKEN_DEL_PROVEEDOR).is_err(),
"un token del proveedor no puede pasar por sesión de Kesto"
);
}
}