# 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`: ```toml [dependencies] sf-auth-middleware-axum = "0.1" axum = "0.8" tower-sessions = "0.15" tokio = { version = "1", features = ["full"] } ``` ## Quick Start ```rust 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:** ```rust // Point to your callback route let auth_layer = SfAuthLayer::new(|_req| { "http://localhost:3000/auth/callback".to_string() }); ``` **Dynamic Redirect Based on Environment:** ```rust 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:** ```rust // 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: ```rust // 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: ```rust .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: ```rust 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) ```rust use tower_sessions::MemoryStore; let session_store = MemoryStore::default(); ``` ### Redis Store (Production) ```rust use tower_sessions_redis_store::RedisStore; let pool = deadpool_redis::Pool::new(...); let session_store = RedisStore::new(pool); ``` ### PostgreSQL Store (Production) ```rust 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: ```rust 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: ```rust 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: ```bash 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` ```rust pub struct SfAuthLayer { /* ... */ } impl SfAuthLayer { pub fn new(redirect_uri_fn: F) -> Self where F: Fn(&Request) -> String + Send + Sync + 'static } ``` ### `SfUser` ```rust pub struct SfUser { /* ... */ } impl SfUser { pub fn username(&self) -> &str pub fn user_id(&self) -> &str } ``` ### `create_auth_callback` ```rust pub fn create_auth_callback( redirect_uri: impl Into, ) -> impl Fn(Session, Query) -> Future> ``` 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: ```rust 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`: ```rust 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.