get401-axum 0.1.0

Axum integration for get401 authentication — extractors and Tower middleware.
Documentation

get401-axum

get401 authentication for Axum. Provides Axum extractors and Tower middleware layers for JWT verification — choose whichever style fits your architecture.

Backend only. Runs in Tokio-based Axum server applications.

Installation

[dependencies]
get401-axum = "0.1"
get401      = "0.1"          # for TokenClaims, Get401Error types
axum        = "0.8"
tokio       = { version = "1", features = ["full"] }

Setup

Create a Get401Auth and add it to your Axum application state:

use axum::{Router, routing::get};
use get401_axum::Get401Auth;

let auth = Get401Auth::new(
    std::env::var("GET401_APP_ID").unwrap(),
    std::env::var("GET401_ORIGIN").unwrap(),
);

let app = Router::new()
    .route("/me", get(me_handler))
    .with_state(auth);

Composite app state

When your application state contains more than auth:

use axum::extract::FromRef;
use get401_axum::Get401Auth;

#[derive(Clone)]
struct AppState {
    auth: Get401Auth,
    // db: Pool, ...
}

// Required so extractors can find Get401Auth inside AppState
impl FromRef<AppState> for Get401Auth {
    fn from_ref(state: &AppState) -> Self { state.auth.clone() }
}

Style 1 — Extractors (per-handler auth)

Best when different routes have different auth requirements.

Claims — require authentication

use axum::{routing::get, Json, Router};
use get401_axum::{Claims, Get401Auth};
use serde_json::{json, Value};

async fn me(Claims(claims): Claims) -> Json<Value> {
    Json(json!({
        "user_id": claims.sub,
        "roles":   claims.roles,
        "scopes":  claims.scopes(),
    }))
}

let app = Router::new()
    .route("/me", get(me))
    .with_state(auth);

OptionalClaims — optional authentication

async fn feed(OptionalClaims(claims): OptionalClaims) -> Json<Value> {
    match claims {
        Some(c) => Json(json!({ "feed": "personalised", "user": c.sub })),
        None    => Json(json!({ "feed": "public" })),
    }
}

Manual role / scope checks in the handler

use axum::http::StatusCode;

async fn admin(Claims(claims): Claims) -> Result<Json<Value>, StatusCode> {
    if !claims.has_role("ADMIN") {
        return Err(StatusCode::FORBIDDEN);
    }
    Ok(Json(json!({ "message": "hello admin" })))
}

async fn export(Claims(claims): Claims) -> Result<Json<Value>, StatusCode> {
    if !claims.has_scope("reports:export") {
        return Err(StatusCode::FORBIDDEN);
    }
    Ok(Json(json!({ "ok": true })))
}

Style 2 — Tower Layers (route-group auth)

Best for protecting entire groups of routes without touching individual handlers. Successful authentication injects TokenClaims as a request extension.

RequireAuthLayer — require valid token

use axum::{routing::get, Router, Extension};
use get401::TokenClaims;
use get401_axum::{Get401Auth, RequireAuthLayer};

// Handler reads claims from the extension injected by the layer
async fn me(Extension(claims): Extension<TokenClaims>) -> String {
    format!("Hello {}", claims.sub)
}

let app = Router::new()
    .route("/me",      get(me))
    .route("/profile", get(profile))
    .route_layer(RequireAuthLayer::new(auth.clone()));

RequireRolesLayer — require roles

use get401_axum::RequireRolesLayer;

let app = Router::new()
    // At least one role
    .route("/admin", get(admin_handler))
    .route_layer(RequireRolesLayer::any(auth.clone(), ["ADMIN"]))
    // All roles required
    .route("/superadmin", get(super_handler))
    .route_layer(RequireRolesLayer::all(auth.clone(), ["ADMIN", "SUPERUSER"]));

RequireScopeLayer — require a scope

use get401_axum::RequireScopeLayer;

let app = Router::new()
    .route("/reports/export", post(export_handler))
    .route_layer(RequireScopeLayer::new(auth.clone(), "reports:export"));

AuthLayer — soft population (never rejects)

Populates Option<TokenClaims> as an extension on every request. Useful as a global layer when you want claims available everywhere but don't want to block unauthenticated requests at the middleware level.

use axum::Extension;
use get401::TokenClaims;
use get401_axum::AuthLayer;

async fn handler(Extension(claims): Extension<Option<TokenClaims>>) -> String {
    match claims {
        Some(c) => format!("Hello {}", c.sub),
        None    => "Hello anonymous".into(),
    }
}

let app = Router::new()
    .route("/", get(handler))
    .layer(AuthLayer::new(auth));

Mixing both styles

You can freely combine extractors and layers in the same application:

// route_layer wraps every route on the same Router — always isolate
// layer-protected routes in a sub-router and merge them in.
let protected = Router::new()
    .route("/me",    get(me_handler))
    .route("/admin", get(admin_handler))
    .route_layer(RequireRolesLayer::any(auth.clone(), ["USER"]));

let app = Router::new()
    .route("/feed", get(feed))     // OptionalClaims — no layer needed
    .merge(protected)
    .with_state(auth);

HTTP responses on failure

Situation Status
Missing aact cookie 401 Unauthorized
Expired token 401 Unauthorized
Invalid / tampered token 401 Unauthorized
Wrong algorithm 401 Unauthorized
Missing role 403 Forbidden
Missing scope 403 Forbidden
get401 backend unreachable 503 Service Unavailable

All error bodies are JSON: {"error": "<message>"}.


Full example

use axum::{routing::{delete, get, post}, Extension, Json, Router};
use get401::TokenClaims;
use get401_axum::{Claims, Get401Auth, OptionalClaims, RequireRolesLayer, RequireScopeLayer};
use serde_json::{json, Value};
use tokio::net::TcpListener;

async fn public() -> Json<Value> {
    Json(json!({ "message": "no auth needed" }))
}

async fn me(Claims(claims): Claims) -> Json<Value> {
    Json(json!({ "user_id": claims.sub, "roles": claims.roles }))
}

async fn feed(OptionalClaims(claims): OptionalClaims) -> Json<Value> {
    match claims {
        Some(c) => Json(json!({ "personalised": true, "user": c.sub })),
        None    => Json(json!({ "personalised": false })),
    }
}

async fn admin(Extension(claims): Extension<TokenClaims>) -> Json<Value> {
    Json(json!({ "admin": claims.sub }))
}

async fn export(Extension(claims): Extension<TokenClaims>) -> Json<Value> {
    Json(json!({ "export": "started", "by": claims.sub }))
}

#[tokio::main]
async fn main() {
    let auth = Get401Auth::new("my-app-id", "https://myapp.com");

    // IMPORTANT: route_layer wraps every route already on that Router.
    // Use separate sub-routers so each layer only covers its intended routes.
    let admin_routes = Router::new()
        .route("/admin", get(admin))
        .route_layer(RequireRolesLayer::any(auth.clone(), ["ADMIN"]));

    let export_routes = Router::new()
        .route("/reports/export", post(export))
        .route_layer(RequireScopeLayer::new(auth.clone(), "reports:export"));

    let app = Router::new()
        .route("/public", get(public))  // no auth
        .route("/feed",   get(feed))    // optional auth via extractor
        .route("/me",     get(me))      // required auth via extractor
        .merge(admin_routes)
        .merge(export_routes)
        .with_state(auth);

    let listener = TcpListener::bind("0.0.0.0:3000").await.unwrap();
    axum::serve(listener, app).await.unwrap();
}