为 MCP Server 实现 OAuth 2.0:从零到生产的技术实践
详解如何为 MCP Server 添加 OAuth 2.0 支持,涵盖 RFC 8414 元数据发现、PKCE 验证、Token Rotation、ChatGPT 动态回调 URI 适配等关键技术点。
为什么 MCP Server 需要 OAuth?
如果你构建了一个 MCP Server,并且用 API Key 进行认证,大多数场景下是够用的。Claude Desktop、Claude Code、Cursor 等客户端都支持在 Header 中传递 API Key。
但当 ChatGPT 来敲门时,事情变了。
ChatGPT 的 MCP 连接器只支持 OAuth 2.0,不支持手动填写 API Key。 这意味着你的 MCP Server 必须实现标准的 OAuth 2.0 授权码流程,ChatGPT 才能接入。
本文分享我们在 KnowMine 中从零实现 MCP OAuth 2.0 的完整技术方案。
架构全览
ChatGPT KnowMine MCP Server
│ │
│ 1. GET /.well-known/ │
│ oauth-authorization- │
│ server │
│ ◄────────────────────────── │ RFC 8414 元数据
│ │
│ 2. GET /api/oauth/ │
│ authorize?... │
│ ────────────────────────► │ 授权请求 (PKCE)
│ │
│ 3. 用户登录 + 同意授权 │
│ ◄────────────────────────── │ 302 redirect + code
│ │
│ 4. POST /api/oauth/token │
│ grant_type= │
│ authorization_code │
│ ────────────────────────► │ 换取 Token
│ │
│ 5. access_token + │
│ refresh_token │
│ ◄────────────────────────── │ Token 响应
│ │
│ 6. POST /api/mcp │
│ Authorization: │
│ Bearer km_oat_xxx │
│ ────────────────────────► │ 正常 MCP 调用
关键实现点
1. RFC 8414 — OAuth 元数据发现
ChatGPT 连接 MCP Server 时,首先会请求 /.well-known/oauth-authorization-server 来发现 OAuth 端点。
// src/app/.well-known/oauth-authorization-server/route.ts
export function GET() {
const baseUrl = 'https://knowmine.ai';
return Response.json({
issuer: baseUrl,
authorization_endpoint: `${baseUrl}/api/oauth/authorize`,
token_endpoint: `${baseUrl}/api/oauth/token`,
revocation_endpoint: `${baseUrl}/api/oauth/revoke`,
response_types_supported: ['code'],
grant_types_supported: ['authorization_code', 'refresh_token'],
code_challenge_methods_supported: ['S256'],
token_endpoint_auth_methods_supported: ['none'],
});
}
同时实现 RFC 9728 /.well-known/oauth-protected-resource,声明受保护资源的认证要求。
2. PKCE — 防止授权码拦截
ChatGPT 使用 Authorization Code + PKCE(Proof Key for Code Exchange)流程。PKCE 的核心是:
- 客户端生成随机
code_verifier - 计算
code_challenge = BASE64URL(SHA256(code_verifier)) - 授权请求携带
code_challenge - Token 请求携带原始
code_verifier - 服务器验证两者匹配
安全关键: 验证时必须使用 timingSafeEqual 防止时序攻击:
import { createHash, timingSafeEqual } from 'crypto';
function verifyPkceChallenge(
verifier: string,
challenge: string
): boolean {
const computed = createHash('sha256')
.update(verifier)
.digest('base64url');
if (computed.length !== challenge.length) return false;
return timingSafeEqual(
Buffer.from(computed),
Buffer.from(challenge)
);
}
为什么不能用 ===? 字符串比较 === 会在发现第一个不匹配字符时立即返回 false,攻击者可以通过响应时间的微小差异逐字符猜测正确的 challenge。timingSafeEqual 无论匹配与否都执行相同时间的比较。
3. 授权码防重放 — 原子操作
授权码(Authorization Code)是一次性的,使用后必须立即失效。天真的实现可能是:
// ❌ 有竞态条件的实现
const code = await getCode(codeValue);
if (code.used) throw new Error('已使用');
await markCodeAsUsed(code.id); // 两次请求可能同时通过上面的检查
return exchangeForToken(code);
正确做法: 用数据库的原子更新作为锁:
// ✅ 原子操作,防止并发重放
const [updated] = await db
.update(oauthAuthorizationCodes)
.set({ used: true })
.where(
and(
eq(oauthAuthorizationCodes.codeHash, hash(code)),
eq(oauthAuthorizationCodes.used, false) // 原子条件
)
)
.returning();
if (!updated) {
// code 不存在或已被使用
throw new Error('invalid_grant');
}
UPDATE ... WHERE used = false 在数据库层面保证只有一个并发请求能成功。
4. Token 设计 — 前缀路由
我们的 MCP Server 同时支持 API Key 和 OAuth Token。如何在一个 Authorization: Bearer xxx 头中区分两种认证方式?
答案:Token 前缀路由。
// Token 前缀定义
const TOKEN_PREFIXES = {
apiKey: 'km_mcp_', // MCP API Key
oauthAccess: 'km_oat_', // OAuth Access Token
oauthRefresh: 'km_ort_', // OAuth Refresh Token
};
// 认证中间件
function authenticate(token: string) {
if (token.startsWith('km_oat_')) {
return authenticateOAuthToken(token);
}
if (token.startsWith('km_mcp_')) {
return authenticateApiKey(token);
}
throw new Error('unknown token type');
}
这样一个端点 /api/mcp 可以同时服务 Claude(API Key)和 ChatGPT(OAuth Token),无需分离路由。
5. Refresh Token Rotation
OAuth Token 会过期,ChatGPT 会用 Refresh Token 换取新的 Access Token。安全最佳实践是 Refresh Token Rotation:
每次刷新时:
1. 验证旧 Refresh Token
2. 生成新的 Access Token + 新的 Refresh Token
3. 立即撤销旧的 Refresh Token
这确保每个 Refresh Token 只能使用一次。如果攻击者窃取了 Refresh Token 并先于合法客户端使用,合法客户端下次刷新会失败,从而触发告警。
6. ChatGPT 动态回调 URI
传统 OAuth 要求在注册客户端时预先配置 redirect_uri。但 ChatGPT 的回调 URI 是动态生成的:
https://chatgpt.com/connector/oauth/callback/xxx-random-id
每次连接的 xxx-random-id 部分不同。如果严格匹配完整 URI,ChatGPT 永远无法通过验证。
解决方案:可信前缀匹配。
const TRUSTED_REDIRECT_PREFIXES: Record<string, string[]> = {
chatgpt: ['https://chatgpt.com/connector/oauth/'],
coze: ['https://www.coze.com/oauth/callback',
'https://www.coze.cn/oauth/callback'],
};
function validateRedirectUri(
clientId: string,
redirectUri: string
): boolean {
const prefixes = TRUSTED_REDIRECT_PREFIXES[clientId];
if (!prefixes) return false;
return prefixes.some((prefix) =>
redirectUri.startsWith(prefix)
);
}
对已知的可信客户端(如 ChatGPT),我们匹配 URI 前缀而不是完整 URI。这既保证了安全性(只允许已知域名),又兼容了动态路径。
7. Token 存储安全
永远不要明文存储 Token。 数据库中只存储 SHA-256 哈希:
import { createHash, randomBytes } from 'crypto';
function generateToken(prefix: string) {
const random = randomBytes(32).toString('base64url');
const plaintext = `${prefix}${random}`;
const hash = createHash('sha256')
.update(plaintext)
.digest('hex');
return { plaintext, hash };
}
验证时对传入的 Token 计算哈希,再与数据库存储的哈希比对。即使数据库被拖库,攻击者也无法还原出可用的 Token。
数据库设计
三张表:
-- OAuth 客户端注册
CREATE TABLE km_oauth_clients (
id TEXT PRIMARY KEY, -- 'chatgpt', 'coze'
client_secret_hash TEXT, -- 公开客户端可为空
redirect_uris TEXT[], -- 预注册的回调 URI
name TEXT NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- 授权码(一次性)
CREATE TABLE km_oauth_authorization_codes (
id UUID PRIMARY KEY,
code_hash TEXT NOT NULL, -- SHA-256
client_id TEXT REFERENCES km_oauth_clients(id),
user_id UUID REFERENCES km_users(id),
redirect_uri TEXT NOT NULL,
code_challenge TEXT NOT NULL, -- PKCE
scope TEXT DEFAULT 'mcp',
used BOOLEAN DEFAULT FALSE,
expires_at TIMESTAMPTZ NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- Token(Access + Refresh)
CREATE TABLE km_oauth_tokens (
id UUID PRIMARY KEY,
token_hash TEXT NOT NULL, -- SHA-256
token_type TEXT NOT NULL, -- 'access' | 'refresh'
client_id TEXT REFERENCES km_oauth_clients(id),
user_id UUID REFERENCES km_users(id),
scope TEXT DEFAULT 'mcp',
revoked BOOLEAN DEFAULT FALSE,
expires_at TIMESTAMPTZ NOT NULL,
created_at TIMESTAMPTZ DEFAULT NOW()
);
双认证并存的架构
最终架构同时支持两种认证路径:
Claude / Cursor ChatGPT / Coze
│ │
│ Bearer km_mcp_xxx │ Bearer km_oat_xxx
│ │
▼ ▼
┌──────────────────────────────────────┐
│ MCP Auth Middleware │
│ │
│ if (km_mcp_) → validateApiKey() │
│ if (km_oat_) → validateOAuthToken() │
│ │
│ → { userId, authMethod } │
└──────────────────────────────────────┘
│
▼
┌──────────────────────────────────────┐
│ MCP Tool Handler │
│ (不关心认证方式,只需要 userId) │
└──────────────────────────────────────┘
工具处理层完全不感知认证方式——无论用户通过 API Key 还是 OAuth 进来,拿到的都是同一个 userId,执行同样的知识库操作。
踩坑记录
坑 1:Token 端点必须支持 form-urlencoded
OAuth 规范要求 Token 端点接受 application/x-www-form-urlencoded,但很多现代框架默认只解析 JSON。ChatGPT 发送的就是 form-urlencoded 格式。
// 同时支持两种格式
const contentType = request.headers.get('content-type') || '';
let params: Record<string, string>;
if (contentType.includes('application/json')) {
params = await request.json();
} else {
const formData = await request.text();
params = Object.fromEntries(new URLSearchParams(formData));
}
坑 2:授权码有效期要够短
授权码应设置较短的有效期(我们用 10 分钟)。过长会增加被截获的风险,过短可能导致用户来不及完成授权。
坑 3:OAuth 端点也需要速率限制
别忘了给 OAuth Token 验证也加上速率限制。API Key 通常有速率限制,但 OAuth 路径容易被遗漏。
总结
为 MCP Server 添加 OAuth 2.0 支持的工作量不大(约 500 行核心代码),但涉及的安全细节很多。关键点:
- 实现 RFC 8414 元数据发现 — ChatGPT 靠这个找到你的 OAuth 端点
- PKCE + timingSafeEqual — 防止授权码拦截和时序攻击
- 原子操作防重放 — 数据库级别的
UPDATE WHERE used=false - Token 前缀路由 — 一个端点同时服务 API Key 和 OAuth
- 可信前缀匹配 — 适配 ChatGPT 动态回调 URI
- 哈希存储 — 永远不存明文 Token
如果你也在构建 MCP Server,希望这篇文章能帮你少踩几个坑。
源码参考:KnowMine 的 MCP Server 实现基于 Next.js 15 + Drizzle ORM + Neon PostgreSQL。完整连接指南:knowmine.ai/connect