SPEC-002 Phase 1 Task 3 (specs/v2-secure-session-core), code-reviewed APPROVED. - viewer_ws_handler: verify the session-scoped VIEWER token (validate_viewer_token sig+exp+purpose) + token_blacklist.is_revoked + session_id claim == requested session, before upgrade. Raw login JWTs no longer accepted on the viewer plane (closes audit CRITICAL #2; closes the *mechanism* of CRITICAL #1). - mint_viewer_token: authz gate is_admin() || has_permission("view") -> 403. - Agent identity binding: validate_agent_api_key returns AgentKeyAuth; a cak_- verified agent rebinds to the key's machine identity (fails closed if unresolvable), so a key for machine X cannot seize machine Y's session slot. - Frame caps on both WS upgrades (agent 4 MiB, viewer 64 KiB) - closes WS-OOM HIGH. - Viewer->agent input throttle (200 ev/s token bucket, bounded try_send) - closes input-injection MEDIUM. - Startup managed-session reconcile clarified. KNOWN FOLLOW-UPS (tracked todos): (1) authz STRENGTH - the "view" permission is held by every default role incl. viewer, and a viewer token grants input control, so the gate should be "control" or a VIEW_ONLY/CONTROL token split; CRITICAL #1 is mechanism-closed, strength pending decision. (2) revoke minted viewer tokens on logout (currently bounded only by 5-min TTL). Not cargo-check-verified (no toolchain on the authoring host). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
183 lines
5.7 KiB
Rust
183 lines
5.7 KiB
Rust
//! Authentication module
|
|
//!
|
|
//! Handles JWT validation for dashboard users and API key
|
|
//! validation for agents.
|
|
|
|
pub mod agent_keys;
|
|
pub mod jwt;
|
|
pub mod password;
|
|
pub mod token_blacklist;
|
|
|
|
pub use jwt::{Claims, JwtConfig, ViewerClaims};
|
|
pub use password::{generate_random_password, hash_password, verify_password};
|
|
pub use token_blacklist::TokenBlacklist;
|
|
|
|
use axum::{
|
|
extract::FromRequestParts,
|
|
http::{request::Parts, StatusCode},
|
|
};
|
|
use std::sync::Arc;
|
|
|
|
/// Authenticated user from JWT
|
|
#[derive(Debug, Clone)]
|
|
pub struct AuthenticatedUser {
|
|
pub user_id: String,
|
|
pub username: String,
|
|
pub role: String,
|
|
#[allow(dead_code)]
|
|
// TODO(native-remote-control): consumed by the integration API; see docs/specs/native-remote-control/
|
|
pub permissions: Vec<String>,
|
|
}
|
|
|
|
impl AuthenticatedUser {
|
|
/// Check if user has a specific permission.
|
|
///
|
|
/// Admins implicitly hold every permission. Consumed by the viewer-token
|
|
/// authorization gate (`api::sessions::mint_viewer_token`).
|
|
pub fn has_permission(&self, permission: &str) -> bool {
|
|
if self.role == "admin" {
|
|
return true;
|
|
}
|
|
self.permissions.contains(&permission.to_string())
|
|
}
|
|
|
|
/// Check if user is admin
|
|
pub fn is_admin(&self) -> bool {
|
|
self.role == "admin"
|
|
}
|
|
}
|
|
|
|
impl From<Claims> for AuthenticatedUser {
|
|
fn from(claims: Claims) -> Self {
|
|
Self {
|
|
user_id: claims.sub,
|
|
username: claims.username,
|
|
role: claims.role,
|
|
permissions: claims.permissions,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Authenticated agent from API key
|
|
#[derive(Debug, Clone)]
|
|
#[allow(dead_code)] // TODO(native-remote-control): consumed by the integration API; see docs/specs/native-remote-control/
|
|
pub struct AuthenticatedAgent {
|
|
pub agent_id: String,
|
|
pub org_id: String,
|
|
}
|
|
|
|
/// JWT configuration stored in app state
|
|
#[derive(Clone)]
|
|
#[allow(dead_code)] // TODO(native-remote-control): consumed by the integration API; see docs/specs/native-remote-control/
|
|
pub struct AuthState {
|
|
pub jwt_config: Arc<JwtConfig>,
|
|
}
|
|
|
|
impl AuthState {
|
|
#[allow(dead_code)] // TODO(native-remote-control): consumed by the integration API; see docs/specs/native-remote-control/
|
|
pub fn new(jwt_secret: String, expiry_hours: i64) -> Self {
|
|
Self {
|
|
jwt_config: Arc::new(JwtConfig::new(jwt_secret, expiry_hours)),
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Extract authenticated user from request
|
|
#[axum::async_trait]
|
|
impl<S> FromRequestParts<S> for AuthenticatedUser
|
|
where
|
|
S: Send + Sync,
|
|
{
|
|
type Rejection = (StatusCode, &'static str);
|
|
|
|
async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
|
|
// Get Authorization header
|
|
let auth_header = parts
|
|
.headers
|
|
.get("Authorization")
|
|
.and_then(|v| v.to_str().ok())
|
|
.ok_or((StatusCode::UNAUTHORIZED, "Missing Authorization header"))?;
|
|
|
|
// Extract Bearer token
|
|
let token = auth_header
|
|
.strip_prefix("Bearer ")
|
|
.ok_or((StatusCode::UNAUTHORIZED, "Invalid Authorization format"))?;
|
|
|
|
// Get JWT config from extensions (set by middleware)
|
|
let jwt_config = parts
|
|
.extensions
|
|
.get::<Arc<JwtConfig>>()
|
|
.ok_or((StatusCode::INTERNAL_SERVER_ERROR, "Auth not configured"))?;
|
|
|
|
// Get token blacklist from extensions (set by middleware)
|
|
let blacklist = parts
|
|
.extensions
|
|
.get::<Arc<TokenBlacklist>>()
|
|
.ok_or((StatusCode::INTERNAL_SERVER_ERROR, "Auth not configured"))?;
|
|
|
|
// Check if token is revoked
|
|
if blacklist.is_revoked(token).await {
|
|
return Err((StatusCode::UNAUTHORIZED, "Token has been revoked"));
|
|
}
|
|
|
|
// Validate token
|
|
let claims = jwt_config
|
|
.validate_token(token)
|
|
.map_err(|_| (StatusCode::UNAUTHORIZED, "Invalid or expired token"))?;
|
|
|
|
Ok(AuthenticatedUser::from(claims))
|
|
}
|
|
}
|
|
|
|
/// Optional authenticated user (doesn't reject if not authenticated)
|
|
#[derive(Debug, Clone)]
|
|
#[allow(dead_code)] // TODO(native-remote-control): consumed by the integration API; see docs/specs/native-remote-control/
|
|
pub struct OptionalUser(pub Option<AuthenticatedUser>);
|
|
|
|
#[axum::async_trait]
|
|
impl<S> FromRequestParts<S> for OptionalUser
|
|
where
|
|
S: Send + Sync,
|
|
{
|
|
type Rejection = (StatusCode, &'static str);
|
|
|
|
async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
|
|
match AuthenticatedUser::from_request_parts(parts, state).await {
|
|
Ok(user) => Ok(OptionalUser(Some(user))),
|
|
Err(_) => Ok(OptionalUser(None)),
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Require admin role
|
|
#[derive(Debug, Clone)]
|
|
pub struct AdminUser(pub AuthenticatedUser);
|
|
|
|
#[axum::async_trait]
|
|
impl<S> FromRequestParts<S> for AdminUser
|
|
where
|
|
S: Send + Sync,
|
|
{
|
|
type Rejection = (StatusCode, &'static str);
|
|
|
|
async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
|
|
let user = AuthenticatedUser::from_request_parts(parts, state).await?;
|
|
if user.is_admin() {
|
|
Ok(AdminUser(user))
|
|
} else {
|
|
Err((StatusCode::FORBIDDEN, "Admin access required"))
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Validate an agent API key (placeholder for MVP)
|
|
#[allow(dead_code)] // TODO(native-remote-control): consumed by the integration API; see docs/specs/native-remote-control/
|
|
pub fn validate_agent_key(_api_key: &str) -> Option<AuthenticatedAgent> {
|
|
// TODO: Implement actual API key validation against database
|
|
// For now, accept any key for agent connections
|
|
Some(AuthenticatedAgent {
|
|
agent_id: "mvp-agent".to_string(),
|
|
org_id: "mvp-org".to_string(),
|
|
})
|
|
}
|