MCP 系列 7:Keycloak 与 OAuthProxy 实现 MCP OAuth 2.1


上一篇使用本地脚本签发 JWT,已经可以验证身份并限制 Tool 权限。但是客户端仍然需要提前拿到 Token,整个过程没有用户登录、授权确认、授权码回调和 Token 刷新。

这一篇继续实现完整的浏览器授权流程:

FastMCP Client
→ MCP OAuth 发现
→ OAuthProxy 授权确认
→ Keycloak 用户登录
→ Keycloak Scope 同意
→ Authorization Code + PKCE
→ OAuthProxy Token Factory
→ MCP Access Token
→ 受保护的 MCP Tool

协议行为以 MCP 2025-11-25 的授权规范为基线。本机开发使用 HTTP,生产环境必须改成 HTTPS。

1. OAuth 2.1 中有哪些角色

本文涉及四个主要角色:

  • Resource Owner:真正进行登录和授权确认的用户。
  • MCP Client:需要调用 Tool 的 FastMCP 或 LangChain 客户端。
  • Authorization Server:从 MCP Client 的视角看,负责发现、客户端注册、授权码和 Token 的 OAuthProxy。
  • Resource Server:验证 MCP Access Token 并提供 Tool 的 FastMCP Server。

Keycloak 是 OAuthProxy 的上游身份提供方和授权服务,负责用户登录、会话与 Scope 同意。OAuthProxy 同时承担面向 MCP Client 的 Authorization Server 和 Token Factory。区分这两个视角,才不会把上游 Keycloak Token 与下游 MCP Token 混为一个凭据。

Keycloak 与 OAuthProxy 的职责边界

这里不能把 Keycloak 与 OAuthProxy 简单理解成两个重复的认证服务。它们保护的是不同边界:

2. MCP Client 如何发现授权服务

客户端第一次请求 /mcp 时没有 Token,Resource Server 返回 401 和 WWW-Authenticate。客户端再读取 Protected Resource Metadata:

/.well-known/oauth-protected-resource/mcp

这里会告诉客户端:

  • 当前 Resource 是谁。
  • 可以去哪些 Authorization Server 授权。
  • Resource 支持哪些 Scope。

然后客户端读取 Authorization Server Metadata:

/.well-known/oauth-authorization-server

从中发现授权端点、Token 端点、客户端注册端点和 PKCE 能力。

MCP OAuth 2.1 发现与授权流程

这种发现机制让客户端不需要在代码中手工写死全部 OAuth 端点。流程细节可对照 MCP 2025-11-25 Authorization

3. Authorization Code 与 PKCE S256

浏览器授权使用 Authorization Code 流程。客户端不会直接在浏览器中取得 Access Token,而是先获得一次性授权码,再通过后端 Token 端点交换 Token。

PKCE 在授权前生成一段随机 code_verifier,并把它转换成 code_challenge:

code_challenge = BASE64URL(SHA256(code_verifier))

授权请求只发送 code_challenge,Token 请求再发送原始 code_verifier。即使授权码被截获,没有 code_verifier 也不能完成交换。

本例只接受 S256,不使用安全性更弱的 plain。

state 用于把回调与最初的授权请求对应起来,防止登录 CSRF。回调地址也必须与客户端注册信息严格匹配。

4. 为什么增加 OAuthProxy

Keycloak 26.6.2 可以提供标准 OIDC 和 OAuth 服务,但它不接收 RFC 8707 定义的 resource 参数。MCP 授权规范要求客户端在授权请求和 Token 请求中携带 Resource Indicator,让 Token 明确绑定目标资源。

本例使用 OAuthProxy 解决这一边界:

forward_resource=False
resource_base_url="http://127.0.0.1:18130"

forward_resource=False 表示不把 Keycloak 不支持的参数传给上游。OAuthProxy 收到 Keycloak Token 后,不会把它原样返回给 MCP Client,而是重新签发面向 MCP Resource 的 Token。

OAuthProxy Token Factory

这同时满足一个重要安全原则:MCP Client Token 不能被原样转发给下游服务,上游身份 Token 也不能暴露给 MCP Tool。

5. 项目结构与端口

代码位于:

