Saltar a contenido

Aserción de identidad

Traducción automática

Esta página se tradujo automáticamente a partir de la documentación en inglés, y la página en inglés es la versión de referencia. Si algo no se lee bien, Traducciones explica cómo avisarnos.

Un proveedor OAuth ordinario (Clientes OAuth) empieza por hacerle una pregunta al servidor MCP: ¿en qué servidor de autorización confías? Sigue la respuesta adonde apunte y, a partir de ahí, o bien una persona inicia sesión o bien un secreto compartido de antemano ocupa su lugar.

Una empresa no quiere que ninguna de las dos cosas se decida servidor por servidor. Ya tiene un proveedor de identidad en marcha (Okta, Microsoft Entra ID, el tuyo propio); el usuario ya inició sesión en él esta mañana; y es el único lugar donde el equipo de seguridad quiere decidir quién puede acceder a qué. SEP-990, la extensión Enterprise-Managed Authorization, traslada la decisión allí. El IdP firma un JWT de corta duración, un Identity Assertion JWT Authorization Grant, el ID-JAG: una declaración de que este usuario, a través de este cliente, puede acceder a este servidor MCP. El cliente lo intercambia por un token de acceso ordinario. Sin navegador, sin pantalla de consentimiento, sin registro dinámico.

Esta página cubre los dos extremos de ese intercambio. El servidor MCP en sí nunca cambia: sigue siendo el servidor de recursos de Autorización, que comprueba cualquier token que le llegue.

Dos solicitudes de token

Hay dos autoridades distintas en juego, y saber distinguirlas por su nombre es casi todo lo que hace falta para entender esta página. El IdP de la empresa es el proveedor de identidad de tu organización: sabe quién es el empleado, es donde vive la política y es quien emite el ID-JAG. El SDK nunca habla con él. El servidor de autorización MCP es la misma parte que era en Autorización: el emisor nombrado en los metadatos del servidor MCP, lo que acuña los tokens que ese servidor MCP acepta. En un flujo OAuth ordinario, esos dos roles suelen ser una sola caja. Aquí son dos, y toda la concesión consiste en que el segundo acepte confiar en el primero.

El cliente hace una solicitud de token a cada uno.

  1. Al IdP de la empresa. El cliente intercambia el inicio de sesión del usuario (su token de ID de OpenID Connect) por el ID-JAG. Es un intercambio de tokens de RFC 8693, es por completo la API de tu IdP y el SDK no lo hace. Lo haces tú, dentro de un callback asíncrono. Es también donde ocurre la decisión de política: un IdP que dice que no nunca emite el ID-JAG, y no hay nada que presentar.
  2. Al servidor de autorización MCP. El cliente presenta el ID-JAG bajo la concesión jwt-bearer de RFC 7523 (grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer, con el ID-JAG como assertion) y recibe el token de acceso. Esta es la solicitud que hace el SDK, y aceptarla es lo único que esta página añade a un servidor de autorización.

Todo lo que sigue es la segunda solicitud: el cliente que la envía y el servidor de autorización que la responde.

El cliente

IdentityAssertionOAuthProvider vive en mcp.client.auth.extensions.identity_assertion. Como todos los proveedores de Clientes OAuth, es un httpx2.Auth: construyes uno, lo pones en auth= y le pasas el httpx2.AsyncClient al transporte.

client.py
import time
import uuid

import httpx2
import jwt

from mcp import Client
from mcp.client.auth.extensions.identity_assertion import IdentityAssertionOAuthProvider
from mcp.client.streamable_http import streamable_http_client
from mcp.shared.auth import OAuthClientInformationFull, OAuthToken

IDP_SIGNING_KEY = "the-enterprise-idp-signing-key-for-this-demo"


class InMemoryTokenStorage:
    def __init__(self) -> None:
        self.tokens: OAuthToken | None = None
        self.client_info: OAuthClientInformationFull | None = None

    async def get_tokens(self) -> OAuthToken | None:
        return self.tokens

    async def set_tokens(self, tokens: OAuthToken) -> None:
        self.tokens = tokens

    async def get_client_info(self) -> OAuthClientInformationFull | None:
        return self.client_info

    async def set_client_info(self, client_info: OAuthClientInformationFull) -> None:
        self.client_info = client_info


