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

Блог

MCP-сервер должен проверять, кому выдан токен

Авторизация в MCP простыми словами: что должен опубликовать MCP-сервер, какое поле токена должно его называть и два вопроса, оставленных вам спецификацией.

Автор: FoxAuth ·

Токен был выдан для внутренней вики. А открыл все инструменты MCP-сервера.

MCP-сервер поступил так, как поступает большинство первых версий. Он скачал открытые ключи провайдера идентификации — сервиса, который выпускает токены. Проверил подпись токена и срок действия. Прочитал из токена subject — пользователя, на чьё имя выдан токен. И решил, что запрос аутентифицирован. Все проверки прошли, потому что токен был настоящий. Вполне нормальный токен для вики. Но MCP-сервер так и не спросил, для кого этот токен. Поэтому любой, у кого был любой токен этого провайдера, выданный для чего угодно, мог посмотреть список инструментов и вызвать их — от имени того, кто указан в токене.

Спецификация авторизации MCP нужна как раз для того, чтобы такой ошибки не было. Когда увидишь эту ошибку, остальная спецификация перестаёт казаться новым протоколом. MCP-сервер — обычный сервер ресурсов OAuth 2.1: сервер, который принимает токены и в ответ даёт пользоваться тем, что у него есть (здесь — инструментами). От себя спецификация добавляет только записанные шаги поиска (discovery): как клиент, который знает лишь URL сервера, добирается до токена. Я немало времени провёл на другой стороне — писал сервер авторизации, то есть сервер, который выдаёт токены. Мой итог такой: авторизация в MCP — это три документа и один claim (claim — поле внутри токена). И почти все настоящие проблемы — в этом одном claim.

От одного URL до токена: три документа и один claim

Хост агента — это программа, в которой работает ИИ-агент; в терминах OAuth это клиент. Он знает одно: URL вашего MCP-сервера. По этому URL он должен выяснить три вещи: какой сервер авторизации вас защищает, как с этим сервером разговаривать и как ему представиться. А потом вернуться с токеном, который предназначен вам и больше никому. На первые три вопроса отвечают документы. Последнее — claim в токене.

Как хост агента получает токен для MCP-сервера: ответ 401 с адресом метаданных сервера, сами метаданные, метаданные сервера авторизации, запрос авторизации и запрос токена, где параметр resource называет MCP-сервер, и токен, про который MCP-сервер проверяет, что выдан он именно емуХост агента (клиент)MCP-серверСервер авторизации1 POST /mcp без токена2 401 WWW-Authenticate: Bearerresource_metadata=“…”, scope=“mcp:tools-basic”3 GET метаданных защищённого ресурса (RFC 9728)resource, authorization_servers, scopes_supported4 GET метаданных сервера авторизации (RFC 8414 или OIDC Discovery)issuer, endpoints, code_challenge_methods_supported, client_id_metadata_document_supported5 /authorize client_id=host.example/client.json resource=mcp.example.com/mcpPKCE; сервер берёт из client.json имя и адреса редиректа; пользователь даёт согласие6 /token code + code_verifier + тот же resource7 токен доступа с aud = mcp.example.com/mcp8 POST /mcp Authorization: Bearer …9 200 после проверки подписи, iss, exp и aud

Шаг 2 обычный сервер ресурсов сам не делает. Спецификация делает его обязательным (MUST). Так же обязательны документ метаданных, на который указывает шаг 2 (шаг 3), и проверка аудитории (шаг 9). Аудитория — это claim aud в токене: в нём написано, для какого сервера выпущен токен. Сам шаг 2 — две строки. Если запрос пришёл без токена или с токеном, который вы не принимаете, вы отвечаете 401 и говорите, где лежат ваши метаданные. Такой ответ называется challenge: в заголовке WWW-Authenticate сервер подсказывает клиенту, как получить доступ:

HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer resource_metadata="https://mcp.example.com/.well-known/oauth-protected-resource",
scope="mcp:tools-basic"

Эти метаданные — JSON-документ по RFC 9728. Называется он «метаданные защищённого ресурса» (protected resource metadata). В нём MCP-сервер говорит, какой сервер авторизации его защищает:

{
"resource": "https://mcp.example.com/mcp",
"authorization_servers": ["https://auth.example.com/acme"],
"scopes_supported": ["mcp:tools-basic"],
"bearer_methods_supported": ["header"]
}

Дальше работают сервер авторизации и клиент. Клиент берёт из документа издателя (issuer). Issuer — это имя сервера авторизации, записанное в виде URL; каждый токен, который этот сервер подписывает, несёт этот URL в поле iss. По этому URL клиент скачивает метаданные уже самого сервера авторизации. Стандартных (well-known) адресов для них два: один задаёт RFC 8414, другой — OpenID Connect Discovery. Клиент обязан попробовать оба, потому что спецификация требует от сервера публиковать только один. Затем клиент проходит обычный authorization code flow с PKCE: получает код через браузер и меняет его на токен, а PKCE не даёт воспользоваться кодом тому, кто его перехватил. И в запросе авторизации, и в запросе токена клиент передаёт ещё один параметр. RFC для него есть с 2020 года, но на практике он до сих пор совсем не обычен.

Параметр resource делает токен годным только для вашего сервера

Это параметр resource из RFC 8707 (Resource Indicators). С ним клиент говорит, для какого сервера ему нужен токен. Спецификация MCP требует передавать его в обоих запросах. Именно он превращает «токен от провайдера» в «токен для этого MCP-сервера». Сервер авторизации записывает этот URI в токен как аудиторию (aud). А MCP-сервер отклоняет любой токен, у которого аудитория не совпадает точно с его собственным идентификатором. На этой проверке токен вики и срезался бы. И ничто другое его бы не остановило.

Вот токен, который сервер авторизации возвращает в сценарии со схемы, в декодированном виде:

{
"iss": "https://auth.example.com/acme",
"aud": "https://mcp.example.com/mcp",
"sub": "248289761001",
"client_id": "https://host.example/client.json",
"scope": "mcp:tools-basic",
"iat": 1759226400,
"exp": 1759227300,
"jti": "o1S6Qw3bH9c_UZL0"
}

На стороне MCP-сервера всё сводится к четырём проверкам этого токена. Две из них вы должны задать сами, потому что JWT-библиотека не может их угадать:

import { createRemoteJWKSet, jwtVerify } from 'jose';
const keys = createRemoteJWKSet(new URL('https://auth.example.com/acme/jwks'));
export async function principalFor(authorization) {
const token = authorization?.replace(/^Bearer /i, '');
if (!token) return null;
const { payload } = await jwtVerify(token, keys, {
issuer: 'https://auth.example.com/acme',
audience: 'https://mcp.example.com/mcp'
});
return { subject: payload.sub, scopes: (payload.scope ?? '').split(' ') };
}

Подпись и срок действия (exp) библиотека проверяет сама. issuer и audience задаёте вы, и первая попытка упустила как раз audience. Любая библиотека на любом языке делает те же четыре проверки. Код выше, по сути, и есть чек-лист.

Дольше всего до меня доходило другое. Одна и та же строка теперь стоит в трёх местах. Пишут её три разные стороны, и везде она должна совпадать. MCP-сервер публикует её в своих метаданных как resource. Оператор сервера авторизации объявляет её, то есть регистрирует как идентификатор защищённого ресурса. Клиент отправляет её как resource, а токен несёт её как aud. Сравнение точное — после канонизации: URL с каждой стороны сначала приводится к одному стандартному написанию (подробнее ниже). Точное оно намеренно. Никакого совпадения по префиксу. Никакого «токен для родительского пути годится и для дочернего». Ведь именно так токен одной интеграции и начинает работать в другой.