mcp/p07_keycloak_oauth_agent/
├── 01_prepare_local_config.py
├── 02_check_authorization_metadata.py
├── 03_oauth_client_login.py
├── 04_test_oauth_scopes.py
├── 05_agent_with_oauth_mcp.py
├── oauth_proxy_server.py
├── docker-compose.yml
├── .env.example
├── requirements.in
├── requirements-lock.txt
└── README.md

使用的本机端口如下:

端口 服务
18080 Qwen3 OpenAI 兼容接口
18130 OAuthProxy 与 MCP Resource Server
18131 Keycloak
18132 FastMCP Client 临时回调服务

安装依赖:

cd source/_posts/llm_learning
source .venv_mcp_auth/bin/activate

python -m pip install -r mcp/p07_keycloak_oauth_agent/requirements-lock.txt
python -m pip check

主要版本:

fastmcp==3.3.1
mcp==1.27.1
langchain-mcp-adapters==0.2.2
keycloak==26.6.2

这里的 keycloak==26.6.2 表示 Docker 镜像版本,不是 Python 包。

6. 生成本地配置

01_prepare_local_config.py 会生成:

  • Keycloak 管理员随机密码。
  • 测试用户随机密码。
  • Keycloak 上游客户端 Secret。
  • OAuthProxy RSA 签名密钥。
  • OAuth Client 本地缓存加密密钥。
  • Realm 导入文件。

运行:

python mcp/p07_keycloak_oauth_agent/01_prepare_local_config.py

输出只包含稳定信息:

本地 OAuth 配置已生成到 .secrets,未输出密码和签名密钥。
Keycloak Realm: mcp-demo
测试用户: mcp-user

登录文件使用 MCP_TEST_USERNAME 和 MCP_TEST_PASSWORD 两个专用变量名。不要使用 zsh 中已有特殊含义的 USERNAME,否则脚本可能读取到 macOS 当前登录用户,而不是 Keycloak 测试用户。

敏感文件保存在:

mcp/p07_keycloak_oauth_agent/.secrets/

生成脚本会把 .secrets 和 Token 缓存目录设为 0700,把密码、密钥与 Realm 导入文件设为 0600。仅加密文件内容还不够,目录权限也要阻止其他本地用户枚举文件名。

不增加 –force 时,脚本不会覆盖已有密钥。这样可以避免 OAuthProxy 重启后突然更换签名密钥。

使用 –force 重新生成配置时,加密密钥也会变化,因此脚本会同步清空旧 Token 缓存,避免新密钥读取旧密文时失败。

Realm 配置中启用了 Authorization Code,关闭 Direct Access Grant 和 Service Account,并强制上游客户端使用 PKCE S256:

"standardFlowEnabled": True,
"directAccessGrantsEnabled": False,
"serviceAccountsEnabled": False,
"consentRequired": True,
"redirectUris": ["http://127.0.0.1:18130/auth/callback"],
"attributes": {
    "pkce.code.challenge.method": "S256",
},
"optionalClientScopes": ["profile:read", "profile:write"],
"protocolMappers": [
    {
        "name": "mcp-preferred-username",
        "protocolMapper": "oidc-usermodel-property-mapper",
        "config": {
            "user.attribute": "username",
            "claim.name": "preferred_username",
            "access.token.claim": "true",
        },
    },
    # 另一个 Mapper 把 mcp-oauth-proxy 写入 Access Token 的 aud。
],

preferred_username Mapper 让服务端能够在验证 Token 后执行资料归属检查;Audience Mapper 则保证上游 Token 只能交给 mcp-oauth-proxy 使用。两者用途不同,不能相互替代。

7. 启动 Keycloak 26.6.2

docker-compose.yml:

services:
  keycloak:
    image: quay.io/keycloak/keycloak:26.6.2
    container_name: llm-learning-p07-keycloak
    command: start-dev --import-realm
    env_file:
      - .secrets/keycloak.env
    ports:
      - "127.0.0.1:18131:8080"
    volumes:
      - p07_keycloak_data:/opt/keycloak/data
      - ./.secrets/realm-import.json:/opt/keycloak/data/import/realm-import.json:ro
    healthcheck:
      test:
        - CMD-SHELL
        - >-
          exec 3<>/dev/tcp/127.0.0.1/9000 &&
          printf 'GET /health/ready HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n' >&3 &&
          grep -q '200 OK' <&3
      interval: 5s
      timeout: 3s
      retries: 30

