JavaRush /Курсы /ChatGPT Apps /Зачем нужна аутентификация в ChatGPT App и краткая эволюц...

Зачем нужна аутентификация в ChatGPT App и краткая эволюция OAuth

ChatGPT Apps
10 уровень , 0 лекция
Открыта

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 схема примерно такая:

  1. Сначала через OAuth вы подтверждаете, что пользователь действительно залогинен в вашем IdentityProvider (IdP) (например, Keycloak/Auth0) и получаете токен с его идентификатором. Это аутентификация.
  2. Дальше 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.

Нам нужен способ, при котором:

  1. Наш внешний IdP (Keycloak, Auth0, Hydra+Kratos и т.п.) знает реального пользователя: логин, email, userId, возможно, tenant.
  2. Этот IdP выдаёт короткоживущий токен, который ChatGPT может безопасно передавать MCP-серверу в HTTP-заголовке Authorization: Bearer <token>.
  3. MCP-сервер читает токен, проверяет подпись, issuer, audience, срок действия и scopes, вытаскивает sub (идентификатор пользователя) и уже на основании этого мапит пользователя на свои сущности (accountId, tenantId).
  4. Эти же scopes позволяют тонко управлять правами: один токен даёт только read:gifts, другой ещё и write:gifts или checkout.
  5. Если токен отсутствует или у него не те 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 и т.п.), как мы увидим в следующих лекциях.

1
Задача
ChatGPT Apps, 10 уровень, 0 лекция
Недоступна
PKCE-генератор (code_verifier + code_challenge)
PKCE-генератор (code_verifier + code_challenge)
1
Задача
ChatGPT Apps, 10 уровень, 0 лекция
Недоступна
Защищённый MCP tool + mcp/www_authenticate (scopes + resource_metadata)
Защищённый MCP tool + mcp/www_authenticate (scopes + resource_metadata)
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