Security follow-ups (audit 2026-05-30, both reviewed APPROVE): - MEDIUM: viewer tokens were never blacklisted on logout, so a minted session-scoped viewer token stayed valid up to its 5-min TTL after the user logged out. Add a per-user ViewerTokenRegistry (Arc<Mutex<HashMap<sub, Vec<(token, expires_at)>>>>, prune-on-insert) on AppState; mint_viewer_token registers each token under the user sub; logout drains take_for_user(sub) and blacklists each via the existing token_blacklist. The viewer WS already calls is_revoked, so no WS change. Key chain user.user_id == ViewerClaims.sub == registry key verified consistent. 8 new tests. - LOW: relay chat logs now emit content length, not the chat body (support-chat can carry secrets/PII). cargo fmt/clippy(-D warnings)/test green on GURU-5070 (37 agent + 61 server). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
287 lines
9.5 KiB
Rust
287 lines
9.5 KiB
Rust
//! Logout and token revocation endpoints
|
|
|
|
use axum::{
|
|
extract::{Request, State},
|
|
http::{HeaderMap, StatusCode},
|
|
Json,
|
|
};
|
|
use serde::Serialize;
|
|
use tracing::{info, warn};
|
|
use uuid::Uuid;
|
|
|
|
use crate::auth::AuthenticatedUser;
|
|
use crate::AppState;
|
|
|
|
use super::auth::ErrorResponse;
|
|
|
|
/// Extract JWT token from Authorization header
|
|
fn extract_token_from_headers(
|
|
headers: &HeaderMap,
|
|
) -> Result<String, (StatusCode, Json<ErrorResponse>)> {
|
|
let auth_header = headers
|
|
.get("Authorization")
|
|
.and_then(|v| v.to_str().ok())
|
|
.ok_or_else(|| {
|
|
(
|
|
StatusCode::UNAUTHORIZED,
|
|
Json(ErrorResponse {
|
|
error: "Missing Authorization header".to_string(),
|
|
}),
|
|
)
|
|
})?;
|
|
|
|
let token = auth_header.strip_prefix("Bearer ").ok_or_else(|| {
|
|
(
|
|
StatusCode::UNAUTHORIZED,
|
|
Json(ErrorResponse {
|
|
error: "Invalid Authorization format".to_string(),
|
|
}),
|
|
)
|
|
})?;
|
|
|
|
Ok(token.to_string())
|
|
}
|
|
|
|
/// Logout response
|
|
#[derive(Debug, Serialize)]
|
|
pub struct LogoutResponse {
|
|
pub message: String,
|
|
}
|
|
|
|
/// POST /api/auth/logout - Revoke current token (logout)
|
|
///
|
|
/// Adds the user's current JWT token to the blacklist, effectively logging them out.
|
|
/// The token will no longer be valid for any requests.
|
|
pub async fn logout(
|
|
State(state): State<AppState>,
|
|
user: AuthenticatedUser,
|
|
request: Request,
|
|
) -> Result<Json<LogoutResponse>, (StatusCode, Json<ErrorResponse>)> {
|
|
// Extract token from headers
|
|
let token = extract_token_from_headers(request.headers())?;
|
|
|
|
// Add the login JWT to the blacklist.
|
|
state.token_blacklist.revoke(&token).await;
|
|
|
|
// Also revoke any outstanding session-scoped VIEWER tokens this user minted
|
|
// (CRITICAL #2). The login-JWT blacklist alone leaves a viewer token minted
|
|
// before logout valid for the rest of its 5-minute TTL, keeping a live
|
|
// viewer/remote-control plane open after logout. `user.user_id` is the `sub`
|
|
// the viewer tokens were registered under (the same claim stamped into them).
|
|
// The viewer WS already blacklist-checks the exact token string, so adding
|
|
// them here is sufficient — no WS change needed. take_for_user drains and
|
|
// clears the registry entry; expired tokens are pruned (not returned).
|
|
let viewer_tokens = state.viewer_tokens.take_for_user(&user.user_id);
|
|
let revoked_viewer_count = viewer_tokens.len();
|
|
for viewer_token in viewer_tokens {
|
|
state.token_blacklist.revoke(&viewer_token).await;
|
|
}
|
|
|
|
info!(
|
|
"User {} logged out (login token revoked, {} viewer token(s) revoked)",
|
|
user.username, revoked_viewer_count
|
|
);
|
|
|
|
Ok(Json(LogoutResponse {
|
|
message: "Logged out successfully".to_string(),
|
|
}))
|
|
}
|
|
|
|
/// POST /api/auth/revoke-token - Revoke own token (same as logout)
|
|
///
|
|
/// Alias for logout endpoint for consistency with revocation terminology.
|
|
pub async fn revoke_own_token(
|
|
State(state): State<AppState>,
|
|
user: AuthenticatedUser,
|
|
request: Request,
|
|
) -> Result<Json<LogoutResponse>, (StatusCode, Json<ErrorResponse>)> {
|
|
logout(State(state), user, request).await
|
|
}
|
|
|
|
/// Revoke user request
|
|
#[derive(Debug, serde::Deserialize)]
|
|
pub struct RevokeUserRequest {
|
|
pub user_id: Uuid,
|
|
}
|
|
|
|
/// POST /api/auth/admin/revoke-user - Admin endpoint to revoke all tokens for a user
|
|
///
|
|
/// WARNING: This currently only revokes the admin's own token as a demonstration.
|
|
/// Full implementation would require:
|
|
/// 1. Session tracking table to store active JWT tokens
|
|
/// 2. Query to find all tokens for the target user
|
|
/// 3. Add all found tokens to blacklist
|
|
///
|
|
/// For MVP, we're implementing the foundation but not the full user tracking.
|
|
pub async fn revoke_user_tokens(
|
|
State(_state): State<AppState>,
|
|
admin: AuthenticatedUser,
|
|
Json(req): Json<RevokeUserRequest>,
|
|
) -> Result<Json<LogoutResponse>, (StatusCode, Json<ErrorResponse>)> {
|
|
// Verify admin permission
|
|
if !admin.is_admin() {
|
|
return Err((
|
|
StatusCode::FORBIDDEN,
|
|
Json(ErrorResponse {
|
|
error: "Admin access required".to_string(),
|
|
}),
|
|
));
|
|
}
|
|
|
|
warn!(
|
|
"Admin {} attempted to revoke tokens for user {} - NOT IMPLEMENTED (requires session tracking)",
|
|
admin.username, req.user_id
|
|
);
|
|
|
|
// TODO: Implement session tracking
|
|
// 1. Query active_sessions table for all tokens belonging to user_id
|
|
// 2. Add each token to blacklist
|
|
// 3. Delete session records from database
|
|
|
|
Err((
|
|
StatusCode::NOT_IMPLEMENTED,
|
|
Json(ErrorResponse {
|
|
error: "User token revocation not yet implemented - requires session tracking table"
|
|
.to_string(),
|
|
}),
|
|
))
|
|
}
|
|
|
|
/// GET /api/auth/blacklist/stats - Get blacklist statistics (admin only)
|
|
///
|
|
/// Returns information about the current token blacklist for monitoring.
|
|
#[derive(Debug, Serialize)]
|
|
pub struct BlacklistStatsResponse {
|
|
pub revoked_tokens_count: usize,
|
|
}
|
|
|
|
pub async fn get_blacklist_stats(
|
|
State(state): State<AppState>,
|
|
admin: AuthenticatedUser,
|
|
) -> Result<Json<BlacklistStatsResponse>, (StatusCode, Json<ErrorResponse>)> {
|
|
if !admin.is_admin() {
|
|
return Err((
|
|
StatusCode::FORBIDDEN,
|
|
Json(ErrorResponse {
|
|
error: "Admin access required".to_string(),
|
|
}),
|
|
));
|
|
}
|
|
|
|
let count = state.token_blacklist.len().await;
|
|
|
|
Ok(Json(BlacklistStatsResponse {
|
|
revoked_tokens_count: count,
|
|
}))
|
|
}
|
|
|
|
/// POST /api/auth/blacklist/cleanup - Clean up expired tokens from blacklist (admin only)
|
|
///
|
|
/// Removes expired tokens from the blacklist to prevent memory buildup.
|
|
#[derive(Debug, Serialize)]
|
|
pub struct CleanupResponse {
|
|
pub removed_count: usize,
|
|
pub remaining_count: usize,
|
|
}
|
|
|
|
pub async fn cleanup_blacklist(
|
|
State(state): State<AppState>,
|
|
admin: AuthenticatedUser,
|
|
) -> Result<Json<CleanupResponse>, (StatusCode, Json<ErrorResponse>)> {
|
|
if !admin.is_admin() {
|
|
return Err((
|
|
StatusCode::FORBIDDEN,
|
|
Json(ErrorResponse {
|
|
error: "Admin access required".to_string(),
|
|
}),
|
|
));
|
|
}
|
|
|
|
let removed = state
|
|
.token_blacklist
|
|
.cleanup_expired(&state.jwt_config)
|
|
.await;
|
|
let remaining = state.token_blacklist.len().await;
|
|
|
|
info!(
|
|
"Admin {} cleaned up blacklist: {} tokens removed, {} remaining",
|
|
admin.username, removed, remaining
|
|
);
|
|
|
|
Ok(Json(CleanupResponse {
|
|
removed_count: removed,
|
|
remaining_count: remaining,
|
|
}))
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use crate::auth::{JwtConfig, TokenBlacklist, ViewerAccess, ViewerTokenRegistry};
|
|
use std::time::Duration;
|
|
use uuid::Uuid;
|
|
|
|
/// End-to-end (component-level) proof of CRITICAL #2: a viewer token minted
|
|
/// under a user, then revoked at logout via the registry drain, is rejected
|
|
/// by the SAME blacklist check the viewer WS runs (`is_revoked`).
|
|
///
|
|
/// This exercises the real mint → register → logout-drain → blacklist path
|
|
/// without the HTTP/DB plumbing of the full handler. Uses local component
|
|
/// instances only (no process-global env), so it is parallel-safe.
|
|
#[tokio::test]
|
|
async fn logout_revokes_minted_viewer_token() {
|
|
let jwt = JwtConfig::new("test-secret-at-least-32-chars-long!!".to_string(), 24);
|
|
let registry = ViewerTokenRegistry::new();
|
|
let blacklist = TokenBlacklist::new();
|
|
|
|
let user_sub = Uuid::new_v4().to_string();
|
|
let session_id = Uuid::new_v4();
|
|
let tenant_id = Uuid::new_v4();
|
|
|
|
// Mint a viewer token and register it under the user (as mint_viewer_token does).
|
|
let viewer_token = jwt
|
|
.create_viewer_token(&user_sub, session_id, tenant_id, ViewerAccess::Control)
|
|
.unwrap();
|
|
registry.register(&user_sub, &viewer_token, Duration::from_secs(300));
|
|
|
|
// The viewer WS check (is_revoked) passes BEFORE logout — token is live.
|
|
assert!(!blacklist.is_revoked(&viewer_token).await);
|
|
|
|
// Logout drains the user's viewer tokens into the blacklist (handler logic).
|
|
for tok in registry.take_for_user(&user_sub) {
|
|
blacklist.revoke(&tok).await;
|
|
}
|
|
|
|
// After logout the same WS check now REJECTS the viewer token.
|
|
assert!(blacklist.is_revoked(&viewer_token).await);
|
|
// The token also remains a structurally valid viewer JWT (not expired),
|
|
// proving revocation — not natural expiry — is what blocks it.
|
|
assert!(jwt.validate_viewer_token(&viewer_token).is_ok());
|
|
}
|
|
|
|
/// A different user's logout must NOT revoke this user's viewer token.
|
|
#[tokio::test]
|
|
async fn logout_does_not_revoke_other_users_viewer_token() {
|
|
let jwt = JwtConfig::new("test-secret-at-least-32-chars-long!!".to_string(), 24);
|
|
let registry = ViewerTokenRegistry::new();
|
|
let blacklist = TokenBlacklist::new();
|
|
|
|
let user_a = Uuid::new_v4().to_string();
|
|
let user_b = Uuid::new_v4().to_string();
|
|
let session_id = Uuid::new_v4();
|
|
let tenant_id = Uuid::new_v4();
|
|
|
|
let token_b = jwt
|
|
.create_viewer_token(&user_b, session_id, tenant_id, ViewerAccess::ViewOnly)
|
|
.unwrap();
|
|
registry.register(&user_b, &token_b, Duration::from_secs(300));
|
|
|
|
// user_a logs out — drains only user_a's (empty) token set.
|
|
for tok in registry.take_for_user(&user_a) {
|
|
blacklist.revoke(&tok).await;
|
|
}
|
|
|
|
// user_b's token is untouched.
|
|
assert!(!blacklist.is_revoked(&token_b).await);
|
|
}
|
|
}
|