Verificación de tokens en tu API
El verificador de tokens del SDK comprueba los tokens de acceso y los tokens de ID emitidos por tu entorno de KeenAuth. Úsalo en la API que proteges. Los tres SDK (Go, TypeScript y Python) lo incluyen, y todos siguen las mismas reglas y los mismos vectores de prueba.
Go
import "github.com/keenauth/keenauth-go/verify"
v, err := verify.New(verify.Config{
Issuer: "https://acme.eu.keenauth.app", // el emisor de tu entorno, exactamente
Audience: "https://api.acme.example", // el identificador de tu API
})
if err != nil {
return err
}
// En tu manejador HTTP:
claims, err := v.VerifyAccessToken(r.Context(), token, &verify.DPoPInput{
Proof: r.Header.Get("DPoP"),
Method: r.Method,
URL: "https://api.acme.example" + r.URL.Path,
})
if err != nil {
w.Header().Set("WWW-Authenticate", `Bearer error="invalid_token"`)
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
// claims.Subject, claims.ClientID, claims.Scope
Para los tokens de ID, establece ClientID en la configuración y llama a v.VerifyIDToken(ctx, idToken, verify.IDTokenOptions{Nonce: nonce}).
TypeScript
El verificador de TypeScript usa solo WebCrypto y fetch de la plataforma (Node.js 22 o posterior) y no tiene dependencias.
import { createVerifier, VerifyError } from "@keenauth/node";
const verifier = createVerifier({
issuer: "https://acme.eu.keenauth.app", // el emisor de tu entorno, exactamente
audience: "https://api.acme.example", // el identificador de tu API
});
// En tu manejador HTTP:
try {
const claims = await verifier.verifyAccessToken(token, {
proof: req.get("DPoP") ?? "",
method: req.method,
url: "https://api.acme.example" + req.path,
});
// claims.subject, claims.clientId, claims.scope
} catch (e) {
if (!(e instanceof VerifyError)) throw e;
res.set("WWW-Authenticate", 'Bearer error="invalid_token"').status(401).send("unauthorized");
}
Para los tokens de ID, establece clientId en la configuración y llama a verifier.verifyIdToken(idToken, { nonce }).
Python
El verificador de Python necesita el paquete cryptography, que se instala con el extra verify: pip install "keenauth-python[verify]". Las llamadas son síncronas y seguras para hilos.
from keenauth.verify import DPoPInput, Verifier, VerifierConfig, VerifyError
verifier = Verifier(VerifierConfig(
issuer="https://acme.eu.keenauth.app", # el emisor de tu entorno, exactamente
audience="https://api.acme.example", # el identificador de tu API
))
# En tu manejador HTTP:
try:
claims = verifier.verify_access_token(
token,
DPoPInput(
proof=request.headers.get("DPoP", ""),
method=request.method,
url="https://api.acme.example" + request.path,
),
)
# claims.subject, claims.client_id, claims.scope
except VerifyError:
return Response("unauthorized", status=401, headers={"WWW-Authenticate": 'Bearer error="invalid_token"'})
Para los tokens de ID, establece client_id en la configuración y llama a verifier.verify_id_token(id_token, nonce=nonce).
Step-up (RFC 9470)
Para proteger una operación sensible, exige un inicio de sesión más fuerte o más reciente en cada llamada. Pasa las referencias de clase de contexto de autenticación aceptables (acr) y la antigüedad máxima del inicio de sesión (auth_time). dpop de abajo es la entrada DPoP de los ejemplos anteriores (nil/undefined/None si no usas DPoP).
Go
age := 15 * time.Minute
claims, err := v.VerifyAccessToken(r.Context(), token, dpop, verify.AccessTokenOptions{
AcceptableACR: []string{"urn:idp:acr:mfa", "urn:idp:acr:phr"},
MaxAuthAge: &age,
})
if challenge, ok := verify.StepUpChallenge(err); ok {
w.Header().Set("WWW-Authenticate", challenge)
http.Error(w, "step-up required", http.StatusUnauthorized)
return
}
TypeScript
import { createVerifier, stepUpChallenge } from "@keenauth/node";
try {
const claims = await verifier.verifyAccessToken(token, dpop, {
acceptableAcr: ["urn:idp:acr:mfa", "urn:idp:acr:phr"],
maxAuthAgeSeconds: 900,
});
} catch (e) {
const challenge = stepUpChallenge(e);
if (challenge === undefined) throw e;
res.set("WWW-Authenticate", challenge).status(401).send("step-up required");
}
Python
from datetime import timedelta
from keenauth.verify import VerifyError, step_up_challenge
try:
claims = verifier.verify_access_token(
token,
dpop,
acceptable_acr=["urn:idp:acr:mfa", "urn:idp:acr:phr"],
max_auth_age=timedelta(minutes=15),
)
except VerifyError as err:
challenge = step_up_challenge(err)
if challenge is None:
raise
return Response("step-up required", status=401, headers={"WWW-Authenticate": challenge})
Las comprobaciones se ejecutan después de todas las demás, así que un token no válido siempre devuelve invalid_token. Un token válido que no cumple el requisito recibe el código insufficient_user_authentication, y el error incluye los acr_values y max_age requeridos (solo los que hayas establecido). StepUpChallenge (Go), stepUpChallenge (TypeScript) y step_up_challenge (Python) lo convierten en el valor de la cabecera WWW-Authenticate de RFC 9470 §3, por ejemplo Bearer error="insufficient_user_authentication", error_description="authentication too old", max_age="900". Un cliente que entiende el desafío inicia una nueva solicitud de autorización con esos acr_values y max_age. acr debe ser igual exactamente a uno de los valores (distingue mayúsculas y minúsculas); auth_time es obligatorio cuando estableces una antigüedad máxima, y la tolerancia de desfase del reloj se aplica igual que en los tokens de ID. Los valores urn:idp:acr:1fa, urn:idp:acr:mfa y urn:idp:acr:phr son los que tu entorno publica en acr_values_supported.
DPoP y el esquema de Authorization (RFC 9449)
Un token vinculado con DPoP (cnf.jkt) no sirve sin la clave privada de su cliente, así que una copia robada no puede reproducirse. Dos reglas mantienen esta protección, y el verificador las aplica cuando pasas el esquema de la cabecera Authorization de la solicitud (Bearer o DPoP):
- Un token vinculado con DPoP enviado con el esquema
Bearerse rechaza coninvalid_token, aunque se adjunte una prueba válida (RFC 9449 §7.2). - Un token enviado con el esquema
DPoPdebe estar vinculado con DPoP y venir con una prueba válida (RFC 9449 §7.1).
Para aceptar solo tokens con restricción de remitente en una API (recomendado para API sensibles), establece también "requerir DPoP": un token que no esté vinculado con DPoP recibe el código dpop_required. El asistente de desafío DPoP convierte cualquier error 401 en el valor WWW-Authenticate de RFC 9449 §7.1, por ejemplo DPoP error="invalid_token", error_description="a DPoP-bound access token is required", algs="ES256 EdDSA PS256 RS256".
Go
claims, err := v.VerifyAccessToken(r.Context(), token, dpop, verify.AccessTokenOptions{
Scheme: scheme, // "Bearer" o "DPoP", de la cabecera Authorization
RequireDPoP: true,
})
if challenge, ok := verify.DPoPChallenge(err); ok {
w.Header().Set("WWW-Authenticate", challenge)
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
TypeScript
import { dpopChallenge } from "@keenauth/node";
try {
const claims = await verifier.verifyAccessToken(token, dpop, { scheme, requireDPoP: true });
} catch (e) {
const challenge = dpopChallenge(e);
if (challenge === undefined) throw e;
res.set("WWW-Authenticate", challenge).status(401).send("unauthorized");
}
Python
from keenauth.verify import VerifyError, dpop_challenge
try:
claims = verifier.verify_access_token(token, dpop, scheme=scheme, require_dpop=True)
except VerifyError as err:
challenge = dpop_challenge(err)
if challenge is None:
raise
return Response("unauthorized", status=401, headers={"WWW-Authenticate": challenge})
El middleware del servidor de recursos (Go protect, TypeScript @keenauth/node/middleware, Python keenauth.middleware) lee el esquema por ti. Su ajuste "requerir DPoP" (por API o por ruta) responde a una solicitud Bearer solo con 401 y el desafío DPoP.
Qué comprueba el verificador
- Emisor y claves: el
issdel token debe ser igual al emisor configurado. Las claves se obtienen únicamente deljwks_uridel documento de descubrimiento de ese emisor, y elkidse busca solo en ese conjunto de claves. Un token firmado con la clave de otro entorno se rechaza. - Algoritmo: solo RS256, PS256, ES256 y EdDSA. El
algde la clave debe ser igual alalgdel token;noney los algoritmos HMAC siempre se rechazan. - Tokens de acceso:
typdebe serat+jwt(RFC 9068),auddebe contener la audiencia configurada, ysubyclient_iddeben estar presentes. - Tokens de ID:
auddebe contener tu ID de cliente,azpdebe ser igual a él cuando esté presente, ynonceymax_agese comprueban cuando los pasas. - Tiempo:
expes obligatorio;nbfeiatno pueden estar en el futuro. Se permite un desfase del reloj de como máximo 60 segundos. - DPoP: cuando el token está vinculado a una clave (
cnf.jkt), se requiere una prueba DPoP válida (RFC 9449). Las pruebas reutilizadas se rechazan. Con la opción de esquema, un token vinculado nunca se acepta como token bearer; con "requerir DPoP", solo se aceptan tokens vinculados. - Caché de claves: las claves se almacenan en caché durante el
max-agedel servidor (300 segundos por defecto, como máximo 600). Unkiddesconocido activa como máximo una nueva descarga por minuto.
Errores
Cada error tiene un Code. El mensaje nunca contiene el token ni sus claims.
| Código | Significado | Responde con |
|---|---|---|
invalid_token |
El token no es válido, ha caducado o no es para esta API. | 401 |
jwks_unavailable |
No se pudieron obtener las claves del emisor y no hay ninguna en caché. | 503 |
dpop_invalid |
La prueba DPoP falta o no es válida. | 401 con WWW-Authenticate del asistente de desafío DPoP |
dpop_required |
Requieres DPoP y el token no está vinculado con DPoP. | 401 con WWW-Authenticate del asistente de desafío DPoP |
insufficient_user_authentication |
El token es válido, pero su acr o auth_time no cumple tu requisito (RFC 9470). |
401 con WWW-Authenticate del asistente de step-up |
config_invalid |
La configuración del verificador es incorrecta. | Corrige la configuración. |
Si usas otra biblioteca JWT
Aplica la misma lista de comprobación: vincula el emisor a su documento de descubrimiento, toma las claves solo de ese jwks_uri, exige que el alg de la clave sea igual al alg del token, y rechaza none y los algoritmos HMAC.
Pruebas sin conexión
Para pruebas unitarias y CI, usa el kit de pruebas sin conexión. Genera tokens de prueba que solo acepta un verificador en modo de prueba explícito; un verificador de producción siempre los rechaza.