arcly-http-realtime 0.9.0

WebSocket gateways + SSE for arcly-http.
Documentation
//! The WebSocket handshake must authenticate from `?access_token=<jwt>`.
//!
//! This is the browser path and it can only be tested over a real socket: the
//! whole point is that `new WebSocket(url)` sends *no* Authorization header, so
//! a test that hands the framework a header map proves nothing about it. Here we
//! run the actual axum route and connect a client that, like a browser, can only
//! express its credential in the URL.

use std::collections::HashMap;
use std::sync::Arc;

use arcly_http_core::auth::jwt::{JwtConfig, JwtService};
use arcly_http_core::core::engine::DiContainerBuilder;
use arcly_http_realtime::realtime::connection::ConnectionRegistry;
use arcly_http_realtime::realtime::gateway::GatewayRuntime;
use arcly_http_realtime::realtime::ws::{ws_route, WsTuning};
use futures::{SinkExt, StreamExt};
use tokio_tungstenite::tungstenite::Message;

const SECRET: &str = "ws-query-token-test-secret";

fn jwt() -> JwtService {
    JwtService::new(JwtConfig {
        secret: SECRET.to_owned(),
        ..Default::default()
    })
}

/// Boot the gateway on an ephemeral port. Its one event, `whoami`, echoes back
/// the `sub` the handshake authenticated — so the client can observe exactly
/// which identity (if any) the server bound to the socket.
async fn serve() -> String {
    let registry: &'static ConnectionRegistry = Box::leak(Box::new(ConnectionRegistry::new()));

    let mut dispatch: HashMap<
        &'static str,
        arcly_http_realtime::realtime::gateway::MessageHandler,
    > = HashMap::new();
    dispatch.insert(
        "whoami",
        Arc::new(
            |client: arcly_http_realtime::realtime::connection::WsClient, _data| {
                Box::pin(async move {
                    let sub = client
                        .claims()
                        .and_then(|c| c.get("sub").and_then(|v| v.as_str()))
                        .unwrap_or("anonymous")
                        .to_owned();
                    client.emit("whoami", sub).await;
                    Ok(())
                })
            },
        ),
    );

    let runtime: &'static GatewayRuntime = Box::leak(Box::new(GatewayRuntime {
        path: "/ws/dashboard",
        on_connect: Box::new(|_| Box::pin(async {})),
        on_disconnect: Box::new(|_| Box::pin(async {})),
        dispatch,
    }));

    let mut b = DiContainerBuilder::new();
    b.register(jwt());
    let container = b.freeze();

    let app = axum::Router::new().route(
        "/ws/dashboard",
        ws_route(
            runtime,
            registry,
            container,
            WsTuning::new(16, 0, std::time::Duration::ZERO),
        ),
    );

    let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
    let addr = listener.local_addr().unwrap();
    tokio::spawn(async move {
        axum::serve(listener, app).await.unwrap();
    });
    format!("ws://{addr}/ws/dashboard")
}

/// Ask the gateway who we are, over a socket opened with `url` alone.
async fn whoami(url: &str) -> String {
    let (mut socket, _) = tokio_tungstenite::connect_async(url)
        .await
        .expect("handshake must succeed");
    socket
        .send(Message::Text(r#"{"event":"whoami"}"#.into()))
        .await
        .unwrap();
    loop {
        match socket.next().await.expect("a reply").unwrap() {
            Message::Text(t) => {
                let v: serde_json::Value = serde_json::from_str(&t).unwrap();
                return v["data"].as_str().unwrap_or_default().to_owned();
            }
            _ => continue,
        }
    }
}

/// The gap this closes: a URL-only client — every browser — can now authenticate
/// the handshake, and the gateway sees the same claims a Bearer header produces.
#[tokio::test(flavor = "multi_thread")]
async fn browser_style_client_authenticates_via_query_token() {
    let url = serve().await;
    let token = jwt()
        .issue_access("dashboard-user", "admin", "d@example.com")
        .unwrap();

    assert_eq!(
        whoami(&format!("{url}?access_token={token}")).await,
        "dashboard-user",
        "the query token must populate claims for gateway handlers"
    );
}

/// A forged token in the URL buys nothing: the socket still opens (the gateway
/// decides what anonymous callers may do), but it carries no identity, so every
/// downstream permission gate sees an unauthenticated client.
#[tokio::test(flavor = "multi_thread")]
async fn a_forged_query_token_confers_no_identity() {
    let url = serve().await;
    assert_eq!(
        whoami(&format!("{url}?access_token=forged.not.a.jwt")).await,
        "anonymous",
    );
    assert_eq!(whoami(&url).await, "anonymous", "no token at all");
}