Files
madbase/auth/src/middleware.rs
Vlad Durnea 8ade39ae2d
Some checks failed
CI/CD Pipeline / e2e-tests (push) Has been cancelled
CI/CD Pipeline / build (push) Has been cancelled
CI/CD Pipeline / unit-tests (push) Has been cancelled
CI/CD Pipeline / lint (push) Successful in 3m45s
CI/CD Pipeline / integration-tests (push) Failing after 53s
M0 security hardening: fix all vulnerabilities and resolve build errors
- Fix 5 source files corrupted with markdown formatting by previous AI
- Remove secret logging from auth middleware, signup, and recovery handlers
- Add role validation (ALLOWED_ROLES allowlist) to all 10 data_api + storage handlers
- Fix JavaScript injection in Deno runtime via double-serialization
- Add UUID validation to TUS upload paths to prevent path traversal
- Gate token issuance on email confirmation (AUTH_AUTO_CONFIRM env var)
- Reject unconfirmed users on login with 403
- Prevent OAuth account takeover (409 on email conflict with different provider)
- Replace permissive CORS (allow_origin Any) with ALLOWED_ORIGINS env var
- Wire session-based admin auth into control plane, add POST /platform/v1/login
- Hide secrets from list_projects API via ProjectSummary struct
- Add missing deps (redis, uuid, chrono, tower-http fs feature)
- Fix http version mismatch between reqwest 0.11 and axum 0.7 in proxy
- Clean up all unused imports across workspace

Build: zero errors, zero warnings. Tests: 10 passed, 0 failed.
Made-with: Cursor
2026-03-15 12:54:21 +02:00

131 lines
4.1 KiB
Rust

use axum::{
extract::{Request, State},
http::StatusCode,
middleware::Next,
response::Response,
};
use common::{Config, ProjectContext};
use jsonwebtoken::{decode, Algorithm, DecodingKey, Validation};
use serde::{Deserialize, Serialize};
#[derive(Clone)]
pub struct AuthMiddlewareState {
pub config: Config,
}
#[derive(Debug, Serialize, Deserialize, Clone)]
pub struct Claims {
pub sub: String,
pub email: Option<String>,
pub role: String,
pub exp: usize,
pub iss: String,
pub aud: Option<String>,
}
#[derive(Clone)]
pub struct AuthContext {
pub claims: Option<Claims>,
pub role: String,
}
pub async fn auth_middleware(
State(state): State<AuthMiddlewareState>,
mut req: Request,
next: Next,
) -> Result<Response, StatusCode> {
// 1. Try to get ProjectContext (if available)
// If we are running in multi-tenant mode, ProjectContext should be present.
// If not, we fall back to global config (legacy/single-tenant).
let project_ctx = req.extensions().get::<ProjectContext>().cloned();
// Allow public OAuth routes
let path = req.uri().path();
if path.contains("/authorize") || path.contains("/callback") {
return Ok(next.run(req).await);
}
// Allow public Signed URL access (GET only)
if path.contains("/object/sign/") && req.method() == axum::http::Method::GET {
return Ok(next.run(req).await);
}
// Determine the secret to use
let jwt_secret = if let Some(ctx) = &project_ctx {
tracing::debug!("Using project-specific JWT secret");
ctx.jwt_secret.clone()
} else {
tracing::debug!("ProjectContext not found, using global JWT secret");
state.config.jwt_secret.clone()
};
let auth_header = req
.headers()
.get("Authorization")
.and_then(|h| h.to_str().ok())
.map(|s| s.to_string());
let apikey_header = req
.headers()
.get("apikey")
.and_then(|h| h.to_str().ok())
.map(|s| s.to_string());
// Logic:
// 1. Bearer Token takes precedence for identity (Claims).
// 2. API Key is checked if no Bearer token, OR it acts as the "Client Key" (anon/service).
// Usually Supabase requires 'apikey' header ALWAYS, and Authorization header OPTIONAL (for user context).
let token = if let Some(auth) = auth_header {
auth.strip_prefix("Bearer ").map(|t| t.to_string())
} else {
// If no Auth header, check apikey header as fallback (e.g. for anon requests)
apikey_header.clone()
};
if let Some(token) = token {
let mut validation = Validation::new(Algorithm::HS256);
validation.validate_exp = true;
validation.validate_aud = false;
// validation.set_audience(&["authenticated"]); // If we used audience
match decode::<Claims>(
&token,
&DecodingKey::from_secret(jwt_secret.as_bytes()),
&validation,
) {
Ok(token_data) => {
let claims = token_data.claims;
let role = claims.role.clone();
let ctx = AuthContext {
claims: Some(claims),
role,
};
req.extensions_mut().insert(ctx);
return Ok(next.run(req).await);
}
Err(e) => {
// Invalid token
tracing::error!("Token validation failed: {}", e);
return Err(StatusCode::UNAUTHORIZED);
}
}
}
// No valid token found.
// Assign "anon" role if apikey is valid anon key?
// Or just default to "anon" role without claims?
// Supabase usually requires a valid JWT even for anon. The 'anon key' IS a JWT with role='anon'.
// So if decoding failed above, we returned Unauthorized.
// If no header provided at all?
// We should allow public routes to proceed?
// But this middleware is applied to ALL routes in /rest, /auth etc.
// /auth/v1/signup needs to be accessible.
// But wait, even signup requires the 'anon' key in Supabase.
// So: strict check.
Err(StatusCode::UNAUTHORIZED)
}