@vantageos/mcp-auth-kit
v0.1.0
Published
MCP OAuth 2.1 resource-server facade — 401 challenge, RFC 9728 Protected Resource Metadata, RFC 8414 Authorization-Server discovery, RS256 JWKS verification, audience/issuer binding. Reuses @vantageos/cloud-identity unchanged for tenant/scope resolution d
Maintainers
Readme
@vantageos/mcp-auth-kit
An OAuth 2.1 resource-server facade for MCP servers. It answers the
question @vantageos/cloud-identity deliberately does not: is this bearer
token real, and was it minted for this resource?
Reuse contract — read this first
This package is not re-implementing authentication or tenancy. It is the
pre-auth layer that sits in front of @vantageos/cloud-identity, which
already provides tenant/scope resolution and explicitly states in its own
docs that it is "not an authentication system" (its decodeUnverifiedBearer
helper decodes a JWT payload without verifying it — the payload is
attacker-controlled until something proves the signature).
mcp-auth-kit closes exactly that gap and stops:
inbound request
│
▼
mcp-auth-kit (this package)
1. 401 + WWW-Authenticate challenge on missing/failed auth
2. RFC 9728 Protected Resource Metadata
3. RFC 8414 / OIDC Authorization-Server discovery (Clerk-as-AS)
4. RS256 signature verification against Clerk's JWKS
5. audience / issuer binding (rejects cross-tenant replay)
│
▼ verified JWT payload
@vantageos/cloud-identity (reused UNCHANGED — not modified by this package)
tenant resolution, scope filtering, `getEffectiveTenantId`, etc.Everything downstream of "the signature checks out and it was minted for
this resource" is @vantageos/cloud-identity's job. This package never
duplicates that logic.
Install
npm install @vantageos/mcp-auth-kit @vantageos/cloud-identityNode 20 or later. ESM only.
Quick start
import { createRemoteJWKSet } from "jose";
import { verifyMcpBearerRequest, deriveClerkDiscoveryUrls } from "@vantageos/mcp-auth-kit";
const { jwksUrl } = deriveClerkDiscoveryUrls("https://your-tenant.clerk.accounts.dev");
const jwks = createRemoteJWKSet(new URL(jwksUrl));
const result = await verifyMcpBearerRequest(request.headers.get("authorization"), {
realm: "mcp",
resourceMetadataUrl: "https://mcp.example.com/.well-known/oauth-protected-resource",
jwks,
issuer: "https://your-tenant.clerk.accounts.dev",
audience: "https://mcp.example.com",
});
if (!result.ok) {
return new Response(null, { status: result.status, headers: result.headers });
}
// result.payload is the verified JWT claim set — hand it to
// @vantageos/cloud-identity for tenant/scope resolution from here.Serving the well-known documents
import { buildProtectedResourceMetadata } from "@vantageos/mcp-auth-kit";
// GET /.well-known/oauth-protected-resource
const metadata = buildProtectedResourceMetadata({
resource: "https://mcp.example.com",
authorizationServers: ["https://your-tenant.clerk.accounts.dev"],
});Modules
| Module | Responsibility |
|---|---|
| challenge | 401 + WWW-Authenticate envelope (RFC 6750) |
| protected-resource-metadata | RFC 9728 discovery document |
| as-discovery | Derives Clerk's OIDC discovery + JWKS URLs (RFC 8414) |
| jwt-verify | RS256 signature verification + issuer/audience binding |
| facade | Composes the above into one verifyMcpBearerRequest call |
Security notes
- Signature verification is RS256 only, against the authorization server's
published JWKS — never a shared-secret comparison.
jwtVerifyis called withalgorithms: ["RS256"]pinned explicitly (seesrc/jwt-verify.ts); jose validates the token'salgheader against this allow-list BEFORE the key resolver is ever invoked, so neither an unsigned (alg: none) token nor an HS256 token forged with the RSA public modulus as an HMAC secret (the classic RS256<->HS256 algorithm-confusion attack) can reach a state where forged material would verify. Both attack classes are covered by tests intest/jwt-verify.test.ts, proved by a mutation probe that removes the pin and shows the forged token verify (RED), then restores it (GREEN) — see PR body for the pasted transcript. issuerandaudienceare required configuration, never optional with a silent default. A caller that omits either gets a thrown configuration error, not a verification that quietly skips the check.- A token correctly signed by the right issuer but minted for a different
resource (
audmismatch), or by a different issuer altogether (issmismatch), is rejected — both are cross-tenant replay variants enforced byjose's claim validation, not a downstream optional check.
Standards references (pinned)
This package implements, and is reviewed against, these specific pinned references — never a floating "latest" pointer:
- ElPi Corp
mcp-standard.mdCritical Rule #14 (OAuth 2.1 resource server, axis 6) —elpiarthera/ElPi-Corp@8fed375(resources/references/mcp-standard.md, section "OAuth 2.1 resource server"). - RFC 9728 — OAuth 2.0 Protected Resource Metadata — https://www.rfc-editor.org/rfc/rfc9728
- RFC 8414 — OAuth 2.0 Authorization Server Metadata — https://www.rfc-editor.org/rfc/rfc8414
- RFC 7518 §3.3 — JSON Web Algorithms, RS256 (RSASSA-PKCS1-v1_5 using SHA-256) — https://www.rfc-editor.org/rfc/rfc7518#section-3.3
- RFC 7517 — JSON Web Key (JWKS format) — https://www.rfc-editor.org/rfc/rfc7517
RFC numbers are immutable once published, so citing the RFC number is itself a pinned reference — there is no "latest RFC 9728" to drift toward.
Testing
npm testJWTs in tests are signed with a fixture RSA keypair using fixed,
explicit epoch-second iat/exp values — no Date.now() or
Math.random() dependency, so the suite is reproducible in any harness.
License
FSL-1.1-Apache-2.0. See LICENSE.
