1. Po co w ogóle potrzebne jest uwierzytelnianie w ChatGPT App
Zacznijmy od najważniejszego: użytkownik w ChatGPT ≠ użytkownik w Twojej usłudze.
ChatGPT ma własne konto użytkownika. Twój serwis ma własne userId, tenantId, role, billing, zamówienia. Między nimi nie ma domyślnego magicznego powiązania. Jeśli po prostu uruchomisz serwer MCP i opiszesz kilka tools, ChatGPT będzie je wywoływać jako pewien abstrakcyjny klient.
Przypomnijmy nasz przykładowy GiftGenius — ChatGPT App, który pomaga dobierać prezenty i zarządzać listami życzeń. Co chcemy umieć zrobić:
- Pokazywać użytkownikowi jego zapisane listy prezentów.
- Pozwalać oznaczać prezenty jako „kupione” lub „odebrane”.
- Pokazywać historię zamówień (zwłaszcza jeśli potem wejdziemy w commerce/ACP).
Bez uwierzytelniania serwer MCP w ogóle nie wie, „kto to jest”. W najlepszym razie widzi jakieś techniczne identyfikatory połączenia i anonimowy subject, który OpenAI daje do identyfikacji i rate limitów, ale wyraźnie ostrzega, że nie należy go używać do autoryzacji.
Uwierzytelnianie vs autoryzacja
Warto od razu rozdzielić dwa pojęcia.
- Uwierzytelnianie (AuthN) odpowiada na pytanie: kto to jest?
- Autoryzacja (AuthZ) odpowiada: co temu „komuś” wolno robić?
Dla ChatGPT App schemat wygląda mniej więcej tak:
- Najpierw przez OAuth potwierdzasz, że użytkownik rzeczywiście jest zalogowany w Twoim Identity Provider (IdP) (np. Keycloak/Auth0) i dostajesz token z jego identyfikatorem. To jest uwierzytelnianie.
- Dalej serwer MCP czyta token, wyciąga z niego sub, role i inne claims i decyduje, czy temu użytkownikowi wolno wywołać konkretny instrument (list_orders, delete_profile itp.). To jest autoryzacja.
Na poziomie kodu można to przedstawić (uproszczone):
// Typ danych, które serwer MCP chce znać o użytkowniku
export interface AuthContext {
userId: string;
roles: string[];
}
// Przykład użycia w obsłudze narzędzia (tool)
async function listGiftLists(auth: AuthContext | null) {
if (!auth) {
throw new Error("User is not authenticated");
}
// Pobieramy z bazy tylko listy tego użytkownika
return db.giftLists.findMany({ where: { ownerId: auth.userId } });
}
Bez userId i ról po prostu nie napiszesz poprawnej logiki biznesowej. Wszystko zamieni się w „jedno duże wspólne konto dla wszystkich”.
2. Dlaczego „klucz API w .env” to nie rozwiązanie
Jako deweloperzy mamy naturalny odruch: „Zrobię klucz API, włożę do .env i wszystko zadziała”. I rzeczywiście, dla wewnętrznych integracji serwis–serwis klucze API są normalnym narzędziem. Ale gdy tylko wchodzą do gry realni użytkownicy i ChatGPT App, podejście „jeden klucz dla wszystkich” przestaje działać.
Spójrzmy na typowy kod z wcześniejszych modułów, gdzie po prostu wołaliśmy z MCP do własnego backendu:
// mcp/backendClient.ts
export const backendClient = new BackendClient({
baseUrl: process.env.BACKEND_URL!,
apiKey: process.env.BACKEND_API_KEY!, // jeden klucz dla całego ChatGPT
});
Z punktu widzenia backendu teraz wszystkie żądania wyglądają tak samo: „to integracja ChatGPT”. Żadnej różnicy między Maszą i Paszą. To oznacza:
- Nie da się pokazać „panelu użytkownika” — serwer nie wie, czyj on jest.
- Nie da się rozdzielić uprawnień: „ten użytkownik może tylko czytać, a ten także kupować”.
- Nie da się powiązać zamówień z konkretną osobą w Twoim głównym systemie.
W świecie MCP to także jest niebezpieczne. Specyfikacja zaleca używać HTTP-auth (Bearer, klucze API itp.) przez Streamable HTTP, ale podkreśla, że pełny dostęp użytkowników do zasobów chronionych lepiej budować przez OAuth i tokeny, a nie przez jeden klucz serwisowy.
Dodatkowo, z punktu widzenia polityk OpenAI, dobre aplikacje powinny żądać tylko tych danych, które są rzeczywiście potrzebne, i dawać użytkownikowi kontrolę nad tym, co udostępnia aplikacji. To świetnie pasuje do modelu OAuth scopes, ale w ogóle nie współgra z podejściem „jeden superklucz, który potrafi wszystko”.
Dlaczego klucz serwisowy jest zły w kontekście ChatGPT
Serwisowy klucz API wyraża tożsamość serwisu, a nie użytkownika. Można nim podpisywać wywołania z Twojego serwera MCP do wewnętrznych usług lub zewnętrznych API (np. OpenAI API), ale nie da się powiedzieć: „Oto Wasia, pokaż jemu jego historię zamówień”.
Najprostszy antyprzykład:
// Zły wariant: "udawanie" użytkownika
async function getMyOrdersFromBackend() {
// Serwer MCP wywołuje /orders/me na backendzie
const res = await fetch(`${BACKEND_URL}/orders/me`, {
headers: {
Authorization: `Bearer ${process.env.BACKEND_API_KEY}`,
},
});
// Backend uważa, że "me" to jakiś serwis integracyjny, a nie człowiek
return res.json();
}
Nawet jeśli spróbujesz sztucznie włożyć jakiś anonimowy userId do treści żądania, to i tak pozostanie „partyzancką protezą”. Nadal potrzebujesz:
- Niezawodnego sposobu udowodnienia backendowi, że „to naprawdę Wasia, a nie ktoś inny”.
- Sposobu ograniczania uprawnień konkretnego użytkownika.
- Mechanizmu odwołania dostępu (revoke) dla konkretnego użytkownika, a nie dla wszystkich naraz.
I tutaj na scenę wchodzi OAuth.
3. Mini-słownik: czego w ogóle chcemy od systemu logowania
Zanim wskoczymy w historię OAuth, sformułujmy wymagania wobec „normalnego” systemu uwierzytelniania dla ChatGPT App.
Potrzebujemy sposobu, w którym:
- Nasz zewnętrzny IdP (Keycloak, Auth0, Hydra+Kratos itp.) zna realnego użytkownika: login, e-mail, userId, ewentualnie tenant.
- Ten IdP wydaje krótko żyjący token, który ChatGPT może bezpiecznie przekazać serwerowi MCP w nagłówku Authorization: Bearer <token>.
- Serwer MCP czyta token, weryfikuje podpis, issuer, audience, czas ważności i scopes, wyciąga sub (identyfikator użytkownika) i na tej podstawie mapuje użytkownika na własne byty (accountId, tenantId).
- Te same scopes pozwalają precyzyjnie zarządzać uprawnieniami: jeden token daje tylko read:gifts, inny także write:gifts lub checkout.
- Jeśli token jest nieobecny lub ma niewłaściwe scopes, serwer może zwrócić błąd z _meta["mcp/www_authenticate"], aby ChatGPT pokazał użytkownikowi UI autoryzacji i/lub odświeżył token.
Krótko mówiąc, potrzebujemy standardowego, sprawdzonego protokołu, który to wszystko potrafi. Spoiler: to OAuth 2.1 (i jego starsi/młodsi bracia).
4. Krótka ewolucja OAuth: od dinozaurów do PKCE
Teraz przejdźmy uważnie przez ewolucję OAuth, bez głębokiego zanurzania się w RFC, ale z rozumieniem, dlaczego interesują nas właśnie współczesne wzorce.
OAuth 1.0 / 1.0a: kryptofitness
Historycznie pierwszy był OAuth 1.0. Pozwalał serwisom webowym dawać innym usługom dostęp do swoich zasobów bez przekazywania hasła użytkownika (co już było niezłe). Ale:
- Podpisy żądań były skomplikowane: HMAC na prawie każdym żądaniu, budowanie base strings, normalizacja parametrów.
- Każde żądanie trzeba było podpisywać, przechowywać consumer secret, poprawnie formować podpis.
Większość współczesnych deweloperów nie pała chęcią ręcznego odtwarzania tych wszystkich tańców.
Specyfikacja 1.0a naprawiła pewne podatności, ale ogólna ociężałość pozostała.
OAuth 2.0: framework, a nie „jeden protokół”
OAuth 2.0 mocno uprościł życie: zamiast jednego ściśle opisanego schematu pojawił się zestaw flows (authorization code, implicit, resource owner password, client credentials itd.). To dało elastyczność, ale wprowadziło też zoo implementacji.
Plusy:
- Łatwiej zintegrować SPA, aplikacje mobilne i serwerowe.
- Pojawił się wyraźny podział ról: Resource Owner, Client, Resource Server, Authorization Server.
Minusy:
- W realnym świecie pojawiło się wiele niebezpiecznych „skrótów”. Flow implicit (który wydawał token bezpośrednio do przeglądarki bez serwerowej wymiany kodu) okazał się niebezpieczny.
- Flow password grant (gdy klient po prostu wysyła login/hasło użytkownika w zamian za token) stoi w sprzeczności z filozofią OAuth — i stał się antywzorcem.
Specyfikacja sama w sobie zostawiła zbyt dużo opcji „do wyboru”, stąd powstało mnóstwo rekomendacji i best practices, które żyły w osobnych RFC i wpisach na blogach.
OAuth 2.1: zebraliśmy się, odetchnęliśmy, zrobiliśmy porządek
OAuth 2.1 to próba udokumentowania best practices, które do tego czasu już się ukształtowały w społeczności:
- Skupienie niemal w całości na Authorization Code Flow jako podstawowym wariancie.
- Obowiązkowe stosowanie PKCE (Proof Key for Code Exchange) dla public clients — tych, które nie mogą przechowywać sekretu (np. aplikacje mobilne, SPA i… klienci ChatGPT/MCP).
- Przestarzałe i niebezpieczne flow jak implicit i password grant są po prostu wyłączone ze specyfikacji.
- Rekomendacje krótkiego czasu życia access token i używania refresh tokenów dla dłuższych sesji.
Dlaczego to dla Ciebie ważne? Bo ekosystem wokół MCP i ChatGPT ewidentnie orientuje się na te best practices: Apps SDK i specyfikacja MCP Authorization wprost wymagają authorization code + PKCE, krótkotrwałych tokenów i sensownych scopes.
5. Dlaczego w świecie ChatGPT App myślimy wzorcami OAuth 2.1 + PKCE
Skoro mamy kontekst historyczny, spójrzmy na to przez pryzmat ChatGPT i MCP.
ChatGPT jako public client
ChatGPT (i tacy klienci jak MCP Jam) względem Twojego Auth Server to typowy public client:
- Nie ma i nie może mieć bezpiecznie przechowywanego client_secret.
- Działa w infrastrukturze OpenAI, której nie kontrolujesz.
Dlatego jedyny sensowny wybór to Authorization Code Flow + PKCE, gdzie bezpieczeństwo opiera się nie na secercie klienta, lecz na weryfikacji code challenge i code verifier.
Oficjalna dokumentacja Apps SDK mówi wprost, że ChatGPT, działając jako klient MCP, wykonuje flow Authorization Code + PKCE (S256) i odmówi zakończenia autoryzacji, jeśli Twój Authorization Server nie deklaruje wsparcia PKCE w metadanych: code_challenge_methods_supported: ["S256"].
Jak wygląda flow z perspektywy MCP
Bardzo z grubsza, ale pomocne wyobrazić to sobie tak (sekwencja dla zasobu chronionego):
sequenceDiagram
participant U as Użytkownik
participant C as ChatGPT (klient MCP)
participant AS as Serwer autoryzacji
participant RS as Serwer MCP (Zasób)
U->>C: "Pokaż moje zamówienia"
C->>RS: call_tool(list_orders) bez tokena
RS-->>C: Błąd + _meta["mcp/www_authenticate"]
C->>AS: Otwiera login/consent (Authorization Code + PKCE)
U->>AS: Loguje się i wyraża zgodę (scopes)
AS-->>C: Authorization Code
C->>AS: Wymiana kodu na Access Token (+weryfikacja PKCE)
AS-->>C: Access Token (Bearer)
C->>RS: call_tool(list_orders) z Authorization: Bearer <token>
RS->>RS: Weryfikacja podpisu, issuer, audience, scopes
RS-->>C: Lista zamówień użytkownika
C-->>U: Wyświetla dane
Serwer przy tym wykorzystuje:
- Metadane zasobu chronionego (/.well-known/oauth-protected-resource) — tam ogłasza się jako zasób i wskazuje, który Authorization Server go obsługuje.
- Token, który przychodzi w nagłówku Authorization: Bearer <token>, który sprawdza albo jako JWT po JWK, albo przez introspekcję u Auth Servera.
- Jeśli token nie pasuje po audience lub scopes — serwer może odrzucić żądanie i znów zwrócić WWW-Authenticate-wyzwanie w _meta["mcp/www_authenticate"], aby ChatGPT ponownie przeszedł autoryzację z właściwymi parametrami.
Z punktu widzenia Twojego kodu wygląda to po ludzku: dostajesz na wejściu już zweryfikowany AuthContext i z nim pracujesz.
Mini-przykład: jak narzędzie MCP rozróżnia anonimowego i uwierzytelnionego użytkownika
Na razie bez konkretnego OAuth SDK, tylko koncept:
import type { McpToolHandler } from "./types";
export const listOrders: McpToolHandler = async (_args, context) => {
const auth = context.auth; // załóżmy, że tutaj wkładamy wynik weryfikacji tokenu
if (!auth) {
return {
content: [{ type: "text", text: "Musisz się zalogować, aby zobaczyć zamówienia." }],
_meta: {
// Wyzwanie dla ChatGPT: uruchom flow 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: `Znaleziono zamówień: ${orders.length}` }],
structuredContent: orders
};
};
Właśnie taka podpowiedź _meta["mcp/www_authenticate"] jest opisana w oficjalnej dokumentacji Apps SDK jako wyzwalacz dla UI OAuth po stronie ChatGPT.
6. Co oznacza „krótkotrwały token, minimalne scopes” w praktyce
Ze specyfikacji i przewodników wynika jeszcze kilka ważnych zasad, o których warto pamiętać już teraz, przed kolejnym wykładem o konkretnej konfiguracji IdP.
Krótki czas życia tokenu
Access token powinien żyć krótko. Po co?
- Jeśli wycieknie, atakujący i tak będzie ograniczony czasowo.
- Możesz bezpiecznie zmieniać uprawnienia użytkownika, a po krótkim czasie token „skisnie” i zostanie pobrany nowy.
Zwykle są to minuty lub kilkanaście minut. W zamian dostajesz refresh tokeny i/lub ponowne autoryzacje, ale w kontekście ChatGPT większość rutyny bierze na siebie klient.
Scopes jako sposób ograniczania uprawnień
Scopes to ciągi w stylu gifts.read, gifts.write, orders.read, orders.checkout. Wskazują, do czego użytkownik ma uprawnienia w ramach danego zasobu.
Dla ChatGPT App to szczególnie ważne:
- Możesz wydać token tylko z gifts.read, gdy użytkownik po prostu przegląda listy życzeń.
- A dla operacji ACP/Instant Checkout logiczne jest żądać bardziej restrykcyjnego zestawu praw — np. orders.checkout — i wyraźnie to podświetlić użytkownikowi.
W opisie tools w MCP możesz zadeklarować securitySchemes z konkretnymi scopes dla instrumentów, aby ChatGPT wiedział, jakich praw potrzeba do wywołania danego tool.
Audience: token musi być „dla tego” zasobu MCP
Kolejny istotny szczegół — aud (audience). Serwer MCP powinien sprawdzać, czy token rzeczywiście został wydany dla niego, a nie dla jakiejś sąsiedniej usługi.
W dokumentacji Apps SDK wprost napisano, że ChatGPT będzie przekazywać parametr resource i oczekuje, że Authorization Server odzwierciedli go w tokenie (zwykle w aud), a serwer MCP będzie weryfikował to pole.
Jest spora szansa, że podczas review Twojej aplikacji spróbują podać podrobione auth_token i sprawdzą, czy nie ma luk w implementacji bezpieczeństwa. Dlatego od razu rób to poprawnie.
7. Jak to przekłada się na naszą aplikację GiftGenius
Skupmy się znowu na naszym szkoleniowym App. Teraz mamy mniej więcej taki obraz:
- Jest narzędzie MCP get_gift_ideas, które na podstawie opisu obdarowywanego i budżetu proponuje pomysły na prezenty. To może działać anonimowo.
- Jest narzędzie MCP save_gift_list, które zapisuje listę w bazie. Chcemy, aby było powiązane z konkretnym użytkownikiem.
- Jest narzędzie MCP list_saved_lists, które pokazuje wszystkie zapisane przez użytkownika listy. To na pewno wymaga uwierzytelnienia.
Widżet pokazuje ładne karty prezentów, pozwala klikać „zapisz” i „oznacz jako kupione” — wszystko to w zasadzie front do chronionych narzędzi MCP.
Na poziomie typów może to wyglądać tak:
// Typowanie kontekstu wywołania narzędzia (uproszczone)
interface ToolContext {
auth: AuthContext | null;
}
// Przykład chronionego narzędzia
async function listSavedGiftLists(_input: {}, context: ToolContext) {
if (!context.auth) {
// Tutaj będzie ten sam trick z mcp/www_authenticate co wyżej
throw new Error("Authentication required");
}
return db.giftLists.findMany({
where: { ownerId: context.auth.userId }
});
}
I w momencie, gdy piszesz takie funkcje, staje się jasne: „po prostu klucz API w .env” w niczym nie pomoże. Potrzebny jest pełnoprawny AuthContext, budowany na podstawie zweryfikowanego tokenu OAuth.
Które części aplikacji mogą działać anonimowo, a które nie
Dobre ćwiczenie przed konfiguracją OAuth — przejść po funkcjonalnościach i uczciwie podzielić je na dwie kategorie.
Na przykład w GiftGenius:
Anonimowo:
- Generowanie pomysłów na prezenty na podstawie opisu.
- Prezentacja przykładów i tryb demo z fikcyjnymi danymi.
Tylko dla uwierzytelnionych:
- Przegląd i edycja własnych list życzeń.
- Historia zamówień.
- Wszelkie operacje płatnicze, Instant Checkout, powiązanie z ACP.
W kolejnych wykładach skonfigurujemy Auth Server (np. Keycloak lub zestaw Hydra+Kratos) i serwer MCP tak, aby tokeny do tych działań miały odpowiednie scopes, a narzędzia MCP potrafiły poprawnie odmawiać i prosić ChatGPT o ponowną autoryzację.
8. Typowe błędy w rozumieniu uwierzytelniania w ChatGPT App
Błąd nr 1: „Przecież ChatGPT już zna użytkownika, po co mi własny login?”
Wielu myśli: „ChatGPT ma konto użytkownika, czemu nie użyć go jako userId?”. Ale ChatGPT nie ujawnia Twojej aplikacji realnej tożsamości użytkownika ani nie daje dostępu do swoich kont. W metadanych MCP widzisz co najwyżej anonimowy _meta["openai/subject"], który służy do rate limitów i identyfikacji sesji, ale wprost wskazano, że nie wolno używać go do autoryzacji ani wiązać z realnymi kontami.
Błąd nr 2: „Jeden klucz API dla wszystkich — spoko, to przecież tylko ‘integracja’”
Podejście „wszyliśmy w serwer MCP klucz API do własnego backendu i jest fajnie” działa tylko w scenariuszach, gdzie wszyscy użytkownicy ChatGPT dzielą jedno i to samo konto w Twojej usłudze. Gdy tylko pojawiają się dane osobiste, commerce, ACL — zderzasz się z niemożnością rozróżnienia użytkowników i zarządzania ich uprawnieniami. Klucz API to tożsamość serwisu, a nie użytkownika.
Błąd nr 3: „Zróbmy password grant, to najprostsze”
Nawyk przekazywania loginu/hasła użytkownika do Twojego backendu w zamian za token (Resource Owner Password Credentials Grant) to przestarzały i niebezpieczny wzorzec z wczesnych czasów OAuth 2.0. We współczesnych rekomendacjach i w kontekście OAuth 2.1 jest to antywzorzec. Public clients jak ChatGPT nie powinny w ogóle widzieć haseł Twoich użytkowników — od tego jest Authorization Code + PKCE.
Błąd nr 4: „PKCE to zbędna komplikacja, zróbmy bez tego”
PKCE (zwłaszcza S256) to nie modny marketing, lecz obowiązkowy mechanizm ochrony Authorization Code Flow dla public clients. Bez PKCE skradziony authorization code można ponownie wykorzystać. W specyfikacji MCP Authorization i w Apps SDK wprost wskazano, że ChatGPT wymaga deklaracji wsparcia PKCE w metadanych Authorization Server i używa właśnie tego mechanizmu. Jeśli go wyłączysz, flow po prostu nie ruszy.
Błąd nr 5: „Poprośmy od razu o wszystkie możliwe scopes — na wszelki wypadek”
Czasem kusi, by zrobić token z uprawnieniami „otwórz i sformatuj dysk C:”. Ale to narusza zasadę minimalizacji uprawnień (PoLP) i kłóci się z politykami zarówno OpenAI, jak i większości IdP. Lepiej dokładnie przemyśleć, jakie scopes naprawdę są potrzebne Twojemu ChatGPT App: jedne do odczytu, inne do zapisu, osobne do commerce. To nie tylko podnosi bezpieczeństwo, ale też wpływa na UX zgody: użytkownik widzi zrozumiały i ograniczony zestaw praw, a nie straszną listę dwudziestu niejasnych pozycji.
Błąd nr 6: „Serwer MCP sam będzie przechowywał loginy/hasła i rysował UI logowania”
Serwer MCP to Resource Server, a nie Auth Server. Powinien umieć weryfikować tokeny, ogłaszać swoje metadane .well-known i zwracać wyzwania WWW-Authenticate, ale nie zajmować się logowaniem i przechowywaniem haseł. Do logowania/consent lepiej używać wyspecjalizowanego Authorization Server (Keycloak, Hydra, Auth0 itd.), co zobaczymy w kolejnych wykładach.
GO TO FULL VERSION