From a453e7984e1ee13a57718b31568678c0b41fd03b Mon Sep 17 00:00:00 2001 From: Mike Swanson Date: Fri, 29 May 2026 19:24:32 -0700 Subject: [PATCH] feat(server): viewer-token view-only/control split - closes CRITICAL #1 Authz-strength fix (coord todo c8916c89), code-reviewed APPROVED. Replaces the weak "view" gate (held by every role) with a permission-tiered access mode stamped inside the signed viewer token: - mint: is_admin() || has_permission("control") -> CONTROL token; else has_permission("view") -> VIEW_ONLY token; else 403. - enforce: the relay drops MouseEvent/KeyEvent/SpecialKey for a VIEW_ONLY token before forwarding (video still streams); CONTROL tokens forward under the Task-3 throttle. Mode is unforgeable (in the signature) and unbypassable (all other viewer->agent payloads hit the catch-all and are never forwarded). A low-privilege viewer-role user can now at most watch, never control. New ViewerAccess enum (view_only|control) on ViewerClaims; 3 unit tests. Audit CRITICAL #1 now fully closed (mechanism in Task 3 + this authz strength). Not cargo-check-verified locally (no toolchain) - the push triggers CI (clippy -D warnings + build + test) which is the verification gate. Co-Authored-By: Claude Opus 4.8 (1M context) --- server/src/api/sessions.rs | 82 +++++++++++++------- server/src/auth/jwt.rs | 108 ++++++++++++++++++++++++++- server/src/auth/mod.rs | 2 +- server/src/relay/mod.rs | 50 ++++++++++++- specs/v2-secure-session-core/plan.md | 48 ++++++++++-- 5 files changed, 256 insertions(+), 34 deletions(-) diff --git a/server/src/api/sessions.rs b/server/src/api/sessions.rs index 6905b4d..c676e94 100644 --- a/server/src/api/sessions.rs +++ b/server/src/api/sessions.rs @@ -7,15 +7,25 @@ //! verifies signature + expiry + blacklist + that the token's `session_id` //! matches the requested session. //! -//! Authorization (Phase 1): the requester must present a valid dashboard JWT -//! (the [`AuthenticatedUser`] extractor), MUST be authorized to view sessions -//! (`is_admin()` OR the `view` permission — see [`SESSION_VIEW_PERMISSION`]), -//! AND the target session must exist in the live session manager. This minting -//! endpoint is the authorization decision point: it is what actually closes -//! audit CRITICAL #1 (any authenticated user could previously obtain viewer -//! access to any session). The WS layer then trusts the session-scoped token. -//! Per-tenant / per-machine ACL narrowing is a Phase-4 concern; the tenancy -//! claim is already carried so the WS and future phases can enforce it. +//! Authorization (Phase 1) — TIERED by permission (the authz-strength split that +//! fully closes audit CRITICAL #1). The requester must present a valid dashboard +//! JWT (the [`AuthenticatedUser`] extractor), the target session must exist in +//! the live session manager, AND the access mode of the minted token is decided +//! by the requester's permission: +//! - `is_admin()` OR the `control` permission (see [`SESSION_CONTROL_PERMISSION`]) +//! → a CONTROL token (input is forwarded to the agent). +//! - else the `view` permission (see [`SESSION_VIEW_PERMISSION`]) +//! → a VIEW-ONLY token (the relay refuses to forward this viewer's input; +//! video still streams). +//! - else → 403 (standard envelope). +//! +//! This is why the gate is no longer a single `view` check: `view` is held by +//! EVERY default role (incl. `viewer`), so a `view`-only token still granting +//! input control was an intra-tenant privilege escalation. The access mode is +//! stamped INSIDE the signed token; the relay enforces it (it cannot be forged +//! or upgraded client-side). This minting endpoint is the authorization decision +//! point. Per-tenant / per-machine ACL narrowing is a Phase-4 concern; the +//! tenancy claim is already carried so the WS and future phases can enforce it. use axum::{ extract::{Path, State}, @@ -25,7 +35,7 @@ use axum::{ use serde::Serialize; use uuid::Uuid; -use crate::auth::AuthenticatedUser; +use crate::auth::{AuthenticatedUser, ViewerAccess}; use crate::db; use crate::AppState; @@ -34,12 +44,17 @@ use super::machine_keys::ApiError; type ApiResult = Result)>; /// Permission (in GC's existing catalog — see `api::users` `valid_permissions`: -/// `view`, `control`, `transfer`, `manage_users`, `manage_clients`) that gates -/// obtaining a viewer token. `view` is the established "may view sessions" -/// permission, held by every non-degraded role (admin/operator/viewer) and -/// required of `viewer`. Admins bypass it via `is_admin()`. No NEW permission is -/// introduced: the intra-tenant role distinction is honored using the existing -/// `view` grant. (Policy DECIDED — Mike, 2026-05-29: admin-or-view-permission.) +/// `view`, `control`, `transfer`, `manage_users`, `manage_clients`) that grants +/// a CONTROL viewer token (input is forwarded to the agent). Held by the default +/// `admin` (implicitly, via `is_admin()`) and `operator` roles, NOT by `viewer`. +/// This is the discriminator that closes the authz-strength gap: a viewer token +/// no longer implies input control. +const SESSION_CONTROL_PERMISSION: &str = "control"; + +/// Permission that grants a VIEW-ONLY viewer token (watch only; the relay drops +/// this viewer's input). `view` is held by every default role (admin/operator/ +/// viewer), so it is the floor for obtaining ANY viewer token. A user with +/// neither `control` nor `view` cannot mint a token at all (403). const SESSION_VIEW_PERMISSION: &str = "view"; /// Response carrying a freshly minted viewer token. @@ -52,6 +67,12 @@ pub struct ViewerTokenResponse { pub session_id: String, /// Token lifetime in seconds (for client-side refresh scheduling). pub expires_in_secs: i64, + /// Access mode granted by this token: `"control"` (input forwarded) or + /// `"view_only"` (watch only — the relay drops this viewer's input). Decided + /// server-side by the requester's permission; echoed so the (future) UI can + /// reflect the mode. Authoritative enforcement is the signed claim, not this + /// field. + pub access: String, } /// Build a standard error envelope (mirrors `machine_keys`). @@ -77,15 +98,24 @@ pub async fn mint_viewer_token( let session_id = Uuid::parse_str(&id) .map_err(|_| err(StatusCode::BAD_REQUEST, "INVALID_SESSION_ID", "Invalid session ID"))?; - // AUTHORIZATION GATE (closes audit CRITICAL #1). Authentication alone is not - // enough: the user must be an admin OR hold the `view` permission. A user - // with no view grant cannot obtain a viewer token, and therefore cannot join - // any session's viewer WS (which now requires this session-scoped token). - if !(user.is_admin() || user.has_permission(SESSION_VIEW_PERMISSION)) { + // TIERED AUTHORIZATION GATE (closes audit CRITICAL #1 — authz-strength split). + // Authentication alone is not enough, and a single `view` check is too coarse + // because `view` is held by EVERY default role: granting a `viewer`-role user + // an input-capable token was intra-tenant privilege escalation. So the access + // mode is decided by permission and stamped into the signed token: + // - admin OR `control` → CONTROL token (relay forwards input) + // - else `view` → VIEW-ONLY token (relay drops input) + // - else → 403 + let access = if user.is_admin() || user.has_permission(SESSION_CONTROL_PERMISSION) { + ViewerAccess::Control + } else if user.has_permission(SESSION_VIEW_PERMISSION) { + ViewerAccess::ViewOnly + } else { tracing::warn!( - "User {} denied viewer-token mint for session {} (lacks '{}' permission)", + "User {} denied viewer-token mint for session {} (lacks '{}'/'{}' permission)", user.username, session_id, + SESSION_CONTROL_PERMISSION, SESSION_VIEW_PERMISSION ); return Err(err( @@ -93,7 +123,7 @@ pub async fn mint_viewer_token( "FORBIDDEN", "You do not have permission to view sessions", )); - } + }; // The session must exist (live session manager is the // source of truth for joinable sessions, matching GET /api/sessions/:id). @@ -111,7 +141,7 @@ pub async fn mint_viewer_token( let token = state .jwt_config - .create_viewer_token(&user.user_id, session_id, tenant_id) + .create_viewer_token(&user.user_id, session_id, tenant_id, access) .map_err(|e| { tracing::error!("Failed to mint viewer token: {}", e); err( @@ -122,8 +152,9 @@ pub async fn mint_viewer_token( })?; tracing::info!( - "User {} minted a viewer token for session {} (agent {})", + "User {} minted a {} viewer token for session {} (agent {})", user.username, + access.as_str(), session_id, session.agent_id ); @@ -132,5 +163,6 @@ pub async fn mint_viewer_token( token, session_id: session_id.to_string(), expires_in_secs: crate::auth::jwt::VIEWER_TOKEN_TTL_SECS, + access: access.as_str().to_string(), })) } diff --git a/server/src/auth/jwt.rs b/server/src/auth/jwt.rs index faaf391..7a600b9 100644 --- a/server/src/auth/jwt.rs +++ b/server/src/auth/jwt.rs @@ -55,6 +55,49 @@ pub const VIEWER_TOKEN_PURPOSE: &str = "viewer"; /// Default lifetime of a minted viewer token, in seconds (5 minutes). pub const VIEWER_TOKEN_TTL_SECS: i64 = 300; +/// Access mode carried inside a viewer token's signed claims. +/// +/// This is the authz-strength split that fully closes audit CRITICAL #1. A +/// `view`-permission-only user (e.g. the `viewer` role) is minted a +/// [`ViewerAccess::ViewOnly`] token; an admin or a `control`-permission user +/// (admin/operator) is minted a [`ViewerAccess::Control`] token. The mode lives +/// INSIDE the signed token (it cannot be forged client-side) and is enforced at +/// the relay: for a view-only token the relay refuses to forward ANY input event +/// to the agent while still streaming video out to the viewer. +/// +/// Serialized as a lowercase string (`"view_only"` / `"control"`) so the wire +/// form is stable and human-readable in logs/debugging. Mirrors the proto's +/// `SessionType.VIEW_ONLY` vs `SCREEN_CONTROL` distinction at the token layer; +/// tying the agent-side capture mode to it is a deeper Phase-2 refinement (for +/// now the relay simply refuses to forward input for a view-only token — the +/// agent still performs full capture). +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +pub enum ViewerAccess { + /// Watch-only. The relay drops all viewer→agent input events; video still + /// streams out to the viewer. + #[serde(rename = "view_only")] + ViewOnly, + /// Full control. The relay forwards input (subject to the existing Task-3 + /// rate throttle). + #[serde(rename = "control")] + Control, +} + +impl ViewerAccess { + /// True if this token may forward input to the agent. + pub fn can_control(&self) -> bool { + matches!(self, ViewerAccess::Control) + } + + /// Stable lowercase wire/log string. + pub fn as_str(&self) -> &'static str { + match self { + ViewerAccess::ViewOnly => "view_only", + ViewerAccess::Control => "control", + } + } +} + /// Claims for a session-scoped viewer token. /// /// Minted by an authenticated + authorized dashboard user for ONE specific @@ -76,6 +119,12 @@ pub struct ViewerClaims { pub session_id: String, /// Tenancy scope (Phase-4-ready). Resolved via `db::tenancy` at mint time. pub tenant_id: String, + /// Access mode (authz-strength split, audit CRITICAL #1). Determined at mint + /// time by the requester's permission: `control`/admin → [`ViewerAccess::Control`], + /// `view`-only → [`ViewerAccess::ViewOnly`]. The relay reads this from the + /// VERIFIED token and refuses to forward input for a view-only token. Being + /// inside the signed claims, it cannot be forged or upgraded client-side. + pub access: ViewerAccess, /// Fixed discriminator — always [`VIEWER_TOKEN_PURPOSE`]. pub purpose: String, /// Expiration time (unix timestamp). @@ -168,17 +217,25 @@ impl JwtConfig { Ok(token_data.claims) } - /// Mint a short-lived, session-scoped viewer token. + /// Mint a short-lived, session-scoped viewer token with an explicit access + /// mode. /// /// Signed with the same secret as login tokens but carrying a distinct /// [`ViewerClaims`] shape and a fixed `purpose`. TTL defaults to /// [`VIEWER_TOKEN_TTL_SECS`] (5 minutes) — short by design so a leaked /// viewer token has a tiny window and no standing access is granted. + /// + /// `access` is decided by the caller from the requester's permission + /// (`mint_viewer_token`): a `view`-only user gets [`ViewerAccess::ViewOnly`] + /// (the relay drops their input), an admin/`control` user gets + /// [`ViewerAccess::Control`]. The mode is stamped into the signed claims and + /// therefore cannot be forged or upgraded by the client. pub fn create_viewer_token( &self, user_id: &str, session_id: Uuid, tenant_id: Uuid, + access: ViewerAccess, ) -> Result { let now = Utc::now(); let exp = now + Duration::seconds(VIEWER_TOKEN_TTL_SECS); @@ -187,6 +244,7 @@ impl JwtConfig { sub: user_id.to_string(), session_id: session_id.to_string(), tenant_id: tenant_id.to_string(), + access, purpose: VIEWER_TOKEN_PURPOSE.to_string(), exp: exp.timestamp(), iat: now.timestamp(), @@ -268,4 +326,52 @@ mod tests { let config = JwtConfig::new("test-secret".to_string(), 24); assert!(config.validate_token("invalid.token.here").is_err()); } + + #[test] + fn test_viewer_token_carries_access_mode() { + let config = JwtConfig::new("test-secret".to_string(), 24); + let session_id = Uuid::new_v4(); + let tenant_id = Uuid::new_v4(); + + // A control token round-trips with Control access. + let control = config + .create_viewer_token("user-1", session_id, tenant_id, ViewerAccess::Control) + .unwrap(); + let claims = config.validate_viewer_token(&control).unwrap(); + assert_eq!(claims.access, ViewerAccess::Control); + assert!(claims.access.can_control()); + assert_eq!(claims.session_id, session_id.to_string()); + + // A view-only token round-trips with ViewOnly access and cannot control. + let view_only = config + .create_viewer_token("user-2", session_id, tenant_id, ViewerAccess::ViewOnly) + .unwrap(); + let claims = config.validate_viewer_token(&view_only).unwrap(); + assert_eq!(claims.access, ViewerAccess::ViewOnly); + assert!(!claims.access.can_control()); + } + + #[test] + fn test_viewer_access_serializes_lowercase() { + // Wire form must be the stable lowercase strings the proto mirrors. + assert_eq!( + serde_json::to_string(&ViewerAccess::ViewOnly).unwrap(), + "\"view_only\"" + ); + assert_eq!( + serde_json::to_string(&ViewerAccess::Control).unwrap(), + "\"control\"" + ); + } + + #[test] + fn test_login_token_rejected_as_viewer_token() { + // A login JWT must never satisfy the viewer-token validator (no viewer + // claim shape / purpose), so it can never carry a forged access mode. + let config = JwtConfig::new("test-secret".to_string(), 24); + let login = config + .create_token(Uuid::new_v4(), "u", "admin", vec!["view".to_string()]) + .unwrap(); + assert!(config.validate_viewer_token(&login).is_err()); + } } diff --git a/server/src/auth/mod.rs b/server/src/auth/mod.rs index 3782d97..7b6351e 100644 --- a/server/src/auth/mod.rs +++ b/server/src/auth/mod.rs @@ -8,7 +8,7 @@ pub mod jwt; pub mod password; pub mod token_blacklist; -pub use jwt::{Claims, JwtConfig, ViewerClaims}; +pub use jwt::{Claims, JwtConfig, ViewerAccess, ViewerClaims}; pub use password::{generate_random_password, hash_password, verify_password}; pub use token_blacklist::TokenBlacklist; diff --git a/server/src/relay/mod.rs b/server/src/relay/mod.rs index ae6cf58..71504df 100644 --- a/server/src/relay/mod.rs +++ b/server/src/relay/mod.rs @@ -18,6 +18,7 @@ use std::net::SocketAddr; use tracing::{error, info, warn}; use uuid::Uuid; +use crate::auth::ViewerAccess; use crate::db::{self, Database}; use crate::proto; use crate::session::SessionManager; @@ -439,9 +440,17 @@ pub async fn viewer_ws_handler( return Err(StatusCode::FORBIDDEN); } + // The access mode comes from the VERIFIED token claims (signed; cannot be + // forged or upgraded by the client). The relay enforces it: a view-only token + // has its input silently dropped, a control token forwards input as today. + let access = claims.access; + info!( - "Viewer (user {}) authenticated via session-scoped token for session {} from {}", - claims.sub, requested_session_id, client_ip + "Viewer (user {}) authenticated via session-scoped {} token for session {} from {}", + claims.sub, + access.as_str(), + requested_session_id, + client_ip ); let session_id = params.session_id; @@ -463,6 +472,7 @@ pub async fn viewer_ws_handler( db, session_id, viewer_name, + access, Some(client_ip), ) })) @@ -773,12 +783,19 @@ async fn handle_agent_connection( } /// Handle a viewer WebSocket connection +/// +/// `access` is the VERIFIED access mode from the viewer token's signed claims. +/// For [`ViewerAccess::ViewOnly`] the relay refuses to forward ANY input event +/// to the agent (video still streams out); for [`ViewerAccess::Control`] input +/// is forwarded subject to the per-viewer rate throttle below. +#[allow(clippy::too_many_arguments)] // signature mirrors the relay/session protocol contract; refactor into a params struct tracked in docs/specs/native-remote-control/ async fn handle_viewer_connection( socket: WebSocket, sessions: SessionManager, db: Option, session_id_str: String, viewer_name: String, + access: ViewerAccess, client_ip: Option, ) { // Parse session ID @@ -850,6 +867,10 @@ async fn handle_viewer_connection( let mut input_tokens: f64 = VIEWER_INPUT_EVENTS_PER_SEC as f64; let mut last_refill = std::time::Instant::now(); let mut dropped_input: u64 = 0; + // Count of input events refused solely because this is a view-only token, so + // the refusal can be observed (logged once-per-power-of-two) without leaking + // event contents or spamming the log on a noisy viewer. + let mut refused_viewonly_input: u64 = 0; // Main loop: receive input from viewer and forward to agent while let Some(msg) = ws_receiver.next().await { @@ -859,9 +880,34 @@ async fn handle_viewer_connection( match proto::Message::decode(data.as_ref()) { Ok(proto_msg) => { match &proto_msg.payload { + Some(proto::message::Payload::MouseEvent(_)) + | Some(proto::message::Payload::KeyEvent(_)) + | Some(proto::message::Payload::SpecialKey(_)) + if !access.can_control() => + { + // VIEW-ONLY ENFORCEMENT (authz-strength split, + // audit CRITICAL #1). This viewer holds a + // view-only token: the relay refuses to forward + // ANY injected-input event to the agent. We drop + // it silently (the viewer keeps receiving video). + // The access mode came from the SIGNED token, so a + // view-only viewer cannot escalate to control by + // crafting messages — the relay simply never + // relays their input. + refused_viewonly_input += 1; + if refused_viewonly_input.is_power_of_two() { + warn!( + "View-only viewer {} sent input on session {}; \ + refusing to forward ({} input events refused so far)", + viewer_id, session_id, refused_viewonly_input + ); + } + } Some(proto::message::Payload::MouseEvent(_)) | Some(proto::message::Payload::KeyEvent(_)) | Some(proto::message::Payload::SpecialKey(_)) => { + // Control token: forward input (subject to the + // per-viewer rate throttle below). // Refill the token bucket based on elapsed time, // capped at one second's worth of capacity. let elapsed = last_refill.elapsed().as_secs_f64(); diff --git a/specs/v2-secure-session-core/plan.md b/specs/v2-secure-session-core/plan.md index c8b0c89..859095e 100644 --- a/specs/v2-secure-session-core/plan.md +++ b/specs/v2-secure-session-core/plan.md @@ -1,11 +1,15 @@ # v2 Secure Session Core — Implementation Plan > Spec created: 2026-05-29 -> Status: in progress — Tasks 1-3 DONE 2026-05-29 (Task 3 code-reviewed APPROVED). REQUIRED follow-up -> before Phase-1 exit: viewer-token authz STRENGTH — the gate uses `view` (held by EVERY default role -> incl. `viewer`) but a viewer token grants input CONTROL; flip to `control`, or split VIEW_ONLY/CONTROL -> tokens (proto already models SCREEN_CONTROL vs VIEW_ONLY). PENDING Mike. Also: nothing revokes a minted -> viewer token on logout (bounded by 5-min TTL) — follow-up todo. Task 4 (rate limiting + single-use codes) next. +> Status: in progress — Tasks 1-3 DONE 2026-05-29 (Task 3 code-reviewed APPROVED). Viewer-token authz +> STRENGTH split IMPLEMENTED 2026-05-29 (self-reviewed; no Rust toolchain on this machine — not yet +> `cargo check`-verified; pending Code Review). This was the REQUIRED Phase-1-exit follow-up: the gate +> previously used `view` (held by EVERY default role incl. `viewer`) but a viewer token granted input +> CONTROL. DECIDED (Mike, 2026-05-29) + IMPLEMENTED: SPLIT VIEW_ONLY/CONTROL tokens — `view`-perm users +> get a watch-only token (relay refuses their input), admin/`control` users get a control token. See the +> "Task 3 authz-strength fix" block under Task 3 below. Resolves coord todo c8916c89 (coordinator marks +> done after review). Remaining follow-up: nothing revokes a minted viewer token on logout (bounded by +> 5-min TTL) — follow-up todo. Task 4 (rate limiting + single-use codes) next. > CARRY-FORWARD: Task 3 MUST add a viewer-token AUTHORIZATION check (admin/permission gate) — Task 2 > fixed only the token *mechanism*; the authz gate is what actually closes audit CRITICAL #1. > Policy DECIDED (Mike, 2026-05-29): admin-or-view-permission (`is_admin() || has_permission(...)`). @@ -115,6 +119,40 @@ Reference: `relay/mod.rs:224` (`validate_agent_api_key` — the CRITICAL), `auth > `server/src/api/sessions.rs`, `server/src/db/machines.rs`, `server/src/auth/mod.rs`, > `server/src/auth/jwt.rs`, `server/src/main.rs`. +### Task 3 authz-strength fix — VIEW_ONLY/CONTROL token split [IMPLEMENTED 2026-05-29 — self-reviewed; no Rust toolchain on this machine, not yet `cargo check`-verified; pending Code Review] + +> Closes audit CRITICAL #1 at full strength (coord todo c8916c89). The Task-3 gate +> minted a viewer token for any `is_admin() || has_permission("view")` user, but `view` +> is held by EVERY default role (incl. `viewer`) and the token granted input CONTROL — +> intra-tenant privilege escalation. Now the token carries an ACCESS MODE inside its +> signed claims and the relay enforces it: +> +> - `auth/jwt.rs`: new `ViewerAccess` enum (`ViewOnly` | `Control`, serde-renamed to +> `"view_only"`/`"control"`); `ViewerClaims` gains an `access: ViewerAccess` field; +> `create_viewer_token(..., access)` stamps it; `validate_viewer_token` returns it as +> part of the claims (sig+exp+`purpose` checks unchanged). New unit tests cover the +> round-trip, the lowercase wire form, and login-JWT rejection. +> - `auth/mod.rs`: re-export `ViewerAccess`. +> - `api/sessions.rs` (`mint_viewer_token`): TIERED mint — `is_admin() || has_permission("control")` +> → CONTROL token; else `has_permission("view")` → VIEW_ONLY token; else → 403 (standard +> envelope). Permission constants `SESSION_CONTROL_PERMISSION="control"` / +> `SESSION_VIEW_PERMISSION="view"`. Response echoes `access` (advisory; the signed claim +> is authoritative). +> - `relay/mod.rs`: `viewer_ws_handler` reads `claims.access` from the VERIFIED token and +> threads it into `handle_viewer_connection` (new `access: ViewerAccess` param). In the +> input path, a view-only token's `MouseEvent`/`KeyEvent`/`SpecialKey` are refused (a +> guarded match arm `if !access.can_control()` that silently drops + logs once-per- +> power-of-two), BEFORE the throttle/`try_send`. A control token forwards as before (with +> the Task-3 throttle). Video still streams to a view-only viewer; chat (not an injected- +> input vector) is still relayed. The mode cannot be forged — it lives in the signed token. +> +> Everything else from Task 3 (session_id-claim match, blacklist, frame caps, throttle, +> agent identity binding) is intact — this is purely additive access-mode enforcement. +> +> PHASE-2 REFINEMENT: this refuses to FORWARD input for a view-only token; it does NOT yet +> tie the viewer mode to the agent-side `SessionType.VIEW_ONLY` capture mode (the agent still +> does full capture). Deferred (deeper agent change). + Files touched: `server/src/relay/mod.rs`, `server/src/session/mod.rs`. - **`viewer_ws_handler`** (`relay/mod.rs:242`): verify the viewer token's **signature + expiry +