Volga
Home
Agent Skill
API Docs
GitHub
  • English
  • Русский
Home
Agent Skill
API Docs
GitHub
  • English
  • Русский
  • Home
  • Getting Started

    • Quick Start
    • Handlers
    • Route Parameters
    • Query Parameters
    • Route Groups
  • Requests & Responses

    • Headers
    • Handling JSON
    • Handling Form Data
    • Working with Files
    • Multipart Responses
    • Raw Request Body
    • Cookies
    • Validating Requests
  • Middleware & Infrastructure

    • Basic Middleware
    • Custom Middleware
    • Parameterized Middleware
    • Response Compression
    • Request Decompression
    • CORS (Cross-Origin Resource Sharing)
    • Static Files
    • Rate Limiting
    • Configuration Files
    • OpenAPI
  • Security & Access

    • Authentication and Authorization
    • OAuth 2.1 & OpenID Connect
    • OAuth 2.1 Client
    • Machine-to-Machine Grants
    • DPoP — Sender-Constrained Tokens
  • Reliability & Observability

    • Global Error Handling
    • Tracing & Logging
    • Request cancellation
    • Graceful Shutdown
  • Protocols & Realtime

    • HTTP Versions and Methods
    • HTTPS
    • WebSockets
    • Server-Sent Events (SSE)
  • Advanced Patterns

    • Dependency Injection
    • Custom Handling of HEAD, OPTIONS, and TRACE Methods
  • Agent Skill

OAuth 2.1 & OpenID Connect

Volga ships a full OAuth 2.1 / OpenID Connect foundation on top of its Bearer Token authentication. It lets you build resource servers that validate tokens against an OAuth 2.1 / OIDC issuer's published keys — with no shared secret — and serve the discovery metadata documents clients need to start a flow.

The protocol-level types (error models, metadata documents, the WWW-Authenticate challenge builder, well-known URL derivation) live under volga::auth::oauth and are shared with the standalone OAuth client.

Feature flags

FeatureWhat it enables
oauthOAuth 2.1 / OIDC foundation types at volga::auth::oauth and metadata serving (implied by jwt-auth).
oauth-clientIssuer-based bearer validation — App::with_oauth / App::use_oauth. Implies jwt-auth.
[dependencies]
volga = { version = "...", features = ["oauth-client"] }

Validating Tokens Against an Issuer

Instead of configuring a static DecodingKey, you can point bearer authentication at an OAuth 2.1 / OIDC issuer. Volga fetches the issuer's server metadata (RFC 8414, with an OpenID Connect Discovery fallback) and the JSON Web Key Set it advertises, then validates incoming JWTs keyed by each token's kid.

Describe the issuer with with_oauth(...) and activate it explicitly with use_oauth():

use serde::Deserialize;
use volga::{
    App, ok,
    auth::{AuthClaims, roles},
};

#[tokio::main]
async fn main() -> std::io::Result<()> {
    let mut app = App::new()
        // audience, expiry and the other token checks stay here
        .with_bearer_auth(|auth| auth.with_aud(["https://api.example.com"]))
        // the keys and the `iss` constraint come from the issuer
        .with_oauth(|oauth| oauth.with_issuer("https://auth.example.com"));

    // explicit opt-in — nothing validates against the issuer until this call
    app.use_oauth();

    app.map_get("/protected", protected)
        .authorize::<Claims>(roles(["admin"]));

    app.run().await
}

async fn protected() -> &'static str {
    "Hello from the protected route!"
}

#[derive(Clone, Deserialize)]
struct Claims {
    role: String,
}

impl AuthClaims for Claims {
    fn role(&self) -> Option<&str> {
        Some(&self.role)
    }
}

With issuer-based validation no static decoding key is required — the keys are resolved at runtime. Everything else (aud, expiry, scopes and roles) keeps coming from with_bearer_auth.

Info

The iss claim is constrained to the configured issuer automatically and made required — tokens omitting it, or carrying a different issuer, are rejected.

Key lifecycle

