05 · 认证:让某些工具要登录才能用
这章讲
authenticated_server_python:它有两个工具,一个免登录、一个必须 OAuth。读完你能说清 Apps SDK 的认证三件事:声明、发现、挑战。
1. 要解决的小问题
「搜披萨店」谁都能用,但「看我的历史订单」是私人数据,得先登录。需要一种办法让 ChatGPT 知道:哪些工具要登录、去哪登录、没登录时怎么提示去登录。
2. 三件事概览
| 阶段 | 谁做 | 怎么做 |
|---|---|---|
| ① 声明 | server 列工具时 | 每个工具带 securitySchemes:noauth 还是 oauth2 |
| ② 发现 | server 暴露元数据 | RFC 9728 的 /.well-known/oauth-protected-resource,指向授权服务器 |
| ③ 挑战 | server 在 call_tool 里 | 没带有效 token 就回 WWW-Authenticate,ChatGPT 据此发起登录 |
3. 声明:securitySchemes
两套 scheme 定义在 authenticated_server_python/main.py:187-200:
MIXED_TOOL_SECURITY_SCHEMES=[{noauth}, {oauth2}]—— 既可匿名也可登录(搜索工具用)。OAUTH_ONLY_SECURITY_SCHEMES=[{oauth2}]—— 必须登录(历史订单工具用)。
列工具时两个工具各挂一套(authenticated_server_python/main.py:370-398)。注意 securitySchemes 既进 _meta(_tool_meta:343-344)又作为 types.Tool 的顶层字段传(:378、:392)。
4. 发现:RFC 9728 protected-resource 元数据
Server 注册一个标准路由 /.well-known/oauth-protected-resource{path}(authenticated_server_python/main.py:321-326 的 protected_resource_metadata),返回 ProtectedResourceMetadata:
# 示意,非源码 —— 告诉客户端"去哪登录"
ProtectedResourceMetadata(
resource=RESOURCE_SERVER_URL, # 这资源是谁
authorization_servers=[AUTHORIZATION_SERVER_URL], # 去这个授权服务器拿 token
scopes_supported=RESOURCE_SCOPES,
)
URL 由 RESOURCE_SERVER_URL 解析拼出(authenticated_server_python/main.py:170-179);两个授权相关环境变量缺失会启动即报错(:151-164)——强制部署时配齐。
5. 挑战:WWW-Authenticate
关键在「需登录」工具的 call_tool 分支(authenticated_server_python/main.py:498-503):先从请求头抠 Bearer token,没有就返回 OAuth 挑战。
# 示意,非源码 —— 没 token 就发挑战
if not _get_bearer_token_from_request():
return _oauth_error_result("Authentication required: no access token provided.")
_oauth_error_result(:238-260)返回一个 isError=True 的结果,并在 _meta 里塞 mcp/www_authenticate 头(:253-257),值由 _build_www_authenticate_value(:228-235)拼出,里面带 resource_metadata="..." 指回 §4 那个元数据 URL。ChatGPT 收到这个挑战(inferred)就知道该把用户引到授权服务器登录,拿到 token 后重发请求。
抠 token 写得很防御(_get_bearer_token_from_request:263-318):依次尝试 request.headers、ASGI scope headers、dict 形式的 request,大小写都试,最后校验 Bearer 前缀。这是因为不同传输/中间件下 request 对象形态不一(inferred)。
6. 关键细节
- 匿名工具的处理就没这道关卡:搜 索工具(
SEARCH_TOOL_NAME)的分支(:482-496)直接返回结果,不查 token。 - 这个 server 同样复用了 03 章的
widgetSessionId(:352),以及 01 章的_meta四件套——认证只是叠加在同一套 widget 契约之上的一层。 - 真正的 token 校验/换取用户身份不在本仓:server 只检查「有没有 Bearer token」,不验签、不查 scope(
RESOURCE_SCOPES是空列表 :168)。这是 demo 简化。
7. 代码地图
| 主题 | 文件路径 | 符号名 |
|---|---|---|
| 两套 scheme | authenticated_server_python/main.py | MIXED_TOOL_SECURITY_SCHEMES、OAUTH_ONLY_SECURITY_SCHEMES |
| RFC 9728 元数据 | authenticated_server_python/main.py | protected_resource_metadata、PROTECTED_RESOURCE_METADATA |
| 发挑战 | authenticated_server_python/main.py | _oauth_error_result、_build_www_authenticate_value |
| 抠 token | authenticated_server_python/main.py | _get_bearer_token_from_request |
| 工具分支鉴权 | authenticated_server_python/main.py | _call_tool_request(past_orders 分支) |