sf-auth-middleware-axum/README.md

377 lines
12 KiB
Markdown
Raw Permalink Normal View History

2026-02-01 17:31:42 -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
2026-02-01 17:31:42 -08:00
- **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
2026-02-01 17:31:42 -08:00
## 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};
2026-02-01 17:31:42 -08:00
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")))
2026-02-01 17:31:42 -08:00
// 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()
2026-02-01 17:31:42 -08:00
}))
// 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
2026-02-01 17:31:42 -08:00
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:
2026-02-01 17:31:42 -08:00
```
https://snazzyfellas.com/api/redirect/authenticate?redirect_uri={your_callback_route}
2026-02-01 17:31:42 -08:00
```
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:
2026-02-01 17:31:42 -08:00
```
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)
```
2026-02-01 17:31:42 -08:00
## Architecture
### Middleware Layer (`SfAuthLayer`)
2026-02-01 17:31:42 -08:00
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.
2026-02-01 17:31:42 -08:00
**Simple Static Redirect to Callback:**
2026-02-01 17:31:42 -08:00
```rust
// Point to your callback route
let auth_layer = SfAuthLayer::new(|_req| {
"http://localhost:3000/auth/callback".to_string()
});
2026-02-01 17:31:42 -08:00
```
**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)
});
```
2026-02-01 17:31:42 -08:00
**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:
2026-02-01 17:31:42 -08:00
```rust
// Redirect to dashboard after auth
.route("/auth/callback", get(create_auth_callback("/dashboard")))
2026-02-01 17:31:42 -08:00
// 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()
2026-02-01 17:31:42 -08:00
}))
```
### 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);
```
2026-02-01 17:31:42 -08:00
## 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
2026-02-01 17:31:42 -08:00
## API Reference
### `SfAuthLayer`
2026-02-01 17:31:42 -08:00
```rust
pub struct SfAuthLayer { /* ... */ }
2026-02-01 17:31:42 -08:00
impl SfAuthLayer {
pub fn new<F>(redirect_uri_fn: F) -> Self
where
F: Fn(&Request) -> String + Send + Sync + 'static
2026-02-01 17:31:42 -08:00
}
```
### `SfUser`
```rust
pub struct SfUser { /* ... */ }
impl SfUser {
pub fn username(&self) -> &str
pub fn user_id(&self) -> &str
}
```
### `create_auth_callback`
2026-02-01 17:31:42 -08:00
```rust
pub fn create_auth_callback(
redirect_uri: impl Into<String>,
) -> impl Fn(Session, Query<CallbackQuery>) -> Future<Output = Result<Response, SfAuthError>>
2026-02-01 17:31:42 -08:00
```
Creates a handler that validates authentication and redirects to the specified URI on success.
2026-02-01 17:31:42 -08:00
## 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
2026-02-01 17:31:42 -08:00
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)
2026-02-01 17:31:42 -08:00
.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).
2026-02-01 17:31:42 -08:00
## License
This project is licensed under the MIT License.
## Contributing
Contributions are welcome! Please feel free to submit a Pull Request.