Перейти к содержанию

Утверждение идентичности

Машинный перевод

Эта страница переведена с английской документации автоматически, и основной версией остаётся английская страница. Если что-то читается неправильно, на странице Переводы объясняется, как об этом сообщить.

Обычный OAuth-провайдер (OAuth-клиенты) начинает с вопроса к MCP-серверу: какому серверу авторизации тот доверяет? Он идёт за ответом, куда бы тот ни указывал, а дальше либо человек входит в систему, либо его заменяет заранее выданный общий секрет.

В корпоративной среде ни то ни другое не должно решаться на уровне отдельного сервера. Там уже работает провайдер идентификации (Okta, Microsoft Entra ID, ваш собственный); пользователь уже вошёл в него сегодня утром; и именно там, в одном месте, служба безопасности хочет решать, кому что доступно. SEP-990, расширение Enterprise-Managed Authorization, переносит это решение туда. IdP подписывает короткоживущий JWT — Identity Assertion JWT Authorization Grant, или ID-JAG: утверждение о том, что этот пользователь через этот клиент может обращаться к этому MCP-серверу. Клиент обменивает его на обычный токен доступа. Ни браузера, ни экрана согласия, ни динамической регистрации.

Эта страница — обе стороны этого обмена. Сам MCP-сервер не меняется вовсе: это всё тот же сервер ресурсов со страницы Авторизация, который проверяет любой пришедший токен.

Два запроса токена

Здесь участвуют две разные инстанции, и различать их по именам — это почти всё, что нужно для понимания этой страницы. Корпоративный IdP — провайдер идентификации вашей организации: он знает, кто этот сотрудник, в нём живёт политика доступа, и он выпускает ID-JAG. SDK с ним никогда не общается. Сервер авторизации MCP — та же сторона, что и на странице Авторизация: издатель, названный в метаданных MCP-сервера, тот, кто выпускает токены, которые этот MCP-сервер принимает. В обычном OAuth-сценарии обе роли, как правило, играет одна система. Здесь их две, и весь грант сводится к тому, что вторая соглашается доверять первой.

Клиент делает по одному запросу токена к каждой.

  1. К корпоративному IdP. Клиент обменивает вход пользователя (его ID-токен OpenID Connect) на ID-JAG. Это обмен токенов по RFC 8693, это целиком API вашего IdP, и SDK этот запрос не делает. Его делаете вы — внутри одного асинхронного колбэка. Здесь же принимается решение по политике: IdP, который говорит «нет», просто не выпускает ID-JAG, и предъявлять нечего.
  2. К серверу авторизации MCP. Клиент предъявляет ID-JAG по гранту jwt-bearer из RFC 7523 (grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer, ID-JAG в параметре assertion) и получает токен доступа. Этот запрос делает SDK, а приём такого запроса — единственное, что эта страница добавляет к серверу авторизации.

Всё, что ниже, — о втором запросе: о клиенте, который его отправляет, и о сервере авторизации, который на него отвечает.

Клиент

IdentityAssertionOAuthProvider находится в модуле mcp.client.auth.extensions.identity_assertion. Как и все провайдеры на странице OAuth-клиенты, это httpx2.Auth: создайте экземпляр, передайте его в auth=, отдайте httpx2.AsyncClient транспорту.

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])

Читайте снизу вверх.

  • main() — стандартная функция main() OAuth-клиента (OAuth-клиенты), не изменённая ни в одной строке. В этом и смысл: как только провайдер создан, дальше по цепочке никто не знает, какой грант дал токен.
  • Провайдер принимает то, что другие провайдеры не могут обнаружить сами: client_id и client_secret, которые кто-то заранее зарегистрировал на сервере авторизации, issuer этого сервера авторизации и assertion_provider — асинхронный колбэк, возвращающий свежий ID-JAG по требованию.
  • storage — тот же протокол TokenStorage. Вызываются только два метода для токенов; динамической регистрации здесь нет, так что и запоминать client_info незачем.

Провайдер утверждения

fetch_id_jag(audience, resource) — единственный код, который вы пишете. Он вызывается один раз на каждый обмен токенов, никогда — при создании провайдера, и только после того, как метаданные сервера авторизации получены и проверены, так что неверно настроенный издатель никогда не приведёт к утечке утверждения. Два его аргумента — это два из полей, с которыми должен быть выпущен ID-JAG: audience — издатель сервера авторизации (поле aud в ID-JAG), а resource — канонический идентификатор MCP-сервера (поле resource в ID-JAG). Третье у вас уже есть: поле client_id в ID-JAG должно указывать тот client_id, который вы передали провайдеру, иначе сервер авторизации откажет в обмене.

idp_issue_id_jag над ней — не ваш код. Эта функция замещает провайдер идентификации и подписывает утверждение прямо в процессе, чтобы файл был самодостаточным и можно было прочитать каждое поле, которое несёт ID-JAG. Настоящая fetch_id_jag вместо этого делает первый запрос токена из предыдущего раздела: обмен токенов по RFC 8693 с вашим IdP, определённый черновиком Identity Assertion JWT Authorization Grant, профиль которого задаёт SEP-990. ID-токен вошедшего пользователя передаётся как subject_token, requested_token_type — это собственный URN ID-JAG (urn:ietf:params:oauth:token-type:id-jag), audience и resource проходят насквозь без изменений, а ответ содержит ID-JAG. Именно этот обмен, под этими именами, и нужно искать в документации вашего IdP.

