No description
Find a file
2026-02-01 18:07:22 -08:00
examples Refactor middleware and make auth_callback redirect instead of success message 2026-02-01 18:07:22 -08:00
src Refactor middleware and make auth_callback redirect instead of success message 2026-02-01 18:07:22 -08:00
.envrc Initial commit 2026-02-01 16:13:42 -08:00
.gitignore Move lib to root dir 2026-02-01 17:37:00 -08:00
Cargo.lock Move lib to root dir 2026-02-01 17:37:00 -08:00
Cargo.toml Move lib to root dir 2026-02-01 17:37:00 -08:00
devenv.lock Initial commit 2026-02-01 16:13:42 -08:00
devenv.nix Initial commit 2026-02-01 16:13:42 -08:00
devenv.yaml Initial commit 2026-02-01 16:13:42 -08:00
README.md Refactor middleware and make auth_callback redirect instead of success message 2026-02-01 18:07:22 -08:00

SF Auth Middleware for Axum

Authentication middleware for Axum applications using the SnazzyFellas authentication service with tower_sessions for session management.

Features

  • Struct Middleware: Tower-based middleware with dynamic redirect URI callback
  • Extractor: Type-safe access to authenticated user information via the SfUser extractor
  • Callback Handler: Ready-to-use route handler for authentication callbacks
  • Session Integration: Seamless integration with tower-sessions
  • Fail-Closed Security: Validation failures result in denied access, not automatic approval
  • Flexible Redirects: Use a callback function to dynamically determine redirect URIs

Installation

Add this to your Cargo.toml:

[dependencies]
sf-auth-middleware-axum = "0.1"
axum = "0.8"
tower-sessions = "0.15"
tokio = { version = "1", features = ["full"] }

Quick Start

use axum::{routing::get, Router};
use sf_auth_middleware_axum::{SfAuthLayer, create_auth_callback, SfUser};
use tower_sessions::{MemoryStore, SessionManagerLayer};

#[tokio::main]
async fn main() {
    // Set up session store
    let session_store = MemoryStore::default();
    let session_layer = SessionManagerLayer::new(session_store);

    // Build your application
    let app = Router::new()
        // Public callback route - redirects to /dashboard after successful auth
        .route("/auth/callback", get(create_auth_callback("/dashboard")))
        // Protected routes
        .route("/dashboard", get(dashboard))
        // Apply authentication middleware - points to the callback route
        .layer(SfAuthLayer::new(|_req| {
            "http://localhost:3000/auth/callback".to_string()
        }))
        // Add session layer
        .layer(session_layer);

    // Run the server
    let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap();
    axum::serve(listener, app).await.unwrap();
}

async fn dashboard(user: SfUser) -> String {
    format!("Hello, {}! Your ID: {}", user.username(), user.user_id())
}

How It Works

  1. Protection: Apply the middleware layer to routes that require authentication
  2. Session Check: The middleware checks for sf_username and sf_user_id in the session
  3. Redirect to SF Auth: If not authenticated, calls your callback to get the redirect URI (should point to your /auth/callback route), then redirects to:
    https://snazzyfellas.com/api/redirect/authenticate?redirect_uri={your_callback_route}
    
  4. SF Auth Validates: The SF server authenticates the user and redirects back to your callback route with credentials (user_id, username, key)
  5. Callback Validates: Your callback handler validates credentials with the SF server:
    POST https://snazzyfellas.com/api/redirect/validate
    Body: { "user_id": "...", "key": "..." }
    Response: { "valid": true, "user_id": "..." }
    
  6. Session Setup: On successful validation, sets sf_username and sf_user_id in the session
  7. Final Redirect: Redirects to the URI specified when you created the callback (e.g., /dashboard)
  8. Access Granted: Subsequent requests use the SfUser extractor to access authenticated user data

Example Flow

User -> /dashboard (protected)
  ↓
Middleware checks session (not authenticated)
  ↓
