1. 為什麼 ChatGPT App 一定需要身分驗證
先說重點:ChatGPT 裡的使用者 ≠ 你服務裡的使用者。
ChatGPT 有自己的使用者帳號。你的服務——有自己的 userId、tenantId、角色、計費、訂單。兩者之間預設沒有任何「魔法連結」。 如果你只是啟動一個 MCP 伺服器並宣告幾個 tools,ChatGPT 會把它們當成某個抽象的客戶端來呼叫。
回到我們的範例應用 GiftGenius——一個協助挑選禮物與管理願望清單的 ChatGPT App。我們希望做到:
- 向使用者展示他已儲存的禮物清單。
- 允許將禮物標記為「已購買」或「已收到」。
- 顯示訂單歷史(特別是之後若要接到 commerce/ACP)。
沒有身分驗證,MCP 伺服器根本不知道「這是誰」。它最多只能看到一些連線的技術性識別符與匿名的 subject,這是 OpenAI 用來識別與 rate-limit 的,但也明確警告不應拿來做授權。
身分驗證(Authentication)與授權(Authorisation)
先把兩個概念分清楚很重要。
- 身分驗證(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 世界裡,這也不安全。規範建議透過 Streamable HTTP 使用 HTTP 驗證(Bearer、API 金鑰等),但同時強調,針對使用者對受保護資源的完整存取,應建立在 OAuth 與權杖之上,而不是單一服務金鑰。
此外,從 OpenAI 政策來看,好的應用只應請求真正需要的資料,並讓使用者能控制與 App 分享的內容。這非常契合 OAuth scopes 模型,但完全不適合「一把能做所有事的超級金鑰」。
在 ChatGPT 情境下,服務金鑰的問題
服務用 API 金鑰代表的是服務本身的身分,而不是使用者。它可以用來從你的 MCP 伺服器簽署對內部服務或外部 API(例如 OpenAI API)的呼叫,但它無法表達:「這是瓦西婭,請顯示他的訂單歷史」。
最簡單的反例:
// 不佳做法:對使用者的「障眼法」
async function getMyOrdersFromBackend() {
// MCP 伺服器呼叫 backend 的 /orders/me
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 可以安全地透過 HTTP 標頭 Authorization: Bearer <token> 傳給 MCP 伺服器。
- 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。
缺點:
- 真實世界出現許多危險捷徑。implicit flow (在瀏覽器直接取得權杖、不經伺服器交換)被證明不安全。
- password grant (客戶端把使用者帳密送去換權杖)違背 OAuth 精神,成為反模式。
規範本身保留太多「可選項」,於是衍生大量建議與最佳實務,散落在不同 RFC 與部落格。
OAuth 2.1:回歸共識、收斂最佳實務
OAuth 2.1 是一次把既有最佳實務寫進文件的整理:
- 幾乎專注於 Authorization Code Flow 作為主要工作馬。
- 對 public 客戶端——無法安全保存密鑰(像行動 App、SPA、以及 ChatGPT/MCP 客戶端)——PKCE 成為必須。
- 過時且不安全的流程如 implicit 與 password grant 被移出規範。
- 建議 access token 短效,並使用 refresh token 維持長期會話。
為何這對你重要?因為 MCP 與 ChatGPT 的生態明顯以這些最佳實務為準:Apps SDK 與 MCP Authorization 規範明確要求授權碼 + PKCE、短效權杖與正規的 scopes。
5. 為何在 ChatGPT App 世界,我們採用 OAuth 2.1 + PKCE 的模式思考
現在有了歷史脈絡,換個角度從 ChatGPT 與 MCP 來看。
ChatGPT 是 public client
ChatGPT(以及像 MCP Jam 這類客戶端)對你的授權伺服器而言,是典型的public client:
- 它沒有、也無法安全保存 client_secret。
- 它運行於你無法控制的 OpenAI 基礎設施中。
因此唯一合理的選擇是Authorization Code Flow + PKCE,安全性不靠客戶端密鑰,而是靠 code challenge/verifier 的檢核。
Apps SDK 官方文件直言,ChatGPT 作為 MCP 客戶端,會執行 Authorization Code + PKCE(S256)流程,且若你的授權伺服器沒有在中繼資料宣告支援 PKCE: code_challenge_methods_supported: ["S256"], 它就會拒絕完成授權。
從 MCP 的視角看整個流程長怎樣
粗略但實用地看,可用下列時序圖描述(針對受保護資源):
sequenceDiagram
participant U as 使用者
participant C as ChatGPT (MCP 客戶端)
participant AS as 授權伺服器
participant RS as MCP 伺服器(資源)
U->>C: "顯示我的訂單"
C->>RS: call_tool(list_orders) 未附帶權杖
RS-->>C: 錯誤 + _meta["mcp/www_authenticate"]
C->>AS: 開啟登入/同意頁(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> 帶來的權杖,伺服器可依 JWK 驗證 JWT,或透過授權伺服器進行 introspection。
- 若權杖的 audience 或 scopes 不符——伺服器可拒絕請求並再次回傳 WWW-Authenticate 挑戰於 _meta["mcp/www_authenticate"], 讓 ChatGPT 依正確參數重新走授權。
就你的程式碼而言,這相當友善:你會拿到一個已驗證好的 AuthContext 然後直接使用。
小範例:MCP 工具如何區分匿名與已驗證的使用者
先不引入特定 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 官方文件中作為觸發 ChatGPT 顯示 OAuth UI 的機制。
6. 「短效權杖、最小化 scopes」在實務上的意義
從規範與指南還能推導出幾個要點,值得在進入下一堂 IdP 具體設定前先記住。
權杖存活時間要短
Access token 應該短命。為什麼?
- 若外洩,攻擊者能用的時間也有限。
- 你可以安全地更改使用者權限;很快權杖就會過期並重新申請。
通常是數分鐘或十幾分鐘。作為交換,你會使用 refresh token 與/或重走授權,但在 ChatGPT 情境,多數繁瑣工作由客戶端處理。
用 scopes 限制權限
Scopes 是像 gifts.read、gifts.write、 orders.read、orders.checkout 這樣的字串,指出使用者在該資源下具備哪些權限。
對 ChatGPT App 格外重要:
- 當使用者只是瀏覽願望清單時,你可以只發出具備 gifts.read 的權杖。
- 至於 ACP/Instant Checkout 等操作,合理地需要更嚴格的權限——例如 orders.checkout,並清楚提示給使用者。
在 MCP 的工具描述中,你已可宣告 securitySchemes 與特定 scopes,讓 ChatGPT 知道呼叫某個工具所需的權限。
Audience:權杖必須是「給這個」MCP 資源的
另一個重點是 aud(audience)。MCP 伺服器應該驗證權杖確實是發給它的,而不是其他服務。
Apps SDK 文件指出,ChatGPT 會傳入參數 resource,並期待授權伺服器將其反映在權杖中(通常在 aud),而 MCP 伺服器應驗證該欄位。
你的應用在審查時,很可能會被注入偽造的 auth_token 來測試安全漏洞;一開始就請把實作做好。
7. 把這套做法放進我們的 GiftGenius 應用
再聚焦到我們的教學 App。現在大致是這樣:
- 有 MCP 工具 get_gift_ideas,根據收禮者描述與預算提出禮物點子。這可以匿名運作。
- 有 MCP 工具 save_gift_list,將清單儲存到資料庫。我們希望它綁定到特定使用者。
- 有 MCP 工具 list_saved_lists,顯示使用者儲存的所有清單。這絕對需要身分驗證。
Widget 會展示漂亮的禮物卡片,讓人點擊「儲存」「標記為已購買」——本質上就是受保護 MCP 工具的前端。
在型別層面可能長這樣:
// 工具呼叫的上下文型別(簡化)
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」完全幫不上忙。你需要的是以驗證過的 OAuth 權杖建構出的完整 AuthContext。
哪些功能可匿名、哪些必須登入
在設定 OAuth 前的好練習,是誠實檢視功能並分成兩類。
以 GiftGenius 為例:
可匿名:
- 依描述產生禮物點子。
- 展示範例與使用假資料的 Demo 模式。
僅限已驗證:
- 檢視與編輯個人願望清單。
- 訂單歷史。
- 任何支付操作、Instant Checkout、與 ACP 的綁定。
在後續課程中,我們會設定授權伺服器(例如 Keycloak 或 Hydra+Kratos)與 MCP 伺服器,讓對應操作擁有正確的 scopes,而 MCP 工具能正確拒絕並要求 ChatGPT 重新授權。
8. 理解 ChatGPT App 身分驗證時的常見錯誤
錯誤一:「ChatGPT 已經知道使用者了,我還要自己的登入幹嘛?」
很多人想:「ChatGPT 有使用者帳號,為什麼不直接把它當成 userId?」但 ChatGPT 不會揭露你的真實使用者身分,也不會讓你存取它的帳號。在 MCP 中繼資料裡,你頂多看到匿名的 _meta["openai/subject"], 它用於 rate-limit 與會話識別,但明確指出不能用於授權或綁定到真實帳號。
錯誤二:「大家共用一把 API 金鑰沒問題,反正只是『整合』」
把 API 金鑰硬塞進 MCP 伺服器連到你 backend 然後偷笑,只適合所有 ChatGPT 使用者共用你服務裡同一個帳號的情境。一旦牽涉個資、commerce、ACL——你就無法區分使用者與管理其權限。API 金鑰代表的是服務身分,而不是使用者。
錯誤三:「來做個 password grant,最簡單」
把使用者帳密傳給你 backend 以換取權杖(Resource Owner Password Credentials Grant),是 OAuth 2.0 早期的過時且不安全作法。在現今建議與 OAuth 2.1 的情境下,它是反模式。像 ChatGPT 這樣的 public 客戶端不應該看到你使用者的密碼——這正是 Authorization Code + PKCE 存在的理由。
錯誤四:「PKCE 太複雜了,不要用」
PKCE(特別是 S256)不是潮流名詞,而是為 public 客戶端保護 Authorization Code Flow 的必要機制。沒有 PKCE,被竊取的授權碼可以被重放。在 MCP Authorization 規範與 Apps SDK 中明確指出,ChatGPT 要求授權伺服器在中繼資料宣告支援 PKCE,並使用該機制。若你把它關掉,流程根本跑不起來。
錯誤五:「先把所有 scopes 全要了,以防萬一」
有時會想做個能「上天下海還能格式化 C 槽」的權杖。但這違反權限最小化(PoLP),也會與 OpenAI 與多數 IdP 的政策相衝突。更好的方式是清楚規劃你的 ChatGPT App 真正需要的 scopes:哪些用於讀、哪些用於寫、哪些用於 commerce。這不只提升安全,也影響授權 UX:使用者會看到清楚且有限的權限組,而不是二十條看不懂的清單。
錯誤六:「MCP 伺服器自己存帳密、自己畫登入 UI」
MCP 伺服器是 Resource Server,而非 Auth Server。它應該會驗證權杖、發布自己的 .well-known 中繼資料,並回傳 WWW-Authenticate 挑戰,但不該處理登入與保存密碼。登入/同意應交由專門的 Authorization Server(Keycloak、Hydra、Auth0 等),我們會在後續課程中看到。
GO TO FULL VERSION