//! 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)> { 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, user: AuthenticatedUser, request: Request, ) -> Result, (StatusCode, Json)> { // 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, user: AuthenticatedUser, request: Request, ) -> Result, (StatusCode, Json)> { 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, admin: AuthenticatedUser, Json(req): Json, ) -> Result, (StatusCode, Json)> { // 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, admin: AuthenticatedUser, ) -> Result, (StatusCode, Json)> { 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, admin: AuthenticatedUser, ) -> Result, (StatusCode, Json)> { 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); } }