Redirect to: https://snazzyfellas.com/api/redirect/authenticate?redirect_uri=http://myapp.com/auth/callback
  ↓
SF Auth validates user
  ↓
Redirect to: http://myapp.com/auth/callback?user_id=123&username=john&key=abc
  ↓
Callback validates with SF server
  ↓
Session set (sf_username, sf_user_id)
  ↓
Redirect to: /dashboard
  ↓
User accesses /dashboard (authenticated)

Architecture

Middleware Layer (SfAuthLayer)

The SfAuthLayer is a Tower middleware that checks authentication before allowing requests through. It takes a callback function that determines where users should be redirected after authentication.

Simple Static Redirect to Callback:

// Point to your callback route
let auth_layer = SfAuthLayer::new(|_req| {
    "http://localhost:3000/auth/callback".to_string()
});

Dynamic Redirect Based on Environment:

let auth_layer = SfAuthLayer::new(|req| {
    // Use different callback URLs for different environments
    let host = req.headers()
        .get("host")
        .and_then(|h| h.to_str().ok())
        .unwrap_or("localhost:3000");
    
    format!("http://{}/auth/callback", host)
});

Multiple Callback Routes:

// Different sections can use different callback routes with different post-auth destinations
let admin_layer = SfAuthLayer::new(|_req| {
    "http://myapp.com/admin/callback".to_string()
});

let user_layer = SfAuthLayer::new(|_req| {
    "http://myapp.com/user/callback".to_string()
});

// Then define different callback handlers:
.route("/admin/callback", get(create_auth_callback("/admin/dashboard")))
.route("/user/callback", get(create_auth_callback("/user/profile")))

Callback Route (create_auth_callback)

Create a callback handler that specifies where to redirect users after successful authentication:

// Redirect to dashboard after auth
.route("/auth/callback", get(create_auth_callback("/dashboard")))

// Or use a full URL
.route("/auth/callback", get(create_auth_callback("https://myapp.com/dashboard")))

Important: The callback route must be publicly accessible (not behind the auth middleware).

The callback handler:

  • Receives user_id, username, and key as query parameters from SF auth server
  • Validates credentials with the SF server
  • Sets session values (sf_username, sf_user_id) on successful validation
  • Redirects to the specified URI on success
  • Returns error on validation failure (fail-closed)

Middleware Configuration: The middleware's redirect URI callback should point to this callback route:

.layer(SfAuthLayer::new(|_req| {
    "http://localhost:3000/auth/callback".to_string()
}))

Extractor (SfUser)

Use the SfUser extractor in your handlers to access authenticated user data:

async fn protected_handler(user: SfUser) -> String {
    format!("Username: {}, ID: {}", user.username(), user.user_id())
}

The extractor provides:

  • user.username() - The authenticated user's username
  • user.user_id() - The authenticated user's ID

If the session doesn't contain valid credentials, the extractor returns a 401 Unauthorized error.

Session Keys

The middleware uses fixed session keys for consistency:

  • sf_username - Stores the authenticated user's username
  • sf_user_id - Stores the authenticated user's ID

Error Handling

The library uses a fail-closed security model:

  • Network Errors: If validation API calls fail, authentication is denied
  • Invalid Response: Malformed responses from the validation endpoint result in denied access
  • Validation Failure: If the SF server returns valid: false, session is not set
  • User ID Mismatch: If the returned user_id doesn't match the request, authentication is denied

All errors implement Axum's IntoResponse trait for automatic HTTP error responses.

Session Store

This library works with any tower-sessions store. Common options:

Memory Store (Development)

use tower_sessions::MemoryStore;
let session_store = MemoryStore::default();

Redis Store (Production)

use tower_sessions_redis_store::RedisStore;
let pool = deadpool_redis::Pool::new(...);
let session_store = RedisStore::new(pool);

PostgreSQL Store (Production)