volumes:
  p07_keycloak_data:

启动:

docker compose \
  -f mcp/p07_keycloak_oauth_agent/docker-compose.yml \
  up -d

确认健康状态后,可以访问 Realm 的 OIDC 元数据:

curl --noproxy '*' \
  http://127.0.0.1:18131/realms/mcp-demo/.well-known/openid-configuration

实测 issuer 为:

http://127.0.0.1:18131/realms/mcp-demo

并且 code_challenge_methods_supported 包含 S256。

8. 编写 OAuthProxy MCP Server

oauth_proxy_server.py 的核心代码如下:

"""使用 Keycloak 处理登录,并由 OAuthProxy 签发面向 MCP 的访问令牌。"""

import os
from pathlib import Path

from dotenv import load_dotenv
from fastmcp import FastMCP
from fastmcp.server.auth import OAuthProxy, require_scopes
from fastmcp.server.auth.providers.jwt import JWTVerifier
from fastmcp.server.dependencies import get_access_token


BASE_DIR = Path(__file__).resolve().parent
load_dotenv(BASE_DIR / ".secrets" / "oauth.env")

KEYCLOAK_BASE_URL = os.environ["KEYCLOAK_BASE_URL"]
KEYCLOAK_REALM = os.environ["KEYCLOAK_REALM"]
KEYCLOAK_ISSUER = f"{KEYCLOAK_BASE_URL}/realms/{KEYCLOAK_REALM}"
MCP_BASE_URL = os.environ["MCP_BASE_URL"]

# Keycloak Token 只在 OAuthProxy 内部验证和保存,不直接交给 MCP Tool。
upstream_verifier = JWTVerifier(
    jwks_uri=f"{KEYCLOAK_ISSUER}/protocol/openid-connect/certs",
    issuer=KEYCLOAK_ISSUER,
    audience=os.environ["KEYCLOAK_CLIENT_ID"],
    algorithm="RS256",
    required_scopes=["profile:read"],
)

auth = OAuthProxy(
    upstream_authorization_endpoint=(
        f"{KEYCLOAK_ISSUER}/protocol/openid-connect/auth"
    ),
    upstream_token_endpoint=f"{KEYCLOAK_ISSUER}/protocol/openid-connect/token",
    upstream_revocation_endpoint=(
        f"{KEYCLOAK_ISSUER}/protocol/openid-connect/revoke"
    ),
    upstream_client_id=os.environ["KEYCLOAK_CLIENT_ID"],
    upstream_client_secret=os.environ["KEYCLOAK_CLIENT_SECRET"],
    token_verifier=upstream_verifier,
    base_url=MCP_BASE_URL,
    resource_base_url=MCP_BASE_URL,
    valid_scopes=["openid", "profile:read", "profile:write"],
    forward_pkce=True,
    # Keycloak 26.6.2 不支持 RFC 8707 Resource Indicators,因此不向上游转发。
    forward_resource=False,
    jwt_signing_key=os.environ["OAUTH_PROXY_SIGNING_KEY"],
    require_authorization_consent=True,
    allowed_client_redirect_uris=[
        "http://127.0.0.1:*/*",
        "http://localhost:*/*",
    ],
)

mcp = FastMCP(
    name="keycloak-oauth-profile-mcp",
    instructions="提供经过 OAuth 2.1 登录和 Scope 授权的用户资料工具。",
    auth=auth,
)

USER_PROFILES = {
    "user-001": {"name": "小明", "city": "杭州", "note": "喜欢周末去西湖散步"},
    "user-002": {"name": "小红", "city": "上海", "note": "喜欢阅读"},
}
AUTHORIZED_PROFILES = {
    "mcp-user": "user-001",
}


def can_access_profile(user_id: str) -> bool:
    """根据已验证的 Keycloak 身份执行对象级授权。"""

    token = get_access_token()
    username = token.claims.get("preferred_username")
    return AUTHORIZED_PROFILES.get(username) == user_id


@mcp.tool(auth=require_scopes("profile:read"))
def get_user_profile(user_id: str) -> dict:
    """读取固定用户资料,需要 profile:read 权限。"""

    if not can_access_profile(user_id):
        return {"error": "forbidden", "message": "当前身份无权读取该用户资料"}
    profile = USER_PROFILES.get(user_id)
    if profile is None:
        return {"error": "用户不存在"}
    return {
        "authenticated": True,
        "profile": profile,
    }


@mcp.tool(auth=require_scopes("profile:write"))
def update_user_note(user_id: str, note: str) -> dict:
    """更新固定用户的备注,需要 profile:write 权限。"""

    if not can_access_profile(user_id):
        return {"error": "forbidden", "message": "当前身份无权修改该用户资料"}
    profile = USER_PROFILES.get(user_id)
    if profile is None:
        return {"error": "用户不存在"}
    profile["note"] = note
    return {
        "authenticated": True,
        "user_id": user_id,
        "note": note,
    }


if __name__ == "__main__":
    mcp.run(
        transport="http",
        host="127.0.0.1",
        port=18130,
        path="/mcp",
        show_banner=False,
    )

启动:

python mcp/p07_keycloak_oauth_agent/oauth_proxy_server.py

这里没有为了提取用户名而“关闭签名验证后解码 JWT”。get_access_token() 读取的 claims 来自 JWTVerifier 已经验证过的 Keycloak Access Token。这些 claims 只在服务端用于对象级授权,不会传入 Prompt。

本机 HTTP 会出现非安全 Cookie 警告,这是开发环境的预期提示。生产环境必须使用 HTTPS,并设置严格的回调地址白名单。

9. 验证 OAuth 元数据

02_check_authorization_metadata.py 只打印稳定字段,不输出 Session ID、请求 ID 或 Token。

"""检查 MCP 401 Challenge、Protected Resource Metadata 和授权服务器元数据。"""

import json

import httpx


MCP_URL = "http://127.0.0.1:18130/mcp"
RESOURCE_METADATA_URL = (
    "http://127.0.0.1:18130/.well-known/oauth-protected-resource/mcp"
)
AUTHORIZATION_METADATA_URL = (
    "http://127.0.0.1:18130/.well-known/oauth-authorization-server"
)


def print_selected(name: str, data: dict, keys: list[str]) -> None:
    """只打印与协议发现有关的稳定字段。"""

    selected = {key: data.get(key) for key in keys}
    print(name + ":", json.dumps(selected, ensure_ascii=False))


with httpx.Client(trust_env=False, timeout=10) as client:
    unauthorized = client.get(MCP_URL)
    print("mcp_status:", unauthorized.status_code)
    print("has_bearer_challenge:", "Bearer" in unauthorized.headers.get("www-authenticate", ""))

    resource_metadata = client.get(RESOURCE_METADATA_URL)
    print("resource_metadata_status:", resource_metadata.status_code)
    print_selected(
        "resource_metadata",
        resource_metadata.json(),
        ["resource", "authorization_servers", "scopes_supported"],
    )

    authorization_metadata = client.get(AUTHORIZATION_METADATA_URL)
    print("authorization_metadata_status:", authorization_metadata.status_code)
    print_selected(
        "authorization_metadata",
        authorization_metadata.json(),
        [
            "issuer",
            "authorization_endpoint",
            "token_endpoint",
            "registration_endpoint",
            "code_challenge_methods_supported",
        ],
    )

真实结果:

mcp_status: 401
has_bearer_challenge: True
resource_metadata_status: 200
authorization_metadata_status: 200
authorization_metadata: {"issuer":"http://127.0.0.1:18130/","authorization_endpoint":"http://127.0.0.1:18130/authorize","token_endpoint":"http://127.0.0.1:18130/token","registration_endpoint":"http://127.0.0.1:18130/register","code_challenge_methods_supported":["S256"]}

registration_endpoint 表示当前 OAuthProxy 支持 Dynamic Client Registration。规范还允许预注册和 Client ID Metadata Document,不能把 DCR 视为唯一方式。

10. 真实完成浏览器登录

03_oauth_client_login.py 使用 FastMCP OAuth。首次运行时,它会自动完成元数据发现、DCR、PKCE 和本地回调服务。

"""使用 FastMCP OAuth Client 完成浏览器登录并调用只读 Tool。"""

import asyncio
import os
from pathlib import Path

from cryptography.fernet import Fernet
from dotenv import load_dotenv
from fastmcp import Client
from fastmcp.client.auth import OAuth
from key_value.aio.stores.disk import DiskStore
from key_value.aio.wrappers.encryption import FernetEncryptionWrapper


BASE_DIR = Path(__file__).resolve().parent
load_dotenv(BASE_DIR / ".secrets" / "oauth.env")


def create_oauth() -> OAuth:
    """创建带加密磁盘缓存的 OAuth 客户端认证对象。"""

    disk_store = DiskStore(directory=BASE_DIR / ".secrets" / "oauth-client-cache")
    encrypted_store = FernetEncryptionWrapper(
        key_value=disk_store,
        fernet=Fernet(os.environ["OAUTH_STORAGE_ENCRYPTION_KEY"].encode("ascii")),
    )
    return OAuth(
        mcp_url="http://127.0.0.1:18130/mcp",
        scopes=["profile:read"],
        callback_port=18132,
        token_storage=encrypted_store,
        client_name="llm-learning-oauth-client",
    )


async def main() -> None:
    """首次运行打开浏览器,登录后列出并调用授权范围内的 Tool。"""

    async with Client(
        "http://127.0.0.1:18130/mcp",
        auth=create_oauth(),
        timeout=180,
    ) as client:
        tools = await client.list_tools()
        print("authorized_tools:", [tool.name for tool in tools])
        result = await client.call_tool("get_user_profile", {"user_id": "user-001"})
        print("read_result:", result.content[0].text)

        # Scope 允许读取资料,但服务端仍需检查资料是否属于当前用户。
        denied_result = await client.call_tool(
            "get_user_profile", {"user_id": "user-002"}
        )
        print("other_user_result:", denied_result.content[0].text)


if __name__ == "__main__":
    asyncio.run(main())

运行:

python mcp/p07_keycloak_oauth_agent/03_oauth_client_login.py

浏览器先显示 OAuthProxy 的客户端授权确认页:

OAuthProxy 授权确认

确认回调地址属于当前客户端后,进入 Keycloak 登录页:

Keycloak 登录页

登录后,Keycloak 显示上游客户端请求的 Scope:

Keycloak Scope 授权

授权完成后,浏览器回到 http://localhost:18132/callback,FastMCP Client 自动交换 Token 并建立 MCP Session。

首次授权的真实 Tool 调用结果:

authorized_tools: ['get_user_profile', 'update_user_note']
read_result: {"authenticated":true,"profile":{"name":"小明","city":"杭州","note":"喜欢周末去西湖散步"}}
other_user_result: {"error":"forbidden","message":"当前身份无权读取该用户资料"}

客户端缓存经过 Fernet 加密,并保存在 Git 忽略目录。不要使用明文 JSON 保存 Access Token 和 Refresh Token。

这里虽然在 OAuth(scopes=…) 中写了 profile:read,首次发现仍申请了 Resource Metadata 公布的全部 Scope。这是 MCP Client 的 Scope 选择策略:如果 WWW-Authenticate 没有指定 Scope,则优先使用 Protected Resource Metadata 中的 scopes_supported。因此“构造函数里写了只读”不能代替服务端最小权限设计。下一节会把已经取得的授权降为只读。

11. 使用 Refresh Token 降低权限

初次发现时,MCP Client 会按资源元数据选择 Scope。需要缩小权限时,可以在 Refresh Token 请求中只申请原授权的子集。

04_test_oauth_scopes.py 请求:

response = await http_client.post(
    "http://127.0.0.1:18130/token",
    data={
        "grant_type": "refresh_token",
        "refresh_token": tokens.refresh_token,
        "client_id": client_info.client_id,
        "scope": "profile:read",
        "resource": "http://127.0.0.1:18130/mcp",
    },
)

# Refresh Token 可能被轮换,必须把完整响应写回加密缓存。
read_only_tokens = OAuthToken.model_validate(response.json())
await storage.set_tokens(read_only_tokens)

这里再次携带 resource,避免 Token 被交换给错误的目标服务。

只保存新的 Access Token 而丢掉轮换后的 Refresh Token,会导致下一次刷新失败。因此脚本把完整的 OAuthToken 写回加密缓存,再使用新的只读 Access Token 连接 MCP。

如果 Refresh Token 已被轮换或失效,脚本会明确报错并要求重新授权。它不会回退到缓存中未确认 Scope 的旧 Access Token,因为这会让“只读验证”变成不可重现的测试。

一次真实验证结果:

