1. 为什么 ChatGPT App 需要身份验证
先说重点:ChatGPT 中的用户 ≠ 你服务中的用户。
ChatGPT 有它自己的用户账号。你的服务有自己的 userId、tenantId、角色、计费、订单。两者之间并没有默认的“魔法”关联。 如果你只是启动了一个 MCP 服务器并定义了几个 tools,ChatGPT 会把它们当作某个抽象客户端来调用。
回到我们的示例应用 GiftGenius——一个帮助挑选礼物和管理愿望单的 ChatGPT App。我们希望做到:
- 向用户展示其已保存的礼物清单。
- 允许将礼物标记为“已购买”或“已收到”。
- 展示订单历史(尤其是之后要接入 commerce/ACP 时)。
没有身份验证时,MCP 服务器完全不知道“这是谁”。它最多只能看到一些连接的技术性标识和一个 OpenAI 提供的匿名 subject(用于标识与限流),而且官方明确提醒不要把它用于授权。
身份验证 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 Key 放进 .env”不是解决方案
作为开发者,我们有个本能反应:“做个 API Key,放进 .env,就都能跑了”。确实,对服务到服务(service-to-service)的内部集成而言,API Key 是合适的工具。但一旦涉及真实用户与 ChatGPT App,“一个密钥通吃”的做法就会崩盘。
回顾早些模块里的典型代码,我们只是从 MCP 打到自己的后端:
// mcp/backendClient.ts
export const backendClient = new BackendClient({
baseUrl: process.env.BACKEND_URL!,
apiKey: process.env.BACKEND_API_KEY!, // 整个 ChatGPT 共用一个密钥
});
对后端而言,现在所有请求看起来都一样:“这是 ChatGPT 集成”。“玛莎”和“帕沙”之间没有任何差别。于是:
- 无法显示“个人中心”——服务器不知道它属于谁。
- 无法区分权限:“这个用户只能读,那个用户还能下单”。
- 无法把订单绑定到具体的人(在你的主系统里)。
在 MCP 世界里,这也不安全。规范建议通过 Streamable HTTP 使用 HTTP 认证(Bearer、API Key 等),但强调用户访问受保护资源时,最好使用 OAuth 与令牌,而不是单一的服务密钥。
另外,从 OpenAI 的政策看,一个好的应用应只请求确有必要的数据,并让用户掌控与 App 共享的内容。这与 OAuth 的 scopes 模型高度契合,却与“一个全能超级密钥”的思路背道而驰。
在 ChatGPT 语境下,服务密钥有什么问题
服务 API Key 表达的是服务本身的身份,而非用户身份。它可用于你的 MCP 服务器调用内部服务或外部 API(如 OpenAI API),但无法表达:“这是小王,请向他展示他的订单历史。”
最简单的反例:
// 反面示例: "障眼法" 式对待用户
async function getMyOrdersFromBackend() {
// MCP 服务器向后端调用 /orders/me
const res = await fetch(`${BACKEND_URL}/orders/me`, {
headers: {
Authorization: `Bearer ${process.env.BACKEND_API_KEY}`,
},
});
// 后端认为 "me" 指的是某个集成服务,而不是个人用户
return res.json();
}
即使你尝试在请求体里硬塞某个匿名的 userId,这也仍是“土法上马”。你仍然需要:
- 一个可靠的方法向后端证明“这确实是小王,而不是别人”。
- 一种为特定用户限权的方式。
- 只针对某个用户撤销(revoke)访问的机制,而不是“一撤了之,所有人都失效”。
这时就该 OAuth 登场了。
3. 术语小抄:我们到底希望登录系统具备什么
在跳进 OAuth 的历史之前,先明确一下一个“像样的” ChatGPT App 身份验证系统应满足的需求。
我们需要这样一种方式:
- 外部 IdP(Keycloak、Auth0、Hydra+Kratos 等)认识真实用户:登录名、邮箱、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 string、规范化参数等。
- 每个请求都要签、要存 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 客户端(无法安全保存密钥,如移动端、SPA,以及 ChatGPT/MCP 客户端)PKCE(Proof Key for Code Exchange)为强制要求。
- 淘汰不安全的 implicit 与 password grant。
- 建议 access token 短期有效,并用 refresh token 支持长会话。
为何这对你很重要?因为围绕 MCP 与 ChatGPT 的生态显然正对齐这些最佳实践:Apps SDK 与 MCP Authorization 规范明确要求 Authorization Code + 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 与 code verifier 的校验。
Apps SDK 官方文档明确指出:ChatGPT 作为 MCP 客户端,会执行带 PKCE(S256)的 Authorization Code flow;如果你的授权服务器没有在元数据里声明支持 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: 用 code 置换 Access Token (+PKCE 校验)
AS-->>C: Access Token (Bearer)
C->>RS: call_tool(list_orders) 带 Authorization: Bearer
RS->>RS: 校验签名、issuer、audience、scopes
RS-->>C: 用户订单列表
C-->>U: 展示数据
服务器会用到:
- 受保护资源的元数据(/.well-known/oauth-protected-resource)——声明自己是资源以及对应的授权服务器。
- 通过请求头 Authorization: Bearer <token> 传来的令牌, 要么作为 JWT 用 JWK 校验,要么走授权服务器的 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 的 tools 描述里,你已经可以为 securitySchemes 声明具体的 scopes,这样 ChatGPT 就知道调用某个 tool 需要哪些权限。
Audience:令牌必须“发给这个”MCP 资源
另一个关键点是 aud(audience)。MCP 服务器必须校验令牌确实是发给它的,而不是其他服务的。
Apps SDK 文档明确写道:ChatGPT 会传入 resource 参数,并期望授权服务器在令牌里反映这一点(通常在 aud 中);MCP 服务器应检查该字段。
你的应用在审核中很可能会收到伪造的 auth_token 来测试安全实现是否有漏洞。因此请一开始就把这些校验做好。
7. 这在我们的 GiftGenius 应用里如何落地
再回到我们的教学用 App。当前大致是这样:
- 有 MCP tool get_gift_ideas,根据收礼人描述与预算给出礼物创意。这可以匿名运行。
- 有 MCP tool save_gift_list,把清单保存到数据库。我们希望它绑定到具体用户。
- 有 MCP tool list_saved_lists,展示用户保存的所有清单。这显然需要身份验证。
Widget 会展示精美的礼物卡片,提供“保存”“标记为已购买”等点击操作——本质上它是受保护 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 }
});
}
一旦你开始写这样的函数,就会立刻明白:“仅靠 .env 里的 API Key”根本无济于事。你需要完整的 AuthContext,而它必须建立在经验证的 OAuth 令牌之上。
哪些功能可以匿名,哪些必须登录
在配置 OAuth 之前,一个不错的练习是如实地将功能划分为两类。
例如,在 GiftGenius 中:
匿名可用:
- 基于描述生成礼物创意。
- 示例/演示模式(使用虚拟数据)。
仅限已认证用户:
- 查看与编辑个人愿望单。
- 订单历史。
- 任何支付操作、Instant Checkout、与 ACP 的绑定。
在接下来的课程里,我们会配置授权服务器(如 Keycloak 或 Hydra+Kratos 组合)与 MCP 服务器,使不同操作的令牌具备恰当的 scopes,而 MCP 工具能正确拒绝并要求 ChatGPT 重新授权。
8. 在 ChatGPT App 中理解认证的常见误区
误区 1:“ChatGPT 已经知道用户了,我还要自己的登录干什么?”
很多人会想:“ChatGPT 有用户账号,为什么不直接把它当 userId 用?”但 ChatGPT 不会向你透露其真实用户身份,也不会给你访问它账号体系的能力。你在 MCP 元数据里最多能看到一个匿名的 _meta["openai/subject"], 它用于限流与会话标识,并明确标注不能用于授权或绑定到真实账号。
误区 2:“所有人共用一个 API Key 很正常,这只是‘集成’”
“把后端 API Key 烧进 MCP 服务器里就万事大吉”的做法只适用于所有 ChatGPT 用户共享同一账号的场景。一旦涉及个人数据、交易、ACL,你就会发现无法区分用户、也无法管理他们的权限。API Key 表达的是服务身份,而非用户身份。
误区 3:“直接上 password grant,最简单”
把用户的登录名/密码传给后端再换令牌(Resource Owner Password Credentials Grant)是 OAuth 2.0 早期的陈旧且不安全的模式。在现代建议里、也在 OAuth 2.1 语境下,它是反模式。像 ChatGPT 这样的 public 客户端不应该看到你的用户密码——这正是 Authorization Code + PKCE 存在的意义。
误区 4:“PKCE 是多余的复杂度,去掉吧”
PKCE(尤其是 S256)并非“营销名词”,而是为 public 客户端保护 Authorization Code Flow 的必需机制。没有 PKCE,被窃取的 authorization code 可以被复用。MCP Authorization 规范与 Apps SDK 均明确要求授权服务器元数据声明支持 PKCE,并使用该机制;禁用它,流程就跑不通。
误区 5:“一次性申请所有 scopes,以防万一”
有时会想签发一个能“上天下地、无所不能”的令牌。这违反最小权限原则(PoLP),也与 OpenAI 与多数 IdP 的政策相悖。请明确你 ChatGPT App 实际需要的 scopes:读与写分开、交易另设。它不仅提高安全性,也改善同意(consent)体验:用户看到的是清晰、有限的权限,而不是二十条看不懂的字符串。
误区 6:“MCP 服务器自己存放账号密码并做登录 UI”
MCP 服务器是 Resource Server,而非 Auth Server。它应会验证令牌、声明自己的 .well-known 元数据并返回 WWW-Authenticate 挑战,但不应承担登录与存储密码。登录/同意应交由专业的授权服务器(Keycloak、Hydra、Auth0 等),后续课程会演示。
GO TO FULL VERSION