Saltar al contenido principal
intermediatePart 5

La clave que expira: darle a un agente su propia identidad en el gateway

· 20 min de lectura
Rafael Fernandes
Ingeniero de PLN y Redactor Técnico en WiLine
Share:
authentik+LiteLLM

La Parte 4 puso control de acceso por grupos sobre la interfaz de administración del gateway y después, al final, llamó a la API desde un shell sin cuenta, sin sesión y sin grupo. Respondió con normalidad. La conclusión fue que el SSO protege un plano de control y que el plano de datos autentica a quien llama por máquina con claves — cosa que tiene que hacer, porque un agente que corre a las 3 de la mañana no puede completar un login en un navegador.

Eso era cierto y era también un punto y aparte, no una respuesta. "Los clientes máquina usan claves" te deja con una credencial que nunca expira, que ningún proveedor de identidad conoce, y que sobrevive a la persona que la creó. La Parte 4 lo dijo sin rodeos: sacar a alguien de un grupo no revoca sus claves, una clave filtrada no se ve afectada por la identidad en absoluto, y las claves sobreviven a las personas.

Esta parte le da a la máquina una identidad en lugar de una clave.

authentik le emite al agente un token con el flujo de client credentials — sin navegador, sin pantalla de consentimiento, sin humano. El token va firmado, lleva un scope y expira a los cinco minutos. El gateway lo verifica localmente contra las claves públicas de authentik y rechaza cualquier cosa sin el scope correcto. Al final, tres códigos de estado enseñan el límite aguantando.

Reproducibilidad

Una WEC Instance: 8 vCPU AMD EPYC 7601, 15 GB RAM, Docker 29.1.3, kernel 5.15. authentik 2026.8.1, la misma instancia de las Partes 3 y 4 en el puerto host 9100. LiteLLM 1.96.2 de la Parte 1 de la serie del gateway, enlazado a 127.0.0.1:4000, con los modelos servidos por WEC Inference. La verificación usa PyJWT 2.13.0 y cryptography 50.0.0, ambos ya presentes en la imagen de LiteLLM.

HTTP plano sobre una LAN privada, como en las Partes 3 y 4. Sustituye las direcciones por las tuyas.

Dos flujos, dos preguntas distintas​

Las Partes 3 y 4 usaron el flujo de authorization code de principio a fin. Una persona pulsa un botón, se le redirige a authentik, escribe una contraseña, ve una pantalla de consentimiento, y vuelve con un código que la aplicación intercambia por un token. Cada paso de eso da por hecho un navegador y una persona sentada delante.

El flujo de client credentials responde otra pregunta. No hay usuario. El cliente es el principal, y lo demuestra con un ID y un secreto, en una sola petición, sin redirección a ninguna parte.

La propiedad que merece atención: el gateway nunca le pregunta a authentik si un token es bueno. Descarga las claves públicas de authentik una vez, las cachea, y comprueba la firma ella misma. La identidad escala sin que el proveedor de identidad se convierta en un cuello de botella — ni en un punto único de fallo en cada petición.

Requisitos previos​

Paso 1 — Un cliente con exactamente un flujo​

Applications → Providers → New Provider → OAuth2/OpenID Provider → Next.

  • Provider Name — agent-gateway
  • Authorization Flow — deja default-provider-authorization-explicit-consent. Es obligatorio, y aquí es inerte: gobierna la pantalla de consentimiento que ve una persona, y ninguna persona va a usar este cliente. Aun así tienes que elegir uno.
  • Client Type — Confidential. La propia descripción de authentik es la razón: "Confidential clients are capable of maintaining the confidentiality of their credentials such as client secrets." Client credentials no tiene nada más que ese secreto para demostrar identidad, así que un cliente público no puede usar este flujo en absoluto.
  • Redirect URIs — déjalo vacío. Aquí nunca se redirige nada a ninguna parte.

Después baja hasta Grant Types, y mira con qué viene un proveedor nuevo.

El campo Grant Types en un proveedor nuevo de authentik con siete de ocho casillas marcadas: Authorization Code, Implicit, Hybrid, Refresh token, Client credentials, Password y Device-code, con solo Token exchange sin marcar

Siete de ocho, activados por defecto. Dos de ellos — Implicit y Password — no llegaron a OAuth 2.1, que dice claramente que "some features available in OAuth 2.0, such as the Implicit or Resource Owner Credentials grant types, are not specified in OAuth 2.1" (§10.1). Password en concreto hace que el cliente maneje la contraseña real del usuario, y por eso cayó en desuso. Nadie te preguntó si los querías y nada te avisa de que están puestos.

Desmarca todo excepto Client credentials.

El mismo campo Grant Types con solo Client credentials marcado y las otras siete casillas vacías

Es el mismo reflejo de mínimo privilegio que la Parte 4 aplicó a la pertenencia a grupos, una capa más abajo. Un cliente que solo va a usar un flujo debería tener permitido solo un flujo, para que una mala configuración en otro sitio no pueda convertirlo en un endpoint que acepta contraseñas.

Vuelve y revisa los proveedores que ya creaste

Esos valores por defecto se aplicaron también a todos los proveedores que creaste en las Partes 3 y 4, y nada los ha recortado desde entonces. Pregúntale a la base de datos, no a la interfaz:

cd ~/authentik && docker compose exec server ak shell -c "
from authentik.providers.oauth2.models import OAuth2Provider
for p in OAuth2Provider.objects.all():
print(f'{p.name}: {p.grant_types}')" 2>/dev/null | tail -5
Provider for Langfuse: ['authorization_code', 'implicit', 'urn:ietf:params:oauth:grant-type:device_code']
Provider for LiteLLM: ['authorization_code', 'implicit', 'hybrid', 'refresh_token', 'client_credentials', 'password', 'urn:ietf:params:oauth:grant-type:device_code']
agent-gateway: ['client_credentials']

El proveedor del gateway sigue teniendo password y client_credentials además del flujo que realmente usa. Ninguno hacía falta, y el flujo de contraseña en particular es una vía de entrada viva que nadie puso a propósito. Recorta cada proveedor a los flujos que usa.

Una lista de redirecciones vacía no es una regla vacía

Lee el texto de ayuda del propio authentik en el campo Redirect URIs: "If no explicit authorization redirect URIs are specified, the first successfully used authorization redirect URI will be saved."

Vacío no significa "denegar todo". Significa confianza en el primer uso — authentik fija la redirección que aparezca primero y la conserva. Eso es inofensivo para un cliente que nunca redirige, que es nuestro caso. No es inofensivo en un proveedor interactivo, donde significa que no tienes ninguna validación de redirección hasta que alguien inicia sesión y te la fija. La Parte 3 avisaba de poner el modo en .*; esta es la versión silenciosa del mismo peligro.

Paso 2 — La aplicación a la que pertenece​

Lee las credenciales e intenta obtener un token. Todavía no va a funcionar, y la razón es lo más útil de este post.

mkdir -p ~/agent-auth
cd ~/agent-auth
umask 077
cat > .env <<EOF
AK_TOKEN_URL=http://10.80.4.212:9100/application/o/token/
AK_CLIENT_ID=<el client id de la página del proveedor>
AK_CLIENT_SECRET=<el client secret de la página del proveedor>
EOF

umask 077 crea el fichero con permisos 600 en vez de hacer chmod después, así que no hay ninguna ventana en la que el secreto sea legible por todo el mundo.

Un pequeño ayudante, porque vas a pedir tokens constantemente:

cat > ~/agent-auth/token.sh <<'EOF'
#!/usr/bin/env bash
set -euo pipefail
source "$(dirname "$0")/.env"

curl -sS -X POST "$AK_TOKEN_URL" \
-d grant_type=client_credentials \
-d client_id="$AK_CLIENT_ID" \
-d client_secret="$AK_CLIENT_SECRET" \
-d scope="${1:-}" \
| jq -r .access_token
EOF
chmod +x ~/agent-auth/token.sh

Pide un token:

cd ~/agent-auth
source .env
curl -sS -X POST "$AK_TOKEN_URL" \
-d grant_type=client_credentials \
-d client_id="$AK_CLIENT_ID" \
-d client_secret="$AK_CLIENT_SECRET" | jq
{
"error": "invalid_grant",
"error_description": "The provided authorization grant or refresh token is invalid, expired, revoked, does not match the redirection URI used in the authorization request, or was issued to another client",
"request_id": "757c397b97a745259e288b6d4ff1553c"
}

Cada cláusula de esa descripción es falsa en nuestro caso. Nada ha expirado, nada está revocado, no se usó ninguna redirección, y el cliente es el dueño de esas credenciales. El log del servidor registra el 400 y ninguna razón. Los Events de authentik tampoco enseñan nada útil.

La causa real se ve en la lista de Providers, que lo marca sin disimulo:

La lista de Providers de authentik mostrando agent-gateway con un aviso que dice Provider not assigned to any application, junto a dos proveedores sanos asignados a Langfuse y LiteLLM

Un proveedor sin aplicación no puede emitir un token. authentik autoriza contra la aplicación, así que un proveedor por su cuenta no tiene contra qué autorizar — y dice invalid_grant en lugar de decir eso.

Esto no está en la documentación de client credentials de authentik, que documenta cinco formas de autenticarse — tres con credenciales estáticas y dos con JWT — y no dice nada sobre necesitar una aplicación.

Empareja una. Fíjate en la ruta: el asistente de New Application siempre crea un proveedor nuevo, así que no puede adoptar el que acabamos de construir.

El paso Configure the Application del asistente New application, con su lista de cinco pasos a la izquierda: Application, Choose a Provider, Configure Provider, Configure Bindings, Review and Submit

Su lista de pasos lo delata — Choose a Provider es el paso 2, y todas las opciones de ahí construyen uno nuevo. La flecha junto al botón es donde vive el otro camino.

Applications → Applications → New Application ▾ → with Existing Provider…

  • Name — Agent Gateway
  • Slug — agent-gateway, escrito a mano. El hallazgo de la Parte 4 fue que authentik deriva los slugs de los nombres y convirtió LiteLLM en lite-llm; el slug acaba en la URL del emisor, así que merece la pena ponerlo a mano siempre.
  • Provider — agent-gateway
  • Hidden from Application Dashboard — márcalo. Ninguna persona debería ver nunca una tarjeta de un cliente máquina.

Repite la petición de token y funciona. No cambió nada salvo el emparejamiento.

Paso 3 — Un scope que significa algo​

El token que obtienes ahora lleva "scope": "". Demuestra quién llama y no concede ninguna autoridad. Un gateway que lo reciba sabe que el cliente es agent-gateway y nada más.

Customization → Property Mappings → New Property Mapping → Scope Mapping.

  • Mapping Name — gateway-invoke, la etiqueta del objeto dentro de authentik
  • Scope name — gateway:invoke, la cadena que piden los clientes y que aparece en el token
  • Expression — return {}

El formulario de Scope Mapping con Mapping Name gateway-invoke, Scope name gateway, una descripción, y la expresión return llaves vacías

Los dos campos de nombre son cosas distintas y el formulario no lo deja claro. La expresión es Python cuyo valor de retorno se fusiona con los claims del token; un diccionario vacío es lo correcto aquí, porque queremos el scope nombrado en el token, no datos extra dentro de él.

Adjúntalo: Applications → Providers → agent-gateway → Edit → Advanced protocol settings → Scopes, y mueve gateway-invoke de Available a Selected.

La lista doble de Scopes del proveedor con email, openid, profile y gateway-invoke en la columna Selected

Lee el texto de ayuda bajo ese panel, porque es todo el mecanismo:

"Select which scopes can be used by the client. The client still has to specify the scope to access the data."

Adjuntar un scope lo hace disponible. El cliente todavía tiene que pedirlo. Por eso el primer token volvió vacío aunque ya había tres scopes adjuntos — nadie los había pedido.

Pulsar un scope ya seleccionado lo marca para borrar

La lista doble marca entradas para borrado cuando las pulsas en la columna derecha, y la única señal es una línea de texto pequeño encima que dice "4 items selected. 1 item marked to remove." Guardar en ese momento desvincula en silencio el scope que acabas de añadir. Lee esa línea antes de guardar.

Ahora pídelo:

TOKEN=$(~/agent-auth/token.sh gateway:invoke)
jq -R 'split(".")[1] | gsub("-";"+") | gsub("_";"/") | @base64d | fromjson' <<< "$TOKEN"
{
"iss": "http://10.80.4.212:9100/application/o/agent-gateway/",
"sub": "2451c6a1bab3085590263f119e650448beb013336146bcfee8241dd5832c200b",
"aud": "aObZ8KvKsKVdQ4MuSsTINp0f65Puz5fzXUUBC6iQ",
"exp": 1788901322,
"iat": 1788901022,
"acr": "goauthentik.io/providers/oauth2/default",
"jti": "NRDTukijwLJbW7gpPtn9zUZwzTj6d2kBSZCuFYvX",
"azp": "aObZ8KvKsKVdQ4MuSsTINp0f65Puz5fzXUUBC6iQ",
"uid": "neugHWoWQOQzADgbV1vKwzew2COQ1RgCXfyywgqa",
"scope": "gateway:invoke"
}

El payload del JWT decodificado mostrando iss, sub, aud, exp, jti, uid y scope con el valor gateway

exp menos iat son 300 segundos. Ese es el argumento entero a favor de esto frente a una clave estática: un token pegado en un chat, en un fichero de log o en una captura no vale nada cinco minutos después.

Ahí no hay email ni preferred_username, porque no hay usuario. sub es una cuenta de servicio que authentik creó por ti, llamada ak-agent-gateway-client_credentials — aparece en Directory → Users sin que nadie la pidiera.

Paso 4 — Enseñarle al gateway a verificar​

LiteLLM documenta la autenticación con JWT con un bloque litellm_jwtauth, enforce_scope_based_access y scope_mappings. Configúralo en una instalación estándar y no pasa nada, porque esa misma página dice:

"JWT-based Auth requires a LiteLLM Enterprise license."

Compruébalo antes de escribir configuración:

docker exec llm-gateway python -c "
from litellm.proxy.proxy_server import premium_user
print('premium_user =', premium_user)"
premium_user = False

False significa que todo ese camino documentado está cerrado para ti. La alternativa de código abierto es custom_auth, que le pasa cada petición a una función que escribes tú — y para este trabajo son unas veinticinco líneas.

custom_auth reemplaza la autenticación por clave por completo

Con custom_auth puesto, todas las peticiones pasan por tu función, incluidas las claves virtuales sk- del resto de la serie del gateway y la propia master key. Un handler que solo entienda JWT te deja fuera de tu propio gateway. El de abajo cae de vuelta a la master key a propósito.

Haz copia de seguridad antes de empezar: cp config.yaml config.yaml.bak

~/llm-gateway/jwt_auth.py
import os

import jwt
from fastapi import Request
from litellm.proxy._types import UserAPIKeyAuth

JWKS_URL = os.environ["JWT_PUBLIC_KEY_URL"]
ISSUER = os.environ["JWT_ISSUER"]
AUDIENCE = os.environ["JWT_AUDIENCE"]
REQUIRED_SCOPE = "gateway:invoke"
MASTER_KEY = os.environ["LITELLM_MASTER_KEY"]

_jwks = jwt.PyJWKClient(JWKS_URL)


async def user_api_key_auth(request: Request, api_key: str) -> UserAPIKeyAuth:
token = api_key.removeprefix("Bearer ").strip()

# No es un JWT: cae a la master key para que las herramientas existentes sigan funcionando.
if token.count(".") != 2:
if token == MASTER_KEY:
return UserAPIKeyAuth(api_key=token)
raise Exception("not a JWT and not the master key")

signing_key = _jwks.get_signing_key_from_jwt(token)
claims = jwt.decode(
token,
signing_key.key,
algorithms=["RS256"],
issuer=ISSUER,
audience=AUDIENCE,
)

if REQUIRED_SCOPE not in claims.get("scope", "").split():
raise Exception(f"token is missing scope {REQUIRED_SCOPE}")

return UserAPIKeyAuth(api_key=token, user_id=claims["sub"])

PyJWKClient hace la parte que parece difícil. Descarga el JWKS, empareja la cabecera kid del token con la clave correcta, y la cachea — así que después de la primera petición la verificación es aritmética local. jwt.decode comprueba la firma, el emisor, la audiencia y la expiración en una sola llamada, y lanza excepción si alguna falla.

Tres variables de entorno, añadidas a ~/llm-gateway/.env:

