serverless-plugin-mcp-cognito-proxy
v0.2.1
Published
Serverless Framework plugin: registration-free, stateless OAuth proxy (DCR + CIMD) in front of an existing AWS Cognito pool.
Maintainers
Readme
serverless-plugin-mcp-cognito-proxy
A Serverless Framework plugin that puts a stateless, registration-free OAuth 2.1 façade in front of an existing AWS Cognito user pool, so MCP clients (Claude Code, Claude Desktop, Cursor, …) can log in through Cognito without you pre-registering each client.
Why
The MCP authorization spec expects an authorization server that offers:
- RFC 8414 metadata at
/.well-known/oauth-authorization-server - RFC 7591 Dynamic Client Registration (
/register) and/or Client-ID-Metadata-Documents (CIMD) - loopback
redirect_uris on arbitrary ports (native clients)
Cognito provides none of these: it only serves OIDC discovery, has no DCR, and requires every callback URL to be registered on an app client up front. This plugin deploys a small Lambda that fills exactly that gap and delegates the actual login and token issuance to Cognito.
What it deploys
| Resource | Purpose |
|---|---|
| McpProxyUserPoolClient | A confidential Cognito app client (code flow, hosted UI) whose callback is the proxy. Its generated secret is also the proxy's sealing key. |
| mcpCognitoProxy Lambda | Hono app serving /.well-known/*, /register, /authorize, /auth/callback, /token. |
| mcpProxyJwt authorizer (API Gateway mode only) | An HTTP API JWT authorizer validating Cognito access tokens issued to that app client, attached to your protectedRoutes. |
That's it: no DynamoDB table, no SSM parameters, no IAM statements. The proxy is fully stateless.
How it works
MCP client proxy (this plugin) Cognito
│ GET /.well-known/… │ │
│─────────────────────────▶│ metadata: issuer = proxy, │
│ │ jwks_uri = Cognito │
│ POST /register │ │
│─────────────────────────▶│ client_id = sealed(metadata) ─┐ │
│ GET /authorize │ │ │
│─────────────────────────▶│ state = sealed(txn) ──────────┼────▶│ hosted UI login
│ │◀── /auth/callback?code&state ──┼─────│
│◀─ redirect_uri?code ─────│ code = sealed(cognito code) │ │
│ POST /token (+PKCE) │ │ │
│─────────────────────────▶│ open code, check PKCE ────────┼────▶│ /oauth2/token
│◀─ Cognito tokens ────────│◀───────────────────────────────┼─────│Every piece of in-flight state — the DCR registration, the authorization transaction, the authorization code — travels with the client as an AES-256-GCM sealed blob (JWE, key derived from the app client secret via HKDF). The Cognito code is exchanged only at /token time, so Cognito enforces single use. refresh_token grants are forwarded to Cognito, so long-lived sessions work.
By default the tokens returned to the client are Cognito's own. With tokenIssuer: proxy the access token is re-minted under the proxy's issuer (copying sub, scope, cognito:groups, username, client_id, adding mcp_client_id and aud from the RFC 8707 resource), signed with an ES256 key derived from the same secret — still nothing stored.
Install
npm install --save-dev serverless-plugin-mcp-cognito-proxyplugins:
- serverless-plugin-mcp-cognito-proxyConfiguration
custom:
mcpCognitoProxy:
userPoolId: us-east-1_xxxxxxx # required
cognitoDomain: https://your-pool.auth.us-east-1.amazoncognito.com # required (hosted UI domain)
issuerUrl: https://api.example.com # optional: defaults to the deployed HTTP API endpoint
protectedRoutes: [/mcp] # optional: omit for standalone AS mode (see below)
scopes: [openid, email, profile] # optional: default shown
tokenIssuer: cognito # optional: cognito (default) | proxy — see Mode 2| Option | Description |
|---|---|
| userPoolId | Existing pool, <region>_<id>. The Cognito issuer is derived from the region prefix. |
| cognitoDomain | The pool's hosted-UI domain (Cognito-prefix or custom). |
| issuerUrl | Public base URL of the proxy. Leave unset unless you front the API with a custom domain; the plugin derives it from HttpApi.ApiEndpoint in a single deploy. |
| protectedRoutes | HTTP API paths in this service to put behind the JWT authorizer. |
| scopes | Scopes requested from Cognito and allowed on the app client. |
| tokenIssuer | cognito: clients get Cognito's tokens unchanged. proxy: /token re-mints the access token as an ES256 JWT under the proxy's issuer (P-256 key derived from the app client secret; JWKS served at /.well-known/jwks.json). Refresh and ID tokens are Cognito's in both modes. |
Mode 1: protecting routes on API Gateway (HTTP API)
Use this when your MCP server is a Lambda in the same Serverless service, behind an HTTP API.
custom:
mcpCognitoProxy:
userPoolId: ${env:USER_POOL_ID}
cognitoDomain: ${env:COGNITO_DOMAIN}
protectedRoutes:
- /mcp
functions:
mcp:
handler: handler.mcp
events:
- httpApi: { method: "*", path: /mcp }The plugin:
- serves
/.well-known/oauth-protected-resource[/mcp]naming the proxy as the authorization server; - injects an HTTP API JWT authorizer with
issuerUrl= the Cognito pool issuer andaudience= the injected app client id (API Gateway accepts Cognito'sclient_idclaim in place ofaud); - attaches it to every
httpApievent whosepathis inprotectedRoutes.
Your handler receives the Cognito access token claims in event.requestContext.authorizer.jwt.claims (sub, cognito:groups, scope, …).
Verify after deploy:
curl -s $ENDPOINT/.well-known/oauth-protected-resource/mcp | jq
curl -s -o /dev/null -w "%{http_code}\n" -X POST $ENDPOINT/mcp # 401
claude mcp add --transport http example $ENDPOINT/mcp # then /mcp in Claude CodeMode 2: authorization server for Amazon Bedrock AgentCore Gateway
AgentCore Gateway already does the resource-server half: it returns 401 with a WWW-Authenticate pointing at its own /.well-known/oauth-protected-resource, and validates JWTs via its customJwtAuthorizer. What it lacks is an authorization server that MCP clients can discover and register with — Cognito on its own fails at RFC 8414/7591 (awslabs/agentcore-samples#1056). This plugin provides that AS.
Deploy the proxy with no protectedRoutes and tokenIssuer: proxy:
service: mcp-auth
provider: { name: aws, runtime: nodejs20.x }
plugins:
- serverless-plugin-mcp-cognito-proxy
custom:
mcpCognitoProxy:
userPoolId: ${env:USER_POOL_ID}
cognitoDomain: ${env:COGNITO_DOMAIN}
tokenIssuer: proxyThen point the Gateway's inbound authorizer at the proxy (CloudFormation shown; the console/CLI take the same values — see examples/agentcore-gateway for a complete stack including a Lambda tool target):
Gateway:
Type: AWS::BedrockAgentCore::Gateway
DependsOn: [HttpApiStage, McpCognitoProxyLambdaFunction] # the Gateway fetches discovery on creation
Properties:
Name: my-gateway
ProtocolType: MCP
AuthorizerType: CUSTOM_JWT
RoleArn: !GetAtt GatewayRole.Arn
AuthorizerConfiguration:
CustomJWTAuthorizer:
DiscoveryUrl: !Join ["", [!GetAtt HttpApi.ApiEndpoint, "/.well-known/openid-configuration"]]
AllowedClients: [!Ref McpProxyUserPoolClient]DiscoveryUrl→ the proxy. The Gateway derives its protected-resource metadata from it, so clients discover the proxy (with DCR/CIMD) instead of Cognito.AllowedClientsmatches theclient_idclaim, which proxy-minted tokens copy from Cognito's token: the injected app client id.
Why tokenIssuer: proxy is required here (verified against a live Gateway): the Gateway compares the token's iss to the issuer in the discovery document, and reports a mismatch as 403 insufficient_scope. The same Cognito token is accepted when the Gateway's discovery URL is Cognito's — but then the Gateway advertises Cognito as the AS and MCP clients can't register. Re-minting under the proxy's issuer satisfies both. AgentCore accepts the ES256 signature.
Gotcha: the Gateway caches the discovery document it fetched at creation. If you switch tokenIssuer on an existing deployment, the Gateway keeps validating against the old jwks_uri (401 invalid_token) until it is updated or recreated (aws bedrock-agentcore-control update-gateway … with the same configuration is enough).
Security model
Be deliberate about what you are trusting when you deploy this:
- The Cognito app client secret is the root of trust. The sealing key (client registrations, transactions, codes) and, in
tokenIssuer: proxymode, the ES256 signing key are both derived from it via HKDF with distinct labels. The secret is visible in the proxy Lambda's environment configuration, so anyone withlambda:GetFunctionConfiguration(or CloudFormation read access to the resolved stack) in your account can forge registrations, codes, and — in proxy mode — access tokens. Scope those IAM permissions accordingly. Rotating the app client secret invalidates all outstanding registrations, codes, and proxy-issued tokens at once. - Authorization codes are single-use because the inner Cognito code is exchanged only at
/tokentime and Cognito enforces single use; the proxy's sealed code additionally expires after 2 minutes and is PKCE-bound (S256 required, verified with a constant-time compare). - Login and consent stay with Cognito. The proxy never sees passwords; it holds no tokens at rest (it holds nothing at rest — there is no datastore to breach).
- DCR registrations expire after 30 days (the sealed
client_idcarries its own expiry). MCP clients handle this by re-registering; other clients must tolerate it. - CIMD fetches are defensive:
https://client_ids are fetched without following redirects, with a 5s timeout, a JSON content-type requirement, and a 64KB size cap, and the document must echo theclient_idand use only https or loopback redirect URIs. /registeris unauthenticated by design (RFC 7591 open registration). Because registration writes nothing, it cannot be used to fill a database; a registration is only a signed statement of redirect URIs.
Compatibility
- Serverless Framework v4 (v3 is untested).
- API Gateway HTTP API (
httpApievents) only — not REST API (httpevents). - An existing Cognito user pool with a hosted-UI domain (Cognito-prefix or custom).
- The proxy Lambda runs on
nodejs20.x. - Verified against MCP clients using OAuth discovery + DCR/CIMD with PKCE S256; grants:
authorization_code,refresh_token.
Client support
- Redirect URIs (both DCR and CIMD):
https://anywhere, orhttp://on a loopback host (localhost,127.0.0.1,[::1]). Loopback URIs match regardless of port (RFC 8252 §7.3), so a client can registerhttp://localhost/callbackand redirect tohttp://localhost:51234/callback— this is what Claude Code does. - DCR (RFC 7591):
client_idis a self-contained sealed blob valid for 30 days. - CIMD: an
https://client_id(e.g. Claude Code'shttps://claude.ai/oauth/claude-code-client-metadata) is fetched and its document must echo theclient_idand use allowed redirect URIs. - PKCE S256 required;
token_endpoint_auth_method: none. - Grants:
authorization_code,refresh_token.
Examples
examples/ contains deployable stacks for both modes plus a shared Cognito pool. Install the plugin into them from a packed tarball — a file:../.. link makes Serverless's packager follow the symlink back into the repo and loop until it runs out of memory:
npm run build && npm pack --pack-destination examples
cd examples/cognito && npx serverless deploy # shared pool + hosted UI domain
cd ../api-gateway && npm install && npx serverless deploy
cd ../agentcore-gateway && npm install && npx serverless deployDevelopment
npm install
npm test # vitest: plugin + runtime
npm run typecheck
npm run build # dist/ (plugin, CJS) + dist/runtime/index.mjs (Lambda bundle, esbuild)The Lambda bundle ships inside the npm package and is copied into the consumer's service as .mcp-proxy/ during serverless package (and removed afterwards). Add .mcp-proxy/ to your .gitignore.
