Merge PR #1313: exempt /api/v1/stream/pose WS from bearer auth + UI token field
This commit is contained in:
commit
d59ca00baa
14
ui/style.css
14
ui/style.css
|
|
@ -4088,6 +4088,20 @@ a:focus-visible,
|
|||
color: #fff;
|
||||
}
|
||||
|
||||
.qs-text-input {
|
||||
padding: var(--space-6) var(--space-8);
|
||||
border-radius: var(--radius-sm);
|
||||
border: 1px solid var(--color-border);
|
||||
background: var(--color-secondary);
|
||||
color: var(--color-text);
|
||||
font-size: var(--font-size-sm);
|
||||
}
|
||||
|
||||
.qs-text-input:focus {
|
||||
outline: 2px solid var(--color-primary);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
/* --- Screenshot Flash --- */
|
||||
|
||||
.screenshot-flash {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,10 @@
|
|||
// Quick Settings Panel - Centralized configuration for all UI features
|
||||
// Accessible via gear icon in header
|
||||
|
||||
import { apiService } from '../services/api.service.js';
|
||||
|
||||
const API_TOKEN_STORAGE_KEY = 'ruview-api-token';
|
||||
|
||||
export class QuickSettings {
|
||||
constructor(app) {
|
||||
this.app = app;
|
||||
|
|
@ -10,10 +14,21 @@ export class QuickSettings {
|
|||
}
|
||||
|
||||
init() {
|
||||
this.applyStoredApiToken();
|
||||
this.createButton();
|
||||
this.createPanel();
|
||||
}
|
||||
|
||||
// Apply a previously-saved bearer token to apiService as early as
|
||||
// possible, before any tab's REST calls fire. The server only ever
|
||||
// checks the `Authorization: Bearer` header (see bearer_auth.rs) — this
|
||||
// intentionally never puts the token in a URL query string.
|
||||
applyStoredApiToken() {
|
||||
let token = null;
|
||||
try { token = localStorage.getItem(API_TOKEN_STORAGE_KEY); } catch { /* noop */ }
|
||||
if (token) apiService.setAuthToken(token);
|
||||
}
|
||||
|
||||
createButton() {
|
||||
this.button = document.createElement('button');
|
||||
this.button.className = 'settings-gear';
|
||||
|
|
@ -70,6 +85,18 @@ export class QuickSettings {
|
|||
<span class="qs-switch"></span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="qs-section">
|
||||
<div class="qs-section-title">API Access</div>
|
||||
<div class="qs-row" style="flex-direction: column; align-items: stretch; gap: 6px;">
|
||||
<span>Bearer token (set only if the server enforces RUVIEW_API_TOKEN)</span>
|
||||
<input type="password" id="qs-api-token" class="qs-text-input" placeholder="Paste token..." autocomplete="off" style="width: 100%; box-sizing: border-box;">
|
||||
<div style="display: flex; gap: 8px;">
|
||||
<button class="qs-btn" id="qs-api-token-save">Save & Apply</button>
|
||||
<button class="qs-btn-danger" id="qs-api-token-clear">Clear</button>
|
||||
</div>
|
||||
<span id="qs-api-token-status" style="font-size: 0.85em; opacity: 0.75;"></span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="qs-section">
|
||||
<div class="qs-section-title">Data</div>
|
||||
<div class="qs-row">
|
||||
|
|
@ -112,6 +139,30 @@ export class QuickSettings {
|
|||
}
|
||||
});
|
||||
|
||||
this.panel.querySelector('#qs-api-token-save').addEventListener('click', () => {
|
||||
const input = this.panel.querySelector('#qs-api-token');
|
||||
const status = this.panel.querySelector('#qs-api-token-status');
|
||||
const token = input.value.trim();
|
||||
if (!token) {
|
||||
status.textContent = 'Enter a token first, or use Clear to remove one.';
|
||||
return;
|
||||
}
|
||||
try { localStorage.setItem(API_TOKEN_STORAGE_KEY, token); } catch { /* noop */ }
|
||||
apiService.setAuthToken(token);
|
||||
status.textContent = 'Token saved and applied. Reloading...';
|
||||
setTimeout(() => window.location.reload(), 600);
|
||||
});
|
||||
|
||||
this.panel.querySelector('#qs-api-token-clear').addEventListener('click', () => {
|
||||
const input = this.panel.querySelector('#qs-api-token');
|
||||
const status = this.panel.querySelector('#qs-api-token-status');
|
||||
try { localStorage.removeItem(API_TOKEN_STORAGE_KEY); } catch { /* noop */ }
|
||||
apiService.setAuthToken(null);
|
||||
input.value = '';
|
||||
status.textContent = 'Token cleared. Reloading...';
|
||||
setTimeout(() => window.location.reload(), 600);
|
||||
});
|
||||
|
||||
this.panel.querySelector('#qs-clear-data').addEventListener('click', () => {
|
||||
try {
|
||||
localStorage.clear();
|
||||
|
|
@ -154,6 +205,10 @@ export class QuickSettings {
|
|||
if (this.getSetting('compact')) {
|
||||
document.body.classList.add('compact-mode');
|
||||
}
|
||||
const status = this.panel.querySelector('#qs-api-token-status');
|
||||
let hasToken = false;
|
||||
try { hasToken = !!localStorage.getItem(API_TOKEN_STORAGE_KEY); } catch { /* noop */ }
|
||||
if (status) status.textContent = hasToken ? 'A token is currently set.' : 'No token set (auth is off or unnecessary).';
|
||||
}
|
||||
|
||||
prefersReducedMotion() {
|
||||
|
|
|
|||
|
|
@ -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"));
|
||||
|
|
|
|||
Loading…
Reference in New Issue