Tip

Свежий ID-JAG запрашивается для каждого обмена, и в этом весь смысл: это одноразовый грант, живущий считаные минуты, и сервер авторизации на этой странице отказывается принимать один и тот же дважды. Не кэшируйте его. Повторно используется токен доступа, который вы на него покупаете.

Издатель задаётся в конфигурации

Вот где всё переворачивается. OAuthClientProvider спрашивает сервер ресурсов, какой сервер авторизации использовать, и идёт за ответом, куда бы тот ни указывал. Этот провайдер так не делает: issuer обязателен, метаданные RFC 8414 запрашиваются по собственному пути well-known этого издателя, конечная точка токенов должна иметь тот же origin, что и издатель, а сервер ресурсов вообще ни о чём не спрашивают.

Расширение этого не требует; это сознательно более строгий выбор. У этого клиента есть две вещи, которые стоит украсть: заранее зарегистрированный секрет и утверждение, привязанное к аудитории, — и клиент, позволивший скомпрометированному MCP-серверу направить себя на сервер авторизации злоумышленника, отправил бы туда и то и другое. Закрепление издателя при создании провайдера исключает этот разговор вовсе.

Warning

Настроенный issuer сравнивается с полем issuer документа метаданных простым сравнением строк по RFC 8414 §3.3: символ в символ, включая завершающую косую черту, без нормализации. Не угадывайте его. Запросите /.well-known/oauth-authorization-server у своего сервера авторизации и скопируйте значение issuer, которое он вернёт. Для сервера авторизации на этой странице это https://auth.example.com/, с косой чертой, потому что его издатель построен из URL-объекта pydantic. Несовпадение останавливает процесс на OAuthFlowError: Authorization server metadata issuer mismatch ещё до отправки каких-либо учётных данных или утверждения.

Конфиденциальный клиент

client_secret обязателен; без него конструктор выбрасывает ValueError. Профиль IETF, лежащий в основе SEP-990, оставляет этот грант только конфиденциальным клиентам, SEP-990 требует, чтобы клиент аутентифицировался, а этот SDK обеспечивает и то и другое, настаивая на общем секрете. token_endpoint_auth_method выбирает, где он передаётся: client_secret_post (по умолчанию, в теле формы) или client_secret_basic (заголовок HTTP Basic). Профиль допускает ещё private_key_jwt; этот провайдер его не поддерживает.

Tip

Читайте client_secret из переменных окружения или менеджера секретов и никогда — из системы контроля версий.

Что провайдер делает за вас

Первый запрос уходит без аутентификации, и ответ сервера 401 запускает процесс.

  1. Обнаружение. Провайдер получает метаданные сервера авторизации по пути well-known RFC 8414 настроенного издателя, проверяет, что issuer в документе совпадает, и проверяет, что конечная точка токенов имеет тот же origin, что и издатель.
  2. Утверждение. Он вызывает ваш assertion_provider и дожидается результата.
  3. Обмен. Он отправляет POST-запрос с грантом jwt-bearer на конечную точку токенов, сохраняет OAuthToken и повторяет ваш исходный запрос с заголовком Authorization: Bearer ....

Ответ 403, в WWW-Authenticate которого указано insufficient_scope, повторяет шаги 2 и 3 с объединением вашего scope и запрошенного в этом ответе. (scope — всегда лишь просьба; сервер авторизации с этой страницы выдаёт то, что сказано в ID-JAG, и ничего больше.) Токена обновления здесь нет нигде: когда токен доступа истекает, следующий 401 приводит к выпуску свежего ID-JAG и новому обмену — и это тот рычаг, который держит в руках IdP. Ошибки — те же два исключения, что и на остальной странице OAuth-клиенты: OAuthFlowError для обнаружения и проверки и его подкласс OAuthTokenError, когда конечная точка токенов отвечает отказом.

Сервер авторизации

Чаще всего на этом можно остановиться. Сервер авторизации MCP — чей-то чужой продукт, приём ID-JAG — настройка, которую нужно включить в нём, а половина SEP-990, которую реализует SDK, — это описанный выше клиент.

SDK может и сам быть сервером авторизации: create_auth_routes возвращает маршруты сервера авторизации списком, который может смонтировать любое Starlette-приложение, — именно так его запускает examples/servers/simple-auth/ в репозитории. SEP-990 добавляет к этой поверхности один флаг и один метод:

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 открывает всё остальное. Когда флаг выключен (а по умолчанию это так), /token отвечает на этот грант unsupported_grant_type, даже если вы реализовали хук, и метаданные о нём не упоминают. Когда включён, в метаданных появляется тип гранта jwt-bearer, а в authorization_grant_profiles_supported — поле, через которое расширение объявляет о поддержке, — указывается urn:ietf:params:oauth:grant-profile:id-jag. (Клиент этого SDK его никогда не читает: он настроен на одного издателя и просто делает запрос.)
  • exchange_identity_assertion — это и есть хук. К моменту его запуска SDK уже аутентифицировал клиент, отклонил публичные клиенты и отклонил клиенты, в регистрации которых этот грант не указан. Вы получаете IdentityAssertionParams (сырое assertion, запрошенные scopes и resource) и возвращаете обычный OAuthToken.
  • Динамическая регистрация клиентов отклоняет этот грант безусловно, поэтому get_client здесь отдаёт клиент, заведённый вручную. Клиент ID-JAG не может появиться, зарегистрировав сам себя.
  • Половина класса — отказы. OAuthAuthorizationServerProvider — это весь сервер авторизации, поэтому он требует и сценарий с кодом авторизации; сервер, который ещё и выполняет вход пользователей, реализует эти методы по-настоящему, а у этого ровно одна дверь.

Warning

SDK никогда не декодирует утверждение: только ваше развёртывание знает, какому IdP оно доверяет и какие ключи этот IdP публикует, поэтому на всём, что внутри exchange_identity_assertion, держится безопасность. Проверяйте подпись по опубликованным ключам IdP (его JWKS; общий секрет здесь — только для демонстрации), а также iss и exp, согласно RFC 7523 §3. Требуйте, чтобы typ в заголовке JWT был oauth-id-jag+jwt — это защита профиля от того, чтобы какой-нибудь другой JWT был повторно предъявлен как грант. Требуйте, чтобы aud был вашим собственным издателем. Требуйте, чтобы поле client_id в ID-JAG совпадало с тем клиентом, что был аутентифицирован обработчиком, а поле resource называло ресурс, который вы действительно обслуживаете. Отслеживайте jti до наступления exp утверждения, чтобы оно принималось лишь однажды. И берите выданные области доступа и, главное, resource выпускаемого токена из проверенного ID-JAG, а не из запроса: params.resource — это то, что ввёл клиент. Полные правила обработки — в спецификации Enterprise-Managed Authorization.

Некорректное утверждение отклоняйте через TokenError("invalid_grant", ...). Второй код ошибки в этом сценарии — invalid_target: им отклоняется ID-JAG, называющий ресурс, который вы не обслуживаете, — именно это не даёт серверу выпускать токены для чужих ресурсов. А выданные области доступа берутся из поля scope ID-JAG (утверждение без него тоже отклоняется); ваш сервер может вместо этого отображать группы пользователя.

И обратите внимание, чего в возвращаемом OAuthToken нет: токена обновления. IdP решает, как долго пользователь сохраняет доступ, решая, выпускать ли следующий ID-JAG. Выпущенный здесь токен обновления тихо вернул бы это решение обратно.

Info

Сервер, который по-прежнему встраивает свой сервер авторизации через auth_server_provider=, приходит к тому же коду через AuthSettings(identity_assertion_enabled=True). На странице Авторизация объясняется, почему новым серверам не стоит с этого начинать.

Check

Соедините два файла с этой страницы — и весь грант сведётся к одному 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"}

Ни /authorize, ни /register, ни запроса метаданных защищённого ресурса. По сети проходят только запрос, получивший 401, запрос well-known, этот обмен, а затем обычный MCP-трафик с приложенным bearer-токеном. А sub, который ваш валидатор прочитал из ID-JAG, — ровно то, что get_access_token().subject сообщает внутри инструмента.

Попробуйте сами

examples/stories/identity_assertion/ в репозитории SDK — это эта страница в действии: тот же валидатор exchange_identity_assertion, MCP-сервер, закрытый его токенами, IdP-заглушка и клиент — в одной самопроверяющейся программе. Команда uv run python -m stories.identity_assertion.client --http прогоняет весь обмен и проверяет, что пользователь, которого назвал IdP, — тот же, кого видит инструмент.

Итоги

  • SEP-990 позволяет корпоративному провайдеру идентификации, а не конечному пользователю, решать, к каким MCP-серверам может обращаться клиент. IdP закрепляет это решение подписью в ID-JAG.
  • Получение ID-JAG — это обмен токенов по RFC 8693 с вашим IdP, и SDK его не делает. Предъявление его серверу авторизации MCP — грант jwt-bearer из RFC 7523, и тут SDK реализует обе стороны.
  • IdentityAssertionOAuthProvider — ещё один httpx2.Auth: заранее зарегистрированный конфиденциальный клиент, закреплённый issuer и один колбэк assertion_provider(audience, resource). Ни браузера, ни регистрации, ни токена обновления.
  • Сервер авторизации никогда не обнаруживается через сервер ресурсов. Задайте issuer в точности той строкой, которую отдаёт его документ метаданных; сравнение идёт символ в символ.
  • На стороне сервера — identity_assertion_enabled=True плюс exchange_identity_assertion. SDK аутентифицирует клиент и ограничивает доступ к гранту; проверка ID-JAG целиком на вас, а выпущенный токен привязан к resource из ID-JAG, а не из запроса.

Единственная сторона, которой эта страница так и не коснулась, — MCP-сервер. То, что он делает с только что выпущенным вами токеном, он уже делал на странице Авторизация.