Skip to content

Auth: JWT/OIDC provider with claim-based role extraction #57

@avelino

Description

@avelino

Today the only auth providers for mcp serve are BearerTokenAuth (static token → subject map) and ForwardedUserAuth (reads a header set by an upstream reverse proxy like oauth2-proxy). Both cover small deployments well, but neither fits the case of a team already using an OIDC provider (Keycloak, Auth0, Okta, Google Workspace, Azure AD) and wanting to hand out short-lived JWTs to users or services instead of managing a static token map.

With #53 in place, identities can carry real roles. A JWT provider is the natural way to feed those roles from an existing identity platform — the claims are already there, we just need to read them.

Depends on: #53 (so the provider has a well-defined contract for populating roles on AuthIdentity).

Goal

Add a new auth provider that validates JWTs on incoming requests, extracts subject and roles from configurable claims, and plugs into the same AuthIdentity pipeline the other providers use. It must work against standard OIDC providers out of the box without custom glue.

Expected behavior

Configuration

Extend serverAuth in servers.json to accept a new provider type:

"serverAuth": {
  "provider": "jwt",
  "jwt": {
    "issuer": "https://auth.example.com/realms/mcp",
    "audience": ["mcp-proxy"],
    "jwksUrl": "https://auth.example.com/realms/mcp/protocol/openid-connect/certs",
    "subjectClaim": "sub",
    "rolesClaim": "groups",
    "leewaySeconds": 30
  }
}
  • issuer (required): expected iss claim. Tokens with a different issuer are rejected.
  • audience (required): one or more expected aud values. Token is valid if its aud intersects this list.
  • jwksUrl (required for asymmetric keys): URL to fetch the JWKS document. Cached in memory, refreshed on unknown kid, with a sensible minimum refresh interval to avoid hammering the IdP.
  • subjectClaim (default "sub"): which claim to read as the identity subject.
  • rolesClaim (default "groups"): which claim to read as the roles list. Must support:
    • A top-level array claim ("groups": ["dev", "oncall"]).
    • A dot-path into nested claims ("realm_access.roles" → common Keycloak layout).
    • A single string value, treated as a one-element array.
  • leewaySeconds (default 30): clock skew tolerance for exp / nbf / iat.
  • Optional future-proofing fields (document as reserved but not required in v1): algorithms allow-list, requiredClaims, staticJwks for offline/testing.

Token extraction

  • Read the bearer token from the Authorization: Bearer <token> header. Same extraction path as BearerTokenAuth, but validation differs.
  • Missing / malformed header → Unauthorized, no identity synthesized, same behavior as other providers on missing creds.

Validation

  • Signature verified against the JWKS, matching kid from the token header.
  • iss, aud, exp, nbf, iat all validated, with leewaySeconds applied to time claims.
  • Any failure → Unauthorized. Failures must be logged with enough context to debug (expected issuer vs actual, etc.) but must never log the token itself.
  • On JWKS fetch failures for an unknown kid: retry once, then reject the token. Never fall back to an expired cached key silently — log loudly.

Identity population

  • subject: value of the configured subjectClaim. Missing claim → reject.
  • roles: value of the configured rolesClaim, normalized to Vec<String>. Missing claim → empty vec (not an error — an authenticated user with no roles is valid).
  • From the ACL's perspective, the resulting AuthIdentity is indistinguishable from one produced by the other providers, so all of ACL: new role-based schema, union evaluation, and read/write enforcement #55's enforcement applies uniformly.

JWKS caching

  • In-memory cache keyed by jwksUrl. No on-disk persistence.
  • Cache miss on an unknown kid triggers a refresh, rate-limited to avoid DoS amplification (e.g., at most one refresh per N seconds per URL, implementation choice).
  • Reasonable default TTL so revoked keys eventually stop working even without traffic-driven invalidation.

Out of scope

  • Token issuance or user management. This provider validates tokens; it does not mint them.
  • Opaque token introspection (RFC 7662). Only JWTs. Introspection can be a separate provider later if needed.
  • mTLS / DPoP / token binding.
  • Dynamic per-request claim transformation pipelines. The mapping is: one claim for subject, one claim for roles. If users need more, they can configure their IdP to shape the claim how they want.
  • Replacing BearerTokenAuth or ForwardedUserAuth. This is an additional provider, not a replacement.

Technical pointers

  • A maintained Rust JWT library handles signature validation, claims parsing, and JWKS — pick one already in the ecosystem rather than rolling crypto by hand. The choice of crate is up to the implementer; preference for something with active maintenance and support for the common OIDC algorithms (RS256, RS384, RS512, ES256, EdDSA).
  • Provider code belongs alongside the existing providers in src/server_auth/providers.rs (or a new module under src/server_auth/ if it gets large).
  • The AuthProvider trait may need a small extension to allow async operations (JWKS fetching). If it is sync today, the cleanest path is to make the validation step async and let the proxy already-async request handler await it.
  • Tests: use a fixture JWKS and pre-signed tokens (valid, expired, wrong issuer, wrong audience, missing subject claim, nested roles claim, unknown kid). Do not call out to a real IdP in unit tests; a local static JWKS is enough.
  • Integration test against mcp acl check (from ACL: mcp acl check CLI and enriched audit log entries #56) would be nice: feed it a synthetic JWT-backed identity and verify the ACL decision, but only if the provider test surface allows it cleanly.

Success criteria

  • A team using Keycloak can point mcp serve at their realm with only the four required config fields and have tokens issued by that realm authenticate correctly.
  • Roles from the JWT (whether at groups, roles, or realm_access.roles) flow into AuthIdentity.roles and are honored by the ACL evaluator from ACL: new role-based schema, union evaluation, and read/write enforcement #55.
  • Expired, wrong-audience, wrong-issuer, and tampered tokens are all rejected with clear log messages that never include the token value.
  • JWKS rotation works: issuing a token with a new kid triggers a refresh and the token is accepted on the next try without restarting the proxy.

See the full redesign plan at docs/acl-redesign-plan.md.

Metadata

Metadata

Assignees

No one assigned

    Labels

    aclAccess control lists and authorizationauthOAuth, tokens, authenticationconfigConfiguration and servers.jsonenhancementNew feature or improvementproxyServe/proxy mode (mcp serve)securitySecurity, authorization, and hardening

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions