# modo::auth::jwt
JWT authentication for the `modo` framework: token encoding, decoding, Tower middleware, pluggable token sources, and optional revocation.
Requires the `auth` feature flag.
```toml
[dependencies]
modo = { version = "0.6", features = ["auth"] }
```
## Key Types
| `Claims<T>` | JWT claims (7 registered fields + typed custom payload) |
| `JwtConfig` | YAML-deserialized configuration |
| `JwtEncoder` | Signs tokens (HS256) |
| `JwtDecoder` | Verifies and decodes tokens |
| `JwtLayer<T>` | Tower layer — installs JWT auth on an axum route |
| `Bearer` | Extractor for the raw `Authorization: Bearer` string |
| `JwtError` | Typed error variants with static `code()` strings |
| `Revocation` | Trait for pluggable token revocation backends |
| `TokenSource` | Trait for pluggable token extraction (header, cookie, query) |
| `BearerSource` | Extracts token from `Authorization: Bearer <token>` |
| `QuerySource` | Extracts token from a named query parameter |
| `CookieSource` | Extracts token from a named cookie |
| `HeaderSource` | Extracts token from a custom request header |
| `HmacSigner` | HS256 HMAC-SHA256 `TokenSigner` / `TokenVerifier` implementation |
| `TokenSigner` | Trait for JWT signing (extends `TokenVerifier`) |
| `TokenVerifier` | Trait for JWT signature verification |
| `ValidationConfig` | Leeway, issuer, and audience validation policy |
## Configuration
```yaml
jwt:
secret: "${JWT_SECRET}"
default_expiry: 3600 # seconds; omit to require explicit exp on every token
leeway: 5 # clock skew tolerance in seconds; defaults to 0
issuer: "my-app" # optional; reject tokens with a different iss
audience: "api" # optional; reject tokens with a different aud
```
Construct services from config:
```rust,ignore
use modo::auth::jwt::{JwtConfig, JwtEncoder, JwtDecoder};
let mut config = JwtConfig::new("my-secret");
config.default_expiry = Some(3600);
let encoder = JwtEncoder::from_config(&config);
let decoder = JwtDecoder::from_config(&config);
// Or share the same key material:
let decoder = JwtDecoder::from(&encoder);
```
## Usage
### Encoding tokens
```rust,ignore
use std::time::Duration;
use serde::{Serialize, Deserialize};
use modo::auth::jwt::{Claims, JwtConfig, JwtEncoder};
use modo::id;
#[derive(Clone, Serialize, Deserialize)]
struct AppClaims { role: String }
let mut config = JwtConfig::new("my-secret");
config.default_expiry = Some(3600);
let encoder = JwtEncoder::from_config(&config);
let claims = Claims::new(AppClaims { role: "admin".into() })
.with_sub(id::ulid())
.with_iat_now()
.with_exp_in(Duration::from_secs(3600))
.with_jti(id::ulid()); // required for revocation checks
let token: String = encoder.encode(&claims).unwrap();
```
### Decoding tokens
```rust,ignore
use modo::auth::jwt::{Claims, JwtConfig, JwtDecoder};
#[derive(Clone, serde::Serialize, serde::Deserialize)]
struct AppClaims { role: String }
let mut config = JwtConfig::new("my-secret");
config.default_expiry = Some(3600);
let decoder = JwtDecoder::from_config(&config);
let token: String = /* JWT string received from the client */;
let claims: Claims<AppClaims> = decoder.decode(&token).unwrap();
println!("{}", claims.subject().unwrap_or("?"));
```
### Middleware (axum Router)
```rust,ignore
use axum::{Router, routing::get};
use modo::auth::jwt::{Claims, JwtConfig, JwtDecoder, JwtLayer};
#[derive(Clone, serde::Serialize, serde::Deserialize)]
struct AppClaims { role: String }
async fn me_handler(claims: Claims<AppClaims>) -> String {
format!("hello {}", claims.subject().unwrap_or("?"))
}
let mut config = JwtConfig::new("my-secret");
config.default_expiry = Some(3600);
let decoder = JwtDecoder::from_config(&config);
let app: Router = Router::new()
.route("/me", get(me_handler))
.layer(JwtLayer::<AppClaims>::new(decoder));
```
### Optional authentication
```rust,ignore
use modo::auth::jwt::Claims;
#[derive(Clone, serde::Serialize, serde::Deserialize)]
struct AppClaims { role: String }
async fn feed_handler(claims: Option<Claims<AppClaims>>) -> String {
match claims {
Some(c) => format!("auth:{}", c.custom.role),
None => "anon".into(),
}
}
```
### Custom token sources
```rust,ignore
use std::sync::Arc;
use modo::auth::jwt::{
JwtConfig, JwtDecoder, JwtLayer, BearerSource, QuerySource, CookieSource, TokenSource,
};
#[derive(Clone, serde::Serialize, serde::Deserialize)]
struct AppClaims { role: String }
let mut config = JwtConfig::new("my-secret");
config.default_expiry = Some(3600);
let decoder = JwtDecoder::from_config(&config);
let layer = JwtLayer::<AppClaims>::new(decoder)
.with_sources(vec![
Arc::new(BearerSource) as Arc<dyn TokenSource>,
Arc::new(QuerySource("token")) as Arc<dyn TokenSource>,
Arc::new(CookieSource("jwt")) as Arc<dyn TokenSource>,
]);
```
### Token revocation
```rust,ignore
use std::pin::Pin;
use std::sync::Arc;
use modo::auth::jwt::{JwtConfig, JwtDecoder, JwtLayer, Revocation};
use modo::Result;
struct MyRevocationStore;
impl Revocation for MyRevocationStore {
fn is_revoked(&self, jti: &str) -> Pin<Box<dyn Future<Output = Result<bool>> + Send + '_>> {
Box::pin(async move {
// Query your DB or cache here
Ok(false)
})
}
}
let mut config = JwtConfig::new("my-secret");
config.default_expiry = Some(3600);
let decoder = JwtDecoder::from_config(&config);
let layer = JwtLayer::<()>::new(decoder)
.with_revocation(Arc::new(MyRevocationStore));
```
Tokens without a `jti` are accepted without calling `is_revoked`.
A backend error causes a fail-closed `401`.
### Error identity in error handlers
```rust,ignore
use modo::auth::jwt::JwtError;
// Before IntoResponse (e.g. in a guard):
// if let Some(&JwtError::Expired) = err.source_as::<JwtError>() { /* ... */ }
// After IntoResponse (e.g. in a custom error handler):
// if err.error_code() == Some("jwt:expired") { /* ... */ }
```
## Error handling
All error codes are prefixed `jwt:` — see `JwtError::code()` for the full list.
| `MissingToken` | `jwt:missing_token` | 401 | No token found by any `TokenSource` |
| `InvalidHeader` | `jwt:invalid_header` | 401 | Token header cannot be decoded or parsed |
| `MalformedToken` | `jwt:malformed_token` | 401 | Token does not have the expected three-part structure |
| `DeserializationFailed` | `jwt:deserialization_failed` | 401 | Payload cannot be deserialized into the target claims type |
| `InvalidSignature` | `jwt:invalid_signature` | 401 | Signature does not match the signing key |
| `Expired` | `jwt:expired` | 401 | `exp` is in the past (beyond leeway), or `exp` is missing |
| `NotYetValid` | `jwt:not_yet_valid` | 401 | `nbf` is in the future (beyond leeway) |
| `InvalidIssuer` | `jwt:invalid_issuer` | 401 | `iss` does not match the required issuer |
| `InvalidAudience` | `jwt:invalid_audience` | 401 | `aud` does not match the required audience |
| `Revoked` | `jwt:revoked` | 401 | Token `jti` found in the revocation store |
| `RevocationCheckFailed` | `jwt:revocation_check_failed` | 401 | Revocation backend returned an error (fail-closed) |
| `AlgorithmMismatch` | `jwt:algorithm_mismatch` | 401 | Token header specifies a different algorithm than the verifier |
| `SigningFailed` | `jwt:signing_failed` | 500 | HMAC signing operation failed |
| `SerializationFailed` | `jwt:serialization_failed` | 500 | Claims could not be serialized to JSON |