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;
|
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 --- */
|
||||||
|
|
||||||
.screenshot-flash {
|
.screenshot-flash {
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,10 @@
|
||||||
// Quick Settings Panel - Centralized configuration for all UI features
|
// Quick Settings Panel - Centralized configuration for all UI features
|
||||||
// Accessible via gear icon in header
|
// Accessible via gear icon in header
|
||||||
|
|
||||||
|
import { apiService } from '../services/api.service.js';
|
||||||
|
|
||||||
|
const API_TOKEN_STORAGE_KEY = 'ruview-api-token';
|
||||||
|
|
||||||
export class QuickSettings {
|
export class QuickSettings {
|
||||||
constructor(app) {
|
constructor(app) {
|
||||||
this.app = app;
|
this.app = app;
|
||||||
|
|
@ -10,10 +14,21 @@ export class QuickSettings {
|
||||||
}
|
}
|
||||||
|
|
||||||
init() {
|
init() {
|
||||||
|
this.applyStoredApiToken();
|
||||||
this.createButton();
|
this.createButton();
|
||||||
this.createPanel();
|
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() {
|
createButton() {
|
||||||
this.button = document.createElement('button');
|
this.button = document.createElement('button');
|
||||||
this.button.className = 'settings-gear';
|
this.button.className = 'settings-gear';
|
||||||
|
|
@ -70,6 +85,18 @@ export class QuickSettings {
|
||||||
<span class="qs-switch"></span>
|
<span class="qs-switch"></span>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</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">
|
||||||
<div class="qs-section-title">Data</div>
|
<div class="qs-section-title">Data</div>
|
||||||
<div class="qs-row">
|
<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', () => {
|
this.panel.querySelector('#qs-clear-data').addEventListener('click', () => {
|
||||||
try {
|
try {
|
||||||
localStorage.clear();
|
localStorage.clear();
|
||||||
|
|
@ -154,6 +205,10 @@ export class QuickSettings {
|
||||||
if (this.getSetting('compact')) {
|
if (this.getSetting('compact')) {
|
||||||
document.body.classList.add('compact-mode');
|
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() {
|
prefersReducedMotion() {
|
||||||
|
|
|
||||||
|
|
@ -34,6 +34,15 @@ pub const API_TOKEN_ENV: &str = "RUVIEW_API_TOKEN";
|
||||||
/// Path prefix the middleware protects when auth is enabled.
|
/// Path prefix the middleware protects when auth is enabled.
|
||||||
pub const PROTECTED_PREFIX: &str = "/api/v1/";
|
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`).
|
/// Cheap, cloneable handle to the configured token (or `None`).
|
||||||
#[derive(Debug, Clone, Default)]
|
#[derive(Debug, Clone, Default)]
|
||||||
pub struct AuthState {
|
pub struct AuthState {
|
||||||
|
|
@ -93,7 +102,8 @@ pub async fn require_bearer(
|
||||||
let Some(expected) = auth.token.clone() else {
|
let Some(expected) = auth.token.clone() else {
|
||||||
return next.run(request).await;
|
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;
|
return next.run(request).await;
|
||||||
}
|
}
|
||||||
let supplied = request
|
let supplied = request
|
||||||
|
|
@ -141,6 +151,7 @@ mod tests {
|
||||||
.route("/health", get(|| async { "ok" }))
|
.route("/health", get(|| async { "ok" }))
|
||||||
.route("/api/v1/info", get(|| async { "ok" }))
|
.route("/api/v1/info", get(|| async { "ok" }))
|
||||||
.route("/api/v1/sensitive", axum::routing::post(|| 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/>" }))
|
.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]
|
#[test]
|
||||||
fn ct_eq_basics() {
|
fn ct_eq_basics() {
|
||||||
assert!(ct_eq(b"abc", b"abc"));
|
assert!(ct_eq(b"abc", b"abc"));
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue