Add middleware
This commit is contained in:
parent
be36cde694
commit
88b21fc08d
11 changed files with 2083 additions and 17 deletions
1267
sf-auth-middleware-axum/Cargo.lock
generated
1267
sf-auth-middleware-axum/Cargo.lock
generated
File diff suppressed because it is too large
Load diff
|
|
@ -5,3 +5,17 @@ edition = "2024"
|
|||
|
||||
[dependencies]
|
||||
axum = "0.8.8"
|
||||
tower = "0.5"
|
||||
tower-sessions = "0.15"
|
||||
http = "1.0"
|
||||
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
thiserror = "2.0"
|
||||
tokio = { version = "1.0", features = ["full"] }
|
||||
tracing = "0.1"
|
||||
urlencoding = "2.1"
|
||||
async-trait = "0.1"
|
||||
|
||||
[dev-dependencies]
|
||||
tracing-subscriber = "0.3"
|
||||
|
|
|
|||
255
sf-auth-middleware-axum/README.md
Normal file
255
sf-auth-middleware-axum/README.md
Normal file
|
|
@ -0,0 +1,255 @@
|
|||
# SF Auth Middleware for Axum
|
||||
|
||||
Authentication middleware for Axum applications using the SnazzyFellas authentication service with tower_sessions for session management.
|
||||
|
||||
## Features
|
||||
|
||||
- **Middleware**: Automatically redirect unauthenticated users to the SF auth endpoint
|
||||
- **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
|
||||
|
||||
## 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, middleware};
|
||||
use sf_auth_middleware_axum::{SfAuthConfig, sf_auth_middleware, auth_callback, SfUser};
|
||||
use tower_sessions::{MemoryStore, SessionManagerLayer};
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
// Configure the authentication middleware
|
||||
let config = SfAuthConfig::new("https://myapp.com/dashboard");
|
||||
|
||||
// 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 (no auth required)
|
||||
.route("/auth/callback", get(auth_callback))
|
||||
// Protected routes
|
||||
.route("/dashboard", get(dashboard))
|
||||
.layer(middleware::from_fn(move |session, req, next| {
|
||||
sf_auth_middleware(config.clone(), session, req, next)
|
||||
}))
|
||||
// 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 to routes that require authentication
|
||||
2. **Session Check**: The middleware checks for `sf_username` and `sf_user_id` in the session
|
||||
3. **Redirect**: If not authenticated, redirects to the SF authentication endpoint:
|
||||
```
|
||||
https://snazzyfellas.com/api/redirect/authenticate?redirect_uri={your_configured_uri}
|
||||
```
|
||||
4. **Callback**: The SF server redirects back to `/auth/callback` with credentials (`user_id`, `username`, `key`)
|
||||
5. **Validation**: The 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. **Access Granted**: Use the `SfUser` extractor in handlers to access authenticated user data
|
||||
|
||||
## Architecture
|
||||
|
||||
### Configuration (`SfAuthConfig`)
|
||||
|
||||
Configure the redirect URI where users should land after authentication:
|
||||
|
||||
```rust
|
||||
let config = SfAuthConfig::new("https://myapp.com/dashboard");
|
||||
```
|
||||
|
||||
### Middleware (`sf_auth_middleware`)
|
||||
|
||||
The middleware function checks authentication and redirects unauthenticated users:
|
||||
|
||||
```rust
|
||||
use axum::middleware;
|
||||
|
||||
.layer(middleware::from_fn(move |session, req, next| {
|
||||
sf_auth_middleware(config.clone(), session, req, next)
|
||||
}))
|
||||
```
|
||||
|
||||
### Callback Route (`auth_callback`)
|
||||
|
||||
Mount this handler at `/auth/callback` to receive authentication callbacks:
|
||||
|
||||
```rust
|
||||
.route("/auth/callback", get(auth_callback))
|
||||
```
|
||||
|
||||
This route:
|
||||
- Receives `user_id`, `username`, and `key` as query parameters
|
||||
- Validates credentials with the SF server
|
||||
- Sets session values on successful validation
|
||||
- Returns error on validation failure (fail-closed)
|
||||
|
||||
### 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);
|
||||
```
|
||||
|
||||
## 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)
|
||||
|
||||
## API Reference
|
||||
|
||||
### `SfAuthConfig`
|
||||
|
||||
```rust
|
||||
pub struct SfAuthConfig { /* ... */ }
|
||||
|
||||
impl SfAuthConfig {
|
||||
pub fn new(redirect_uri: impl Into<String>) -> Self
|
||||
pub fn redirect_uri(&self) -> &str
|
||||
}
|
||||
```
|
||||
|
||||
### `SfUser`
|
||||
|
||||
```rust
|
||||
pub struct SfUser { /* ... */ }
|
||||
|
||||
impl SfUser {
|
||||
pub fn username(&self) -> &str
|
||||
pub fn user_id(&self) -> &str
|
||||
}
|
||||
```
|
||||
|
||||
### `sf_auth_middleware`
|
||||
|
||||
```rust
|
||||
pub async fn sf_auth_middleware(
|
||||
config: SfAuthConfig,
|
||||
session: Session,
|
||||
req: Request,
|
||||
next: Next,
|
||||
) -> Response
|
||||
```
|
||||
|
||||
### `auth_callback`
|
||||
|
||||
```rust
|
||||
pub async fn auth_callback(
|
||||
session: Session,
|
||||
Query(params): Query<CallbackQuery>,
|
||||
) -> Result<Response, SfAuthError>
|
||||
```
|
||||
|
||||
## 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
|
||||
|
||||
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_expiry(Expiry::OnInactivity(Duration::hours(2)));
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
This project is licensed under the MIT License.
|
||||
|
||||
## Contributing
|
||||
|
||||
Contributions are welcome! Please feel free to submit a Pull Request.
|
||||
144
sf-auth-middleware-axum/examples/basic.rs
Normal file
144
sf-auth-middleware-axum/examples/basic.rs
Normal file
|
|
@ -0,0 +1,144 @@
|
|||
use axum::{middleware, response::Html, routing::get, Router};
|
||||
use sf_auth_middleware_axum::{auth_callback, sf_auth_middleware, SfAuthConfig, SfUser};
|
||||
use tower_sessions::{MemoryStore, SessionManagerLayer};
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() {
|
||||
// Set up tracing for debugging
|
||||
tracing_subscriber::fmt::init();
|
||||
|
||||
// Configure the SF authentication middleware
|
||||
// The redirect_uri should point to where users should land after authentication
|
||||
let config = SfAuthConfig::new("http://localhost:3000/dashboard");
|
||||
|
||||
// Set up session store using in-memory storage
|
||||
// In production, you'd want to use a persistent store like Redis or PostgreSQL
|
||||
let session_store = MemoryStore::default();
|
||||
let session_layer = SessionManagerLayer::new(session_store);
|
||||
|
||||
// Build the application router
|
||||
let app = Router::new()
|
||||
// Public route - no authentication required
|
||||
.route("/", get(home))
|
||||
// Authentication callback route - must be publicly accessible
|
||||
// This is where the SF auth server redirects users after authentication
|
||||
.route("/auth/callback", get(auth_callback))
|
||||
// Protected routes - require authentication
|
||||
.route("/dashboard", get(dashboard))
|
||||
.route("/profile", get(profile))
|
||||
// Apply authentication middleware to protected routes
|
||||
.layer(middleware::from_fn(move |session, req, next| {
|
||||
sf_auth_middleware(config.clone(), session, req, next)
|
||||
}))
|
||||
// Apply session layer (must be after the routes)
|
||||
.layer(session_layer);
|
||||
|
||||
// Start the server
|
||||
let listener = tokio::net::TcpListener::bind("0.0.0.0:3000")
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
println!("Server running on http://localhost:3000");
|
||||
println!("Try accessing:");
|
||||
println!(" - http://localhost:3000/ (public)");
|
||||
println!(" - http://localhost:3000/dashboard (protected, will redirect to SF auth)");
|
||||
println!(" - http://localhost:3000/profile (protected, will redirect to SF auth)");
|
||||
|
||||
axum::serve(listener, app).await.unwrap();
|
||||
}
|
||||
|
||||
/// Public home page
|
||||
async fn home() -> Html<&'static str> {
|
||||
Html(
|
||||
r#"
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>SF Auth Example</title>
|
||||
<style>
|
||||
body { font-family: Arial, sans-serif; max-width: 800px; margin: 50px auto; padding: 20px; }
|
||||
a { color: #0066cc; text-decoration: none; }
|
||||
a:hover { text-decoration: underline; }
|
||||
.button {
|
||||
display: inline-block;
|
||||
padding: 10px 20px;
|
||||
background: #0066cc;
|
||||
color: white;
|
||||
border-radius: 4px;
|
||||
margin: 10px 5px;
|
||||
}
|
||||
.button:hover { background: #0052a3; text-decoration: none; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Welcome to SF Auth Example</h1>
|
||||
<p>This is a public page that anyone can access.</p>
|
||||
<p>Try accessing protected pages:</p>
|
||||
<a href="/dashboard" class="button">Go to Dashboard (Protected)</a>
|
||||
<a href="/profile" class="button">Go to Profile (Protected)</a>
|
||||
<p>When you try to access a protected page, you'll be redirected to the SnazzyFellas authentication server.</p>
|
||||
</body>
|
||||
</html>
|
||||
"#,
|
||||
)
|
||||
}
|
||||
|
||||
/// Protected dashboard page
|
||||
async fn dashboard(user: SfUser) -> Html<String> {
|
||||
Html(format!(
|
||||
r#"
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Dashboard</title>
|
||||
<style>
|
||||
body {{ font-family: Arial, sans-serif; max-width: 800px; margin: 50px auto; padding: 20px; }}
|
||||
.user-info {{ background: #f0f0f0; padding: 15px; border-radius: 4px; margin: 20px 0; }}
|
||||
a {{ color: #0066cc; }}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>Dashboard</h1>
|
||||
<div class="user-info">
|
||||
<h2>Authenticated User</h2>
|
||||
<p><strong>Username:</strong> {}</p>
|
||||
<p><strong>User ID:</strong> {}</p>
|
||||
</div>
|
||||
<p><a href="/">Back to Home</a> | <a href="/profile">View Profile</a></p>
|
||||
</body>
|
||||
</html>
|
||||
"#,
|
||||
user.username(),
|
||||
user.user_id()
|
||||
))
|
||||
}
|
||||
|
||||
/// Protected profile page
|
||||
async fn profile(user: SfUser) -> Html<String> {
|
||||
Html(format!(
|
||||
r#"
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>Profile</title>
|
||||
<style>
|
||||
body {{ font-family: Arial, sans-serif; max-width: 800px; margin: 50px auto; padding: 20px; }}
|
||||
.profile {{ background: #e8f4f8; padding: 20px; border-radius: 4px; margin: 20px 0; }}
|
||||
a {{ color: #0066cc; }}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>User Profile</h1>
|
||||
<div class="profile">
|
||||
<h2>{}</h2>
|
||||
<p><strong>ID:</strong> {}</p>
|
||||
<p>This is your protected profile page.</p>
|
||||
</div>
|
||||
<p><a href="/">Back to Home</a> | <a href="/dashboard">View Dashboard</a></p>
|
||||
</body>
|
||||
</html>
|
||||
"#,
|
||||
user.username(),
|
||||
user.user_id()
|
||||
))
|
||||
}
|
||||
66
sf-auth-middleware-axum/src/callback.rs
Normal file
66
sf-auth-middleware-axum/src/callback.rs
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
use axum::{
|
||||
extract::Query,
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
use serde::Deserialize;
|
||||
use tower_sessions::Session;
|
||||
|
||||
use crate::{client::validate_user, error::SfAuthError};
|
||||
|
||||
/// Query parameters received by the callback route
|
||||
#[derive(Debug, Deserialize)]
|
||||
pub struct CallbackQuery {
|
||||
user_id: String,
|
||||
username: String,
|
||||
key: String,
|
||||
}
|
||||
|
||||
/// Handler for the authentication callback route.
|
||||
///
|
||||
/// This route should be mounted at `/auth/callback` in your application.
|
||||
/// It receives `user_id`, `username`, and `key` as query parameters,
|
||||
/// validates the credentials with the SF authentication server, and
|
||||
/// sets the session if validation succeeds.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```ignore
|
||||
/// use axum::{routing::get, Router};
|
||||
/// use sf_auth_middleware_axum::auth_callback;
|
||||
///
|
||||
/// let app = Router::new()
|
||||
/// .route("/auth/callback", get(auth_callback));
|
||||
/// ```
|
||||
///
|
||||
/// # Query Parameters
|
||||
///
|
||||
/// - `user_id`: The user's ID
|
||||
/// - `username`: The user's username
|
||||
/// - `key`: The authentication key to validate
|
||||
///
|
||||
/// # Returns
|
||||
///
|
||||
/// Returns a 200 OK response with a success message if validation succeeds,
|
||||
/// or an error response if validation fails.
|
||||
pub async fn auth_callback(
|
||||
session: Session,
|
||||
Query(params): Query<CallbackQuery>,
|
||||
) -> Result<Response, SfAuthError> {
|
||||
// Validate the credentials with the SF server
|
||||
let validated_user_id = validate_user(params.user_id.clone(), params.key).await?;
|
||||
|
||||
// Set session values only if validation succeeded
|
||||
session
|
||||
.insert("sf_username", params.username.clone())
|
||||
.await
|
||||
.map_err(|e| SfAuthError::Session(e.to_string()))?;
|
||||
|
||||
session
|
||||
.insert("sf_user_id", validated_user_id)
|
||||
.await
|
||||
.map_err(|e| SfAuthError::Session(e.to_string()))?;
|
||||
|
||||
// Return success response
|
||||
// Note: The SF auth server handles the redirect, so we just confirm success
|
||||
Ok("Authentication successful".into_response())
|
||||
}
|
||||
64
sf-auth-middleware-axum/src/client.rs
Normal file
64
sf-auth-middleware-axum/src/client.rs
Normal file
|
|
@ -0,0 +1,64 @@
|
|||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::error::SfAuthError;
|
||||
|
||||
const VALIDATION_URL: &str = "https://snazzyfellas.com/api/redirect/validate";
|
||||
|
||||
/// Request payload for validation API
|
||||
#[derive(Debug, Serialize)]
|
||||
struct ValidationRequest {
|
||||
user_id: String,
|
||||
key: String,
|
||||
}
|
||||
|
||||
/// Response from validation API
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ValidationResponse {
|
||||
valid: bool,
|
||||
user_id: String,
|
||||
}
|
||||
|
||||
/// Validates user credentials with the SF authentication server.
|
||||
///
|
||||
/// Makes a POST request to the validation endpoint with the user_id and key.
|
||||
/// Returns `Ok(user_id)` if validation succeeds, or an error otherwise.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `user_id` - The user ID to validate
|
||||
/// * `key` - The authentication key to validate
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns an error if:
|
||||
/// - The HTTP request fails
|
||||
/// - The validation response indicates invalid credentials
|
||||
/// - The returned user_id doesn't match the requested user_id
|
||||
pub(crate) async fn validate_user(user_id: String, key: String) -> Result<String, SfAuthError> {
|
||||
let client = reqwest::Client::new();
|
||||
|
||||
let request_payload = ValidationRequest {
|
||||
user_id: user_id.clone(),
|
||||
key,
|
||||
};
|
||||
|
||||
let response = client
|
||||
.post(VALIDATION_URL)
|
||||
.json(&request_payload)
|
||||
.send()
|
||||
.await?;
|
||||
|
||||
let validation_response: ValidationResponse = response.json().await?;
|
||||
|
||||
// Check if validation succeeded
|
||||
if !validation_response.valid {
|
||||
return Err(SfAuthError::ValidationFailed);
|
||||
}
|
||||
|
||||
// Verify that the returned user_id matches what we sent
|
||||
if validation_response.user_id != user_id {
|
||||
return Err(SfAuthError::UserIdMismatch);
|
||||
}
|
||||
|
||||
Ok(validation_response.user_id)
|
||||
}
|
||||
41
sf-auth-middleware-axum/src/config.rs
Normal file
41
sf-auth-middleware-axum/src/config.rs
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
/// Configuration for SF authentication middleware
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SfAuthConfig {
|
||||
/// The redirect URI to pass to the authentication endpoint.
|
||||
/// This is where users will be redirected after successful authentication.
|
||||
redirect_uri: String,
|
||||
}
|
||||
|
||||
impl SfAuthConfig {
|
||||
/// Creates a new `SfAuthConfig` with the specified redirect URI.
|
||||
///
|
||||
/// # Arguments
|
||||
///
|
||||
/// * `redirect_uri` - The URI where users should be redirected after authentication
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```ignore
|
||||
/// use sf_auth_middleware_axum::SfAuthConfig;
|
||||
///
|
||||
/// let config = SfAuthConfig::new("https://myapp.com/dashboard");
|
||||
/// ```
|
||||
pub fn new(redirect_uri: impl Into<String>) -> Self {
|
||||
Self {
|
||||
redirect_uri: redirect_uri.into(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Returns the configured redirect URI
|
||||
pub fn redirect_uri(&self) -> &str {
|
||||
&self.redirect_uri
|
||||
}
|
||||
|
||||
/// Builds the full authentication URL with the redirect_uri query parameter
|
||||
pub(crate) fn auth_url(&self) -> String {
|
||||
format!(
|
||||
"https://snazzyfellas.com/api/redirect/authenticate?redirect_uri={}",
|
||||
urlencoding::encode(&self.redirect_uri)
|
||||
)
|
||||
}
|
||||
}
|
||||
43
sf-auth-middleware-axum/src/error.rs
Normal file
43
sf-auth-middleware-axum/src/error.rs
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
use axum::{
|
||||
http::StatusCode,
|
||||
response::{IntoResponse, Response},
|
||||
};
|
||||
use thiserror::Error;
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum SfAuthError {
|
||||
#[error("Session error: {0}")]
|
||||
Session(String),
|
||||
|
||||
#[error("Validation API request failed: {0}")]
|
||||
ValidationRequest(#[from] reqwest::Error),
|
||||
|
||||
#[error("Validation failed: user not valid")]
|
||||
ValidationFailed,
|
||||
|
||||
#[error("User ID mismatch in validation response")]
|
||||
UserIdMismatch,
|
||||
|
||||
#[error("Missing required query parameter: {0}")]
|
||||
MissingQueryParam(String),
|
||||
|
||||
#[error("Unauthorized: user not authenticated")]
|
||||
Unauthorized,
|
||||
}
|
||||
|
||||
impl IntoResponse for SfAuthError {
|
||||
fn into_response(self) -> Response {
|
||||
let status = match self {
|
||||
SfAuthError::Unauthorized => StatusCode::UNAUTHORIZED,
|
||||
SfAuthError::MissingQueryParam(_) => StatusCode::BAD_REQUEST,
|
||||
SfAuthError::ValidationFailed | SfAuthError::UserIdMismatch => {
|
||||
StatusCode::FORBIDDEN
|
||||
}
|
||||
_ => StatusCode::INTERNAL_SERVER_ERROR,
|
||||
};
|
||||
|
||||
let body = self.to_string();
|
||||
|
||||
(status, body).into_response()
|
||||
}
|
||||
}
|
||||
79
sf-auth-middleware-axum/src/extractor.rs
Normal file
79
sf-auth-middleware-axum/src/extractor.rs
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
use axum::{
|
||||
extract::FromRequestParts,
|
||||
http::request::Parts,
|
||||
};
|
||||
use tower_sessions::Session;
|
||||
|
||||
use crate::error::SfAuthError;
|
||||
|
||||
/// Authenticated user information extracted from the session.
|
||||
///
|
||||
/// This extractor can be used in route handlers to access the authenticated user's
|
||||
/// username and user ID. If the user is not authenticated (session keys are missing),
|
||||
/// the extraction will fail with an `Unauthorized` error.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```ignore
|
||||
/// use axum::{routing::get, Router};
|
||||
/// use sf_auth_middleware_axum::SfUser;
|
||||
///
|
||||
/// async fn protected_handler(user: SfUser) -> String {
|
||||
/// format!("Hello, {}! Your ID: {}", user.username(), user.user_id())
|
||||
/// }
|
||||
///
|
||||
/// let app = Router::new().route("/protected", get(protected_handler));
|
||||
/// ```
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SfUser {
|
||||
username: String,
|
||||
user_id: String,
|
||||
}
|
||||
|
||||
impl SfUser {
|
||||
/// Creates a new `SfUser` instance.
|
||||
pub(crate) fn new(username: String, user_id: String) -> Self {
|
||||
Self { username, user_id }
|
||||
}
|
||||
|
||||
/// Returns the authenticated user's username.
|
||||
pub fn username(&self) -> &str {
|
||||
&self.username
|
||||
}
|
||||
|
||||
/// Returns the authenticated user's ID.
|
||||
pub fn user_id(&self) -> &str {
|
||||
&self.user_id
|
||||
}
|
||||
}
|
||||
|
||||
impl<S> FromRequestParts<S> for SfUser
|
||||
where
|
||||
S: Send + Sync,
|
||||
{
|
||||
type Rejection = SfAuthError;
|
||||
|
||||
fn from_request_parts(
|
||||
parts: &mut Parts,
|
||||
state: &S,
|
||||
) -> impl std::future::Future<Output = Result<Self, Self::Rejection>> + Send {
|
||||
async move {
|
||||
// Extract the session from the request
|
||||
let session = Session::from_request_parts(parts, state)
|
||||
.await
|
||||
.map_err(|_| SfAuthError::Session("Failed to extract session".to_string()))?;
|
||||
|
||||
// Get username from session
|
||||
let username: Option<String> = session.get("sf_username").await.unwrap_or(None);
|
||||
|
||||
// Get user_id from session
|
||||
let user_id: Option<String> = session.get("sf_user_id").await.unwrap_or(None);
|
||||
|
||||
// Both must be present for a valid authenticated user
|
||||
match (username, user_id) {
|
||||
(Some(username), Some(user_id)) => Ok(SfUser::new(username, user_id)),
|
||||
_ => Err(SfAuthError::Unauthorized),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,7 +1,73 @@
|
|||
async fn auth_middleware(
|
||||
session: Session,
|
||||
req: Request<Body>,
|
||||
next: Next
|
||||
) -> Result<Response, StatusCode> {
|
||||
let user_id = Option<String> = session.get("user_id").await.unwrap_or(None);
|
||||
}
|
||||
//! # SF Auth Middleware for Axum
|
||||
//!
|
||||
//! This library provides authentication middleware for Axum applications using
|
||||
//! the SnazzyFellas authentication service with tower_session for session management.
|
||||
//!
|
||||
//! ## Features
|
||||
//!
|
||||
//! - **Middleware**: Automatically redirect unauthenticated users to the SF auth endpoint
|
||||
//! - **Extractor**: Type-safe access to authenticated user information
|
||||
//! - **Callback Handler**: Ready-to-use route for handling authentication callbacks
|
||||
//! - **Session Integration**: Seamless integration with tower_session
|
||||
//!
|
||||
//! ## Quick Start
|
||||
//!
|
||||
//! ```no_run
|
||||
//! use axum::{routing::get, Router, middleware};
|
||||
//! use sf_auth_middleware_axum::{SfAuthConfig, sf_auth_middleware, auth_callback, SfUser};
|
||||
//! use tower_session::{SessionManagerLayer, MemoryStore};
|
||||
//!
|
||||
//! #[tokio::main]
|
||||
//! async fn main() {
|
||||
//! // Configure the authentication middleware
|
||||
//! let config = SfAuthConfig::new("https://myapp.com/dashboard");
|
||||
//!
|
||||
//! // 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 (no auth required)
|
||||
//! .route("/auth/callback", get(auth_callback))
|
||||
//! // Protected routes
|
||||
//! .route("/protected", get(protected_handler))
|
||||
//! .layer(middleware::from_fn(move |session, req, next| {
|
||||
//! sf_auth_middleware(config.clone(), session, req, next)
|
||||
//! }))
|
||||
//! // 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 protected_handler(user: SfUser) -> String {
|
||||
//! format!("Hello, {}! Your ID: {}", user.username(), user.user_id())
|
||||
//! }
|
||||
//! ```
|
||||
//!
|
||||
//! ## How It Works
|
||||
//!
|
||||
//! 1. **Protection**: Apply the middleware to routes that require authentication
|
||||
//! 2. **Check**: The middleware checks for `sf_username` and `sf_user_id` in the session
|
||||
//! 3. **Redirect**: If not authenticated, redirects to `https://snazzyfellas.com/api/redirect/authenticate?redirect_uri={your_uri}`
|
||||
//! 4. **Callback**: The SF server redirects back to `/auth/callback` with credentials
|
||||
//! 5. **Validation**: The callback handler validates credentials with the SF server
|
||||
//! 6. **Session**: On success, sets `sf_username` and `sf_user_id` in the session
|
||||
//! 7. **Access**: Use the `SfUser` extractor in handlers to access authenticated user data
|
||||
|
||||
mod callback;
|
||||
mod client;
|
||||
mod config;
|
||||
mod error;
|
||||
mod extractor;
|
||||
mod middleware;
|
||||
|
||||
// Public exports
|
||||
pub use callback::auth_callback;
|
||||
pub use config::SfAuthConfig;
|
||||
pub use error::SfAuthError;
|
||||
pub use extractor::SfUser;
|
||||
pub use middleware::sf_auth_middleware;
|
||||
|
|
|
|||
47
sf-auth-middleware-axum/src/middleware.rs
Normal file
47
sf-auth-middleware-axum/src/middleware.rs
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
use axum::{
|
||||
extract::Request,
|
||||
middleware::Next,
|
||||
response::{IntoResponse, Redirect, Response},
|
||||
};
|
||||
use tower_sessions::Session;
|
||||
|
||||
use crate::config::SfAuthConfig;
|
||||
|
||||
/// Middleware function that enforces SF authentication.
|
||||
///
|
||||
/// This middleware checks if the user has valid session credentials (`sf_username` and `sf_user_id`).
|
||||
/// If not authenticated, it redirects to the SF authentication endpoint.
|
||||
///
|
||||
/// # Example
|
||||
///
|
||||
/// ```ignore
|
||||
/// use axum::{routing::get, Router, middleware};
|
||||
/// use sf_auth_middleware_axum::{SfAuthConfig, sf_auth_middleware};
|
||||
///
|
||||
/// let config = SfAuthConfig::new("https://myapp.com/dashboard");
|
||||
///
|
||||
/// let app = Router::new()
|
||||
/// .route("/protected", get(|| async { "Protected!" }))
|
||||
/// .layer(middleware::from_fn(move |session, req, next| {
|
||||
/// sf_auth_middleware(config.clone(), session, req, next)
|
||||
/// }));
|
||||
/// ```
|
||||
pub async fn sf_auth_middleware(
|
||||
config: SfAuthConfig,
|
||||
session: Session,
|
||||
req: Request,
|
||||
next: Next,
|
||||
) -> Response {
|
||||
// Try to get username and user_id from session
|
||||
let username: Option<String> = session.get("sf_username").await.unwrap_or(None);
|
||||
let user_id: Option<String> = session.get("sf_user_id").await.unwrap_or(None);
|
||||
|
||||
// Check if both are present
|
||||
if username.is_some() && user_id.is_some() {
|
||||
// User is authenticated, proceed with the request
|
||||
next.run(req).await
|
||||
} else {
|
||||
// User is not authenticated, redirect to auth endpoint
|
||||
Redirect::to(&config.auth_url()).into_response()
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue