use std::time::Duration;
use jsonwebtoken::jwk::JwkSet;
use jsonwebtoken::{Algorithm, DecodingKey, Validation, decode_header};
use serde::Deserialize;
use crate::config::OidcSettings;
use crate::error::AppError;
use crate::state::AuthState;
const RUTA_DESCUBRIMIENTO: &str = "/.well-known/openid-configuration";
pub const DISCOVERY_TIMEOUT: Duration = Duration::from_secs(10);
pub const SCOPE: &str = "openid email";
pub const TOKEN_LENGTH: usize = 32;
pub const MARGEN_DE_RELOJ: u64 = 60;
#[derive(Debug, Clone, Deserialize)]
pub struct Discovery {
pub authorization_endpoint: String,
pub token_endpoint: String,
pub jwks_uri: String,
}
pub fn discovery_url(issuer: &str) -> String {
format!("{}{RUTA_DESCUBRIMIENTO}", issuer.trim_end_matches('/'))
}
pub async fn fetch_discovery(settings: &OidcSettings) -> Result<Discovery, AppError> {
let url = discovery_url(&settings.issuer);
let cliente = reqwest::Client::builder()
.timeout(DISCOVERY_TIMEOUT)
.build()
.map_err(|error| {
AppError::BadGateway(format!(
"no se pudo preparar el cliente del descubrimiento: {error}"
))
})?;
let respuesta = cliente.get(&url).send().await.map_err(|error| {
AppError::BadGateway(format!(
"el proveedor de identidad no respondió al descubrimiento: {error}"
))
})?;
if !respuesta.status().is_success() {
return Err(AppError::BadGateway(format!(
"el proveedor de identidad contestó {} al descubrimiento",
respuesta.status()
)));
}
respuesta.json::<Discovery>().await.map_err(|error| {
AppError::BadGateway(format!(
"el documento de descubrimiento no es válido: {error}"
))
})
}
pub async fn discovery_cached(auth: &AuthState) -> Result<Discovery, AppError> {
if let Some(discovery) = auth.discovery.read().await.clone() {
return Ok(discovery);
}
let discovery = fetch_discovery(auth.settings.as_ref()).await?;
*auth.discovery.write().await = Some(discovery.clone());
Ok(discovery)
}
#[derive(Debug, Clone)]
pub struct AuthorizationRequest {
pub url: String,
pub state: String,
pub nonce: String,
}
pub fn build_authorization_url(
settings: &OidcSettings,
discovery: &Discovery,
) -> AuthorizationRequest {
let state = token_aleatorio();
let nonce = token_aleatorio();
let parametros = [
("response_type", "code"),
("client_id", settings.client_id.as_str()),
("redirect_uri", settings.redirect_url.as_str()),
("scope", SCOPE),
("state", state.as_str()),
("nonce", nonce.as_str()),
];
let consulta = parametros
.iter()
.map(|(clave, valor)| {
format!(
"{}={}",
urlencoding::encode(clave),
urlencoding::encode(valor)
)
})
.collect::<Vec<_>>()
.join("&");
let separador = if discovery.authorization_endpoint.contains('?') {
'&'
} else {
'?'
};
AuthorizationRequest {
url: format!("{}{separador}{consulta}", discovery.authorization_endpoint),
state,
nonce,
}
}
fn token_aleatorio() -> String {
use rand::Rng;
rand::thread_rng()
.sample_iter(&rand::distributions::Alphanumeric)
.take(TOKEN_LENGTH)
.map(char::from)
.collect()
}
pub const INTERCAMBIO_TIMEOUT: Duration = Duration::from_secs(10);
#[derive(Deserialize)]
struct TokenResponse {
id_token: String,
}
#[derive(Deserialize)]
struct IdTokenClaims {
sub: String,
email: String,
nonce: String,
iat: u64,
}
#[derive(Debug, Clone)]
pub struct IdentidadVerificada {
pub sub: String,
pub email: String,
}
const ID_TOKEN_INVALIDO: &str = "el ID token no es válido";
pub async fn canjear_codigo(
settings: &OidcSettings,
discovery: &Discovery,
code: &str,
) -> Result<String, AppError> {
let cliente = reqwest::Client::builder()
.timeout(INTERCAMBIO_TIMEOUT)
.build()
.map_err(|error| {
AppError::BadGateway(format!("no se pudo preparar el cliente del canje: {error}"))
})?;
let formulario = [
("grant_type", "authorization_code"),
("code", code),
("redirect_uri", settings.redirect_url.as_str()),
("client_id", settings.client_id.as_str()),
("client_secret", settings.client_secret.expose()),
];
let respuesta = cliente
.post(&discovery.token_endpoint)
.form(&formulario)
.send()
.await
.map_err(|error| {
AppError::BadGateway(format!(
"el proveedor de identidad no respondió al canje del código: {error}"
))
})?;
if !respuesta.status().is_success() {
return Err(AppError::BadGateway(format!(
"el proveedor de identidad contestó {} al canje del código",
respuesta.status()
)));
}
respuesta
.json::<TokenResponse>()
.await
.map(|datos| datos.id_token)
.map_err(|error| {
AppError::BadGateway(format!("la respuesta del canje no es válida: {error}"))
})
}
async fn fetch_jwks(url: &str) -> Result<JwkSet, AppError> {
let cliente = reqwest::Client::builder()
.timeout(INTERCAMBIO_TIMEOUT)
.build()
.map_err(|error| {
AppError::BadGateway(format!("no se pudo preparar el cliente del JWKS: {error}"))
})?;
let respuesta = cliente.get(url).send().await.map_err(|error| {
AppError::BadGateway(format!(
"el proveedor de identidad no respondió al JWKS: {error}"
))
})?;
if !respuesta.status().is_success() {
return Err(AppError::BadGateway(format!(
"el proveedor de identidad contestó {} al JWKS",
respuesta.status()
)));
}
respuesta
.json::<JwkSet>()
.await
.map_err(|error| AppError::BadGateway(format!("el JWKS no es válido: {error}")))
}
fn rechazar_id_token(causa: &str) -> AppError {
tracing::debug!(causa, "ID token rechazado");
AppError::Unauthorized(ID_TOKEN_INVALIDO.to_owned())
}
pub async fn verificar_id_token(
settings: &OidcSettings,
discovery: &Discovery,
id_token: &str,
nonce_esperado: &str,
) -> Result<IdentidadVerificada, AppError> {
let jwks = fetch_jwks(&discovery.jwks_uri).await?;
let cabecera = match decode_header(id_token) {
Ok(cabecera) => cabecera,
Err(error) => {
return Err(rechazar_id_token(&format!(
"la cabecera del ID token no es válida: {error}"
)));
}
};
let Some(kid) = cabecera.kid else {
return Err(rechazar_id_token("el ID token no declara kid"));
};
let Some(jwk) = jwks.find(&kid) else {
return Err(rechazar_id_token(
"el ID token va firmado con una clave que el jwks_uri no publica",
));
};
let clave = match DecodingKey::from_jwk(jwk) {
Ok(clave) => clave,
Err(error) => {
return Err(rechazar_id_token(&format!(
"la clave del JWKS no es utilizable: {error}"
)));
}
};
let mut validacion = Validation::new(Algorithm::RS256);
validacion.leeway = 0;
validacion.validate_aud = true;
validacion.set_audience(&[settings.client_id.as_str()]);
validacion.set_issuer(&[settings.issuer.as_str()]);
validacion.set_required_spec_claims(&["exp", "iss", "aud"]);
let datos = match jsonwebtoken::decode::<IdTokenClaims>(id_token, &clave, &validacion) {
Ok(datos) => datos,
Err(error) => {
return Err(rechazar_id_token(&format!(
"la verificación del ID token falló: {error}"
)));
}
};
if datos.claims.nonce != nonce_esperado {
return Err(rechazar_id_token(
"el nonce del ID token no es el emitido en el login",
));
}
let ahora = jsonwebtoken::get_current_timestamp();
if datos.claims.iat > ahora.saturating_add(MARGEN_DE_RELOJ) {
return Err(rechazar_id_token("el ID token se emitió en el futuro"));
}
Ok(IdentidadVerificada {
sub: datos.claims.sub,
email: datos.claims.email,
})
}
#[cfg(test)]
mod tests {
use axum::Router;
use axum::http::{StatusCode, header};
use super::{Discovery, MARGEN_DE_RELOJ, build_authorization_url};
use crate::api::api_router;
use crate::auth::session;
use crate::config::OidcSettings;
use crate::secret::SecretString;
use crate::state::{PendingLogin, RechazoState, STATE_TTL};
use crate::test_support::{
EMAIL, OIDC_CLIENT_ID, OIDC_REDIRECT_URL, SESSION_SECRET, SUB, auth_oidc, callback_url,
capturar_log, estado_oidc_con_emisor, get, header_text, login_state_y_nonce,
parametros_de_consulta, proveedor_falso::IdToken, proveedor_falso::ProveedorFalso,
proveedor_falso::token_con_alg_none, router_oidc_con_emisor, token_de_set_cookie,
};
use std::time::{Duration, Instant};
async fn location_del_login(router: Router) -> String {
let respuesta = get(router, "/api/v1/auth/login").await;
assert_eq!(
respuesta.status(),
StatusCode::FOUND,
"el login debe contestar 302"
);
header_text(&respuesta, header::LOCATION).expect("el 302 debe traer Location")
}
#[tokio::test]
async fn la_redireccion_lleva_los_seis_parametros() {
let proveedor = ProveedorFalso::servir().await;
let router = router_oidc_con_emisor(&proveedor.issuer).await;
let location = location_del_login(router).await;
assert!(
location.starts_with(&format!("{}/authorize?", proveedor.issuer)),
"la redirección no apunta al authorization_endpoint: {location}"
);
let consulta = location
.split_once('?')
.map(|(_, consulta)| consulta)
.expect("la redirección debe traer cadena de consulta");
assert_eq!(
consulta.split('&').count(),
6,
"la redirección debe llevar exactamente seis parámetros: {location}"
);
assert!(
location.contains(
"redirect_uri=http%3A%2F%2Flocalhost%3A8080%2Fapi%2Fv1%2Fauth%2Fcallback"
),
"el redirect_uri debe ir codificado en la Location: {location}"
);
assert!(
location.contains("scope=openid%20email"),
"el scope debe ir codificado (openid%20email) en la Location: {location}"
);
let parametros = parametros_de_consulta(&location);
assert_eq!(
parametros.get("response_type").map(String::as_str),
Some("code"),
"falta response_type=code: {location}"
);
assert_eq!(
parametros.get("client_id").map(String::as_str),
Some(OIDC_CLIENT_ID),
"falta el client_id: {location}"
);
assert_eq!(
parametros.get("redirect_uri").map(String::as_str),
Some(OIDC_REDIRECT_URL),
"falta el redirect_uri: {location}"
);
let scope = parametros
.get("scope")
.expect("la redirección debe traer scope");
assert!(
scope.split_whitespace().any(|valor| valor == "openid"),
"el scope debe incluir openid: {scope}"
);
assert!(
scope.split_whitespace().any(|valor| valor == "email"),
"el scope debe incluir email: {scope}"
);
assert!(
parametros
.get("state")
.is_some_and(|state| !state.is_empty()),
"falta el state: {location}"
);
assert!(
parametros
.get("nonce")
.is_some_and(|nonce| !nonce.is_empty()),
"falta el nonce: {location}"
);
}
#[tokio::test]
async fn cada_login_usa_un_state_y_un_nonce_nuevos() {
let proveedor = ProveedorFalso::servir().await;
let router = router_oidc_con_emisor(&proveedor.issuer).await;
let primera = parametros_de_consulta(&location_del_login(router.clone()).await);
let segunda = parametros_de_consulta(&location_del_login(router).await);
let state_a = primera
.get("state")
.expect("el primer login debe traer state");
let state_b = segunda
.get("state")
.expect("el segundo login debe traer state");
let nonce_a = primera
.get("nonce")
.expect("el primer login debe traer nonce");
let nonce_b = segunda
.get("nonce")
.expect("el segundo login debe traer nonce");
assert_ne!(state_a, state_b, "dos logins no pueden compartir state");
assert_ne!(nonce_a, nonce_b, "dos logins no pueden compartir nonce");
for (nombre, valor) in [
("state", state_a),
("state", state_b),
("nonce", nonce_a),
("nonce", nonce_b),
] {
assert_eq!(
valor.chars().count(),
32,
"el {nombre} debe medir 32 caracteres: {valor}"
);
}
}
#[tokio::test]
async fn el_login_no_establece_sesion() {
let proveedor = ProveedorFalso::servir().await;
let router = router_oidc_con_emisor(&proveedor.issuer).await;
let respuesta = get(router, "/api/v1/auth/login").await;
assert_eq!(respuesta.status(), StatusCode::FOUND);
assert!(
header_text(&respuesta, header::SET_COOKIE).is_none(),
"el login no puede emitir una Set-Cookie de sesión"
);
}
#[test]
fn el_endpoint_que_ya_trae_consulta_no_duplica_el_interrogante() {
let settings = OidcSettings {
issuer: "https://proveedor.test".to_owned(),
client_id: OIDC_CLIENT_ID.to_owned(),
client_secret: SecretString::new("secreto-de-prueba".to_owned()),
redirect_url: OIDC_REDIRECT_URL.to_owned(),
};
let discovery = Discovery {
authorization_endpoint: "https://proveedor.test/authorize?audience=kesto".to_owned(),
token_endpoint: "https://proveedor.test/token".to_owned(),
jwks_uri: "https://proveedor.test/jwks".to_owned(),
};
let solicitud = build_authorization_url(&settings, &discovery);
assert!(
solicitud
.url
.starts_with("https://proveedor.test/authorize?audience=kesto&"),
"el endpoint que ya trae consulta debe continuar con `&`: {}",
solicitud.url
);
assert_eq!(
solicitud.url.matches('?').count(),
1,
"no puede haber dos interrogantes en la URL: {}",
solicitud.url
);
}
async fn assert_rechazo_sin_cookie(respuesta: axum::response::Response) {
assert_eq!(
respuesta.status(),
StatusCode::UNAUTHORIZED,
"el callback debía rechazar con un error"
);
assert!(
token_de_set_cookie(&respuesta).is_none(),
"un rechazo no puede emitir cookie de sesión"
);
}
fn claims_completos(proveedor: &ProveedorFalso, nonce: &str) -> serde_json::Value {
let ahora = jsonwebtoken::get_current_timestamp();
serde_json::json!({
"iss": proveedor.issuer,
"aud": OIDC_CLIENT_ID,
"sub": SUB,
"email": EMAIL,
"nonce": nonce,
"iat": ahora,
"exp": ahora + 600,
})
}
#[tokio::test]
async fn un_state_desconocido_se_rechaza_sin_emitir_cookie() {
let proveedor = ProveedorFalso::servir().await;
let router = router_oidc_con_emisor(&proveedor.issuer).await;
let respuesta = get(
router,
&callback_url("state-que-nadie-emitio", "codigo-inventado"),
)
.await;
assert_rechazo_sin_cookie(respuesta).await;
}
#[tokio::test]
async fn un_state_caducado_se_rechaza_sin_emitir_cookie() {
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 hace = Instant::now()
.checked_sub(STATE_TTL + Duration::from_secs(1))
.expect("el reloj de pruebas necesita un uptime mayor que el TTL");
{
let mut logins = auth.logins.lock().await;
logins.insert(
"state-caducado".to_owned(),
PendingLogin {
nonce: "nonce-caducado".to_owned(),
issued_at: hace,
},
);
}
let respuesta = get(router, &callback_url("state-caducado", "codigo-x")).await;
assert_rechazo_sin_cookie(respuesta).await;
}
#[tokio::test]
async fn un_state_reutilizado_se_rechaza_sin_emitir_cookie() {
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 token = proveedor.firmar(&IdToken::valido(&proveedor.issuer, OIDC_CLIENT_ID, &nonce));
proveedor.montar_token_endpoint(&token).await;
let primera = get(router.clone(), &callback_url(&state, "codigo-1")).await;
assert_eq!(
primera.status(),
StatusCode::FOUND,
"el primer callback debe consumir el state y completar el flujo"
);
let segunda = get(router, &callback_url(&state, "codigo-2")).await;
assert_rechazo_sin_cookie(segunda).await;
}
#[tokio::test]
async fn el_consumo_purga_los_pendientes_caducados_y_el_rastro_de_consumidos() {
let auth = auth_oidc().await;
let hace = Instant::now()
.checked_sub(STATE_TTL + Duration::from_secs(1))
.expect("la purga necesita un uptime mayor que STATE_TTL");
{
let mut logins = auth.logins.lock().await;
logins.insert(
"pendiente-caducado".to_owned(),
PendingLogin {
nonce: "nonce-caducado".to_owned(),
issued_at: hace,
},
);
let mut consumidos = auth.consumidos.lock().await;
consumidos.insert("consumido-viejo".to_owned(), hace);
}
let rechazo = auth
.consumir_login("state-que-nadie-emitio")
.await
.expect_err("un state desconocido debe rechazarse");
assert_eq!(rechazo, RechazoState::Desconocido);
assert!(
auth.logins.lock().await.is_empty(),
"el pendiente caducado debe purgarse al consumir: el mapa no puede crecer"
);
assert!(
auth.consumidos.lock().await.is_empty(),
"la marca de consumo vieja debe purgarse al consumir: el rastro no puede crecer"
);
}
#[tokio::test]
async fn un_error_del_proveedor_no_llega_a_canjear_nada() {
let proveedor = ProveedorFalso::servir().await;
let router = router_oidc_con_emisor(&proveedor.issuer).await;
proveedor
.montar_token_endpoint("id-token-que-no-debe-usarse")
.await;
let respuesta = get(
router,
"/api/v1/auth/callback?error=access_denied&state=state-x",
)
.await;
assert_eq!(
respuesta.status(),
StatusCode::UNAUTHORIZED,
"un error del proveedor debe rechazarse"
);
assert_eq!(
proveedor.peticiones_al_token().await,
0,
"un error del proveedor no puede llegar a canjear el código"
);
}
#[tokio::test]
async fn un_aud_ajeno_no_se_acepta() {
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 mut claims = IdToken::valido(&proveedor.issuer, OIDC_CLIENT_ID, &nonce);
claims.aud = "otro-cliente-distinto".to_owned();
proveedor
.montar_token_endpoint(&proveedor.firmar(&claims))
.await;
let respuesta = get(router, &callback_url(&state, "codigo")).await;
assert_rechazo_sin_cookie(respuesta).await;
}
#[tokio::test]
async fn un_token_caducado_no_se_acepta() {
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 ahora = jsonwebtoken::get_current_timestamp();
let mut claims = IdToken::valido(&proveedor.issuer, OIDC_CLIENT_ID, &nonce);
claims.iat = ahora - 60;
claims.exp = ahora - 30;
proveedor
.montar_token_endpoint(&proveedor.firmar(&claims))
.await;
let respuesta = get(router, &callback_url(&state, "codigo")).await;
assert_rechazo_sin_cookie(respuesta).await;
}
#[tokio::test]
async fn un_id_token_sin_exp_no_se_acepta() {
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 token = proveedor.firmar_claims(&serde_json::json!({
"iss": proveedor.issuer,
"aud": OIDC_CLIENT_ID,
"sub": SUB,
"email": EMAIL,
"nonce": nonce,
"iat": jsonwebtoken::get_current_timestamp(),
}));
proveedor.montar_token_endpoint(&token).await;
let (buffer, _guard) = capturar_log();
let respuesta = get(router, &callback_url(&state, "codigo")).await;
let log = buffer.texto();
assert_rechazo_sin_cookie(respuesta).await;
assert!(
log.contains("la verificación del ID token falló: Missing required claim: exp"),
"el rechazo no viene de que falte exp: {log}"
);
}
#[tokio::test]
async fn un_id_token_emitido_en_el_futuro_no_se_acepta() {
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 ahora = jsonwebtoken::get_current_timestamp();
let token = proveedor.firmar_claims(&serde_json::json!({
"iss": proveedor.issuer,
"aud": OIDC_CLIENT_ID,
"sub": SUB,
"email": EMAIL,
"nonce": nonce,
"iat": ahora + 600,
"exp": ahora + 1800,
}));
proveedor.montar_token_endpoint(&token).await;
let (buffer, _guard) = capturar_log();
let respuesta = get(router, &callback_url(&state, "codigo")).await;
let log = buffer.texto();
assert_rechazo_sin_cookie(respuesta).await;
assert!(
log.contains("el ID token se emitió en el futuro"),
"el rechazo no viene del iat adelantado al reloj: {log}"
);
}
#[tokio::test]
async fn un_iat_dentro_del_margen_de_reloj_no_tumba_el_login() {
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 desfase = (MARGEN_DE_RELOJ / 2).max(5);
let mut claims = IdToken::valido(&proveedor.issuer, OIDC_CLIENT_ID, &nonce);
claims.iat = jsonwebtoken::get_current_timestamp() + desfase;
proveedor
.montar_token_endpoint(&proveedor.firmar(&claims))
.await;
let respuesta = get(router, &callback_url(&state, "codigo")).await;
assert_eq!(
respuesta.status(),
StatusCode::FOUND,
"un desfase de reloj dentro del margen no puede tumbar el login"
);
assert!(
token_de_set_cookie(&respuesta).is_some(),
"el callback debe completar el flujo y emitir la cookie de sesión"
);
}
#[tokio::test]
async fn un_id_token_sin_iat_no_se_acepta() {
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 token = proveedor.firmar_claims(&serde_json::json!({
"iss": proveedor.issuer,
"aud": OIDC_CLIENT_ID,
"sub": SUB,
"email": EMAIL,
"nonce": nonce,
"exp": jsonwebtoken::get_current_timestamp() + 600,
}));
proveedor.montar_token_endpoint(&token).await;
let (buffer, _guard) = capturar_log();
let respuesta = get(router, &callback_url(&state, "codigo")).await;
let log = buffer.texto();
assert_rechazo_sin_cookie(respuesta).await;
assert!(
log.contains("ID token rechazado"),
"la captura de log no está en pie: el rechazo no quedó registrado: {log}"
);
let payload = token.split('.').nth(1).expect("un JWT tiene tres partes");
assert!(
!log.contains(token.as_str()),
"el rechazo publicó el ID token en el log: {log}"
);
assert!(
!log.contains(payload),
"el rechazo publicó el payload del ID token en el log: {log}"
);
}
#[tokio::test]
async fn un_nonce_distinto_no_se_acepta() {
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-ajeno");
proveedor
.montar_token_endpoint(&proveedor.firmar(&claims))
.await;
let respuesta = get(router, &callback_url(&state, "codigo")).await;
assert_rechazo_sin_cookie(respuesta).await;
}
#[tokio::test]
async fn un_id_token_sin_kid_no_se_acepta() {
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 token = proveedor.firmar_sin_kid(&claims_completos(&proveedor, &nonce));
proveedor.montar_token_endpoint(&token).await;
let (buffer, _guard) = capturar_log();
let respuesta = get(router, &callback_url(&state, "codigo")).await;
let log = buffer.texto();
assert_rechazo_sin_cookie(respuesta).await;
assert!(
log.contains("el ID token no declara kid"),
"el rechazo no viene de la ausencia de kid: {log}"
);
}
#[tokio::test]
async fn un_id_token_con_un_kid_que_el_jwks_no_publica_no_se_acepta() {
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 token = proveedor.firmar_con_kid_ajeno(&claims_completos(&proveedor, &nonce));
proveedor.montar_token_endpoint(&token).await;
let (buffer, _guard) = capturar_log();
let respuesta = get(router, &callback_url(&state, "codigo")).await;
let log = buffer.texto();
assert_rechazo_sin_cookie(respuesta).await;
assert!(
log.contains("una clave que el jwks_uri no publica"),
"el rechazo no viene de que el kid no esté en el JWKS: {log}"
);
}
#[tokio::test]
async fn una_firma_fuera_del_jwks_no_se_acepta() {
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_con_clave_ajena(&claims))
.await;
let respuesta = get(router, &callback_url(&state, "codigo")).await;
assert_rechazo_sin_cookie(respuesta).await;
}
#[tokio::test]
async fn un_id_token_con_alg_none_no_se_acepta() {
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 token = token_con_alg_none(&claims_completos(&proveedor, &nonce));
proveedor.montar_token_endpoint(&token).await;
let (buffer, _guard) = capturar_log();
let respuesta = get(router, &callback_url(&state, "codigo")).await;
let log = buffer.texto();
assert_rechazo_sin_cookie(respuesta).await;
assert!(
log.contains("la cabecera del ID token no es válida"),
"el rechazo no viene de la cabecera: {log}"
);
}
#[tokio::test]
async fn un_id_token_firmado_en_hs256_no_se_acepta() {
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 token = proveedor.firmar_confundiendo_algoritmo(&claims_completos(&proveedor, &nonce));
proveedor.montar_token_endpoint(&token).await;
let (buffer, _guard) = capturar_log();
let respuesta = get(router, &callback_url(&state, "codigo")).await;
let log = buffer.texto();
assert_rechazo_sin_cookie(respuesta).await;
assert!(
log.contains("la verificación del ID token falló: InvalidAlgorithm"),
"el rechazo no viene del algoritmo fijado a RS256: {log}"
);
}
#[tokio::test]
async fn un_iss_distinto_no_se_acepta() {
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 mut claims = IdToken::valido(&proveedor.issuer, OIDC_CLIENT_ID, &nonce);
claims.iss = "https://otro-emisor.test/realms/kesto".to_owned();
proveedor
.montar_token_endpoint(&proveedor.firmar(&claims))
.await;
let respuesta = get(router, &callback_url(&state, "codigo")).await;
assert_rechazo_sin_cookie(respuesta).await;
}
#[tokio::test]
async fn un_token_verificado_se_convierte_en_sesion() {
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")).await;
assert_eq!(
respuesta.status(),
StatusCode::FOUND,
"un token válido debe completar el flujo"
);
let jwt =
token_de_set_cookie(&respuesta).expect("el callback debe emitir la cookie de sesión");
let claims = session::verify(&SecretString::new(SESSION_SECRET.to_owned()), &jwt)
.expect("la cookie emitida debe verificar con KESTO_SECRET");
assert_eq!(
claims.sub, SUB,
"el sub de la sesión debe ser el del ID token"
);
assert_eq!(
claims.email, EMAIL,
"el email de la sesión debe ser el del ID token"
);
}
#[tokio::test]
async fn el_flujo_no_depende_de_userinfo() {
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")).await;
assert_eq!(
respuesta.status(),
StatusCode::FOUND,
"el flujo debe completarse sin userinfo_endpoint"
);
assert_eq!(
proveedor.peticiones_a_userinfo().await,
0,
"no se puede pedir userinfo: el ID token verificado sobra"
);
}
}