Skip to content

MCP OAuth Stage 5: withMCPAuth wrapper #95

@heskew

Description

@heskew

MCP OAuth Stage 5: withMCPAuth wrapper

Sub-issue of #86. Plugin-provided guard for app-owned MCP routes. Validates bearer tokens, enforces audience binding, and returns the spec-compliant 401 + WWW-Authenticate that closes the discovery loop.

Context

The plugin doesn't own the MCP endpoint itself — the app does (server.http('/mcp', appHandler)). But the spec-mandated 401 + WWW-Authenticate: Bearer resource_metadata="..." contract is non-trivial and security-sensitive; every Harper-MCP app should NOT reimplement it. withMCPAuth wraps the app's handler and enforces it once, centrally. This is the symmetric counterpart to the existing withOAuthValidation wrapper used for human-OAuth-protected routes.

What this adds

  1. withMCPAuth(handler, options?) wrapper exported from @harperfast/oauth. Apps use it as:
    server.http('/mcp', withMCPAuth(myMcpHandler));
  2. Bearer-token extraction from Authorization: Bearer <jwt> header. Query-string tokens REJECTED per MCP spec.
  3. JWT verification against the public key(s) from harper_oauth_mcp_keys (via Stage 4's tokenIssuer.ts).
  4. Audience validation — JWT aud claim must equal the configured canonical resource URI (mcp.resource). Mismatch → 401.
  5. Expiration / not-before validation per RFC 7519.
  6. Spec-compliant 401 response on any failure: 401 Unauthorized with WWW-Authenticate: Bearer resource_metadata="<absolute URL to PRM>", pointing at Stage 2's /.well-known/oauth-protected-resource.
  7. Attaches verified token claims to the request as request.mcp = { sub, client_id, aud, scope } so the app handler can authorize per-user.

Spec requirements (all MUST, MCP 2025-06-18)

  • Server MUST respond with 401 + WWW-Authenticate: Bearer resource_metadata="..." on unauthenticated MCP requests (RFC 9728 §5.1)
  • Server MUST validate access tokens were issued for them specifically (RFC 8707 §2 — audience binding)
  • Authorization: Bearer only — no query-string tokens (RFC 6750)
  • Token signature, expiration, audience all required

Acceptance

  • withMCPAuth(handler, options?) exported from @harperfast/oauth
  • Unauthenticated request (no Authorization header) returns 401 + WWW-Authenticate: Bearer resource_metadata="<canonical PRM URL>"
  • Malformed Authorization (not Bearer <token>) returns 401 with same WWW-Authenticate
  • Token with invalid signature returns 401
  • Token with expired exp returns 401
  • Token whose aud doesn't match mcp.resource returns 401
  • Token in URL query string is REJECTED (treated as no token presented; never validated)
  • Valid token: request.mcp = { sub, client_id, aud, scope } populated; underlying handler invoked
  • Wrapper preserves the underlying handler's response shape verbatim (no double-wrapping)
  • Unit tests cover all rejection branches and the happy path; contract test confirms WWW-Authenticate header value matches the PRM URL Stage 2 serves
  • Documented in README and docs/configuration.md

Dependencies

Out of scope


🤖 Generated with Claude Code

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions