Skip to content

Node WebSocket connection fails: no /ws route and auth middleware blocks device-token auth #381

@lijunle-bot

Description

@lijunle-bot

Summary

Remote node connections fail because the node client connects to /ws, but the gateway only registers /ws/chat as its WebSocket route. Additionally, the HTTP auth middleware (auth_gate) blocks the WebSocket upgrade before the connection reaches the WebSocket handler, which has its own device-token authentication logic.

Steps to Reproduce

  1. Set up gateway on machine A with auth configured (password/passkey)
  2. On machine B, run moltis node add --host ws://<gateway-host>:<port>/ws --token <device-token>
  3. Start the node service on machine B
  4. Observe that the node never appears as connected in the gateway UI

Expected Behavior

The node connects to the gateway via WebSocket, authenticates with its device token, and appears in the Nodes list.

Actual Behavior

The connection fails at the HTTP layer before the WebSocket upgrade. The gateway returns a 303 redirect (to /login) because:

  1. No /ws route exists — The gateway only registers /ws/chat as its WebSocket endpoint (in build_gateway_base() in server.rs), but the node client connects to /ws.

  2. Auth middleware blocks device-token connections — Even if the node connected to /ws/chat, the auth_gate middleware in auth_middleware.rs would reject it. The middleware's check_auth() function only validates session cookies and Bearer API keys. It does not check device tokens. Since the node client sends no cookies or API keys in the HTTP upgrade request, auth_gate returns Unauthorized and the request never reaches the WebSocket handler.

  3. The WebSocket handler already supports device-token authws.rs handle_connection() has complete device-token verification logic: it checks params.auth.device_token against the pairing store, assigns the NODE role, and registers the node. But this code is unreachable because the HTTP middleware rejects the request first.

Root Cause Analysis

There's an auth layering mismatch between the HTTP middleware and the WebSocket protocol:

  • HTTP layer (auth_middleware.rscheck_auth()): Checks session cookies and API keys only. No device token support.
  • WebSocket layer (ws.rshandle_connection()): Checks device tokens, API keys, passwords, and legacy tokens. Full auth logic for nodes.

The node client's auth flow is designed as a two-phase process:

  1. HTTP WebSocket upgrade (no auth headers)
  2. JSON connect message with device_token field (post-upgrade, at the application protocol level)

But phase 1 is blocked by the HTTP auth middleware, so phase 2 never executes.

Code References

Gateway route registration (server.rsbuild_gateway_base())

Only /ws/chat is registered — no /ws route:

let mut router = Router::new()
    .route("/health", get(health_handler))
    .route("/ws/chat", get(ws_upgrade_handler));

Auth middleware (auth_middleware.rs)

is_public_path() does not include /ws or /ws/chat:

fn is_public_path(path: &str) -> bool {
    matches!(
        path,
        "/health" | "/auth/callback" | "/manifest.json" | "/sw.js" | "/login" | "/setup-required"
    ) || path.starts_with("/api/auth/")
        || path.starts_with("/api/public/")
        || path.starts_with("/api/channels/msteams/")
        || path.starts_with("/assets/")
        || path.starts_with("/share/")
}

check_auth() checks cookies and API keys but not device tokens:

pub async fn check_auth(store: &CredentialStore, headers: &HeaderMap, is_local: bool) -> AuthResult {
    // ... checks session cookie ...
    // ... checks Bearer API key ...
    // No device token check
    AuthResult::Unauthorized
}

WebSocket handler (ws.rshandle_connection())

Device token auth exists but is unreachable for remote nodes:

// Check device token first (used by paired nodes).
if !authenticated
    && let Some(ref dt) = params.auth.as_ref().and_then(|a| a.device_token.clone())
    && let Some(ref store) = state.pairing_store
{
    match store.verify_device_token(dt).await {
        Ok(Some(verification)) => {
            authenticated = true;
            // ... assigns NODE role, registers node ...
        },
        // ...
    }
}

Suggested Fix

Add a /ws route and make it a public path so node connections bypass auth_gate:

  1. Add /ws route in server.rs build_gateway_base():
let mut router = Router::new()
    .route("/health", get(health_handler))
    .route("/ws/chat", get(ws_upgrade_handler))
    .route("/ws", get(ws_upgrade_handler));  // Node connections
  1. Add /ws to is_public_path() in auth_middleware.rs:
fn is_public_path(path: &str) -> bool {
    matches!(
        path,
        "/health" | "/auth/callback" | "/manifest.json" | "/sw.js" | "/login" | "/setup-required" | "/ws"
    ) || // ...
}

This is safe because:

  • The WebSocket handler (ws.rs) already has complete auth logic for all connection types
  • Invalid device tokens are rejected and the connection is closed immediately
  • Browser clients continue to use /ws/chat behind cookie auth as before
  • /ws becomes the dedicated node entry point with device-token-only auth at the protocol level

Related

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions