feat(workspace): full server + sync implementation, all tests green
- solitaire_server: Axum auth, sync push/pull, leaderboard, daily challenge, account deletion, JWT middleware, rate limiting via tower_governor, SQLite migrations, health endpoint - solitaire_server: expose build_test_router (no rate limiting) so integration tests work without a peer IP in oneshot requests - solitaire_sync: SyncPayload, merge logic, shared API types - solitaire_data: SyncProvider trait, LocalOnlyProvider, SolitaireServerClient, auth_tokens keyring integration, blanket Box<dyn SyncProvider> impl - solitaire_data/settings: derive Default on SyncBackend (clippy fix) - .sqlx/: offline query cache so server compiles without a live DB - sqlx: removed non-existent "offline" feature flag - keyring v2: fixed Entry::new() returning Result<Entry> - sqlx 0.8: all SQLite TEXT columns wrapped in Option<T> - Integration tests: max_connections(1) on in-memory pool so all connections share the same schema All 191 tests pass; cargo clippy -D warnings clean. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,117 @@
|
||||
//! Axum middleware for JWT authentication.
|
||||
//!
|
||||
//! Extracts and validates the `Authorization: Bearer <token>` header, then
|
||||
//! injects the authenticated `user_id` into request extensions so handlers
|
||||
//! can access it via `Extension<AuthenticatedUser>`.
|
||||
|
||||
use axum::{
|
||||
extract::{FromRequestParts, Request},
|
||||
http::request::Parts,
|
||||
middleware::Next,
|
||||
response::Response,
|
||||
};
|
||||
use jsonwebtoken::{decode, DecodingKey, Validation};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::error::AppError;
|
||||
|
||||
/// The claims encoded in our JWT access tokens.
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct Claims {
|
||||
/// Subject — the user's UUID string.
|
||||
pub sub: String,
|
||||
/// Expiry timestamp (Unix seconds).
|
||||
pub exp: usize,
|
||||
/// Token kind: `"access"` or `"refresh"`.
|
||||
pub kind: String,
|
||||
}
|
||||
|
||||
/// The authenticated user identity injected into request extensions after
|
||||
/// successful JWT validation.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AuthenticatedUser {
|
||||
/// The authenticated user's UUID, as a string.
|
||||
pub user_id: String,
|
||||
}
|
||||
|
||||
/// Axum middleware function that validates the Bearer JWT and injects
|
||||
/// [`AuthenticatedUser`] into request extensions.
|
||||
///
|
||||
/// Returns `401 Unauthorized` if the token is missing, expired, or invalid.
|
||||
pub async fn require_auth(
|
||||
mut req: Request,
|
||||
next: Next,
|
||||
) -> Result<Response, AppError> {
|
||||
let secret = std::env::var("JWT_SECRET")
|
||||
.map_err(|_| AppError::Internal("JWT_SECRET not set".into()))?;
|
||||
|
||||
let token = extract_bearer_token(req.headers())
|
||||
.ok_or(AppError::Unauthorized)?;
|
||||
|
||||
let claims = validate_access_token(&token, &secret)?;
|
||||
|
||||
req.extensions_mut().insert(AuthenticatedUser {
|
||||
user_id: claims.sub,
|
||||
});
|
||||
|
||||
Ok(next.run(req).await)
|
||||
}
|
||||
|
||||
/// Extract the raw token string from `Authorization: Bearer <token>`.
|
||||
fn extract_bearer_token(headers: &axum::http::HeaderMap) -> Option<String> {
|
||||
let value = headers.get("Authorization")?.to_str().ok()?;
|
||||
let token = value.strip_prefix("Bearer ")?;
|
||||
Some(token.to_string())
|
||||
}
|
||||
|
||||
/// Decode and validate a JWT access token, returning its claims on success.
|
||||
pub fn validate_access_token(token: &str, secret: &str) -> Result<Claims, AppError> {
|
||||
let key = DecodingKey::from_secret(secret.as_bytes());
|
||||
let mut validation = Validation::default();
|
||||
validation.validate_exp = true;
|
||||
|
||||
let data = decode::<Claims>(token, &key, &validation)
|
||||
.map_err(|_| AppError::Unauthorized)?;
|
||||
|
||||
if data.claims.kind != "access" {
|
||||
return Err(AppError::Unauthorized);
|
||||
}
|
||||
|
||||
Ok(data.claims)
|
||||
}
|
||||
|
||||
/// Decode and validate a JWT refresh token, returning its claims on success.
|
||||
pub fn validate_refresh_token(token: &str, secret: &str) -> Result<Claims, AppError> {
|
||||
let key = DecodingKey::from_secret(secret.as_bytes());
|
||||
let mut validation = Validation::default();
|
||||
validation.validate_exp = true;
|
||||
|
||||
let data = decode::<Claims>(token, &key, &validation)
|
||||
.map_err(|_| AppError::Unauthorized)?;
|
||||
|
||||
if data.claims.kind != "refresh" {
|
||||
return Err(AppError::Unauthorized);
|
||||
}
|
||||
|
||||
Ok(data.claims)
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Axum extractor — allows handlers to receive AuthenticatedUser directly
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[axum::async_trait]
|
||||
impl<S> FromRequestParts<S> for AuthenticatedUser
|
||||
where
|
||||
S: Send + Sync,
|
||||
{
|
||||
type Rejection = AppError;
|
||||
|
||||
async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
|
||||
parts
|
||||
.extensions
|
||||
.get::<AuthenticatedUser>()
|
||||
.cloned()
|
||||
.ok_or(AppError::Unauthorized)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user