实战:SEP-990 企业托管授权的客户端与服务端全解析)
MCP Python SDK 身份断言ID-JAG实战SEP-990 企业托管授权的客户端与服务端全解析【免费下载链接】python-sdkThe official Python SDK for Model Context Protocol servers and clients项目地址: https://gitcode.com/gh_mirrors/pythonsd/python-sdk本文以 mcpModel Context Protocol 官方 Python SDK的「Identity assertion / 身份断言」能力为主线完整讲解SEP-990 Enterprise-Managed Authorization企业托管授权扩展的实现企业身份提供商IdP签发短时 JWT——ID-JAGIdentity Assertion JWT Authorization Grant客户端以 RFC 7523jwt-bearer授权类型将它换成普通访问令牌全程无浏览器、无同意页、无动态客户端注册。读完本文你将掌握IdentityAssertionOAuthProvider客户端的配置与exchange_identity_assertion授权服务器钩子的实现并理解两次令牌请求各自的分工与安全边界。从服务器决定信任谁到企业决定信任谁普通 OAuth 流程参见 OAuth 客户端的开端是客户端向 MCP 服务器提一个问题你信任哪个授权服务器客户端跟着答案走之后要么由真人登录要么用一个预共享密钥顶替真人。企业环境不希望授权决策分散在每个服务器上。企业通常已经部署了身份提供商Okta、Microsoft Entra ID 或自建系统用户早上已经登录过它安全团队希望在一个地方统一决定谁能访问什么。SEP-990Enterprise-Managed Authorization 扩展把决策权挪到 IdPIdP 签署一个短时 JWT——ID-JAG其内容是这个用户通过这个客户端可以访问这个 MCP 服务器。客户端凭它换取普通访问令牌。没有浏览器、没有同意页、没有动态注册。本文讨论这个交换的两端。MCP 服务器本身完全不变它仍然是 授权 页面里的资源服务器校验任何到来的令牌。两次令牌请求企业 IdP 与 MCP 授权服务器理解本页的关键是把两个不同的权威机构区分开企业 IdPEnterprise IdP你所在组织的身份提供商。它知道员工是谁承载访问策略签发 ID-JAG。SDK 从不与它通信。MCP 授权服务器MCP authorization server与 授权 页面中的角色相同——MCP 服务器元数据中声明的签发者issuer负责铸造该 MCP 服务器接受的令牌。普通 OAuth 流程里这两个角色通常是一个系统在这里它们是两个整个授权类型就是第二个同意信任第一个。客户端分别向两者各发一次令牌请求向企业 IdP客户端用用户的登录态其 OpenID Connect ID 令牌交换 ID-JAG。这是 [RFC 8693] 的令牌交换完全是你 IdP 的 APISDK 不做这个请求——由你在一个异步回调里完成。策略决策也发生在这里说不的 IdP 根本不签发 ID-JAG客户端自然无凭可举。向 MCP 授权服务器客户端按 [RFC 7523] 的jwt-bearer授权类型出示 ID-JAGgrant_typeurn:ietf:params:oauth:grant-type:jwt-bearerID-JAG 放在assertion参数里换回访问令牌。这个请求由 SDK 发出而接受它是本页为授权服务器增加的唯一内容。以下内容全部围绕第二个请求展开发送它的客户端以及回答它的授权服务器。客户端IdentityAssertionOAuthProviderIdentityAssertionOAuthProvider位于模块mcp.client.auth.extensions.identity_assertion。与 OAuth 客户端 页面上的所有 provider 一样它是一个httpx2.Auth构造实例放进auth把httpx2.AsyncClient交给传输层。完整可运行的客户端示例见 docs_src/identity_assertion/tutorial001.py核心代码如下import time import uuid import httpx2 import jwt from mcp import Client from mcp.client.auth.extensions.identity_assertion import IdentityAssertionOAuthProvider from mcp.client.streamable_http import streamable_http_client from mcp.shared.auth import OAuthClientInformationFull, OAuthToken IDP_SIGNING_KEY the-enterprise-idp-signing-key-for-this-demo class InMemoryTokenStorage: def __init__(self) - None: self.tokens: OAuthToken | None None self.client_info: OAuthClientInformationFull | None None async def get_tokens(self) - OAuthToken | None: return self.tokens async def set_tokens(self, tokens: OAuthToken) - None: self.tokens tokens async def get_client_info(self) - OAuthClientInformationFull | None: return self.client_info async def set_client_info(self, client_info: OAuthClientInformationFull) - None: self.client_info client_info def idp_issue_id_jag(subject: str, audience: str, resource: str) - str: now int(time.time()) claims { iss: https://idp.example.com, sub: subject, aud: audience, client_id: finance-agent, resource: resource, scope: notes:read, jti: str(uuid.uuid4()), iat: now, exp: now 300, } return jwt.encode(claims, IDP_SIGNING_KEY, algorithmHS256, headers{typ: oauth-id-jagjwt}) async def fetch_id_jag(audience: str, resource: str) - str: return idp_issue_id_jag(aliceexample.com, audience, resource) oauth IdentityAssertionOAuthProvider( server_urlhttp://localhost:8001/mcp, storageInMemoryTokenStorage(), client_idfinance-agent, client_secretfinance-agent-secret, issuerhttps://auth.example.com/, assertion_providerfetch_id_jag, scopenotes:read, ) async def main() - None: async with httpx2.AsyncClient(authoauth) as http_client: transport streamable_http_client(http://localhost:8001/mcp, http_clienthttp_client) async with Client(transport) as client: result await client.list_tools() print([tool.name for tool in result.tools])请从下往上读这段代码main()是 OAuth 客户端 页面的标准 OAuth 客户端main()一行未改。这正是设计意图一旦 provider 创建完毕下游没有任何人知道令牌来自哪种授权类型。provider 接收其他 provider 无法自行发现的东西预先注册在授权服务器上的client_id和client_secret、该授权服务器的issuer以及assertion_provider——一个按需返回新鲜 ID-JAG 的异步回调。storage是同样的TokenStorage协议。这里只会调用两个令牌存取方法因为不存在动态注册所以没有client_info需要记忆。从源码看IdentityAssertionOAuthProvider 的构造参数及其约束如下参数必填说明server_url是MCP 服务器 URL用于推导资源标识符resourcestorage是实现TokenStorage协议的令牌存储client_id是在 MCP 授权服务器上预先注册的客户端 IDclient_secret是客户端密钥缺失时构造函数抛出ValueErrorSEP-990 强制机密客户端issuer是MCP 授权服务器的 issuer 标识缺失时构造函数抛出ValueError授权服务器是配置而非发现assertion_provider是异步回调(audience, resource) - ID-JAGscope否可选的以空格分隔的 scope 列表token_endpoint_auth_method否机密客户端认证方式client_secret_post默认或client_secret_basicassertion_provider唯一由你编写的代码fetch_id_jag(audience, resource)是你写的唯一代码。它每次令牌交换被 await 一次绝不会在构造时被调用并且只会在授权服务器的元数据被获取并校验之后才调用——因此一个配置错误的 issuer 永远不会导致断言泄漏。它的两个参数是 ID-JAG 必须携带的两个声明claimaudience授权服务器的 issuer即 ID-JAG 的audresourceMCP 服务器的规范标识符即 ID-JAG 的resource声明。第三个字段你已握在手中ID-JAG 的client_id声明必须指向你传给 provider 的那个client_id否则授权服务器会拒绝交换。示例中fetch_id_jag上方的idp_issue_id_jag不是你的代码——它只是替代身份提供商的演示在进程内签名断言使示例文件自包含让你能读到 ID-JAG 携带的每一个声明iss、sub、aud、client_id、resource、scope、jti、iat、exp以及 JWT 头部的typ: oauth-id-jagjwt。真实的fetch_id_jag应该发起上一节所说的第一次令牌请求按 [RFC 8693] 向你的 IdP 做令牌交换SEP-990 所 profile 的 Identity Assertion JWT Authorization Grant 草案定义了这个交换。具体而言已登录用户的 ID 令牌放进subject_tokenrequested_token_type用 ID-JAG 自己的 URNurn:ietf:params:oauth:token-type:id-jagaudience与resource原样透传响应里带回 ID-JAG。这组名字subject_token、requested_token_type等正是你要在 IdP 文档中查找的内容。提示每次交换都会请求新鲜 ID-JAG这正是它存在的意义——这是一次性、存活仅数分钟的授权本文的授权服务器拒绝接受同一个 ID-JAG 两次。不要缓存它。真正被复用的是用它换来的访问令牌。issuer 是配置不是发现这里是整个模型反转之处。OAuthClientProvider会问资源服务器该用哪个授权服务器然后跟着答案走这个 provider 拒绝这样做issuer必填[RFC 8414] 元数据从该 issuer 自己的 well-known 路径获取令牌端点必须与该 issuer 同源资源服务器则永远不会被询问任何问题。扩展本身并不要求如此严格这是 SDK 刻意做出的更严格选择。这个客户端携带两样值得被窃取的东西——预先注册的密钥和绑定受众的断言——如果客户端允许一个被攻陷的 MCP 服务器把它引向攻击者的授权服务器这两样都会被寄过去。在构造时固定 issuer直接消灭了这场对话。警告配置的issuer与元数据文档的issuer字段按 [RFC 8414] §3.3 做简单字符串比较逐字符比较包含末尾斜杠不做任何规范化。不要靠猜。向你的授权服务器请求/.well-known/oauth-authorization-server把返回的issuer值原样复制。对本文的授权服务器而言这个值是https://auth.example.com/——带斜杠因为它的 issuer 由 pydantic URL 对象构建。一旦不匹配流程会在发送任何凭证或断言之前停在OAuthFlowError: Authorization server metadata issuer mismatch。机密客户端client_secret必填缺失时构造函数抛出ValueError。SEP-990 底下的 IETF profile 把该授权类型保留给机密客户端confidential clientSEP-990 要求客户端必须认证而这个 SDK 通过强制共享密钥同时落实了两点。token_endpoint_auth_method决定密钥在哪里传输client_secret_post默认放进表单体client_secret_basic放进 HTTP Basic 认证头。profile 还允许private_key_jwt但该 provider 不支持它。从源码看src/mcp/client/auth/extensions/identity_assertion.pyclient_secret_basic的实现严格按 RFC 6749 §2.3.1对client_id与client_secret分别 URL 编码用冒号连接后做 base64再拼进Authorization: Basic ...头。提示从环境变量或密钥管理器读取client_secret永远不要放进版本控制。provider 自动为你做的工作第一次请求不带认证发出服务器的401响应启动整个流程发现Discovery从配置 issuer 的 [RFC 8414] well-known 路径获取授权服务器元数据校验文档中的issuer是否匹配并校验令牌端点与 issuer 同源。断言The assertionawait 你的assertion_provider等待结果。交换Exchange向令牌端点 POSTjwt-bearer授权类型保存OAuthToken然后用Authorization: Bearer ...重放你最初的请求。若服务器返回403且WWW-Authenticate指明insufficient_scope则用你的 scope ∪ 挑战中要求的 scope再执行第 2、3 步。scope永远只是请求本文的授权服务器只授予 ID-JAG 中声明的 scope不多给一分。整个流程中不存在刷新令牌访问令牌过期后下一次401会促成签发新鲜 ID-JAG 并再次交换——而这就是 IdP 握在手里的杠杆。失败时抛出与 OAuth 客户端 其余部分相同的两个异常发现与校验失败用OAuthFlowError令牌端点拒绝时用其子类OAuthTokenError。从 源码实现 可以确认整个_auth_flow的关键细节元数据发现只做一次并缓存令牌端点同源比较把端口规范化为协议默认端口显式:443/:80与不写端口视为相同 origin断言在每次需要时都重新调用assertion_provider绝不缓存。授权服务器identity_assertion_enabled 与 exchange_identity_assertion大多数时候故事到客户端为止就结束了MCP 授权服务器是别人的产品接受 ID-JAG只是它需要打开的一项配置SDK 实现的 SEP-990 的一半就是上面的客户端。但 SDK 也可以充当授权服务器本身create_auth_routes把授权服务器的路由作为列表返回任何 Starlette 应用都能挂载它——仓库里的examples/servers/simple-auth/正是这样运行一个授权服务器的。SEP-990 在这个表面上增加了一个开关和一个方法。完整示例见 docs_src/identity_assertion/tutorial002.py核心代码如下import secrets import time import jwt from pydantic import AnyHttpUrl from starlette.applications import Starlette from mcp.server.auth.provider import ( AccessToken, AuthorizationCode, AuthorizationParams, AuthorizeError, IdentityAssertionParams, OAuthAuthorizationServerProvider, RefreshToken, TokenError, ) from mcp.server.auth.routes import create_auth_routes from mcp.shared.auth import JWT_BEARER_GRANT_TYPE, OAuthClientInformationFull, OAuthToken ISSUER https://auth.example.com/ MCP_SERVER http://localhost:8001/mcp IDP_ISSUER https://idp.example.com IDP_SIGNING_KEY the-enterprise-idp-signing-key-for-this-demo REGISTERED_CLIENTS { finance-agent: OAuthClientInformationFull( client_idfinance-agent, client_secretfinance-agent-secret, redirect_urisNone, grant_types[JWT_BEARER_GRANT_TYPE], token_endpoint_auth_methodclient_secret_post, ) } class EnterpriseAuthorizationServer(OAuthAuthorizationServerProvider[AuthorizationCode, RefreshToken, AccessToken]): def __init__(self) - None: self.access_tokens: dict[str, AccessToken] {} self.seen_jtis: set[str] set() async def get_client(self, client_id: str) - OAuthClientInformationFull | None: return REGISTERED_CLIENTS.get(client_id) async def load_access_token(self, token: str) - AccessToken | None: return self.access_tokens.get(token) async def exchange_identity_assertion( self, client: OAuthClientInformationFull, params: IdentityAssertionParams ) - OAuthToken: try: header jwt.get_unverified_header(params.assertion) claims jwt.decode( params.assertion, IDP_SIGNING_KEY, algorithms[HS256], issuerIDP_ISSUER, audienceISSUER, options{require: [iss, sub, aud, exp, iat, jti, client_id, resource, scope]}, ) except jwt.InvalidTokenError as error: raise TokenError(invalid_grant, the assertion did not verify) from error if header.get(typ) ! oauth-id-jagjwt: raise TokenError(invalid_grant, the assertion is not an ID-JAG) if claims[client_id] ! client.client_id: raise TokenError(invalid_grant, the assertion was issued to a different client) if claims[resource] ! MCP_SERVER: raise TokenError(invalid_target, the assertion is for a resource this server does not serve) if claims[jti] in self.seen_jtis: raise TokenError(invalid_grant, the assertion has already been used) self.seen_jtis.add(claims[jti]) scopes claims[scope].split() access_token fmcp_{secrets.token_hex(16)} self.access_tokens[access_token] AccessToken( tokenaccess_token, client_idclaims[client_id], scopesscopes, expires_atint(time.time()) 300, resourceclaims[resource], subjectclaims[sub], ) return OAuthToken(access_tokenaccess_token, token_typeBearer, expires_in300, scope .join(scopes)) async def authorize(self, client: OAuthClientInformationFull, params: AuthorizationParams) - str: raise AuthorizeError(unauthorized_client, this authorization server only accepts ID-JAGs) async def load_authorization_code(self, client: OAuthClientInformationFull, authorization_code: str) - None: return None async def exchange_authorization_code( self, client: OAuthClientInformationFull, authorization_code: AuthorizationCode ) - OAuthToken: raise TokenError(invalid_grant, this authorization server only accepts ID-JAGs) async def load_refresh_token(self, client: OAuthClientInformationFull, refresh_token: str) - None: return None async def exchange_refresh_token( self, client: OAuthClientInformationFull, refresh_token: RefreshToken, scopes: list[str] ) - OAuthToken: raise TokenError(invalid_grant, this authorization server only accepts ID-JAGs) provider EnterpriseAuthorizationServer() auth_app Starlette( routescreate_auth_routes(provider, issuer_urlAnyHttpUrl(ISSUER), identity_assertion_enabledTrue) )要点逐条拆解identity_assertion_enabledTrue是总开关。关闭时默认值即关闭/token对这类授权类型回unsupported_grant_type——即使你实现了钩子也一样元数据中也不会提及它。开启后元数据新增jwt-bearer授权类型并在authorization_grant_profiles_supported扩展用来宣告支持的字段中列出urn:ietf:params:oauth:grant-profile:id-jag。SDK 的客户端从不读它客户端只为一个 issuer 做配置直接发起请求。exchange_identity_assertion就是那个钩子。在它运行之前SDK 已经完成了客户端认证、拒绝了公开客户端public client、并拒绝了注册信息中未列出该授权类型的客户端。你拿到的是IdentityAssertionParams——原始assertion、请求的scopes和resource——返回一个普通的OAuthToken即可。协议定义见 src/mcp/server/auth/provider.py 与 钩子契约。动态客户端注册无条件拒绝该授权类型因此这里的get_client返回手工预置的客户端。ID-JAG 客户端不可能注册出自己的存在。类的一半是各种拒绝。OAuthAuthorizationServerProvider是整个授权服务器因此它也要求实现授权码流程一个真正还要给用户做登录的服务器会把这些方法实现成真的而这个示例只有一扇门——authorize、exchange_authorization_code、exchange_refresh_token全部抛错。在钩子运行前SDK 侧的闸门逻辑位于 src/mcp/server/auth/handlers/token.py开关关闭时回unsupported_grant_type客户端没有存储密钥时即公开客户端含none认证方式回unauthorized_client——按 RFC 6749 §5.2 语义客户端已认证但不被允许使用该授权类型之后才构造IdentityAssertionParams并调用钩子。验证 ID-JAG完全由你负责的安全关键环节警告SDK从不解码断言——只有你的部署知道它信任哪个 IdP、该 IdP 发布哪些密钥所以exchange_identity_assertion内部的每一行都承载着安全。请在钩子里落实以下全部规则用 IdP 发布的密钥其 JWKS示例里的共享密钥只是演示用验证签名并按 [RFC 7523] §3 校验iss与exp要求 JWT 头的typ为oauth-id-jagjwt——这是 profile 防止其他 JWT 被当作授权类型重放的保护要求aud是你自己的 issuer要求 ID-JAG 的client_id声明等于处理器已认证的那个客户端要求resource声明指向你真正服务的资源在断言的exp之前持续跟踪jti确保它只被接受一次授予的 scope 以及最重要的一点——签发令牌的resource——一律取自已验证的 ID-JAG绝不取自请求params.resource只是客户端随手填的字符串。拒绝坏断言用TokenError(invalid_grant, ...)。本流程中的另一个错误码是invalid_targetID-JAG 声明的资源不是你所服务的资源时用它拒绝——这正是阻止本服务器为别人的资源铸造令牌的机制。授予的 scope 取自 ID-JAG 的scope声明没有该声明的断言同样被拒你的实现也可以用用户组映射来替代。还要注意返回的OAuthToken不带什么刷新令牌。IdP 通过决定是否签发下一个 ID-JAG来决定用户保持访问多久。在这里铸造刷新令牌等于悄悄把这个决定权还给了客户端。说明仍然通过auth_server_provider内嵌授权服务器的老式服务器可以用AuthSettings(identity_assertion_enabledTrue)到达同一份代码。授权 页面解释了新服务器为何不应从那种方式起步。该开关在 AuthSettings 中定义默认False。完整的令牌交换一次 POST /token把本页两个文件接起来整个授权类型归结为一次POST /tokengrant_typeurn:ietf:params:oauth:grant-type:jwt-bearer assertioneyJhbGciOiJIUzI1NiIsInR5cCI6Im9hdXRoLWlkLWphZytqd3QifQ... client_idfinance-agent resourcehttp://localhost:8001/mcp scopenotes:read client_secretfinance-agent-secret HTTP/1.1 200 OK {access_token: mcp_..., token_type: Bearer, expires_in: 300, scope: notes:read}没有/authorize没有/register没有受保护资源元数据请求。网络上只出现一次引出401的请求、一次 well-known 获取、这次交换、然后就是带着 bearer 令牌的普通 MCP 流量。而你的验证器从 ID-JAG 里读出的sub正是工具内部get_access_token().subject报告的那个用户——这一点由 tests/docs_src/test_identity_assertion.py 端到端验证它用录制式 ASGI 传输确认了线上请求序列恰好是401 → GET /.well-known/oauth-authorization-server → POST /token → 重试并断言grant_type、client_id、resource、scope与断言的 JWT 头部均符合预期。同文件中的其余测试还逐条验证了本页的每个论断main()与 OAuth 客户端页逐字符一致、缺client_secret或issuer时构造抛ValueError、伪造断言被拒为invalid_grant、换受众被拒、未知资源被拒为invalid_target、同一断言重放被拒、开启开关后元数据同时宣告jwt-bearer授权类型与authorization_grant_profiles_supported。亲手跑一遍仓库里的 examples/stories/identity_assertion/ 就是把本页跑成真的同样的exchange_identity_assertion验证器、被其令牌守护的 MCP 服务器、一个 IdP 替身和客户端集成在一个自校验程序中见 examples/stories/identity_assertion/client.py。运行命令uv run python -m stories.identity_assertion.client --http它会跑完整个交换并断言 IdP 所指名的用户与工具看到的用户是同一个whoami工具返回的subject与client_id、scopes均与 IdP 替身签发的一致。小结[SEP-990] 让企业身份提供商——而不是终端用户——决定客户端可以访问哪些 MCP 服务器。IdP 把这个决定签进一个ID-JAG。获取 ID-JAG 是对你的 IdP做的一次 [RFC 8693] 令牌交换SDK 不做向 MCP 授权服务器出示它是 [RFC 7523] 的jwt-bearer授权类型SDK 负责这一段的双方。IdentityAssertionOAuthProvider是又一个httpx2.Auth预先注册的机密客户端、固定的issuer、一个assertion_provider(audience, resource)回调。没有浏览器、没有注册、没有刷新令牌。授权服务器永远不会通过资源服务器被发现。issuer必须配置为与其元数据文档逐字符相等的字符串。服务端一侧是identity_assertion_enabledTrue加上exchange_identity_assertion。SDK 负责认证客户端并闸住授权类型验证 ID-JAG 完全在你签发的令牌绑定 ID-JAG 的resource而不是请求里的。本页唯一没有触及的一方是 MCP 服务器本身它拿你刚铸造的令牌做什么授权 页面里它早就已经在做了。【免费下载链接】python-sdkThe official Python SDK for Model Context Protocol servers and clients项目地址: https://gitcode.com/gh_mirrors/pythonsd/python-sdk创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考