use tower_sessions_sqlx_store::PostgresStore;
let pool = sqlx::PgPool::connect("...").await?;
let session_store = PostgresStore::new(pool);

Advanced Usage

Protecting Specific Routes

You can apply the middleware to specific route groups:

let app = Router::new()
    // Public routes
    .route("/", get(home))
    .route("/about", get(about))
    // Public callback that redirects to admin dashboard
    .route("/auth/callback", get(create_auth_callback("/admin/dashboard")))
    // Protected admin routes
    .nest("/admin", admin_routes().layer(SfAuthLayer::new(|_| {
        "http://myapp.com/auth/callback".to_string()
    })))
    // Apply session layer to everything
    .layer(session_layer);

fn admin_routes() -> Router {
    Router::new()
        .route("/dashboard", get(admin_dashboard))
        .route("/users", get(admin_users))
}

Multiple Callback Routes for Different Sections

Different parts of your app can use different callback routes that redirect to different destinations:

let app = Router::new()
    // Admin callback redirects to admin dashboard
    .route("/admin/callback", get(create_auth_callback("/admin/dashboard")))
    // User callback redirects to user profile
    .route("/user/callback", get(create_auth_callback("/user/profile")))
    // Admin section uses admin callback
    .nest("/admin", admin_routes().layer(SfAuthLayer::new(|_| {
        "http://myapp.com/admin/callback".to_string()
    })))
    // User section uses user callback
    .nest("/user", user_routes().layer(SfAuthLayer::new(|_| {
        "http://myapp.com/user/callback".to_string()
    })))
    .layer(session_layer);

Examples

Run the included example:

cargo run --example basic

Then visit:

  • http://localhost:3000/ - Public home page
  • http://localhost:3000/dashboard - Protected page (will redirect to SF auth)
  • http://localhost:3000/profile - Another protected page

API Reference

SfAuthLayer

pub struct SfAuthLayer { /* ... */ }

impl SfAuthLayer {
    pub fn new<F>(redirect_uri_fn: F) -> Self
    where
        F: Fn(&Request) -> String + Send + Sync + 'static
}

SfUser

pub struct SfUser { /* ... */ }

impl SfUser {
    pub fn username(&self) -> &str
    pub fn user_id(&self) -> &str
}

create_auth_callback

pub fn create_auth_callback(
    redirect_uri: impl Into<String>,
) -> impl Fn(Session, Query<CallbackQuery>) -> Future<Output = Result<Response, SfAuthError>>

Creates a handler that validates authentication and redirects to the specified URI on success.

Security Considerations

  1. HTTPS Required: Always use HTTPS in production for session security
  2. Secure Sessions: Configure session cookies with secure and httponly flags
  3. Session Expiry: Set appropriate session expiration times
  4. Fail-Closed: The middleware denies access on any validation errors
  5. Callback Security: The /auth/callback route validates all credentials before setting session

Example secure session configuration:

use tower_sessions::Expiry;
use time::Duration;

let session_layer = SessionManagerLayer::new(session_store)
    .with_secure(true)
    .with_http_only(true)
    .with_same_site(cookie::SameSite::Lax)
    .with_expiry(Expiry::OnInactivity(Duration::hours(2)));

Troubleshooting

"No session found" errors

Make sure the SessionManagerLayer is applied AFTER your routes but BEFORE the SfAuthLayer:

let app = Router::new()
    .route("/protected", get(handler))
    .layer(SfAuthLayer::new(|_| "...".to_string()))  // Auth layer first
    .layer(session_layer);                            // Session layer last

Callback route requires authentication

The /auth/callback route must be defined BEFORE applying the auth layer, or it should be in a separate router that doesn't have the auth middleware.

Session not persisting

Ensure your session store is properly configured and that cookies are being set correctly (check HTTPS requirements for secure cookies).

License

This project is licensed under the MIT License.

Contributing

Contributions are welcome! Please feel free to submit a Pull Request.