Skip to content

JWT/OIDC Inbound Auth

This page records the design of optional JWT validation for inbound bearer tokens (issue #2375) and describes the behavior that shipped. Configuration and operational guidance live in Security & Admin.

Shipped Behavior

  • Config surface: api_keys.jwt names the issuer, audience, one key source (jwks_uri or static_jwks), the accepted asymmetric algorithms, and the claim mappings. The section is off by default. Static-key authentication is unchanged while it is absent or disabled.
  • Resolution order: the auth middleware matches the presented credential against the key store first. Only a miss is examined as a JWT, so a static key always wins. A token whose unverified iss is not the configured issuer falls through to the existing key-miss path.
  • Validator: the JWT branch verifies the signature against the JWKS and checks iss, aud, exp, and nbf with a bounded clock skew. It accepts only the configured asymmetric algorithms.
  • Same AuthContext: a valid token produces the same AuthContext a static key produces, with the key id derived from the issuer and subject. Per-key rate limits, allowed_models, allowed_backends, budgets, and request attribution work unchanged.
  • Claim mapping: subject_claim, user_claim, and team_claim map identity; scopes_claim, allowed_models_claim, and allowed_backends_claim map grants. A JWT scope list is authoritative, including an empty one, so an unmapped scope claim grants nothing.
  • Key caching: the JWKS is cached with a TTL. An unknown kid triggers at most one refresh per configured interval, which picks up a rotated signing key. Each fetch has a hard timeout and a body-size limit.
  • Failure behavior: an expired, wrong-audience, or wrong-signer token returns 401 with the standard bad-key body, in blocking and permissive mode alike. A JWKS fetch failure fails closed. Rejections and fetch outcomes are counted in Prometheus.
  • Build graph: the validator needs jsonwebtoken, which the embed negative dependency contract denies. The runtime is therefore behind the jwt-auth Cargo feature, which is outside default and full. The config types stay compiled in every build. A binary without the feature warns at load time that the section is inert.

Design Decisions

  • Asymmetric only: none, HMAC algorithms, and every algorithm outside the allow-list are refused before key selection, and a kty: oct JWK can never be selected.
  • No token-selected key material: jku, jwk, and x5u headers are refused. A token cannot choose the key or endpoint used to verify it.
  • Bounded work: token size, algorithm count, kid length, JWKS body size, key count, claim lengths, list counts, and scope count all have fixed limits.
  • Bounded network behavior: the JWKS client follows no redirects, and the URI must be https, or http on a loopback host. The streamed body is cut off at the configured byte limit.
  • Single-flight refresh: concurrent unknown-kid requests share one fetch, and a failed fetch is not retried inside the refresh interval. A stale cache is never used after its TTL.
  • Explicit scope denial: a principal with an authoritative but empty scope list satisfies only routes that require no scope. This keeps an unmapped JWT from inheriting the static-key "empty list is unrestricted" reading.
  • Fail closed on the configured issuer only: a token that names the configured issuer is refused on any later failure. A token for another issuer is indistinguishable from a malformed static key and keeps its previous behavior.

Not Covered

  • Token introspection, refresh-token exchange, and provider discovery (/.well-known/openid-configuration).
  • Per-provider claim mappings: one jwt section maps one issuer.
  • JWKS pushes or scheduled pre-refresh; the cache refreshes on a TTL or an unknown kid.
  • Validating tokens for endpoints with their own authentication (Admin, Files, Metrics), which are unaffected.

History

The issue was opened as a design note. The initial page proposed the config surface, the validator branch, AuthContext reuse, claim mapping, key caching, failure behavior, and the build-graph constraint above. It left two questions open: the exact claim names, and whether the runtime gets its own Cargo feature or joins full. The implementation settled both: claim names are configurable with generic defaults, and the runtime is a separate opt-in feature.