read_scope_tools: ['get_user_profile']
read_with_read_scope: {"authenticated":true,"profile":{"name":"小明","city":"杭州","note":"喜欢周末去西湖散步"}}
write_with_read_scope: rejected ToolError

Refresh Token 可能采用轮换策略。旧 Refresh Token 使用后会失效,客户端必须保存服务端返回的新 Token,不能无限重复使用旧值。

12. 让 LangChain Agent 使用 OAuth MCP Tool

先确认 Qwen3 服务已经启动:

curl --noproxy '*' http://127.0.0.1:18080/v1/models

05_agent_with_oauth_mcp.py 把 OAuth 对象交给 MultiServerMCPClient:

mcp_client = MultiServerMCPClient(
    {
        "profiles": {
            "transport": "http",
            "url": "http://127.0.0.1:18130/mcp",
            "auth": create_oauth(),
        }
    }
)
tools = await mcp_client.get_tools()

其余 Agent 代码与前面的 MCP Agent 一致:

with httpx.Client(trust_env=False) as http_client:
    model = ChatOpenAI(
        model="default_model",
        base_url="http://127.0.0.1:18080/v1",
        api_key="not-needed",
        temperature=0,
        max_tokens=160,
        http_client=http_client,
    )
    agent = create_agent(
        model=model,
        tools=tools,
        system_prompt="必须调用 MCP 用户资料工具回答问题,不要猜测。",
    )
    result = await agent.ainvoke(
        {"messages": [{"role": "user", "content": "user-001 喜欢做什么?"}]}
    )

一次真实运行结果:

OAuth MCP tools: ['get_user_profile']
Tool call: get_user_profile {'user_id': 'user-001'}
Tool result: {"authenticated":true,"profile":{"name":"小明","city":"杭州","note":"喜欢周末去西湖散步"}}
Qwen3 answer: 小明喜欢在周末去西湖散步。

Qwen3 没有接触 Keycloak 密码、Access Token 或 Refresh Token。它只看到 Tool Schema 和 Tool 返回的数据。

13. Token Factory 如何保护上游凭据

OAuthProxy 收到 Keycloak Token 后会完成三件事:

  1. 在服务端交换 Keycloak Authorization Code,加密保存上游 Access Token 与 Refresh Token,并建立 JTI 映射。
  2. 签发只面向 MCP Resource 的 Access Token,客户端不会收到 Keycloak Token。
  3. 每次 MCP 请求先验证下游 Token 和 JTI 映射,再使用 JWTVerifier 校验上游 Token 的签名、Issuer、Audience 和 Scope。

MCP Tool 通过 get_access_token() 读取的是框架交付的已验证授权上下文。工具只使用 preferred_username 做服务端资料归属判断,不复制原始 Keycloak Token,也不把完整 Claim 放入 Tool 返回值或 Prompt。该 Token Factory 行为与 FastMCP OAuthProxy 的参考实现一致。

14. 401、403 与 WWW-Authenticate

常见状态可以这样判断:

  • 401:没有 Token、Token 无效、过期、Audience 错误或签名失败。
  • 403:HTTP 语义上表示身份已经验证,但当前授权不足。

发生 401 时,Resource Server 通过 WWW-Authenticate 告诉客户端去哪里读取 Protected Resource Metadata。但在本文锁定的 fastmcp==3.3.1 中,require_scopes() 的组件级授权会先从 tools/list 过滤无权 Tool;如果客户端强行调用,实测收到的是 ToolError: Unknown tool,而不是可直接观察的 HTTP 403。不能把通用 HTTP 定义与这个框架的具体表现混在一起。

15. 安全边界

15.1 Token 不能放进 URL

Access Token 只能放在 Authorization 请求头中。URL 会进入浏览器历史、代理日志、监控系统和 Referer,更容易泄漏。

15.2 不允许开放任意回调地址

本例为了本机回调允许 localhost 和 127.0.0.1 的动态端口。生产客户端必须配置精确的 HTTPS 回调地址,不能使用任意域名通配符。

15.3 本机 HTTP 不能复制到生产环境

start-dev、HTTP 和非安全 Cookie 只适合本机。生产环境需要 HTTPS、安全 Cookie、反向代理、正式数据库、备份和审计。

15.4 不能把 MCP Token 透传给下游

Token 的 Audience 绑定 MCP Resource。把它转发给其他 API 会破坏受众隔离,也可能造成混淆代理攻击。访问下游服务时应使用该服务自己的凭据或 Token Exchange。