JWT_PUBLIC_KEY_URL=http://10.80.4.212:9100/application/o/agent-gateway/jwks/
JWT_ISSUER=http://10.80.4.212:9100/application/o/agent-gateway/
JWT_AUDIENCE=aObZ8KvKsKVdQ4MuSsTINp0f65Puz5fzXUUBC6iQ

JWT_ISSUER y JWT_AUDIENCE tienen que coincidir exactamente con el iss y el aud que viste en el token decodificado, o se rechazan todos los tokens.

Monta el módulo igual que el gateway ya monta scrubber.py, en docker-compose.yml:

- ./jwt_auth.py:/app/jwt_auth.py:ro

Y en config.yaml, bajo general_settings:

custom_auth: jwt_auth.user_api_key_auth
custom_auth_run_common_checks: true

Esa segunda línea importa más de lo que parece. Sin ella, LiteLLM avisa al arrancar:

El log de arranque de LiteLLM, con el aviso de custom_auth_run_common_checks entre los avisos de registro de modelos, terminando en Application startup complete

"custom_auth is configured but 'custom_auth_run_common_checks' is not set. Problem: budgets, model-access allowlists, and per-model rate limits configured on your DB team/project records will NOT be enforced for custom-auth requests."

Léelo con calma. Añadir autenticación por token habría dejado, por defecto, a todos los portadores de un token exentos de los controles que ya tenías configurados. Habrías hecho el gateway más seguro en una dimensión y más débil en otra sin enterarte, y el único aviso es una línea al arrancar.

Editar una configuración montada no reinicia nada

config.yaml es un bind mount. Cambiar su contenido no cambia ninguna parte de la especificación de compose, así que docker compose up -d informa Container llm-gateway Running y no hace nada. Tus pruebas corren entonces contra el proceso viejo y reproducen los resultados viejos exactamente, lo que parece que tu cambio no tuvo efecto.

Usa docker restart llm-gateway, y confírmalo viendo desaparecer el aviso de arriba del log.

Paso 5 — El límite, en tres códigos de estado​

TOKEN=$(~/agent-auth/token.sh gateway:invoke)
NOSCOPE=$(~/agent-auth/token.sh)

curl -sS -o /dev/null -w 'no token HTTP %{http_code}\n' http://127.0.0.1:4000/v1/chat/completions \
-H 'Content-Type: application/json' \
-d '{"model":"qwen-mid","messages":[{"role":"user","content":"Say OK."}]}'

curl -sS -o /dev/null -w 'wrong scope HTTP %{http_code}\n' http://127.0.0.1:4000/v1/chat/completions \
-H "Authorization: Bearer $NOSCOPE" -H 'Content-Type: application/json' \
-d '{"model":"qwen-mid","messages":[{"role":"user","content":"Say OK."}]}'

curl -sS -o /dev/null -w 'valid token HTTP %{http_code}\n' http://127.0.0.1:4000/v1/chat/completions \
-H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
-d '{"model":"qwen-mid","messages":[{"role":"user","content":"Say OK."}]}'
no token HTTP 401
wrong scope HTTP 401
valid token HTTP 429

Tres llamadas curl imprimiendo no token HTTP 401, wrong scope HTTP 401 y valid token HTTP 429

Las dos primeras no llegan a ningún modelo. La segunda es la que importa: un token válido, correctamente firmado, sin expirar y del emisor correcto, rechazado porque no llevaba gateway:invoke. La identidad por sí sola no es autoridad.

La tercera es la interesante, y no es un 200. Pasó la autenticación y el scope, y la respondió una parte completamente distinta del gateway — la capa de presupuesto y enrutado que configura la serie del gateway, que en esta máquina ya tenía un tope alcanzado. Ese es el sentido de la forma, no un fallo en ella: un 401 significa no sabemos quién eres, o no puedes hacer esto, y cualquier otra cosa significa que la petición pasó la identidad por completo y ahora es decisión de otro. El trabajo de la autenticación termina en las dos primeras líneas.

Confirma que el rechazo vino de tu comprobación y no de otra cosa:

docker logs llm-gateway --since 10m 2>&1 | grep -iE 'missing scope|401 Unauthorized' | tail -5
user_api_key_auth(): Exception occured - token is missing scope gateway:invoke
Exception: token is missing scope gateway:invoke
INFO: "POST /v1/chat/completions HTTP/1.1" 401 Unauthorized