Keys are fetched lazily on the first request and cached, so token validation costs no network round-trip in the common case. The cache maintains itself:

  • A token with an unknown kid triggers a refresh (key rotation), rate-limited by with_refresh_cooldown (default 60 s); concurrent misses share a single refresh.
  • Known kids are re-checked with the issuer once the cached set is older than with_max_key_age (default 15 minutes), so a revoked or re-keyed kid stops validating without a restart.
  • While the issuer is unreachable and keys were already loaded, the last known set keeps serving — an issuer outage does not take token validation down with it. When no keys have ever loaded, protected routes answer 503 (a server-side problem) rather than blaming the token.

Configuration

The issuer is mandatory; everything else has production-safe defaults.

use std::time::Duration;
use volga::App;

let app = App::new()
    .with_bearer_auth(|auth| auth.with_aud(["https://api.example.com"]))
    .with_oauth(|oauth| oauth
        .with_issuer("https://auth.example.com")
        .with_refresh_cooldown(Duration::from_secs(30))
        .with_max_key_age(Duration::from_secs(600))
        // discovery / JWKS transport policy
        .with_client_config(|client| client.require_https(true)));

Since 0.14.1 an OAuthConfig built elsewhere goes in whole with set_oauth(config), the value form of with_oauth. use_oauth() is still required.

For a local development issuer served over plain HTTP, relax the transport policy:

let app = App::new()
    .with_oauth(|oauth| oauth
        .with_issuer("http://127.0.0.1:5000")
        .with_client_config(|client| client.require_https(false)));

With the config feature the same knobs can be described in the [oauth.client] section of the configuration file — fields present in the file override the builder calls, unknown keys fail startup, and activation still requires the explicit App::use_oauth() call in code:

[oauth.client]
issuer = "https://auth.example.com"
jwks_uri = "https://auth.internal/jwks"  # optional, skips discovery (0.13.2+)
refresh_cooldown_secs = 60   # optional
max_key_age_secs = 900       # optional
require_https = true         # optional
timeout_secs = 30            # optional
max_redirects = 5            # optional

When the Issuer's URL Is Not Reachable From the Server

Discovery fetches the issuer's metadata from the URL tokens carry in iss, so it assumes the server can reach that URL. Some deployments can't: the issuer runs on the host as http://localhost:8787 while the API runs in a container and reaches it as http://host.docker.internal:8787, or a Kubernetes service talks to the identity provider by its in-cluster name while tokens name the public ingress host.

Since 0.13.2, with_jwks_uri(...) names the URL the JSON Web Key Set is fetched from, and discovery is skipped:

let mut app = App::new()
    .with_bearer_auth(|auth| auth.with_aud(["https://api.example.com"]))
    .with_oauth(|oauth| oauth
        // the `iss` tokens carry — unchanged
        .with_issuer("http://localhost:8787")
        // where this server fetches the keys from
        .with_jwks_uri("http://host.docker.internal:8787/jwks")
        // the URL is plain HTTP
        .with_client_config(|client| client.require_https(false)));

app.use_oauth();

Only where the keys come from changes. with_issuer is still required and is still the iss every token must carry. The lazy first fetch, the refresh on an unknown kid, with_max_key_age, the last known set served while the URL is unreachable and the 503 before any keys have loaded all work as they do with discovery. In the configuration file the same URL is jwks_uri in [oauth.client].

Warning

Don't point with_issuer at the internal URL instead. Discovery requires the metadata's issuer to equal the URL it was fetched from, the jwks_uri in that metadata still names the public host, and tokens would have to carry the internal URL in iss — so validation fails either way. Keep with_issuer as the tokens name it and set with_jwks_uri.

Tips

The transport policy from with_client_config applies to this URL too: a plain http:// URL needs require_https(false), as above. Keep HTTPS enforced whenever the URL is https://.

Info

OAuthConfig::with_jwks_uri says where this server fetches someone else's keys from. It is unrelated to with_jwks_uri on with_oauth_server_metadata, which publishes the URL of your own keys in the metadata document you serve.

Serving Metadata Documents

A resource server tells clients where to authenticate; an authorization server publishes its endpoints and keys. Volga serves both discovery documents from your application.

Protected Resource Metadata (RFC 9728)

Configure it with with_oauth_resource_metadata (or set_oauth_resource_metadata for the whole value, including the &str identifier shorthand) and serve it with use_oauth_resource_metadata:

let mut app = App::new()
    .with_oauth_resource_metadata(|metadata| metadata
        .with_resource("https://api.example.com")
        .with_authorization_servers(["https://auth.example.com"])
        .with_scopes(["read", "write"])
        .with_bearer_methods(["header"]));

