1. Зачем вообще нужна аутентификация в ChatGPT App
Начнём с главного: пользователь в ChatGPT ≠ пользователь в вашем сервисе.
У ChatGPT есть свой аккаунт пользователя. У вашего сервиса — свои userId, tenantId, роли, биллинг, заказы. Между ними нет магической связи по умолчанию. Если вы просто подняли MCP-сервер и описали несколько tools, ChatGPT будет вызывать их как некий абстрактный клиент.
Вспомним наш условный пример приложения GiftGenius — ChatGPT App, который помогает подбирать подарки и управлять вишлистами. Что мы хотим уметь делать:
- Показывать пользователю его сохранённые списки подарков.
- Позволять отмечать подарки как «купленные» или «полученные».
- Показывать историю заказов (особенно если мы потом пойдём в commerce/ACP).
Без аутентификации MCP-сервер вообще не знает, «кто это такой». Максимум, что он видит — какие-то технические идентификаторы соединения и анонимный subject, который OpenAI даёт для идентификации и rate-limit’ов, но явно предупреждает, что это не надо использовать для авторизации.
Аутентификация vs авторизация
Очень полезно сразу разделить два понятия.
- Аутентификация (AuthN) отвечает на вопрос: кто это?
- Авторизация (AuthZ) отвечает: что этому «кто-то» позволено делать?
Для ChatGPT App схема примерно такая:
- Сначала через OAuth вы подтверждаете, что пользователь действительно залогинен в вашем IdentityProvider (IdP) (например, Keycloak/Auth0) и получаете токен с его идентификатором. Это аутентификация.
- Дальше MCP-сервер читает токен, вытаскивает из него sub, роли и другие claims и решает, можно ли этому пользователю вызывать конкретный инструмент (list_orders, delete_profile и т.п.). Это авторизация.
На уровне кода можно представить это так (упрощённо):
// Тип данных, который MCP-сервер хочет знать о пользователе
export interface AuthContext {
userId: string;
roles: string[];
}
// Пример использования в tool-обработчике
async function listGiftLists(auth: AuthContext | null) {
if (!auth) {
throw new Error("User is not authenticated");
}
// Достаём из БД только списки этого пользователя
return db.giftLists.findMany({ where: { ownerId: auth.userId } });
}
Без userId и ролей вы просто не сможете корректно писать бизнес-логику. Всё превратится в «один большой общий аккаунт на всех».
2. Почему «API-ключ в .env» — не решение
У нас, как у разработчиков, есть естественный рефлекс: «Сделаю API-ключ, положу в .env, и всё будет работать». И действительно, для внутренних интеграций сервис–сервис API-ключи — нормальный инструмент. Но как только в игру вступают реальные пользователи и ChatGPT App, подход «один ключ на всех» ломается.
Посмотрим на типичный код из ранних модулей, где мы просто стучались из MCP на свой backend:
// mcp/backendClient.ts
export const backendClient = new BackendClient({
baseUrl: process.env.BACKEND_URL!,
apiKey: process.env.BACKEND_API_KEY!, // один ключ для всего ChatGPT
});
С точки зрения backend’а теперь все запросы выглядят одинаково: «это ChatGPT-интеграция». Никакой разницы между Машей и Пашей. Значит:
- Нельзя показать «личный кабинет» — сервер не знает, чей он.
- Нельзя разделить права: «этот пользователь может только читать, а этот — ещё и покупать».
- Нельзя привязать заказы к человеку в вашей основной системе.
В мире MCP это ещё и небезопасно. Спецификация рекомендует использовать HTTP-аутентификацию (Bearer, API-ключи и т.п.) через Streamable HTTP, но подчёркивает, что полноценный доступ пользователей к защищённым ресурсам лучше строить через OAuth и токены, а не через один сервисный ключ.
Плюс, с точки зрения политики OpenAI, хорошее приложение должно запрашивать только те данные, которые действительно нужны, и давать пользователю контроль над тем, что он шарит с App. Это отлично ложится на модель OAuth scopes, но совсем не сочетается с подходом «один супер-ключ, который умеет всё».
Чем плох сервисный ключ в контексте ChatGPT
Сервисный API-ключ выражает личность сервиса, а не пользователя. Им можно подписывать вызовы из вашего MCP-сервера к внутренним сервисам или к внешним API (типа OpenAI API), но нельзя говорить: «Вот, это Вася, покажи ему его историю заказов».
Простейший анти-пример:
// Плохой вариант: "обманка" пользователя
async function getMyOrdersFromBackend() {
// MCP-сервер делает вызов к /orders/me на backend
const res = await fetch(`${BACKEND_URL}/orders/me`, {
headers: {
Authorization: `Bearer ${process.env.BACKEND_API_KEY}`,
},
});
// backend считает, что "me" — это некий интеграционный сервис, а не человек
return res.json();
}
Даже если вы попытаетесь искусственно вложить какой-то анонимный userId в тело запроса, это всё равно останется «кустарным велосипедом». Вам всё равно понадобится:
- Надёжный способ доказать backend’у, что «вот это действительно Вася, а не кто-то».
- Способ ограничить права конкретного пользователя.
- Механизм отзыва доступа (revoke) для конкретного пользователя, а не для всех сразу.
И вот тут на сцену выходит OAuth.
3. Мини-словарь: что мы вообще хотим от системы входа
Перед тем как прыгать в историю OAuth, давайте просто сформулируем требования к «нормальной» системе аутентификации для ChatGPT App.
Нам нужен способ, при котором:
- Наш внешний IdP (Keycloak, Auth0, Hydra+Kratos и т.п.) знает реального пользователя: логин, email, userId, возможно, tenant.
- Этот IdP выдаёт короткоживущий токен, который ChatGPT может безопасно передавать MCP-серверу в HTTP-заголовке Authorization: Bearer <token>.
- MCP-сервер читает токен, проверяет подпись, issuer, audience, срок действия и scopes, вытаскивает sub (идентификатор пользователя) и уже на основании этого мапит пользователя на свои сущности (accountId, tenantId).
- Эти же scopes позволяют тонко управлять правами: один токен даёт только read:gifts, другой ещё и write:gifts или checkout.
- Если токен отсутствует или у него не те scopes, сервер может вернуть ошибку с _meta["mcp/www_authenticate"], чтобы ChatGPT показал пользователю UI авторизации и/или переполучил токен.
В общем, нам нужен стандартный, проверенный временем протокол, который всё это умеет. Спойлер: это OAuth 2.1 (и его старшие/младшие братья).
4. Краткая эволюция OAuth: от динозавров до PKCE
Теперь аккуратно пройдёмся по эволюции OAuth, без глубокого погружения в RFC, но с пониманием, почему нас интересуют именно современные паттерны.
OAuth 1.0 / 1.0a: крипто-фитнес
Исторически первым появился OAuth 1.0. Он позволял веб-сайтам давать другим сервисам доступ к своим ресурсам без передачи пароля пользователя (что уже неплохо). Но:
- Подписи запросов были сложными: HMAC-подписывание почти каждого запроса, крутятся base-строки, нормализация параметров.
- Каждый запрос надо было подписывать, хранить consumer secret, уметь правильно формировать подпись.
Большинство современных разработчиков не горят желанием вручную повторять все эти танцы с бубном.
Спецификация 1.0a поправила некоторые уязвимости, но общая громоздкость осталась.
OAuth 2.0: фреймворк, а не «один протокол»
OAuth 2.0 сильно упростил жизнь: вместо одной строго прописанной схемы появился набор flows (authorization code, implicit, resource owner password, client credentials и т.д.). Это придало гибкость, но породило и зоопарк реализаций.
Плюсы:
- Проще интегрировать SPA, мобильные и серверные приложения.
- Появилось чёткое разделение ролей: Resource Owner, Client, Resource Server, Authorization Server.
Минусы:
- В реальном мире появилось много опасных «шорткатов». Flow implicit (который выдавал токен прямо в браузер без серверного обмена по коду) оказался небезопасен.
- Flow password grant (когда клиент просто шлёт логин/пароль пользователя в обмен на токен) противоречит самой философии OAuth — и стал анти-паттерном.
Спецификация сама по себе оставила слишком много вариантов «на выбор», поэтому появилось много рекомендаций и best practices, которые жили в отдельных RFC и блогах.
OAuth 2.1: собрались, выдохнули, навели порядок
OAuth 2.1 — это попытка задокументировать best practices, которые к тому моменту уже сформировались в сообществе:
- Фокус почти целиком на Authorization Code Flow как на основном рабочем варианте.
- В обязательном порядке используется PKCE (Proof Key for Code Exchange) для public-клиентов — тех, кто не может хранить секрет (например, мобильные приложения, SPA и… ChatGPT/MCP клиенты).
- Устаревшие и небезопасные флоу вроде implicit и password grant просто исключены из спецификации.
- Рекомендации по короткому сроку жизни access token и по использованию refresh-токенов для долгоживущих сессий.
Почему это важно вам? Потому что экосистема вокруг MCP и ChatGPT явно ориентируется именно на эти best practices: Apps SDK и спецификация MCP Authorization прямо требуют авторизационный код + PKCE, короткоживущие токены и нормальные scopes.
5. Почему в мире ChatGPT App мы мыслим паттернами OAuth 2.1 + PKCE
Теперь, когда у нас есть исторический контекст, посмотрим на это через призму ChatGPT и MCP.
ChatGPT как public client
ChatGPT (и такие клиенты, как MCP Jam) по отношению к вашему Auth Server — это типичный public client:
- У него нет и не может быть надёжно хранимого client_secret.
- Он запускается в инфраструктуре OpenAI, которую вы не контролируете.
Поэтому единственный адекватный выбор — Authorization Code Flow + PKCE, где безопасность основана не на секрете клиента, а на проверке кода-челленджа и кода-верифайера.
Официальная документация Apps SDK прямо говорит, что ChatGPT, выступая как MCP-клиент, выполняет flow с Authorization Code + PKCE (S256) и откажется завершать авторизацию, если ваш Authorization Server не объявляет поддержку PKCE в метаданных: code_challenge_methods_supported: ["S256"].
Как выглядит флоу с точки зрения MCP
Очень грубо, но полезно представить это так (последовательность для защищённого ресурса):
sequenceDiagram
participant U as Пользователь
participant C as ChatGPT (MCP Client)
participant AS as Auth Server
participant RS as MCP Server (Resource)
U->>C: "Покажи мои заказы"
C->>RS: call_tool(list_orders) без токена
RS-->>C: Ошибка + _meta["mcp/www_authenticate"]
C->>AS: Открывает login/consent (Authorization Code + PKCE)
U->>AS: Логинится и даёт согласие (scopes)
AS-->>C: Authorization Code
C->>AS: Обмен кода на Access Token (+PKCE проверка)
AS-->>C: Access Token (Bearer)
C->>RS: call_tool(list_orders) с Authorization: Bearer <token>
RS->>RS: Проверка подписи, issuer, audience, scopes
RS-->>C: Список заказов пользователя
C-->>U: Показывает данные
Сервер при этом использует:
- Метаданные защищённого ресурса (/.well-known/oauth-protected-resource) — там он объявляет себя как ресурс и указывает, какой Authorization Server обслуживает этот ресурс.
- Токен, который приходит в заголовке Authorization: Bearer <token>, он либо проверяет как JWT по JWK, либо интроспектирует через Auth Server.
- Если токен не подходит по audience или scopes — сервер может отвергнуть запрос и снова отдать WWW-Authenticate-челлендж в _meta["mcp/www_authenticate"], чтобы ChatGPT перепрошёл авторизацию с нужными параметрами.
С точки зрения вашего кода, всё это выглядит гуманно: вы получаете на вход уже проверенный AuthContext и работаете с ним.
Мини-пример: как MCP-tool различает анонимного и аутентифицированного пользователя
Пока без конкретного OAuth-SDK, просто концепт:
import type { McpToolHandler } from "./types";
export const listOrders: McpToolHandler = async (_args, context) => {
const auth = context.auth; // допустим, сюда мы кладём результат проверки токена
if (!auth) {
return {
content: [{ type: "text", text: "Нужно залогиниться, чтобы увидеть заказы." }],
_meta: {
// Челлендж для ChatGPT: запусти OAuth-флоу
"mcp/www_authenticate": [
'Bearer resource_metadata="https://mcp.giftgenius.app/.well-known/oauth-protected-resource", error="insufficient_scope", error_description="Login required to view orders"'
]
},
isError: true
};
}
const orders = await db.orders.findMany({ where: { userId: auth.userId } });
return {
content: [{ type: "text", text: `Найдено заказов: ${orders.length}` }],
structuredContent: orders
};
};
Именно такая _meta["mcp/www_authenticate"] подсказка описана в официальной документации Apps SDK как триггер для OAuth UI со стороны ChatGPT.
6. Что значит «короткоживущий токен, минимальные scopes» на практике
Из спецификаций и гайдов вытекает ещё несколько важных принципов, которые стоит держать в голове уже сейчас, до следующей лекции про конкретную настройку IdP.
Короткий срок жизни токена
Access token должен жить недолго. Зачем?
- Если он утечёт, злоумышленник всё равно будет ограничен во времени.
- Вы можете безопасно менять права пользователя, и через короткое время токен «протухнет» и будет запрошен новый.
Обычно это минуты или десятки минут. В обмен на это вы получаете refresh-токены и/или повторные авторизации, но в контексте ChatGPT большую часть рутины берёт на себя клиентская сторона.
Scopes как способ ограничить права
Scopes — это строки вроде gifts.read, gifts.write, orders.read, orders.checkout. Они указывают, на что именно у пользователя есть права в рамках данного ресурса.
Для ChatGPT App это особенно важно:
- Вы можете выдать токен только с gifts.read, когда пользователь просто просматривает вишлисты.
- А для операций ACP/Instant Checkout логично запрашивать более жёсткий набор прав — например, orders.checkout, и явно подсвечивать это пользователю.
В MCP-описании tools уже есть возможность объявлять securitySchemes с конкретными scopes для инструментов, чтобы ChatGPT знал, какие права нужны для вызова конкретного tool.
Audience: токен должен быть «для этого» MCP-ресурса
Ещё одна важная деталь — aud (audience). MCP-сервер должен проверять, что токен действительно выдан для него, а не для какого-то соседнего сервиса.
В Apps SDK-документации прямо сказано, что ChatGPT будет прокидывать параметр resource и ожидает, что Authorization Server отразит его в токене (обычно в aud), а MCP-сервер будет проверять это поле.
Есть большая вероятность, что в процессе review вашего приложения, ему передадут поддельные auth_token и проверят, нет ли дыр в вашей реализации безопасности. Так что сразу делайте всё правильно.
7. Как это ложится на наше приложение GiftGenius
Давайте опять сконцентрируемся на нашем учебном App. Сейчас у нас примерно такая картина:
- Есть MCP-tool get_gift_ideas, который по описанию получателя и бюджету предлагает идеи подарков. Это может работать анонимно.
- Есть MCP-tool save_gift_list, который сохраняет список в БД. Хочется, чтобы он был привязан к конкретному пользователю.
- Есть MCP-tool list_saved_lists, который показывает все сохранённые пользователем списки. Это точно требует аутентификации.
Виджет показывает красивые карточки подарков, даёт кликать «сохранить» и «отметить купленным» — всё это по сути фронт к защищённым инструментам MCP.
На уровне типов это может выглядеть так:
// Типизация контекста вызова tool (упрощённо)
interface ToolContext {
auth: AuthContext | null;
}
// Пример защищённого инструмента
async function listSavedGiftLists(_input: {}, context: ToolContext) {
if (!context.auth) {
// Здесь будет тот же трюк с mcp/www_authenticate, что и выше
throw new Error("Authentication required");
}
return db.giftLists.findMany({
where: { ownerId: context.auth.userId }
});
}
И вот как только вы пишете такие функции, становится ясно: «просто API-ключ в .env» никак не поможет. Нужен полноценный AuthContext, который строится на основе проверенного OAuth-токена.
Какие части приложения могут работать анонимно, а какие нет
Хорошее упражнение перед настройкой OAuth — пройти по функционалу и честно разделить его на две категории.
Например, в GiftGenius:
Анонимно:
- Генерация идей подарков на основе описания.
- Показ примеров и демо-режим с фиктивными данными.
Только для аутентифицированных:
- Просмотр и редактирование личных вишлистов.
- История заказов.
- Любые платёжные операции, Instant Checkout, привязка к ACP.
В следующих лекциях мы будем настраивать Auth Server (например, Keycloak или связку Hydra+Kratos) и MCP-сервер так, чтобы токены на эти действия имели нужные scopes, а MCP-tools умели корректно отказывать и просить ChatGPT переавторизоваться.
8. Типичные ошибки при понимании аутентификации в ChatGPT App
Ошибка №1: «ChatGPT же уже знает пользователя, зачем мне свой логин?»
Многие думают: «У ChatGPT есть аккаунт пользователя, почему бы мне просто не использовать его как userId?». Но ChatGPT не раскрывает вам реальную идентичность пользователя и не даёт доступа к своим аккаунтам. В MCP-метаданных вы видите разве что анонимный _meta["openai/subject"], который предназначен для rate-limit’ов и идентификации сессии, но прямо указано, что его нельзя использовать для авторизации или привязки к реальным аккаунтам.
Ошибка №2: «Один API-ключ на всех — нормально, это же просто «интеграция»»
Подход «зашили в MCP-сервер API-ключ к своему backend’у и радуемся» работает только для сценариев, где все пользователи ChatGPT делят один и тот же аккаунт в вашем сервисе. Как только появляются личные данные, commerce, ACL — вы упираетесь в невозможность различать пользователей и управлять их правами. API-ключ — это идентичность сервиса, а не пользователя.
Ошибка №3: «Давайте запилим password grant, это проще всего»
Привычка передавать логин/пароль пользователя в ваш backend и обменивать его на токен (Resource Owner Password Credentials Grant) — это устаревший и небезопасный паттерн из ранних времён OAuth 2.0. В современных рекомендациях и в контексте OAuth 2.1 он считается анти-паттерном. Public-клиенты вроде ChatGPT не должны вообще видеть пароли ваших пользователей — для этого и существует Authorization Code + PKCE.
Ошибка №4: «PKCE — это лишняя сложность, давайте без него»
PKCE (особенно S256) — это не модный маркетинг, а обязательный механизм защиты Authorization Code Flow для public-клиентов. Без PKCE украденный authorization code можно переиспользовать. В спецификации MCP Authorization и в Apps SDK прямо указано, что ChatGPT требует объявить поддержку PKCE в метаданных Authorization Server и использует именно этот механизм. Если вы его отключите, флоу просто не заработает.
Ошибка №5: «Давайте сразу запросим все возможные scopes — на всякий случай»
Иногда хочется сделать токен с правами «открыть и формат C: отформатировать». Но это нарушает принцип минимизации прав (PoLP) и бьётся с политиками как OpenAI, так и большинства IdP. Лучше чётко продумать, какие scopes реально нужны вашему ChatGPT App: одни для чтения, другие для записи, отдельные для commerce. Это не только повышает безопасность, но и влияет на UX согласия: пользователь видит понятный и ограниченный набор прав, а не страшный список из двадцати непонятных строк.
Ошибка №6: «MCP-сервер сам будет хранить логины/пароли и рисовать UI логина»
MCP-сервер — это Resource Server, а не Auth Server. Он должен уметь проверять токены, объявлять свои .well-known-метаданные и отдавать WWW-Authenticate-челленджи, но не заниматься логином и хранением паролей. Для логина/consent лучше использовать специализированный Authorization Server (Keycloak, Hydra, Auth0 и т.п.), как мы увидим в следующих лекциях.
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