Перейти до змісту

Твердження про ідентичність

Машинний переклад

Цю сторінку перекладено автоматично з англомовної документації, і основною версією є англомовна сторінка. Якщо щось читається неправильно, на сторінці Переклади пояснено, як про це повідомити.

Звичайний 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) — єдиний код, який ви пишете. Його очікують (await) один раз на кожен обмін токенів, ніколи під час створення провайдера, і лише після того, як метадані сервера авторизації отримано й перевірено, тож неправильно налаштований емітент ніколи не призведе до витоку твердження. Два його аргументи — це два з полів (claims), з якими має бути випущено 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. Твердження. Він очікує (await) ваш assertion_provider.
  3. Обмін. Він надсилає грант jwt-bearer методом POST на кінцеву точку токена, зберігає 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 і вказують urn:ietf:params:oauth:grant-profile:id-jag у authorization_grant_profiles_supported — полі, яким розширення оголошує підтримку. (Клієнт цього 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, жодного отримання protected-resource-metadata. Єдині запити в мережі — той, що отримав 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 виконує весь обмін і перевіряє (assert), що користувач, якого назвав 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-сервер. Те, що він робить із токеном, який ви щойно випустили, він уже робив на сторінці Авторизація.