// GET /.well-known/oauth-protected-resource
app.use_oauth_resource_metadata();

When bearer authentication is configured, the derived metadata URL is advertised automatically in WWW-Authenticate challenges (RFC 9728 §5.1), so an unauthenticated client can discover where to authenticate and start a flow.

Authorization Server Metadata (RFC 8414) & OIDC Discovery

Applications that are themselves an authorization server publish their endpoints via with_oauth_server_metadata and serve the document at one or both discovery paths:

let mut app = App::new()
    .with_oauth_server_metadata(|metadata| metadata
        .with_issuer("https://auth.example.com")
        .with_authorization_endpoint("https://auth.example.com/authorize")
        .with_token_endpoint("https://auth.example.com/token")
        .with_jwks_uri("https://auth.example.com/jwks"));

// authorization servers commonly publish the same document at both paths:
app.use_oauth_server_metadata()  // GET /.well-known/oauth-authorization-server
   .use_oidc_metadata();         // GET /.well-known/openid-configuration

Tips

The server-metadata closure is seeded with the OAuth 2.1 prefills response_types_supported = ["code"] and grant_types_supported = ["authorization_code"]. OIDC-specific fields required by a compliant provider document (subject_types_supported, id_token_signing_alg_values_supported, userinfo_endpoint, …) can be supplied through with_additional_field(...).

Two fields worth naming explicitly became typed builders in v0.9.6 and v0.9.8:

let mut app = App::new()
    .with_oauth_server_metadata(|metadata| metadata
        .with_issuer("https://auth.example.com")
        // RFC 9207: the `iss` parameter is returned in authorization responses,
        // which lets clients detect an authorization server mix-up
        .with_authorization_response_iss_parameter(true)
        // RFC 9449: the algorithms accepted in DPoP proofs
        .with_dpop_signing_algs(["ES256"]));

Announcing RFC 9207 support makes it mandatory for clients: a volga-oauth-client callback validation then rejects a response that carries no iss. with_authorization_response_iss_parameter is also accepted in the [oauth.server] config file section. See DPoP for the client half of the second one; the resource-side dpop_signing_alg_values_supported is available on ProtectedResourceMetadata under the same name.

Both documents can also come from the [oauth.resource] / [oauth.server] sections of the configuration file (the config feature); the file overrides prior builder calls. The set_* shorthand configures a minimal document from the identifier alone:

let mut app = App::new()
    .set_oauth_resource_metadata("https://api.example.com")
    .set_oauth_server_metadata("https://auth.example.com");

app.use_oauth_resource_metadata();
app.use_oauth_server_metadata().use_oidc_metadata();

The Full Flow

Putting the pieces together, a resource server needs only a handful of lines — token validation is wired straight to the issuer's published keys, with no secret configured anywhere:

use volga::{App, auth::{AuthClaims, roles}, ok};
use serde::Deserialize;

#[derive(Clone, Deserialize)]
struct Claims { role: String }

impl AuthClaims for Claims {
    fn role(&self) -> Option<&str> { Some(&self.role) }
}

#[tokio::main]
async fn main() -> std::io::Result<()> {
    let mut app = App::new()
        .with_oauth(|oauth| oauth.with_issuer("https://auth.example.com"))
        // advertised in WWW-Authenticate challenges
        .with_oauth_resource_metadata(|m| m
            .with_resource("https://api.example.com")
            .with_authorization_servers(["https://auth.example.com"]));

    app.use_oauth();
    app.use_oauth_resource_metadata();

    app.map_get("/protected", || async { ok!("Hello from the protected route!") })
        .authorize::<Claims>(roles(["admin"]));

    app.run().await
}

The client side of the same flow — discovery, the Authorization Code + PKCE exchange and calling the protected route — is covered on the OAuth 2.1 Client page.

Examples

  • OAuth Flow — a complete Authorization Code + PKCE flow between an authorization server, a resource server and a client, in one process.
  • OAuth Metadata — serving the RFC 8414 / RFC 9728 / OIDC discovery documents.
Last Updated: 10/21/26, 4:26 PM
Prev
Authentication and Authorization
Next
OAuth 2.1 Client