def idp_issue_id_jag(subject: str, audience: str, resource: str) -> str:
    now = int(time.time())
    claims = {
        "iss": "https://idp.example.com",
        "sub": subject,
        "aud": audience,
        "client_id": "finance-agent",
        "resource": resource,
        "scope": "notes:read",
        "jti": str(uuid.uuid4()),
        "iat": now,
        "exp": now + 300,
    }
    return jwt.encode(claims, IDP_SIGNING_KEY, algorithm="HS256", headers={"typ": "oauth-id-jag+jwt"})


async def fetch_id_jag(audience: str, resource: str) -> str:
    return idp_issue_id_jag("alice@example.com", audience, resource)


oauth = IdentityAssertionOAuthProvider(
    server_url="http://localhost:8001/mcp",
    storage=InMemoryTokenStorage(),
    client_id="finance-agent",
    client_secret="finance-agent-secret",
    issuer="https://auth.example.com/",
    assertion_provider=fetch_id_jag,
    scope="notes:read",
)


async def main() -> None:
    async with httpx2.AsyncClient(auth=oauth, follow_redirects=True) as http_client:
        transport = streamable_http_client("http://localhost:8001/mcp", http_client=http_client)
        async with Client(transport) as client:
            result = await client.list_tools()
            print([tool.name for tool in result.tools])

Léelo desde abajo.

  • main() es el main() estándar de un cliente OAuth (Clientes OAuth), sin cambiar una sola línea. Esa es la idea: una vez que existe el proveedor, nada de lo que viene después sabe qué concesión produjo el token.
  • El proveedor recibe lo que los demás proveedores no pueden descubrir: un client_id y un client_secret que alguien registró de antemano en el servidor de autorización, el issuer de ese servidor de autorización y assertion_provider, un callback asíncrono que devuelve un ID-JAG nuevo cuando se le pide.
  • storage es el mismo protocolo TokenStorage. Solo se llama a los dos métodos de tokens; aquí no hay registro dinámico, así que no hay ningún client_info que recordar.

El proveedor de aserciones

fetch_id_jag(audience, resource) es el único código que escribes. Se espera una vez por intercambio de tokens, nunca en la construcción, y solo después de que los metadatos del servidor de autorización se hayan obtenido y validado, de modo que un emisor mal configurado nunca filtra una aserción. Sus dos argumentos son dos de los claims con los que debe acuñarse el ID-JAG: audience es el emisor del servidor de autorización (el aud del ID-JAG) y resource es el identificador canónico del servidor MCP (el resource del ID-JAG). El tercero ya lo tienes: el claim client_id del ID-JAG debe nombrar el client_id que le diste al proveedor, o el servidor de autorización rechaza el intercambio.

idp_issue_id_jag, justo encima, no es tu código. Hace las veces del proveedor de identidad y firma la aserción dentro del mismo proceso para que el archivo esté completo y puedas leer cada claim que lleva un ID-JAG. Un fetch_id_jag real hace, en cambio, la primera solicitud de token de la sección anterior: un intercambio de tokens de RFC 8693 contra tu IdP, definido por el borrador Identity Assertion JWT Authorization Grant que SEP-990 perfila. El token de ID del usuario que inició sesión entra como subject_token, el requested_token_type es el URN propio del ID-JAG (urn:ietf:params:oauth:token-type:id-jag), audience y resource pasan tal cual, y la respuesta trae el ID-JAG. Ese intercambio, con esos nombres, es lo que debes buscar en la documentación de tu IdP.

Tip

Se solicita un ID-JAG nuevo en cada intercambio, y esa es la idea: es una concesión de un solo uso que vive minutos, y el servidor de autorización de esta página se niega a aceptar el mismo dos veces. No lo guardes en caché. Lo que se reutiliza es el token de acceso que te compra.

El emisor es configuración

Aquí está la inversión. OAuthClientProvider le pregunta al servidor de recursos qué servidor de autorización usar y sigue la respuesta adonde apunte. Este proveedor se niega a hacerlo: issuer es obligatorio, los metadatos de RFC 8414 se obtienen de la ruta well-known propia de ese emisor, el endpoint de token debe estar en el origen de ese emisor y al servidor de recursos nunca se le pregunta nada.

La extensión no exige esto; es una elección deliberadamente más estricta. Este cliente lleva dos cosas que vale la pena robar, un secreto registrado de antemano y una aserción vinculada a una audiencia, y un cliente que dejara que un servidor MCP comprometido lo dirigiera al servidor de autorización de un atacante le enviaría ambas. Fijar el emisor en la construcción elimina esa conversación.

Warning

El issuer configurado se compara con el campo issuer del documento de metadatos mediante la comparación simple de cadenas de RFC 8414 §3.3: carácter por carácter, barra final incluida, sin normalización. No lo adivines. Obtén /.well-known/oauth-authorization-server de tu servidor de autorización y copia el valor issuer que devuelve. Para el servidor de autorización de esta página es https://auth.example.com/, con la barra, porque su emisor se construyó a partir de un objeto URL de pydantic. Una discrepancia detiene el flujo en OAuthFlowError: Authorization server metadata issuer mismatch antes de que se envíe una sola credencial o aserción.

Un cliente confidencial

client_secret es obligatorio; el constructor lanza ValueError si falta. El perfil del IETF que hay debajo de SEP-990 reserva esta concesión para clientes confidenciales, SEP-990 exige que el cliente se autentique, y este SDK hace cumplir ambas cosas insistiendo en un secreto compartido. token_endpoint_auth_method elige por dónde viaja: client_secret_post (el valor por defecto, en el cuerpo del formulario) o client_secret_basic (una cabecera HTTP Basic). El perfil también permite private_key_jwt; este proveedor no lo admite.

Tip

Lee client_secret del entorno o de un gestor de secretos, nunca del control de versiones.

Lo que el proveedor hace por ti

La primera solicitud sale sin autenticar, y el 401 del servidor inicia el flujo.

  1. Descubrimiento. Obtiene los metadatos del servidor de autorización de la ruta well-known de RFC 8414 del emisor configurado, comprueba que el issuer del documento coincide y comprueba que el endpoint de token está en el origen del emisor.
  2. La aserción. Espera tu assertion_provider.
  3. Intercambio. Envía con POST la concesión jwt-bearer al endpoint de token, guarda el OAuthToken y repite tu solicitud original con Authorization: Bearer ....

Un 403 cuyo WWW-Authenticate indica insufficient_scope ejecuta los pasos 2 y 3 de nuevo con la unión de tu scope y el reclamado. (scope nunca es más que una petición; el servidor de autorización de esta página concede lo que dice el ID-JAG y nada más.) No hay token de actualización en ninguna parte de esto: cuando el token de acceso caduca, el siguiente 401 acuña un ID-JAG nuevo y vuelve a intercambiar, y esa es la palanca que tiene el IdP. Los fallos son las mismas dos excepciones que en el resto de Clientes OAuth: OAuthFlowError para el descubrimiento y la validación, y su subclase OAuthTokenError cuando el endpoint de token dice que no.

El servidor de autorización

La mayoría de las veces te detienes aquí. El servidor de autorización MCP es el producto de otra persona, aceptar ID-JAG es una configuración suya que hay que activar, y la mitad de SEP-990 que le toca al SDK es el cliente de arriba.

El SDK también puede ser el servidor de autorización: create_auth_routes devuelve las rutas del servidor de autorización como una lista que cualquier app de Starlette puede montar, que es como examples/servers/simple-auth/ en el repositorio ejecuta uno. SEP-990 añade una bandera y un método a esa superficie:

auth_server.py
import secrets
import time

import jwt
from pydantic import AnyHttpUrl
from starlette.applications import Starlette

from mcp.server.auth.provider import (
    AccessToken,
    AuthorizationCode,
    AuthorizationParams,
    AuthorizeError,
    IdentityAssertionParams,
    OAuthAuthorizationServerProvider,
    RefreshToken,
    TokenError,
)
from mcp.server.auth.routes import create_auth_routes
from mcp.shared.auth import JWT_BEARER_GRANT_TYPE, OAuthClientInformationFull, OAuthToken

ISSUER = "https://auth.example.com/"
MCP_SERVER = "http://localhost:8001/mcp"
IDP_ISSUER = "https://idp.example.com"
IDP_SIGNING_KEY = "the-enterprise-idp-signing-key-for-this-demo"

REGISTERED_CLIENTS = {
    "finance-agent": OAuthClientInformationFull(
        client_id="finance-agent",
        client_secret="finance-agent-secret",
        redirect_uris=None,
        grant_types=[JWT_BEARER_GRANT_TYPE],
        token_endpoint_auth_method="client_secret_post",
    )
}


