Serverless Framework 集成测试的 Cognito 前置依赖:为 MCP 鉴权与发现测试套件预置一次性的 M2M 用户池
【免费下载链接】serverless⚡ Serverless Framework – Effortlessly build apps that auto-scale, incur zero costs when idle, and require minimal maintenance using AWS Lambda and other managed cloud services.项目地址: https://gitcode.com/GitHub_Trending/se/serverless
本指南以 mcp-cognito-prerequisite/README.md 为骨架,深入讲解 Serverless Framework(sf-core)在真实 AWS 集成测试中如何用一个"一次性、持久、按账户部署"的 Cognito 用户池来支撑 MCP 属性的 enforcement-and-discovery 测试套件:包括模板预置了什么资源、为什么必须用自定义资源把密钥写成 SSM SecureString、部署/校验/拆除的命令,以及测试套件如何从 SSM 运行时发现一切、做到"零硬编码、无前置则干净跳过、读取出错则响亮失败"。读完后你可以复现这套部署流程,并理解client_credentialsM2M 鉴权测试中 issuer、token endpoint、scope 与资源服务器之间的真实关系。
这套前置依赖在项目中的定位
Serverless Framework 的mcp属性本身不做鉴权——这是 mcp-auth.test.js 开头就点明的核心立场:"Enforcement is the user's"(执行鉴权是用户的责任)。框架只负责把用户配置的 authorizer 编译成 API Gateway 形态,并可选地在 API Gateway MOCK 路由上发布一份 RFC 9728 protected-resource 文档,供交互式 MCP 客户端发现登录入口。
为了在真实 AWS 上证明这一立场,测试套件需要部署四类受不同访问控制保护的 MCP 服务器。其中cognito这一类需要一个真实存在的 Cognito 用户池做 API Gateway Cognito authorizer 的授权主体。由于用户池 id、域名、客户端密钥等在编写测试时无法预先知道(且不应硬编码进仓库),项目采用了一个独立于测试套件、每次运行自生自灭的 fixture 栈之外的持久化前置依赖:即本目录中的template.yml预置的 Cognito 用户池。
按 TESTING.md 的编排,只有mcp-auth.test.js(enforcement-and-discovery 套件)需要这个 Cognito 前置依赖;缺少它时该套件会打印日志后跳过,其余 MCP 套件(如mcp.test.js)不受影响。
设计原则:一次性部署、持久存在、运行时发现
前置依赖设计上有三条硬约束:
- 持久且按账户一次性部署:用纯 CloudFormation 部署一次后长期保留,测试套件在运行时从 SSM 读取全部标识,因此没有硬编码任何 id。
- 共享同一 fixture 目录在 jest 并行下是竞态:因此本前置与套件分离,套件自己的 fixture 位于 fixture-auth,每套测试独占一个目录。
- 缺失时干净跳过、损坏时响亮失败:套件只在 SSM 前缀真正不可用(空、不完整或完全没有凭证)时跳过;被拒绝、被限流或超时的读取会直接导致任务失败——那意味着 CI 环境坏了,而不是账户主动退出测试。
template.yml 预置了什么
模板位于 template.yml,是一个标准AWSTemplateFormatVersion: '2010-09-09'的 CloudFormation 模板(注意:是纯 CloudFormation,而非 Serverless 服务定义)。它包含三个可覆盖的模板参数:
| 参数 | 默认值 | 说明 |
|---|---|---|
SsmPrefix | /mcp-integration-test/cognito | SSM 参数前缀,池 id、域名、region、两个客户端 id/secret 与 scope 都写在此前缀之下,必须与 cognito.mjs 中导出的DEFAULT_PREFIX保持一致 |
ResourceServerIdentifier | mcp | 资源服务器标识,与ScopeName拼接成完整 scope |
ScopeName | invoke | 自定义 scope 名,完整 scope 字符串形如<identifier>/<name> |
其预置的核心资源包括:
- 一个 Lite 层的用户池(
AWS::Cognito::UserPool,UserPoolName: mcp-integration-test)。Lite 层足够的原因在模板注释中写得很清楚:套件部署的受保护服务器配置了自定义 scope,正是 scope 让 API Gateway 校验该池签发的原始 access token,因此无需 token 定制/pre-token-generation 触发器。 - 一个用户池域名
mcp-integration-test-<account-id>(AWS::Cognito::UserPoolDomain)。域名按账户 id 命名以保证全局唯一,同时它承载/oauth2/token端点,套件正是用它铸币(mint)token。 - 一个资源服务器
mcp(AWS::Cognito::UserPoolResourceServer),带一个自定义 scopeinvoke→ 完整 scope 字符串mcp/invoke,scope 描述为 "Invoke the MCP server"。 - 两个应用客户端 Client A / Client B(
AWS::Cognito::UserPoolClient),均为client_credentialsM2M 客户端:GenerateSecret: true,由 CloudFormation 生成客户端密钥;AllowedOAuthFlows: [client_credentials],AllowedOAuthScopes限定为mcp/invoke;SupportedIdentityProviders: [COGNITO];- 二者都
DependsOn: ResourceServer,确保 scope 先于客户端创建。
两个客户端的角色差异是这套测试的"考点"之一:
- Client A:套件铸造工作 token(working token)的客户端。
- Client B:同一池、同一 scope,但不同 client id。测试套件断言它的 token同样被接受——因为 API Gateway 的 Cognito authorizer 的授权粒度是"池 + scope",永远不是某一个客户端。固定这一个断言,就能阻止读者误以为网关在客户端层面做了更细的收敛。真正要收窄到单个客户端,是服务器模块的职责(token 的
client_id声明会随 authorizer context 到达服务器),这恰好印证了"enforcement is yours"的边界(见 mcp-auth.test.js)。
密钥如何进 SSM:为什么必须自定义资源
CloudFormation 原生的AWS::SSM::Parameter无法创建 SecureString 类型参数——这是模板注释和 README 共同强调的限制。解决办法是模板内联(ZipFile)了一个极小的 Python 3.12 Lambda 自定义资源SsmWriterFunction,栈名派生为<stack-name>-ssm-writer。
它发布的八个 SecureString 参数位于/mcp-integration-test/cognito/下:poolId、domain、region、clientAId、clientASecret、clientBId、clientBSecret、scope。这八个键名与 cognito.mjs 里的REQUIRED_KEYS逐一对应——八个必须全部存在,前置依赖才视为已部署。
几个值得注意的安全与生命周期细节:
- 客户端密钥只存在于 SSM,从不进入栈的 Outputs。模板的 Outputs 只回显非敏感发现值(
UserPoolId、Domain、Region、ClientAId、ClientBId、Scope、SsmPrefix),供管理员部署后目测。 - 自定义资源的 IAM 最小化:
SsmWriterRole只允许ssm:PutParameter/ssm:DeleteParameter,且资源被限定为arn:...:parameter${SsmPrefix}/*;日志写权限只针对该函数自己显式声明的日志组/aws/lambda/${AWS::StackName}-ssm-writer(RetentionInDays: 7)。因为日志组是显式声明的而非首次调用时自动创建,函数名(由栈名派生)在创建角色时就可确定,不存在依赖环,也完全不需要logs:CreateLogGroup。 - 删除即清理:自定义资源在
RequestType == 'Delete'时逐个delete_parameter(对ParameterNotFound静默容错);Create/Update则用Overwrite: True覆盖写入。因此拆除整个栈就会连带清掉八个 SSM 参数,不会遗留凭据。 - 密钥通过
!GetAtt UserPoolClientA.ClientSecret/!GetAtt UserPoolClientB.ClientSecret从客户端资源取回,随模板传入 Lambda 的属性(properties),全程不落明文 Outputs。
两个主机,切勿混淆
README 与 cognito.mjs 都强调同一件事:M2M 流程里有两个不同角色、不同域名的主机:
- Issuer(签发方标识):
https://cognito-idp.<region>.amazonaws.com/<poolId>——套件将其发布到 fixture 的oauthDiscovery.issuer字段,客户端读取受保护资源文档后被告知"去这里登录"。 - Token endpoint(铸币端点):
https://<domain>.auth.<region>.amazoncognito.com/oauth2/token——套件用它铸造 access token。
而 authorizer 实际引用的pool ARN 并不在此发布:套件在运行时由poolId、region和调用方自己的账户 id 推导(见 mcp-auth.test.js:从 STSGetCallerIdentity取 partition 与 account id,拼出arn:<partition>:cognito-idp:<region>:<account>:userpool/<poolId>)。派生而非硬编码,避免了跨分区(partition)假设。
套件如何消费前置依赖:skip 与 fail 的边界
读取逻辑集中在 cognito.mjs 的readCognitoPrerequisite,它在**收集期(collection time)**执行一次,让套件在运行前就能决定是否可跑:
- 用
GetParametersByPathCommand(WithDecryption: true、自动翻页)读取前缀下的全部参数; - 命中
SKIPPABLE_ERROR_NAMES(一个白名单:ParameterNotFound表示前缀不存在;CredentialsProviderError表示凭证链完全拿不到凭证)时才返回null→ 套件describe.skip; - 其余任何失败都会向上抛出,让文件响亮失败。设计上刻意不用
.catch兜底:被拒绝、限流、过期凭证、网络超时都是"本该成功的读取",静默跳过会让鉴权覆盖在什么都没跑的情况下被记为已覆盖,这是套件存在的意义所不允许的。
成功时返回的对象携带八个原始值,以及两个派生工具:issuer、tokenEndpoint,外加零参数的铸币器mintClientA()/mintClientB()。铸币走 mintToken:对 token endpoint POSTgrant_type=client_credentials+scope=mcp/invoke,HTTP Basic 认证,仅用全局fetch,因此请求形态可被单元测试用 stub 的 fetch 验证,无需真实网络与真实池(对应测试在 cognito.test.js)。
读取方(套件侧)在 mcp-auth.test.js 打印明确的跳过警告,指引开发者先部署前置模板。
部署:每个账户执行一次
模板所在目录持有template.yml,会让serverless deploy路由到框架的CloudFormation runner,由后者自行传递 IAM capabilities。从packages/sf-core/tests/integration/mcp-cognito-prerequisite/目录执行:
serverless deploy --stack mcp-integration-test-cognito --region us-east-1- 在已有该栈的账户上重复执行是安全的:会用上面命名的
SsmWriterFunction替换写入函数并重写同样的八个参数。 - 需要留意的一个小尾巴:早期版本创建过自动命名的日志组(
/aws/lambda/<stack>-SsmWriterFunction-*),栈不会接管它,若在意可手动删除。 - 成本几乎为零:Cognito 按 M2M 客户端收取的费用已于 2025 年 11 月取消;剩余成本为每 1000 次 token 请求 $0.00225,即便按每月 1000 次 CI 运行计也约$0.014/月;闲置池在约 0 MAU 下为 $0。
校验与排查
部署后可以用两类命令确认状态:
# 查看栈输出(非敏感发现值) serverless info --stack mcp-integration-test-cognito --region us-east-1 # 确认八个 SecureString 参数存在(--query 只列名称,不回显密钥值) aws ssm get-parameters-by-path --path /mcp-integration-test/cognito \ --with-decryption --region us-east-1 --query 'Parameters[].Name'拆除与迁移
要把前置依赖迁到另一个账户(例如专门的 CI 账户),做法是在目标账户部署同一模板、在此账户拆除:
serverless remove --stack mcp-integration-test-cognito --region us-east-1删除栈时,自定义资源会在 Delete 阶段清掉八个 SSM 参数,无需额外的手工清理步骤。
前置依赖之外的验证全景
虽然本前置只是"跑腿资源",但它支撑的测试断言才是理解其存在意义的关键(见 mcp-auth.test.js 的四个 describe 块):
- cognito:垃圾 token 被网关以 401 拒绝,且从 CloudWatch 计数证明服务器函数从未被调用(
countInvocations按REPORT RequestId过滤整页扫描,拒绝与接受互为对照);用 Client A 的 token 跑完整 MCP 检查清单;再用 Client B 的 token 验证"同池同 scope 的任意客户端都被接受"。 - custom / customRequest:TOKEN 与 REQUEST 两类 Lambda authorizer 形态,校验每次运行随机生成的共享密钥,钉死网关拒绝时的精确响应体(401 +
{"message":"Unauthorized"}+x-amzn-errortype: UnauthorizedException)。 - oauthDiscovery:MOCK 路由上的 RFC 9728 受保护资源文档的逐字节内容与 CORS 头,以及"文档恰好能被服务器路由拒绝的那个未认证客户端读取"这一关键性质;同时断言裸 execute-api 端点上的根探测返回 403(自定义域名映射到根路径时该探测才会解析)。
- open:无 authorizer 的普通 MCP 往返,兼作流式传输与空 202 通知的回归门。
四个服务器在 serverless-auth.yml 中定义,指向同一份服务器模块(与fixture/的副本由 fixture-parity.test.js 强制逐字节一致),唯一变化的变量就是"谁被允许到达它"——这正是本前置依赖服务的验证目标。
延伸阅读
- 前置依赖模板与说明:template.yml、README.md
- 消费方套件与读取库:mcp-auth.test.js、cognito.mjs
- fixture 配置:serverless-auth.yml、fixture-auth/README.md
- 测试编排总览:TESTING.md(含该前置依赖在整个集成测试矩阵中的位置与运行方式)
【免费下载链接】serverless⚡ Serverless Framework – Effortlessly build apps that auto-scale, incur zero costs when idle, and require minimal maintenance using AWS Lambda and other managed cloud services.项目地址: https://gitcode.com/GitHub_Trending/se/serverless
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考