ConduitMcp.OAuth.KeyProvider.JWKS (ConduitMCP v0.11.0)

Copy Markdown View Source

JWKS key provider that fetches keys from an HTTP endpoint with ETS caching.

Fetches JSON Web Key Sets from the authorization server's JWKS URI, caches them in ETS, and auto-refreshes on cache miss or expiration.

Configuration

auth: [
  strategy: :oauth,
  key_provider: {ConduitMcp.OAuth.KeyProvider.JWKS,
    jwks_uri: "https://auth.example.com/.well-known/jwks.json",
    cache_ttl: :timer.hours(1)}    # default: 1 hour
]

The jwks_uri must use https. For local development against a plain-HTTP authorization server, set allow_insecure_jwks: true in the provider config.

Fetches use conservative HTTP settings: redirects are not followed, requests time out (5s connect / 10s receive), and responses are capped at 1MB. If a refresh fails and previously fetched keys are still cached, those stale keys are served (with a logged warning) so a transient authorization-server outage does not hard-fail all authentication — bounded by :stale_max_age (default 24 hours), after which the provider fails closed so revoked keys cannot validate tokens indefinitely.

Security considerations

The jwks_uri must be trusted operator config — never client-derived. Fetches are hardened against SSRF abuse (https-only unless allow_insecure_jwks, redirects disabled, 1MB body cap), but the URI itself is not range-checked: a jwks_uri pointing at a private or link-local address — e.g. the cloud metadata endpoint http(s)://169.254.169.254/... — is fetched, not rejected. Set jwks_uri from a configuration source you control. If it must come from a less-trusted source, restrict outbound egress at the network layer (the library deliberately does not block private ranges, since for most deployments the JWKS endpoint is an internal/private host).

Revocation lag during an outage. While the JWKS endpoint is unreachable, cached keys keep validating tokens until :stale_max_age (default 24h), then the provider fails closed. The trade-off: a key revoked during an outage can still validate tokens for up to :stale_max_age. Lower it if your threat model needs faster revocation; raise it to tolerate longer authorization-server outages.

Refresh coordination

Every authenticated request runs fetch_keys/1, and fetch_key/2 calls refresh_keys/1 for any kid it has not seen. Both are single-flighted through a lock row in the cache table:

  • Single flight. Exactly one process performs an outbound fetch per jwks_uri at a time. Others wait briefly for the cache to be filled and then read it, falling back to the stale-cache path. Without this, a TTL lapse at 500 rps produced 500 simultaneous fetches, each holding a Bandit process for up to 15 s — and IdPs rate-limit JWKS endpoints, so a routine expiry became a total auth outage.

  • Cooldown. At most one outbound fetch happens per jwks_uri per :refresh_cooldown (default 30 s), whether or not it succeeded. Inside that window an unknown kid or a lapsed :cache_ttl is answered from the cache, still subject to :stale_max_age. fetch_signing_key/2 runs before any signature check and authentication runs before rate limiting, so an unauthenticated caller could otherwise drive one outbound fetch per request by inventing a kid, or by sending any token while the IdP is failing. The window applies to a cold cache too: after a failed fetch there is nothing cached to serve, so requests inside it get {:error, :refresh_cooldown} rather than another fetch, and the first request after it fetches again. The first fetch for a jwks_uri has no cooldown to honour. A request that arrives while a fetch is in flight waits for that fetch instead of being answered from the cooldown.

  • Lock age. A lock older than :refresh_lock_max_age (default 30 s, comfortably past the HTTP timeouts) is treated as abandoned, so a crashed holder cannot wedge refreshes.

Requirements

Requires the req package, 0.6.1 or newer: earlier versions carry advisories this provider can reach, including unbounded decompression driven by the response's content-type (GHSA — Req < 0.6.1). This provider additionally sets compressed: false and streams the response through a size-bounded collector, so the 1 MB cap is applied to the bytes actually received rather than to an already-buffered, already-decompressed body.