Один идентификатор в трёх местах, которые пишут три стороны: поле resource в метаданных MCP-сервера, объявление оператора на сервере авторизации и параметр resource, который отправляет клиент и который становится aud в токене. Так же issuer связывает authorization_servers из метаданных с iss в токенеMCP-сервер публикуетresource: mcp.example.com/mcpauthorization_servers: [issuer acme]scopes_supported: [mcp:tools-basic]Оператор объявляетID: mcp.example.com/mcpв проекте acme с issuer /acmescopes: [mcp:tools-basic]Клиент отправляетresource=mcp.example.com/mcpв /acme/authorize и /acme/tokenscope из WWW-AuthenticateТокенaud: mcp.example.com/mcp iss: issuer acmescope: запрошенное ∩ объявленноеодна строка, везде сравнивается точно после канонизации

Как раз в канонизации у нашей первой версии была ошибка. Нашли её, рассуждая над кодом; тесты её не поймали. Спецификация говорит: клиентам следует писать URL без завершающего слеша, а серверам следует принимать схему или хост в верхнем регистре. Поэтому и объявление, и входящий запрос мы канонизируем одной функцией. У неё есть опция для ресурса, у которого завершающий слеш важен. Ошибка была такая. Объявление сохранили со слешем. А при сравнении запрос канонизировали с опциями по умолчанию. Так слеш срезался только с одной стороны. В итоге ресурс, у которого слеш объявлен значимым, сливался со своим двойником без слеша: два разных ресурса считались одним. Обе стороны должны проходить через одну и ту же функцию с одними и теми же опциями. Если это звучит очевидно — мне тоже было очевидно. Задним числом.

И ещё об аудитории. Каждый токен привязан ровно к одному объявленному ресурсу — API или MCP-серверу, который администратор зарегистрировал как защищённый ресурс. Поэтому объявление ресурса может спокойно быть данными, а не кодом. В нашем сервере объявить MCP-сервер — значит заполнить форму в консоли: идентификатор, название для экрана согласия и scope, которые этот сервер понимает. Scope — это именованное право, которое может нести токен, например mcp:tools-basic. Перезапускать ничего не нужно. Объявление читается при каждом запросе токена, без кэша. Причина: если объявление удалили, токены для него должны перестать выдаваться на следующем же запросе, а не когда истечёт какая-нибудь закэшированная копия. Этот урок мы однажды уже усвоили, когда кэш клиентов отдал админке устаревшего клиента. Учиться второй раз нам было незачем.

Агент представляется адресом своего документа — удобно, но рискованно

У хоста агента есть URL вашего MCP-сервера, и больше ничего. Вставить в него идентификатор клиента (client_id) вам некуда. Раньше спецификация первым делом предлагала динамическую регистрацию клиентов. Клиент отправляет POST-запрос с описанием себя и получает в ответ client_id. А сервер авторизации хранит по записи на каждый хост, который хоть раз подключался. Текущая редакция спецификации объявила это устаревшим. Вместо этого на первое место она поставила Client ID Metadata Documents — документы метаданных клиента. По-моему, решение верное. И при этом именно эта часть спецификации даёт больше всего поводов нервничать.

Идея такая: client_id — это сам по себе HTTPS-URL. По этому адресу лежит JSON-документ с описанием клиента — его документ метаданных:

{
"client_id": "https://host.example/client.json",
"client_name": "Example Agent Host",
"redirect_uris": ["http://127.0.0.1:52123/callback", "https://host.example/oauth/callback"],
"token_endpoint_auth_method": "none",
"grant_types": ["authorization_code", "refresh_token"]
}

Сервер авторизации скачивает этот документ, когда он нужен, проверяет его и ничего не сохраняет. Нет эндпоинта регистрации. Нет таблицы клиентов, которая растёт на одну запись с каждым ноутбуком. Имя клиента на экране согласия берётся из документа. Этот документ контролирует оператор клиента, и лежит он на домене, которым оператор доказуемо владеет.

Цена такая. Теперь посторонний человек — без учётной записи и без каких-либо учётных данных — может заставить ваш сервер авторизации отправить HTTP-запрос на любой URL по своему выбору. Вся защита от этого должна быть на сервере. У нас ею занят один модуль: он работает с сетью и больше ни с чем. Он не ходит на частные, loopback- и link-local-адреса. Loopback-адрес — это адрес самой машины, например 127.0.0.1. Link-local-адрес работает только внутри локального сегмента сети. Адреса модуль сравнивает как числа, а не как текст. Причина: парсер URL охотно перепишет IPv4-mapped IPv6-адрес (IPv4-адрес, записанный в форме IPv6) в такой вид, который текстовая проверка пропустит. Редиректы модуль проходит вручную и перепроверяет каждый шаг. Читать он перестаёт после пяти килобайт, и время его работы ограничено. По умолчанию эта функция выключена — ровно из-за первой фразы этого абзаца, про постороннего.

Когда документ получен, проверки короткие. Каждая закрывает конкретный трюк. client_id внутри документа должен побайтно совпадать с URL самого документа. Поэтому выложить у себя документ с чужим идентификатором бесполезно. Поле redirect_uris (адреса, куда браузер возвращается после входа) обязательно и не может быть пустым. Симметричный секрет клиента (общий пароль клиента и сервера) отклоняется: секрет в документе, который может прочитать кто угодно, — уже не секрет. А если клиент входит по ключам, документ должен сказать, где эти ключи лежат.

По-настоящему нерешённым я бы назвал loopback-редиректы. Нативный хост агента — это приложение на компьютере самого пользователя. После входа оно ждёт ответа (callback) на 127.0.0.1, на каком-то порту. Поэтому в его документе указан loopback-редирект. Документ доказывает, что его автор контролирует домен. Но он не может доказать, какая программа на компьютере слушает этот порт. Злоумышленник на той же машине может предъявить client_id настоящего хоста и слушать на своём порту. А пользователь увидит на экране согласия имя настоящего хоста. Спецификация об этом знает. Она говорит, что серверу следует предупреждать, — и всё. Для обычного ресурса мы несём этот риск, как и все.

Но есть одно место, где такой клиент может получить права администратора: собственный административный MCP-эндпоинт этого сервера, через который агент управляет сервером. Там мы превратили предупреждение в жёсткий запрет. Если в документе клиента есть только loopback-редиректы, такого клиента туда не пустят, пока администратор не примет письменную оговорку. В ней сказано, чего такой документ не доказывает.

Чьи пользователи входят через агента, если сервером пользуются несколько команд

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

Допустим, сервер обслуживает несколько тенантов. Тенант — это команда, которая делит сервер с другими, но имеет своих пользователей. И вот приходит хост агента, который представился документом метаданных. Он не принадлежит ни одному тенанту — это просто URL. Чьи пользователи будут входить через него? Мы решили так: решает ресурс. Администратор тенанта объявил этот MCP-сервер в одном из своих проектов. Значит, по запросу с этим ресурсом входят пользователи этого проекта. Почему это безопасно, стоит сказать точно. Связку «этот ресурс — этот проект» создал не клиент, а администратор, в проекте, которым он владеет. Поэтому параметр resource может только выбрать среди объявлений, которые сделал оператор. Создать новое объявление он не может.

Точнее, это было безопасно только против злоумышленников без учётной записи в консоли. Тот, у кого она была, мог объявить что угодно. Идентификаторы были уникальны на весь экземпляр сервера, и идентификатор доставался тому, кто объявил его первым. Значит, враждебный тенант мог объявить чужой MCP-сервер. Настоящий владелец потом при каждой попытке объявить свой сервер упирался бы в конфликт — навсегда. А входы через этот MCP-сервер уходили бы в бакет пользователей злоумышленника. Бакет пользователей — так наш сервер называет набор конечных пользователей со своими правилами входа. Когда у бакета появляется свой адрес, он становится и отдельным issuer.

Первое исправление требовало от каждого объявления доказательства. Сервер скачивал метаданные ресурса и проверял, что в них назван этот issuer. Исправление естественное. Но оно отвергало и каждый MCP-сервер, чьи метаданные сервер авторизации не мог скачать: во внутренней сети, на localhost во время разработки, ещё не развёрнутый. Оно продержалось день.

Два тенанта на одном сервере авторизации, у каждого свой issuer, и оба объявляют один и тот же идентификатор MCP-сервера. Запрос к issuer одного тенанта видит только объявление этого тенанта. На общем корневом issuer объявлять может только суперадминистраторissuer auth.example.com/acmeпользователи и ключи acmeобъявляет mcp.example.com/mcpлюбой админ acme, без fetch и проверокissuer auth.example.com/globexпользователи и ключи globexтоже объявляет mcp.example.com/mcpтолько в своём пространстве имёнобщий issuer auth.example.comкорневой, для тенантов без адресаобъявляет только суперадминв метаданных issuer, а не тенант/acme/authorize?resource=…/mcpнаходит объявление acmeпользователь acme; globex не виден/globex/authorize?resource=…/mcpнаходит объявление globexтот же идентификатор, другой токен/authorize?resource=…/mcpinvalid_target, если не объявлен в корне

Его место заняли пространства имён, и именно такую схему я теперь посоветовал бы всем, кто строит подобное. Пространство имён здесь — это набор объявлений, которые принадлежат одному issuer. Тенант с собственным адресом — это отдельный issuer со своими ключами подписи. А идентификатор ресурса должен быть уникален только внутри одного issuer. Два тенанта могут объявить один и тот же MCP-сервер, и ни один не увидит объявление другого. Ведь поиск объявления смотрит только в пространство имён того адреса, на который пришёл запрос. Объявлению в своём пространстве имён доказательство не нужно, поэтому сервер ничего не скачивает: такое объявление может говорить только о ваших собственных пользователях.

По-настоящему общее пространство имён одно — корневой issuer. На нём обслуживаются все тенанты без собственного адреса. Объявления там принимаются только от суперадминистратора. Причина: в корне метаданные ресурса могут сказать, что доверяют этому issuer. Но какому из тенантов на нём принадлежит ресурс, они сказать не могут. Поэтому в корне никакое доказательство не защитит от захвата: любой тенант мог бы первым объявить чужой идентификатор. Вот почему объявлять там может только суперадминистратор.

Оставались ещё три дыры там же, вокруг объявленных ресурсов. Первые две устроены одинаково: «вызывающий код забыл передать ресурс». Знать о них стоит: именно такие ошибки эта архитектура и делает возможными.

Первая. Резолвер учётных записей (код, который находит пользователя, когда тот входит) искал пользователя не в том тенанте, что шаг входа. Шаг входа передавал ресурс, а резолвер — нет. Симптом — ошибка 500 на экране согласия вместо отказа.

Вторая. Вход по паролю, сброс пароля и регистрация выбирали политику только по клиенту. Возьмём тенант, чьи пользователи могут входить только через upstream-провайдера, то есть внешнего провайдера удостоверений. Через объявленный ресурс до такого тенанта всё равно можно было добраться с паролем.

Обе дыры мы закрыли, сделав ресурс обязательным аргументом. Теперь код, который его не передаёт, не проходит проверку типов. Если брать из этого раздела одно инженерное правило, то такое: когда параметр выбирает тенанта, сделайте так, чтобы функцию нельзя было вызвать без него.

Последняя дыра — машинные токены. Когда токен просят для человека, этот человек даёт согласие. Поэтому любой клиент, даже ничей, может попросить у человека токен к объявленному ресурсу. С токеном client credentials всё иначе: клиент получает его для себя, и никого не спрашивают. Пока мы этого не заметили, работало вот что. Любой конфиденциальный клиент (клиент со своим секретом) любого тенанта или клиент, который зарегистрировал себя сам, мог попросить токен у эндпоинта токенов тенанта-жертвы. И получал его — с ресурсом жертвы в аудитории и с issuer жертвы. Теперь машинный токен для объявленного ресурса получает только клиент того проекта, который этот ресурс объявил. В спецификации об этом ничего нет. И не могло быть: этот вопрос возникает, только когда одним сервером пользуются несколько тенантов.

Каждый scope из списка может получить любой агент — держите список коротким

