Утверждение идентичности
Машинный перевод
Эта страница переведена с английской документации автоматически, и основной версией остаётся английская страница. Если что-то читается неправильно, на странице Переводы объясняется, как об этом сообщить.
Обычный 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-сценарии обе роли, как правило, играет одна система. Здесь их две, и весь грант сводится к тому, что вторая соглашается доверять первой.
Клиент делает по одному запросу токена к каждой.
- К корпоративному IdP. Клиент обменивает вход пользователя (его ID-токен OpenID Connect) на ID-JAG. Это обмен токенов по RFC 8693, это целиком API вашего IdP, и SDK этот запрос не делает. Его делаете вы — внутри одного асинхронного колбэка. Здесь же принимается решение по политике: IdP, который говорит «нет», просто не выпускает ID-JAG, и предъявлять нечего.
- К серверу авторизации 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 транспорту.
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 запускает процесс.
- Обнаружение. Провайдер получает метаданные сервера авторизации по пути well-known RFC 8414 настроенного издателя, проверяет, что
issuerв документе совпадает, и проверяет, что конечная точка токенов имеет тот же origin, что и издатель. - Утверждение. Он вызывает ваш
assertion_providerи дожидается результата. - Обмен. Он отправляет 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 добавляет к этой поверхности один флаг и один метод:
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-сервер. То, что он делает с только что выпущенным вами токеном, он уже делал на странице Авторизация.