El log del gateway mostrando la excepción token is missing scope gateway seguida de una respuesta 401 Unauthorized

Fíjate en dónde vive esa razón. Quien llama recibe un 401 pelado sin explicación, y está bien — decirle a un cliente "tu token es válido pero le falta el scope X" le dice a un atacante exactamente qué tiene que ir a buscar. Eso sí significa que depurar esto pasa siempre por leer el log del gateway, y nunca por el cuerpo de la respuesta.

Habilidad desbloqueada 🏅

Puedes darle a un agente headless su propia identidad: un cliente confidencial restringido a un solo flujo, un scope que significa algo, y un gateway que verifica la firma localmente y rechaza cualquier cosa sin el scope — con el rechazo demostrado desde el log en vez de supuesto desde un código de estado.

Resolución de problemas — los errores que dio esta ejecución​

invalid_client​

Las credenciales no autenticaron en absoluto. Antes de culpar a authentik, mira qué acabó de verdad en tu .env:

awk -F= '{printf "%-18s %3d chars\n", $1, length($2)}' ~/agent-auth/.env
AK_TOKEN_URL 48 chars
AK_CLIENT_ID 40 chars
AK_CLIENT_SECRET 128 chars

El client ID tiene 40 caracteres y el secreto 128. Un valor de longitud cero significa que el problema es el fichero, no el proveedor.

invalid_grant, con todo aparentemente correcto​

El proveedor no está emparejado con una aplicación. Mira el Paso 2 — la lista de Providers lo señala, el texto del error no.

AK_TOKEN_URL: unbound variable, y un secreto corrupto​

echo 'X=1' >> .env sobre un fichero cuya última línea no termina en salto de línea añade a esa línea en vez de crear una nueva:

AK_CLIENT_SECRET=p94oDU...dgJcAK_TOKEN_URL=http://10.80.4.212:9100/application/o/token/

Dos variables destruidas con un solo comando, en silencio — el secreto ahora está mal y la variable nueva no existe. Compruébalo con cat -A, que marca los finales de línea con $, y prefiere escribir el fichero entero con un solo heredoc antes que ir añadiendo.

El cambio de configuración no tuvo efecto​

El contenedor nunca se reinició. Mira el aviso del Paso 4.

401 sin ninguna razón en la respuesta​

Es a propósito. La razón está en docker logs llm-gateway.

Qué te dio esto y qué no​

Hecho: una identidad de máquina emitida por tu proveedor de identidad en vez de acuñada a mano, una credencial que expira en cinco minutos, un scope que hay que pedir y que se comprueba en cada llamada, revocación en un solo sitio, y alguien a quien el gateway puede nombrar en sus logs como algo más que un prefijo de clave.

Sin hacer:

  • El servidor MCP sigue abierto. Todo esto protege el gateway. El servidor de herramientas de la Parte 4 de orquestación de agentes todavía se fía de cualquier cosa que alcance su puerto, issue_refund incluido. El mismo problema, una capa más arriba, y el tema de la próxima parte.
  • Un scope, un cliente. Un despliegue real tiene varios agentes con scopes distintos, y scope_mappings entre scopes y modelos es a dónde va eso.
  • La master key sigue funcionando, a propósito, como fallback en el handler. Sigue siendo la credencial de emergencia que describió la Parte 4, y sigue saltándose todo.
  • Sigue siendo HTTP plano. El token cruza la red en claro, y un token en claro es una clave en claro hasta que expira.
  • La cuenta de servicio es invisible. authentik creó ak-agent-gateway-client_credentials sin preguntar, y nada en el token dice que sea una máquina y no una persona.
Finished this tutorial?
Mark it complete to earn An identity for the agent, not a key on your skill path.

Qué viene después​

El servidor MCP. FastMCP trae un JWTVerifier que acepta un jwks_uri, un issuer, una audience y required_scopes — las mismas cuatro cosas configuradas aquí — así que el servidor de herramientas puede exigir un token con scope antes de listar una herramienta, no digamos ejecutarla. Eso cierra el hueco que dejó abierto la Parte 4 de orquestación de agentes, donde el recorte pasaba en el cliente y el servidor se fiaba de quien alcanzara el puerto.

Para seguir leyendo​

Comments & questions

Hit an error, spotted a typo, or have a question? Leave a note below.