Одна мелочь меня удивила. Допустим, в challenge вашего ответа 401 нет scope. Тогда спецификация велит клиенту запросить все scope из вашего scopes_supported. И говорит, что так и задумано: универсальный клиент не может разумно выбирать среди имён, которых не понимает. Так что длинный список в метаданных — не меню на выбор. Это то, что получит каждый агент, который когда-либо подключится.

Поэтому указывайте в challenge тот scope, который действительно нужен вашим инструментам. scopes_supported держите минимальным — только для базовой работы. А когда впервые вызывают привилегированный инструмент, просите больше через ответ 403 с ошибкой insufficient_scope. Клиент обязан следовать scope из challenge. И он сам отслеживает scope, которые накопил, пока повышал права шаг за шагом (step-up). Поэтому серверу авторизации не нужно ничего об этом помнить.

Наш собственный административный MCP-эндпоинт указывает в списке только openid. Его права берутся из того, в каких группах состоит администратор, а не из scope. А scope, который эндпоинт никогда не проверяет, публиковать незачем.

Что спецификация оставляет вам: формат токена и проблему confused deputy

Первый вопрос — формат токена, и спецификация старательно его не решает. MCP-сервер должен отклонять токены, предназначенные не ему, «или иным образом проверять, что он является их предполагаемым получателем». Такая формулировка допускает два вида токенов. Самодостаточный токен, как в примере выше, несёт все данные в себе; его проверяют по опубликованным ключам. Opaque-токен — это случайная строка без содержимого. Его проверяют интроспекцией, то есть спрашивают у сервера авторизации, действует ли он ещё. По умолчанию мы выдаём самодостаточные токены на пятнадцать минут. Причина: для интроспекции вашему MCP-серверу нужны собственные учётные данные, а в десятиминутном руководстве по настройке на этот шаг нет места. Обратная сторона: самодостаточный токен действует, пока не истечёт. Нужно, чтобы отозванный токен переставал работать за секунды, а не за минуты? Тогда объявите ресурс с форматом opaque, дайте своему MCP-серверу собственного клиента и платите за запрос интроспекции при каждом вызове. Ни один из ответов не ошибочен. Ошибка — не знать, какой из них вы выбрали.

Второй вопрос — проблема confused deputy: посредника, которому доверены чужие полномочия, обращают против их владельца. Решать её — не дело сервера авторизации. Возьмём MCP-сервер, который проксирует сторонний API. С этим API он работает под одним постоянным client id. Своих MCP-клиентов он регистрирует динамически. А коды авторизации пересылает дальше, не показывая своего экрана согласия. Ровно такую конфигурацию руководство по безопасности разбирает атака за атакой. Защита состоит из двух частей. Первая: прокси сам спрашивает согласие для каждого клиента — ещё до того, как начнётся авторизация у стороннего API. Вторая: прокси проверяет аудиторию, чтобы полученный им токен никогда не уходил дальше как есть. Всё, что я описал выше, в этом помогает. Но ничто из этого не сделает работу за вас.

Чего я пока не знаю — как на практике сложится политика доверия к документам метаданных, то есть какие URL клиентов сервер авторизации должен принимать. Спецификация разрешает три подхода: принимать любой HTTPS client_id, пускать только домены из списка или проверять репутацию. Каждый подход открывает свою дорогу для фишинга, потому что имя на экране согласия — это то, что написано в документе. Открытость по умолчанию — то, на чём держится экосистема. А списки разрешённых доменов — то, чего захочет корпоративный заказчик. Подозреваю, что ответ — и то и другое, с выбором для каждого тенанта. И это ещё один вопрос, который спецификация так и будет оставлять тому, кто эксплуатирует сервер.

Хотите увидеть всё целиком? Руководство Защита MCP-сервера с помощью OAuth займёт около десяти минут. Оно проводит через объявление ресурса, две вещи, которые публикует ваш сервер (метаданные и challenge в ответе 401), проверку токена и оба исхода: отказ и успех. Как хосту агента разрешено представляться, решают две настройки: clientIdMetadataDocument.enabled — для документов метаданных и registration.enabled — для динамической регистрации. А в справочнике эндпоинтов указано, что возвращает каждый адрес для discovery.