class EnterpriseAuthorizationServer(OAuthAuthorizationServerProvider[AuthorizationCode, RefreshToken, AccessToken]):
    def __init__(self) -> None:
        self.access_tokens: dict[str, AccessToken] = {}
        self.seen_jtis: set[str] = set()

    async def get_client(self, client_id: str) -> OAuthClientInformationFull | None:
        return REGISTERED_CLIENTS.get(client_id)

    async def load_access_token(self, token: str) -> AccessToken | None:
        return self.access_tokens.get(token)

    async def exchange_identity_assertion(
        self, client: OAuthClientInformationFull, params: IdentityAssertionParams
    ) -> OAuthToken:
        try:
            header = jwt.get_unverified_header(params.assertion)
            claims = jwt.decode(
                params.assertion,
                IDP_SIGNING_KEY,
                algorithms=["HS256"],
                issuer=IDP_ISSUER,
                audience=ISSUER,
                options={"require": ["iss", "sub", "aud", "exp", "iat", "jti", "client_id", "resource", "scope"]},
            )
        except jwt.InvalidTokenError as error:
            raise TokenError("invalid_grant", "the assertion did not verify") from error
        if header.get("typ") != "oauth-id-jag+jwt":
            raise TokenError("invalid_grant", "the assertion is not an ID-JAG")
        if claims["client_id"] != client.client_id:
            raise TokenError("invalid_grant", "the assertion was issued to a different client")
        if claims["resource"] != MCP_SERVER:
            raise TokenError("invalid_target", "the assertion is for a resource this server does not serve")
        if claims["jti"] in self.seen_jtis:
            raise TokenError("invalid_grant", "the assertion has already been used")
        self.seen_jtis.add(claims["jti"])
        scopes = claims["scope"].split()
        access_token = f"mcp_{secrets.token_hex(16)}"
        self.access_tokens[access_token] = AccessToken(
            token=access_token,
            client_id=claims["client_id"],
            scopes=scopes,
            expires_at=int(time.time()) + 300,
            resource=claims["resource"],
            subject=claims["sub"],
        )
        return OAuthToken(access_token=access_token, token_type="Bearer", expires_in=300, scope=" ".join(scopes))

    async def authorize(self, client: OAuthClientInformationFull, params: AuthorizationParams) -> str:
        raise AuthorizeError("unauthorized_client", "this authorization server only accepts ID-JAGs")

    async def load_authorization_code(self, client: OAuthClientInformationFull, authorization_code: str) -> None:
        return None

    async def exchange_authorization_code(
        self, client: OAuthClientInformationFull, authorization_code: AuthorizationCode
    ) -> OAuthToken:
        raise TokenError("invalid_grant", "this authorization server only accepts ID-JAGs")

    async def load_refresh_token(self, client: OAuthClientInformationFull, refresh_token: str) -> None:
        return None

    async def exchange_refresh_token(
        self, client: OAuthClientInformationFull, refresh_token: RefreshToken, scopes: list[str]
    ) -> OAuthToken:
        raise TokenError("invalid_grant", "this authorization server only accepts ID-JAGs")


provider = EnterpriseAuthorizationServer()
auth_app = Starlette(
    routes=create_auth_routes(provider, issuer_url=AnyHttpUrl(ISSUER), identity_assertion_enabled=True)
)
  • identity_assertion_enabled=True lo controla todo. Desactivado, que es el valor por defecto, /token responde a esta concesión con unsupported_grant_type aunque hayas implementado el hook, y los metadatos no la mencionan. Activado, los metadatos ganan el tipo de concesión jwt-bearer y listan urn:ietf:params:oauth:grant-profile:id-jag en authorization_grant_profiles_supported, el campo que la extensión usa para anunciar la compatibilidad. (El cliente de este SDK nunca lo lee: está aprovisionado para un solo emisor y simplemente pregunta.)
  • exchange_identity_assertion es el hook. Antes de que se ejecute, el SDK ha autenticado al cliente, ha rechazado los clientes públicos y ha rechazado los clientes cuyo registro no lista la concesión. Recibes un IdentityAssertionParams (la assertion sin procesar, los scopes solicitados y el resource) y devuelves un OAuthToken simple.
  • El registro dinámico de clientes rechaza esta concesión sin excepciones, así que get_client aquí sirve un cliente aprovisionado a mano. Un cliente ID-JAG no puede registrarse a sí mismo para existir.
  • La mitad de la clase son rechazos. OAuthAuthorizationServerProvider es el servidor de autorización completo, así que también pide el flujo de código de autorización; un servidor que además inicia la sesión de los usuarios implementa esos de verdad, y este tiene exactamente una puerta.

Warning

El SDK nunca decodifica la aserción: solo tu despliegue sabe en qué IdP confía y qué claves publica ese IdP, así que todo lo que hay dentro de exchange_identity_assertion es esencial. Verifica la firma contra las claves publicadas del IdP (su JWKS; el secreto compartido de aquí es el de la demo), así como iss y exp, según RFC 7523 §3. Exige que el typ de la cabecera del JWT sea oauth-id-jag+jwt, la protección del perfil contra que algún otro JWT se reenvíe como concesión. Exige que aud sea tu propio emisor. Exige que el claim client_id del ID-JAG sea igual al cliente que el handler autenticó, y que su claim resource nombre un recurso que realmente sirves. Lleva registro de jti hasta el exp de la aserción para que se acepte una sola vez. Y toma los scopes concedidos y, sobre todo, el resource del token emitido del ID-JAG validado, nunca de la solicitud: params.resource es lo que sea que el cliente escribió. Las reglas de procesamiento completas están en la especificación Enterprise-Managed Authorization.

Rechaza una aserción inválida con TokenError("invalid_grant", ...). El otro código de error de este flujo es invalid_target: un ID-JAG que nombra un recurso que no sirves se rechaza con él, que es lo que impide que este servidor acuñe tokens para el de otra persona. Y los scopes concedidos salen del claim scope del ID-JAG (una aserción sin él también se rechaza); el tuyo podría mapear los grupos del usuario en su lugar.

Y fíjate en lo que el OAuthToken devuelto no lleva: un token de actualización. El IdP decide cuánto tiempo conserva el acceso este usuario decidiendo si emite el siguiente ID-JAG. Un token de actualización acuñado aquí le devolvería en silencio esa decisión.

Info

Un servidor que todavía incrusta su servidor de autorización con auth_server_provider= llega al mismo código a través de AuthSettings(identity_assertion_enabled=True). Autorización explica por qué los servidores nuevos no deberían empezar por ahí.

Check

Conecta los dos archivos de esta página entre sí y toda la concesión es un solo POST /token:

grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer
assertion=eyJhbGciOiJIUzI1NiIsInR5cCI6Im9hdXRoLWlkLWphZytqd3QifQ...
client_id=finance-agent
resource=http://localhost:8001/mcp
scope=notes:read
client_secret=finance-agent-secret

HTTP/1.1 200 OK
{"access_token": "mcp_...", "token_type": "Bearer", "expires_in": 300, "scope": "notes:read"}

Sin /authorize, sin /register, sin obtener los metadatos del recurso protegido. Las únicas solicitudes que se transmiten son la que provocó el 401, la obtención del well-known, este intercambio y luego tráfico MCP ordinario con el bearer adjunto. Y el sub que tu validador leyó del ID-JAG es exactamente lo que get_access_token().subject informa dentro de una herramienta.

Pruébalo

examples/stories/identity_assertion/ en el repositorio del SDK es esta página funcionando de verdad: el mismo validador exchange_identity_assertion, un servidor MCP protegido por sus tokens, un IdP sustituto y el cliente, en un solo programa que se verifica a sí mismo. uv run python -m stories.identity_assertion.client --http ejecuta todo el intercambio y comprueba que el usuario que nombró el IdP es el usuario que ve la herramienta.

Resumen

  • SEP-990 permite que el proveedor de identidad de la empresa, y no el usuario final, decida a qué servidores MCP puede acceder un cliente. El IdP firma esa decisión en un ID-JAG.
  • Obtener el ID-JAG es un intercambio de tokens de RFC 8693 contra tu IdP, y el SDK no lo hace. Presentarlo al servidor de autorización MCP es la concesión jwt-bearer de RFC 7523, y el SDK cubre los dos lados de eso.
  • IdentityAssertionOAuthProvider es otro httpx2.Auth: un cliente confidencial registrado de antemano, un issuer fijado y un callback assertion_provider(audience, resource). Sin navegador, sin registro, sin token de actualización.
  • El servidor de autorización nunca se descubre desde el servidor de recursos. Configura issuer con exactamente la cadena que sirve su documento de metadatos; la comparación es carácter por carácter.
  • Del lado del servidor, identity_assertion_enabled=True más exchange_identity_assertion. El SDK autentica al cliente y controla la concesión; validar el ID-JAG es enteramente cosa tuya, y el token emitido queda vinculado al resource del ID-JAG, no al de la solicitud.

La única parte que esta página nunca tocó es el servidor MCP. Lo que hace con el token que acabas de acuñar ya lo hacía en Autorización.