Skip to content

v0.8.0

Choose a tag to compare

@RomanEmreis RomanEmreis released this 14 Oct 15:27
· 2 commits to main since this release
97b35c3

neva 0.8.0 lets a handler see who is calling, gates tools, prompts and resources on OAuth scopes with a step-up the neva client already follows, and validates an authorization server's tokens when the server cannot reach the issuer's URL. It is a minor bump for narrow source breaks and two stricter token checks; see Migration. MSRV is unchanged at 1.90.

Who is calling

claims() on a handler's Context and on a middleware's MwContext: the claims of the caller's access token, as the HTTP engine validated it, or None without one (#161).

#[tool]
async fn whoami(ctx: Context) -> Result<String, Error> {
    let Some(caller) = ctx.claims() else {
        return Err(Error::new(ErrorCode::InvalidRequest, "no authenticated caller"));
    };
    // A subject is unique only within its issuer: key per-user data by both.
    Ok(format!(
        "{} from {}",
        caller.subject().unwrap_or("nobody"),
        caller.issuer().unwrap_or("no issuer")
    ))
}
  • Claims gains issuer(), audience(), client_id() and scopes() (RFC 9068), which DefaultClaims reads from iss, aud, client_id and scope. downcast_ref::<T>() on dyn Claims gives an engine's own claims type back.
  • claims() compiles without http-server and answers None there, so handler code builds the same on a stdio-only feature set. neva::auth::{Claims, DefaultClaims} need only server.

Scope-gated tools, prompts and resources

scopes = [..] on #[tool], #[prompt] and #[resource], and with_scopes(..) on Tool, Prompt, ResourceTemplate and UiResource. Every listed scope is required (#162).

#[tool(scopes = ["mcp:tools", "mcp:admin"])]
async fn purge_cache() -> &'static str {
    "cache purged"
}
  • A token short of a scope gets a 403 with WWW-Authenticate: Bearer error="insufficient_scope", scope=".." (RFC 6750 section 3.1), and ErrorCode::InsufficientScope (-31403) with requiredScopes and missingScopes in data. The neva client re-authorizes for the union of what it holds and what the challenge names, and retries (SEP-2350).
  • The check runs before dispatch, so a streamed reply and a batch are refused with a 403 as well; a batch is refused whole. Both protocol profiles.
  • scopes_supported in the Protected Resource Metadata defaults to the declared scopes; with_scopes on with_oauth_metadata overrides it.
  • Scopes are checked after roles and permissions, which keep their 200 + InvalidParams refusal. A scope-gated item stays in the listings: a client has to see it before it can step up for it.

Keys for an issuer the server cannot reach

In OAuth issuer mode one URL is the discovery endpoint, the iss tokens must carry, and where clients are sent to authorize. A server in a container, or behind a public ingress, reaches the issuer at another address than the one tokens and clients use (#163).

http.with_auth(|auth| auth.with_oauth(|oauth| oauth
    // what tokens carry in `iss`, and where clients go
    .with_issuer("http://localhost:8787")
    // where this server, in a container, gets the keys
    .with_jwks_uri("http://host.docker.internal:8787/jwks")
    .with_config(|cfg| cfg.with_client_config(|c| c.require_https(false)))))
  • OAuthConfig::with_jwks_uri fetches the key set from that URL and skips discovery. Rotation, refresh cooldown and key age work as with discovery.
  • AuthConfig::with_decoding_key takes a static RSA, EC or Ed25519 public key, from PEM or a JWK, for a server that cannot reach the issuer at all. Pair it with with_alg, with_iss and with_aud. neva::auth::DecodingKey is re-exported. A static key does not follow rotations.
  • A server with both a static key and with_oauth fails to start.
  • The Volga adapter requires volga 0.13.3, which makes static RSA, EC and Ed25519 keys validate tokens at all (RomanEmreis/volga#280), and has with_iss require the claim (RomanEmreis/volga#281).

Migration

DefaultClaims.aud is a Vec<String>, read from a string or an array; it was Option<String>. DefaultClaims has two new public fields, client_id and scope, so a struct literal needs them or ..Default::default(). Prefer caller.audience() and caller.scopes() to the fields.

ErrorCode has a new variant, InsufficientScope. An exhaustive match on it needs an arm.

with_alg replaces the default HS256 instead of adding to it, so with_alg(Algorithm::HS512) no longer accepts HS256 tokens; to accept both, name both. A static key whose family does not match the configured algorithms fails the start, an RSA or EC key without with_alg included.

with_iss refuses a token without iss. It compared the claim only when the token had one. To accept such tokens, leave with_iss out.

Behaviour changes

  • A middleware's own answer, without calling next or after a timeout around it, reaches the caller. It was dropped, and the caller waited out its timeout (#164).
  • A requestState is bound to the caller's issuer as well as its subject. One minted before the upgrade for an authenticated caller is refused once, as a principal mismatch.
  • scopes_supported is no longer empty when with_scopes was not called and the server declares scopes.
  • Under legacy-spec, a reply carrying InsufficientScope is sent with 403, where every dispatched reply was 200.

Fixed

  • A token whose aud is an array is accepted. jsonwebtoken validated it, and DefaultClaims then failed to decode it, so a valid token got a 401 (#161).
  • With tracing on and no subscriber installed, start failures (runtime, transport, HTTP engine) and the MCP Apps resource warning went nowhere, and a misconfigured server exited without a word. They go to stderr now.

The full list is in CHANGELOG.md. Diff: 0.7.1...0.8.0.