Files
guru-connect/server/src/auth/mod.rs
Mike Swanson 0f258788f9
Some checks failed
Build and Test / Build Server (Linux) (push) Failing after 4m3s
Build and Test / Build Agent (Windows) (push) Successful in 7m48s
Build and Test / Security Audit (push) Successful in 4m20s
Build and Test / Build Summary (push) Has been skipped
feat(server): v2 secure-session-core Task 3 - secure relay WS
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>
2026-05-29 19:13:03 -07:00

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(),
})
}