15.5 外部身份字段必须最小化

不要把 Keycloak 的完整 Token Payload 写进 AgentState、Prompt 或 Tool 返回值。只选择业务真正需要的稳定用户标识、租户或角色,并在服务器端完成映射。本文为了让本地演示更直观,使用 preferred_username 映射资料 ID;生产系统通常应使用不会随用户名修改而变化的 sub。

15.6 Scope 不等于数据归属权

profile:read 只说明调用者具备“读取资料”这类操作权限,不代表可以读取任意 user_id。示例还会把已验证的 preferred_username 映射到允许访问的资料 ID。真实系统应该用数据库中的租户、角色和对象归属关系执行这层检查。

16. 常见问题

16.1 Redirect URI 不匹配

FastMCP Client 默认使用:

http://localhost:<port>/callback

如果 OAuthProxy 只允许 127.0.0.1,授权端点会返回 invalid_request。本地配置必须同时考虑实际客户端使用的 Host,但生产环境不要扩大通配范围。

16.2 登录后一直等待

检查回调端口是否被占用,浏览器是否真正回到 localhost:18132/callback,以及 state 是否与最初请求一致。

16.3 Keycloak Token Audience 错误

Keycloak 客户端需要 Audience Mapper,把 mcp-oauth-proxy 写入上游 Access Token 的 aud。否则 JWTVerifier 会拒绝 Token。

16.4 直接向 Keycloak转发 resource

Keycloak 26.6.2 不支持 RFC 8707 Resource Indicators。本例必须设置 forward_resource=False,再由 OAuthProxy 生成面向 MCP Resource 的 Token。

16.5 Refresh Token 失效

Refresh Token 可能轮换。客户端需要保存新值,旧值收到 401 时应重新授权,不能在代码中反复重放。

16.6 重新生成配置后无法登录

01_prepare_local_config.py –force 会生成新的测试密码、客户端密钥和 OAuthProxy 签名密钥。如果此时仍保留旧 Keycloak 数据卷,.secrets 中的新配置就会和数据库中的旧配置不一致。

需要完全重置演示环境时,应先执行 docker compose down -v,再使用 –force 生成配置并重新启动 Keycloak。普通重启不要使用 –force。

17. 停止服务

停止 OAuthProxy 后,再停止 Keycloak:

docker compose \
  -f mcp/p07_keycloak_oauth_agent/docker-compose.yml \
  down

该命令保留 Keycloak 数据卷。只有确认需要完全重置演示环境时才使用:

docker compose \
  -f mcp/p07_keycloak_oauth_agent/docker-compose.yml \
  down -v

18. 总结

组件或机制 本文职责 不能误解为
Keycloak 用户登录、会话和上游 Scope 同意 MCP Resource Server
OAuthProxy MCP 授权发现、DCR、授权码、Token Factory 简单 Token 透传代理
Protected Resource Metadata 告诉 Client Resource 和授权服务器位置 用户资料接口
Authorization Server Metadata 公布 authorize、token、register 和 PKCE 能力 MCP Tool 列表
Authorization Code 浏览器授权后的一次性交换凭据 Access Token
PKCE S256 绑定授权请求和 Token 交换 用户密码加密算法
resource 把授权请求绑定到 MCP Resource Scope
Audience 限制 Token 的目标服务 用户权限列表
Scope 限制允许执行的操作,本文据此过滤 Tool 用户身份或数据归属关系
对象级授权 校验当前身份能否访问指定 user_id 只要有 Scope 就能访问全部数据
OAuthProxy Token Factory 保存上游 Token,签发下游 MCP Token 把 Keycloak Token 原样返回客户端
LangChain MCP Adapter 把 OAuth MCP Tool 交给 Agent 负责用户登录或签发 Token

至此,MCP 章节已经覆盖本地服务、公网服务、Python 与 Java Server、LangChain Agent 集成、JWT、Scope 和 OAuth 2.1。后续如果进入生产部署,还需要继续处理 HTTPS、正式数据库、多实例共享存储、密钥轮换、审计和高可用。


文章作者: hnbian
版权声明: 本博客所有文章除特別声明外,均采用 CC BY 4.0 许可协议。转载请注明来源 hnbian !
评论
  目录