fix(sensing-server): exempt /api/v1/stream/pose WS from bearer auth; add UI token field

Browsers cannot attach an Authorization header to a WebSocket upgrade,
so with RUVIEW_API_TOKEN set the Live Demo pose stream at
/api/v1/stream/pose always failed with 401 — the same reason
/ws/sensing is already exempted (see bearer_auth module docs). Adds a
narrow EXEMPT_PATHS list plus a regression test that the exemption
does not leak to other /api/v1/* paths. Query-string tokens remain
rejected (CWE-598 test untouched).

Also adds an 'API Access' bearer-token field to the QuickSettings
panel: ui/services/api.service.js had setAuthToken() but nothing ever
called it, so enabling RUVIEW_API_TOKEN broke every /api/v1/* call
from the bundled dashboard. The token is stored in localStorage and
applied before the first request.

Fixes #1310

Co-Authored-By: claude-flow <ruv@ruv.net>
This commit is contained in:
erichkusuki
2026-07-11 13:32:02 +02:00
parent e6f26e9ac9
commit 2ddb6a7b02
3 changed files with 101 additions and 1 deletions
@@ -34,6 +34,15 @@ pub const API_TOKEN_ENV: &str = "RUVIEW_API_TOKEN";
/// Path prefix the middleware protects when auth is enabled.
pub const PROTECTED_PREFIX: &str = "/api/v1/";
/// `/api/v1/stream/pose` is a WebSocket upgrade endpoint reachable from
/// browser code. Unlike a plain fetch(), the browser `WebSocket` constructor
/// cannot attach an `Authorization` header to the handshake request, so this
/// path can never carry a bearer token from a stock browser client — the
/// same reasoning that already exempts `/ws/sensing` (see module docs).
/// Exempted here rather than moved out of `/api/v1/*` to avoid an API
/// surface change for existing clients.
const EXEMPT_PATHS: &[&str] = &["/api/v1/stream/pose"];
/// Cheap, cloneable handle to the configured token (or `None`).
#[derive(Debug, Clone, Default)]
pub struct AuthState {
@@ -93,7 +102,8 @@ pub async fn require_bearer(
let Some(expected) = auth.token.clone() else {
return next.run(request).await;
};
if !request.uri().path().starts_with(PROTECTED_PREFIX) {
let path = request.uri().path();
if !path.starts_with(PROTECTED_PREFIX) || EXEMPT_PATHS.contains(&path) {
return next.run(request).await;
}
let supplied = request
@@ -141,6 +151,7 @@ mod tests {
.route("/health", get(|| async { "ok" }))
.route("/api/v1/info", get(|| async { "ok" }))
.route("/api/v1/sensitive", axum::routing::post(|| async { "ok" }))
.route("/api/v1/stream/pose", get(|| async { "ok" }))
.route("/ui/index.html", get(|| async { "<html/>" }))
}
@@ -361,6 +372,26 @@ mod tests {
);
}
/// `/api/v1/stream/pose` is a WebSocket upgrade the browser `WebSocket`
/// constructor drives directly — it cannot attach an `Authorization`
/// header, so this path must stay reachable even with auth ON (mirrors
/// the existing `/ws/sensing` exemption, just inside the `/api/v1/*`
/// prefix this time).
#[tokio::test]
async fn enabled_exempts_pose_stream_websocket() {
let r = wrap(AuthState::from_token("s3cr3t"));
assert_eq!(
status(r.clone(), "GET", "/api/v1/stream/pose", None).await,
StatusCode::OK,
"pose stream WS must stay reachable without a bearer token"
);
// The exemption is narrow: it must not leak to other /api/v1/* paths.
assert_eq!(
status(r, "GET", "/api/v1/info", None).await,
StatusCode::UNAUTHORIZED
);
}
#[test]
fn ct_eq_basics() {
assert!(ct_eq(b"abc", b"abc"));