feat(ui): Sign in with Cognitum, zero-config session secret, executed JS tests

Three loose ends from the browser sign-in work.

--- 1. The last mile: a button ---

The server endpoints existed but nothing in ui/ linked to them, so the feature
was unreachable. QuickSettings gains a "Cognitum Account" panel that renders
from `GET /oauth/status` and offers Sign in / Sign out.

`/oauth/status` is a new endpoint and is deliberately UNGATED — a signed-OUT
browser cannot ask a gated API whether sign-in is available. It returns only
capability flags and, when a session exists, who it belongs to. Never a
credential.

The panel distinguishes four states rather than showing a button that might
404: signed in; sign-in available; auth on but OAuth off (points at the
existing static-token panel); no auth required. A 404 from /oauth/status means
a server predating this work and says so plainly.

Sign-in is a full-page navigation, not fetch(): the server replies 302 and the
browser must follow it carrying the transaction cookie. An XHR would follow the
redirect invisibly and land nowhere.

--- 2. RUVIEW_SESSION_SECRET no longer required ---

Previously `/oauth/start` returned 503 unless an operator invented a secret —
a footgun, since they set RUVIEW_OAUTH_ISSUER, expect sign-in, and get a 503
naming an env var they have never heard of.

Now resolved in order: env var, then `<data_dir>/session-secret`, then generate
one and persist it 0600 (temp file, chmod before rename — the discipline used
for the CLI's credentials). Persisted rather than in-memory so a restart does
not silently sign everyone out.

The env var still wins, which is what a multi-instance deployment needs: several
servers must share a secret or a session issued by one is rejected by the next.
If the file cannot be written we log the reason and continue with an in-memory
secret rather than refusing sign-in outright.

Verified with NO configuration at all: secret generated, file mode 0600,
/oauth/start returns 302, /oauth/status reports browser_signin: true.

--- 3. The JavaScript is now executed by tests ---

`ui/services/ws-ticket.test.mjs`, 9 tests, node:test built-in — no new
dependency and no package.json needed. Run: `node --test ui/services/`.

Covers: no token means no fetch and an unchanged URL; the bearer reaches the
Authorization header and NEVER the URL; `?` vs `&` when a query already exists;
ticket URL-encoding; 404 treated as a pre-ADR-272 server (the property that
lets one UI work against old and new servers, and therefore lets the legacy
escape hatch be removed later); 503 and network failure swallowed rather than
breaking the connect path; and that a fresh ticket is minted per call, since
tickets are single-use and caching one fails on the second reconnect.

STATED PRECISELY, because the distinction matters: this EXECUTES the module in
Node with stubbed fetch/localStorage. It is more than the `node --check` it
replaces and less than a browser — no real WebSocket upgrade, no real cookies,
no page wiring. "The UI JavaScript has never been run" is no longer true of
this module. "Browser-tested" still is not.

Tests: 547 sensing-server lib, 5 wiring integration, 87 ruview-auth, 9 JS.

Co-Authored-By: Ruflo & AQE
This commit is contained in:
Dragan Spiridonov
2026-07-23 10:07:49 +02:00
parent 347698b67c
commit 9299a3b137
4 changed files with 293 additions and 2 deletions
@@ -31,6 +31,7 @@
//! arrived over TLS. Every other attribute — `HttpOnly`, `SameSite=Lax`,
//! `Path=/` — matches, and the signature is what actually protects the value.
use std::path::Path;
use std::time::{SystemTime, UNIX_EPOCH};
use hmac::{Hmac, Mac};
@@ -155,13 +156,86 @@ pub enum SessionError {
InvalidToken(String),
}
fn secret() -> Result<String, SessionError> {
std::env::var(SESSION_SECRET_ENV)
/// Process-wide secret, resolved once.
static SECRET: std::sync::OnceLock<Option<String>> = std::sync::OnceLock::new();
/// Resolve the signing secret: env first, then a persisted file, then generate.
///
/// Requiring an operator to invent a secret before browser sign-in works is a
/// footgun — they set `RUVIEW_OAUTH_ISSUER`, expect sign-in, and get a 503 that
/// names an env var they have never heard of. A single-host appliance has no
/// reason to need that step, so we generate one and persist it `0600` next to
/// the server's other state.
///
/// Persisted rather than in-memory so a restart does not silently sign everyone
/// out. The env var still wins, which is what a multi-instance deployment needs
/// — several servers must share a secret or a session issued by one is
/// rejected by the next.
pub fn init_secret(data_dir: &Path) {
let resolved = std::env::var(SESSION_SECRET_ENV)
.ok()
.filter(|s| !s.trim().is_empty())
.map(|s| {
tracing::info!("browser session secret: from {SESSION_SECRET_ENV}");
s
})
.or_else(|| load_or_create_secret(data_dir));
let _ = SECRET.set(resolved);
}
fn load_or_create_secret(data_dir: &Path) -> Option<String> {
let path = data_dir.join("session-secret");
if let Ok(existing) = std::fs::read_to_string(&path) {
let trimmed = existing.trim().to_string();
if !trimmed.is_empty() {
tracing::info!(path = %path.display(), "browser session secret: loaded");
return Some(trimmed);
}
}
let mut bytes = [0u8; 32];
rand::RngCore::fill_bytes(&mut rand::rngs::OsRng, &mut bytes);
let generated = b64(&bytes);
if let Err(e) = write_secret(&path, &generated) {
tracing::warn!(
path = %path.display(),
error = %e,
"could not persist a browser session secret; sessions will not survive a restart. \
Set {SESSION_SECRET_ENV} to fix this permanently."
);
// Still usable this run — better than refusing sign-in outright.
return Some(generated);
}
tracing::info!(path = %path.display(), "browser session secret: generated");
Some(generated)
}
fn write_secret(path: &Path, value: &str) -> std::io::Result<()> {
if let Some(dir) = path.parent() {
std::fs::create_dir_all(dir)?;
}
let tmp = path.with_extension("tmp");
std::fs::write(&tmp, value)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
// Restrict BEFORE publishing the path, as with the CLI's credentials.
std::fs::set_permissions(&tmp, std::fs::Permissions::from_mode(0o600))?;
}
std::fs::rename(&tmp, path)
}
fn secret() -> Result<String, SessionError> {
SECRET
.get()
.and_then(|s| s.clone())
.ok_or(SessionError::NotConfigured)
}
/// Is browser sign-in usable on this server?
pub fn is_configured() -> bool {
secret().is_ok()
}
/// Begin sign-in: where to redirect, and the cookie to set.
pub fn begin(issuer: &str, client_id: &str, scope: &str, secure: bool) -> Result<(String, String), SessionError> {
let secret = secret()?;
@@ -7673,6 +7673,10 @@ async fn main() {
// ADR-044 §5.3: load persisted runtime config from the data directory.
let data_dir = std::path::PathBuf::from("data");
let runtime_config = load_runtime_config(&data_dir);
// ADR-271: resolve (or generate + persist) the browser-session signing key
// before any request can arrive. Zero-config for a single appliance; the
// env var still wins for a multi-instance deployment that must share one.
wifi_densepose_sensing_server::browser_session::init_secret(&data_dir);
info!(
"Loaded runtime config: dedup_factor={:.2}",
runtime_config.dedup_factor
@@ -8087,6 +8091,10 @@ async fn main() {
.route("/oauth/start", get(oauth_start))
.route("/oauth/callback", get(oauth_callback))
.route("/oauth/logout", get(oauth_logout))
// Ungated on purpose: a signed-OUT browser needs to discover whether
// sign-in is available, and it cannot ask a gated endpoint that.
// Returns only capability + who-you-are, never a credential.
.route("/oauth/status", get(oauth_status))
.route("/api/v1/stream/pose", get(ws_pose_handler))
// Sensing WebSocket on the HTTP port so the UI can reach it without a second port
.route("/ws/sensing", get(ws_sensing_handler))
@@ -9321,3 +9329,27 @@ async fn oauth_logout(headers: axum::http::HeaderMap) -> axum::response::Respons
)
.into_response()
}
/// `GET /oauth/status` — what a signed-out browser needs to render the right UI.
///
/// Deliberately ungated and deliberately thin: capability flags and, if a live
/// session exists, who it belongs to. No token, no scope escalation hints, no
/// server configuration beyond "is sign-in possible here".
async fn oauth_status(
axum::Extension(auth): axum::Extension<wifi_densepose_sensing_server::bearer_auth::AuthState>,
headers: axum::http::HeaderMap,
) -> axum::Json<serde_json::Value> {
use wifi_densepose_sensing_server::browser_session as bs;
let session = headers
.get(axum::http::header::COOKIE)
.and_then(|v| v.to_str().ok())
.and_then(bs::from_cookie_header);
axum::Json(serde_json::json!({
"auth_required": auth.is_enabled(),
"oauth_enabled": auth.oauth_enabled(),
"browser_signin": auth.oauth_enabled() && bs::is_configured(),
"signed_in": session.is_some(),
"account": session.as_ref().map(|s| s.account_id.clone()),
"scope": session.as_ref().map(|s| s.scope.clone()),
}))
}