Files
ruvnet--RuView/v2/crates/wifi-densepose-sensing-server/src/browser_session.rs
T
Dragan Spiridonov 56327d0931 decide: browser sessions are read-only permanently; drop the dead step-up client
Decision: browser-side admin is not wanted. `BROWSER_SIGNIN_SCOPE` stays
`sensing:read`, and the escalate-on-demand design sketched while this was open
is not being built. Destructive operations — training, model delete, recording
delete — keep their home in the CLI, where `--admin` is explicit and typed by a
person. Routing them through a browser would mean either asking every user to
consent to delete capability in order to watch a stream, or building a second
consent flow to avoid that.

Consequences, now settled rather than open:

- The UI's admin controls are unreachable from a Cognitum browser session, and
  that is intended. The manual token-paste field is unchanged and still carries
  whatever authority the pasted token has, so nothing that worked before stops
  working.

- REMOVED the client-side step-up redirect from ui/services/api.service.js. It
  caught an RFC 6750 challenge that can never be issued to a browser, and it
  ended in `return new Promise(() => {})` — so if any other 401 had ever grown
  that header, every caller awaiting it would have hung forever with no error
  and no timeout. Dead code with a trap in it is worse than no code.

- KEPT ADMIN_REVERIFY_SECS as a server-side backstop. Fail-closed and free, so
  if the requested scope is ever widened the freshness requirement is already
  in place. Documented at its definition as a backstop specifically so nobody
  reads its passing tests as evidence the control is exercised — the tests
  reach it through a crate-internal seam that mints an admin cookie the real
  flow does not produce.

ADR-271 also stops hedging on the session TTL: chosen is A at one hour. Option B
(server-side refresh-token store) is not built, and the ADR now names the
residual instead of implying it is closed — within one hour a revoked Cognitum
grant still reads sensing data through an existing browser session. There is no
introspection endpoint, so nothing short of B closes that, and one hour is the
size of the hole we accepted.

Adds `the_cookie_max_age_matches_the_session_expiry`, which the previous ADR
revision asked for and nobody had written: Max-Age and the payload's `exp` are
two independent expressions of one lifetime, and drift means either the browser
presents a session we reject or we hold authority the browser discarded.

Verified: workspace 176 suites clean, UI 22, api.service.js parses.

Co-Authored-By: Ruflo & AQE
2026-07-23 14:47:31 +02:00

1041 lines
42 KiB
Rust

//! Browser sign-in: `GET /oauth/start` → Cognitum → `GET /oauth/callback`
//! → a signed session cookie (ADR-271, browser half).
//!
//! # Why this exists
//!
//! `wifi-densepose login` writes `~/.ruview/credentials.json`. **A browser
//! cannot read that file.** So until now the UI had no way to obtain a Cognitum
//! token at all — the WebSocket ticket mechanism ADR-272 built "for browsers"
//! was only exercisable with the legacy static shared secret that OAuth was
//! meant to replace. An adversarial review found the gap; this closes it.
//!
//! # The pattern, ported from `cognitum-one/freetokens`
//!
//! freetokens (`src/auth/oauth.ts`, live at `freetokens.cognitum.one`) solves
//! exactly this, and the shape is worth stating because it is not the obvious
//! one:
//!
//! **The browser never holds an OAuth token.** The server generates the PKCE
//! verifier and state, keeps them in a signed cookie, performs the code
//! exchange itself, verifies the token, and then issues *its own* session
//! cookie. The access token never reaches page JavaScript, so it cannot be
//! read by an XSS, stored in `localStorage`, or leaked through a URL.
//!
//! # Deviation from freetokens, and why
//!
//! freetokens uses the `__Host-` cookie prefix, which **requires** the `Secure`
//! attribute. It is served only over HTTPS, so that is free. RuView is
//! routinely reached over plain HTTP on a LAN or at `http://localhost`, where a
//! `__Host-`/`Secure` cookie is simply never sent and sign-in would silently
//! fail. So the names carry no prefix and `Secure` is set only when the request
//! 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};
use sha2::Sha256;
use subtle::ConstantTimeEq;
type HmacSha256 = Hmac<Sha256>;
/// Signing key for both cookies. Absent ⇒ browser sign-in is unavailable and
/// `/oauth/start` answers 503, rather than issuing cookies nobody can verify.
pub const SESSION_SECRET_ENV: &str = "RUVIEW_SESSION_SECRET";
/// Public origin this server is reached at, used to build `redirect_uri`.
/// Must match the value registered for the `ruview` OAuth client.
pub const PUBLIC_BASE_URL_ENV: &str = "RUVIEW_PUBLIC_BASE_URL";
const TXN_COOKIE: &str = "ruview_oauth_txn";
const SESSION_COOKIE: &str = "ruview_session";
/// The OAuth round-trip is a page load or two. Ten minutes is generous.
const TXN_TTL_SECS: i64 = 600;
/// How long a browser stays signed in before repeating the redirect.
///
/// One hour, down from twelve. The session cookie is an assertion that this
/// server verified a Cognitum access token; that token lives ~15 minutes, and
/// Cognitum publishes no introspection endpoint, so there is no way to ask
/// whether the grant behind a session still stands. Every second of this TTL is
/// time a revoked or disabled account keeps working. Twelve hours made that
/// window a working day.
///
/// An hour is short enough to bound the damage and long enough that the
/// re-auth redirect is rare; because the user's Cognitum session is normally
/// still alive, that redirect is usually silent.
pub const SESSION_TTL_SECS: i64 = 3600;
/// How recently the user must have actually authenticated for this server to
/// honour a **privileged** (`sensing:admin`) request from a browser session.
///
/// Reads ride the full [`SESSION_TTL_SECS`]; deleting models and recordings, and
/// starting training, do not. This is step-up-by-recency: the blast radius of a
/// stale session is the mutating routes, so those are what get re-verified,
/// rather than making every user re-authenticate hourly for a dashboard whose
/// primary use is watching a live stream.
///
/// **This is a backstop, not an active control.** Browser sign-in requests
/// `sensing:read` only and always will ([`BROWSER_SIGNIN_SCOPE`]), so no browser
/// session holds `sensing:admin` and this branch is never reached in production.
/// It is kept because it is cheap and fail-closed: if the requested scope is
/// ever widened, the freshness requirement is already in place rather than
/// something someone has to remember to add. Its tests exercise it through a
/// crate-internal seam that mints an admin cookie the real flow does not
/// produce — do not read them as evidence the control is exercised.
pub const ADMIN_REVERIFY_SECS: i64 = 300;
/// The scope `/oauth/start` requests. Read-only, deliberately.
///
/// Named rather than inlined because it is a decision, not a detail, and it has
/// two consequences that are easy to widen by accident:
///
/// 1. **The UI's admin controls do not work from a browser session.**
/// `model.service.js` issues `DELETE /api/v1/models/{id}`; from a
/// Cognitum-signed-in browser that is a 401. Admin work goes through the CLI
/// (`wifi-densepose login --admin`) or a pasted admin bearer.
/// 2. **[`ADMIN_REVERIFY_SECS`] therefore guards a case that cannot yet arise.**
/// No browser session holds `sensing:admin`, so the freshness branch never
/// fires in production today. It becomes load-bearing the instant this
/// constant grows, which is the right ordering — but do not mistake its
/// passing tests for evidence that the control is exercised.
///
/// **Decided 2026-07-23: browser-side admin is not wanted.** This stays
/// read-only. Widening it would make every browser sign-in consent to delete
/// capability just to watch a stream, and the destructive operations have a
/// deliberate home — the CLI, where `--admin` is explicit and typed.
pub const BROWSER_SIGNIN_SCOPE: &str = ruview_auth::scope::SENSING_READ;
fn now() -> i64 {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.map(|d| d.as_secs() as i64)
.unwrap_or(0)
}
fn b64(bytes: &[u8]) -> String {
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use base64::Engine;
URL_SAFE_NO_PAD.encode(bytes)
}
fn unb64(s: &str) -> Option<Vec<u8>> {
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
use base64::Engine;
URL_SAFE_NO_PAD.decode(s).ok()
}
/// `<payload-b64>.<hmac-b64>`.
fn sign(payload: &[u8], secret: &str) -> String {
let mut mac = HmacSha256::new_from_slice(secret.as_bytes()).expect("hmac accepts any key size");
let body = b64(payload);
mac.update(body.as_bytes());
format!("{body}.{}", b64(&mac.finalize().into_bytes()))
}
/// Verify and unwrap. Constant-time tag comparison — a byte-at-a-time compare
/// on a MAC is a forgery oracle.
fn unsign(value: &str, secret: &str) -> Option<Vec<u8>> {
let sep = value.rfind('.')?;
let (body, tag) = (&value[..sep], &value[sep + 1..]);
let mut mac = HmacSha256::new_from_slice(secret.as_bytes()).ok()?;
mac.update(body.as_bytes());
let expected = b64(&mac.finalize().into_bytes());
if expected.as_bytes().ct_eq(tag.as_bytes()).into() {
unb64(body)
} else {
None
}
}
/// What the transaction cookie carries between `/oauth/start` and the callback.
#[derive(serde::Serialize, serde::Deserialize)]
struct Transaction {
state: String,
verifier: String,
exp: i64,
}
/// What the session cookie carries after a successful sign-in.
///
/// Deliberately NOT the access token. The browser gets an assertion that this
/// server already verified one — nothing replayable elsewhere.
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
pub struct BrowserSession {
pub subject: String,
pub account_id: String,
pub scope: String,
pub exp: i64,
/// When the user last actually authenticated against Cognitum, unix
/// seconds — the OIDC `auth_time` idea, used here for step-up.
///
/// `#[serde(default)]` so a cookie issued before this field existed still
/// deserializes. It then reads as `0`, which is infinitely stale, so such a
/// session can still read but cannot perform a privileged action until the
/// user signs in again. Fail-closed, and self-healing on next sign-in.
#[serde(default)]
pub auth_time: i64,
}
impl BrowserSession {
pub fn is_live(&self) -> bool {
self.exp > now()
}
pub fn has_scope(&self, want: &str) -> bool {
self.scope.split_whitespace().any(|s| s == want)
}
/// Has the user authenticated recently enough for a privileged action?
///
/// See [`ADMIN_REVERIFY_SECS`]. Note this is deliberately NOT "is the
/// session live" — a session can be perfectly valid for reads and still too
/// old to delete a model.
pub fn recently_authenticated(&self) -> bool {
now() - self.auth_time < ADMIN_REVERIFY_SECS
}
}
fn cookie(name: &str, value: &str, max_age: i64, secure: bool) -> String {
format!(
"{name}={value}; Path=/; Max-Age={max_age}; HttpOnly; SameSite=Lax{}",
if secure { "; Secure" } else { "" }
)
}
/// Read one cookie from a raw `Cookie:` header.
///
/// Returns the FIRST match, which is only safe when the caller has already
/// established there is exactly one — see [`read_all_cookies`] and the
/// shadowing attack it exists to stop. Kept for callers that genuinely want
/// first-match semantics; the credential paths do not.
pub fn read_cookie(header: &str, name: &str) -> Option<String> {
read_all_cookies(header, name).into_iter().next()
}
/// Every value sent under `name`, in header order.
///
/// # Why this is not `read_cookie`
///
/// A `Cookie:` header can legitimately carry the same name more than once —
/// cookies are keyed by (name, domain, path), and RFC 6265 §5.4 orders
/// longer-`Path` matches FIRST. Cookies are also not isolated by port or by
/// scheme, so *any* other service on the same host, or a plain-HTTP MITM
/// injecting a `Set-Cookie`, can add one.
///
/// Taking the first match therefore let an attacker **shadow** a victim's
/// session: sign in normally, capture your own validly-signed
/// `ruview_session`, then get it set with `Path=/ui` on the victim's browser.
/// The victim then sends both, the attacker's first, and it verifies —
/// because it is genuinely signed. The victim silently operates inside the
/// attacker's session; `/oauth/status` reports the attacker's account and the
/// victim's recordings are attributed to them.
///
/// Note the shape of this: the signature was doing its job the whole time.
/// Forgery is not the threat here, and "the signature protects the value" does
/// not answer it. The `__Host-` prefix would — it forbids `Domain` and pins
/// `Path=/` — but it also requires `Secure`, and RuView is routinely reached
/// over plain HTTP on a LAN, where such a cookie is never sent at all.
///
/// So the callers resolve ambiguity themselves: accept only when exactly one
/// candidate verifies. An attacker can still cause a *refusal* by planting a
/// second valid cookie, which is a nuisance; they can no longer cause a
/// silent takeover, which is a compromise.
pub fn read_all_cookies(header: &str, name: &str) -> Vec<String> {
header
.split(';')
.filter_map(|part| {
let (k, v) = part.split_once('=')?;
(k.trim() == name).then(|| v.trim().to_string())
})
.collect()
}
/// Unwrap the one candidate that verifies, or `None` if zero or several do.
///
/// Several verifying means the browser sent two genuinely-signed cookies of the
/// same name — which a legitimate client never does, and which is exactly the
/// shadowing attack described on [`read_all_cookies`]. Refusing is correct: we
/// cannot tell which one the user meant, and guessing is how the takeover works.
fn unsign_unambiguous(header: &str, name: &str, secret: &str) -> Option<Vec<u8>> {
let mut verified = read_all_cookies(header, name)
.into_iter()
.filter_map(|raw| unsign(&raw, secret));
let first = verified.next()?;
match verified.next() {
None => Some(first),
Some(_) => {
tracing::warn!(
cookie = name,
"request carried more than one validly-signed {name}; refusing rather than \
guessing which is the user's — see read_all_cookies"
);
None
}
}
}
#[derive(Debug, thiserror::Error)]
pub enum SessionError {
#[error("browser sign-in is not configured on this server")]
NotConfigured,
#[error("the sign-in request is missing or has expired")]
InvalidTransaction,
#[error("the sign-in state did not match — this response did not come from the flow that started")]
StateMismatch,
#[error("Cognitum sign-in could not be completed: {0}")]
ExchangeFailed(String),
#[error("Cognitum returned a token this server will not accept: {0}")]
InvalidToken(String),
}
/// 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");
// Created 0600, not written-then-chmodded. `fs::write` creates at
// `0666 & !umask`, so this file — the HMAC key for EVERY browser session —
// was world-readable for the window before the chmod. Anyone who read it
// could forge a session cookie for any account with any scope, including
// `sensing:admin`, which is strictly worse than stealing one session.
#[cfg(unix)]
{
use std::io::Write;
use std::os::unix::fs::OpenOptionsExt;
let _ = std::fs::remove_file(&tmp);
let mut f = std::fs::OpenOptions::new()
.write(true)
.create_new(true)
.mode(0o600)
.open(&tmp)?;
f.write_all(value.as_bytes())?;
f.sync_all()?;
}
#[cfg(not(unix))]
std::fs::write(&tmp, value)?;
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()?;
let req = ruview_auth::pkce::generate();
let txn = Transaction {
state: req.state.clone(),
verifier: req.code_verifier,
exp: now() + TXN_TTL_SECS,
};
let payload = serde_json::to_vec(&txn).expect("transaction serializes");
let mut url = url_encode_authorize(issuer, client_id, scope, &req.state, &req.code_challenge);
url.push_str(""); // no-op; keeps the builder readable
Ok((
url,
cookie(TXN_COOKIE, &sign(&payload, &secret), TXN_TTL_SECS, secure),
))
}
fn url_encode_authorize(
issuer: &str,
client_id: &str,
scope: &str,
state: &str,
challenge: &str,
) -> String {
// Percent-encode every value: `scope` legitimately contains a space
// ("sensing:read sensing:admin") and hand-formatting silently truncates it.
fn enc(s: &str) -> String {
s.bytes()
.map(|b| match b {
b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
(b as char).to_string()
}
_ => format!("%{b:02X}"),
})
.collect()
}
format!(
"{}/oauth/authorize?response_type=code&client_id={}&redirect_uri={}&scope={}&state={}&code_challenge={}&code_challenge_method=S256",
issuer.trim_end_matches('/'),
enc(client_id),
enc(&redirect_uri()),
enc(scope),
enc(state),
enc(challenge),
)
}
pub fn public_base_url() -> String {
std::env::var(PUBLIC_BASE_URL_ENV)
.ok()
.filter(|s| !s.trim().is_empty())
.map(|s| s.trim().trim_end_matches('/').to_string())
.unwrap_or_else(|| "http://127.0.0.1:8080".to_string())
}
pub fn redirect_uri() -> String {
format!("{}/oauth/callback", public_base_url())
}
/// Cookie that clears the transaction.
pub fn clear_transaction(secure: bool) -> String {
cookie(TXN_COOKIE, "", 0, secure)
}
/// Cookie that ends the session.
pub fn clear_session(secure: bool) -> String {
cookie(SESSION_COOKIE, "", 0, secure)
}
/// Validate the callback's `state` against the transaction cookie and return
/// the PKCE verifier needed for the exchange.
pub fn verifier_for_callback(cookie_header: &str, state: &str) -> Result<String, SessionError> {
let secret = secret()?;
// Unambiguous: a second validly-signed txn cookie would let an attacker
// substitute their own PKCE verifier, defeating the binding entirely.
let bytes = unsign_unambiguous(cookie_header, TXN_COOKIE, &secret)
.ok_or(SessionError::InvalidTransaction)?;
let txn: Transaction =
serde_json::from_slice(&bytes).map_err(|_| SessionError::InvalidTransaction)?;
if txn.exp < now() {
return Err(SessionError::InvalidTransaction);
}
// CSRF: constant-time, and BEFORE the code is spent.
let ok: bool = txn.state.as_bytes().ct_eq(state.as_bytes()).into();
if !ok {
return Err(SessionError::StateMismatch);
}
Ok(txn.verifier)
}
/// Issue the session cookie for a verified principal.
pub fn issue(principal: &ruview_auth::Principal, secure: bool) -> Result<String, SessionError> {
let secret = secret()?;
let session = BrowserSession {
subject: principal.subject.clone(),
account_id: principal.account_id.clone(),
scope: principal.scopes().collect::<Vec<_>>().join(" "),
// Never outlive our own ceiling, and never inherit the access token's
// 15 minutes either — this is a browser session, not the token.
exp: now() + SESSION_TTL_SECS,
// Stamped at issue, which is the moment a Cognitum token was actually
// verified. Never refreshed by activity: it answers "when did you last
// prove who you are", not "when were you last here".
auth_time: now(),
};
let payload = serde_json::to_vec(&session).expect("session serializes");
Ok(cookie(
SESSION_COOKIE,
&sign(&payload, &secret),
SESSION_TTL_SECS,
secure,
))
}
/// Recover a live session from a request's `Cookie:` header.
pub fn from_cookie_header(cookie_header: &str) -> Option<BrowserSession> {
let secret = secret().ok()?;
let bytes = unsign_unambiguous(cookie_header, SESSION_COOKIE, &secret)?;
let session: BrowserSession = serde_json::from_slice(&bytes).ok()?;
session.is_live().then_some(session)
}
/// Install a usable signing secret for tests in this crate.
///
/// Idempotent: `SECRET` is a `OnceLock`, so whichever test gets there first
/// wins and the rest reuse it. Nothing here depends on the secret's VALUE, only
/// on the process having one, so the race is benign.
#[cfg(test)]
pub(crate) fn init_secret_for_tests() {
let _ = SECRET.set(Some("crate-test-session-secret".to_string()));
}
/// Mint a session cookie VALUE (not a `Set-Cookie` header) for tests elsewhere
/// in this crate — `bearer_auth`, which needs to present one.
///
/// Deliberately goes through the same `sign` path as [`issue`], so a test that
/// presents this is exercising the real verification path rather than a
/// test-only bypass. `ttl` may be negative to forge an already-expired session.
#[cfg(test)]
pub(crate) fn test_cookie_value(subject: &str, account_id: &str, scope: &str, ttl: i64) -> String {
// Freshly authenticated, so step-up does not interfere with tests about
// scope or expiry. Use `test_cookie_value_aged` to exercise step-up itself.
test_cookie_value_aged(subject, account_id, scope, ttl, 0)
}
/// Sign an arbitrary payload with the test secret, so a test elsewhere in the
/// crate can construct a cookie whose SHAPE differs from the current struct —
/// e.g. one issued before a field existed.
#[cfg(test)]
pub(crate) fn test_sign_for_tests(payload: &[u8]) -> String {
init_secret_for_tests();
sign(payload, &secret().expect("secret installed above"))
}
/// As [`test_cookie_value`], but `age` seconds since the user authenticated.
#[cfg(test)]
pub(crate) fn test_cookie_value_aged(
subject: &str,
account_id: &str,
scope: &str,
ttl: i64,
age: i64,
) -> String {
init_secret_for_tests();
let secret = secret().expect("secret installed above");
let session = BrowserSession {
subject: subject.to_string(),
account_id: account_id.to_string(),
scope: scope.to_string(),
exp: now() + ttl,
auth_time: now() - age,
};
sign(&serde_json::to_vec(&session).expect("serializes"), &secret)
}
#[cfg(test)]
mod tests {
use super::*;
const SECRET: &str = "test-secret-value";
/// Pull a cookie's value out of a `Set-Cookie` header, as a browser would
/// when later sending it back in a `Cookie:` header.
fn value_of(set_cookie: &str) -> String {
set_cookie
.split(';')
.next()
.and_then(|kv| kv.split_once('='))
.map(|(_, v)| v.to_string())
.expect("Set-Cookie has name=value")
}
fn query_param(url: &str, key: &str) -> String {
url.split(['?', '&'])
.find_map(|p| p.strip_prefix(&format!("{key}=")))
.unwrap_or_else(|| panic!("{key} missing from {url}"))
.to_string()
}
// ── public API: the surface that had no tests at all ──────────────
//
// Every test below this line covers a PUBLIC function. The tests that
// already existed all targeted private helpers (sign, unsign, cookie,
// read_cookie, is_live, has_scope), so `issue`, `from_cookie_header`,
// `begin`, `verifier_for_callback` and `is_configured` — the entire
// browser sign-in flow — had no executable evidence behind them.
#[test]
fn a_server_with_a_secret_reports_browser_sign_in_as_available() {
init_secret_for_tests();
assert!(is_configured(), "sign-in must be offered once a secret exists");
}
#[test]
fn begin_produces_an_authorize_url_carrying_every_required_parameter() {
init_secret_for_tests();
let (url, set_cookie) =
begin("https://auth.cognitum.one/", "ruview", "sensing:read", false).unwrap();
assert!(url.starts_with("https://auth.cognitum.one/oauth/authorize?"), "{url}");
assert!(url.contains("response_type=code"), "{url}");
assert!(url.contains("client_id=ruview"), "{url}");
// S256 only — the AS rejects `plain`, so getting this wrong is a
// sign-in that always fails.
assert!(url.contains("code_challenge_method=S256"), "{url}");
assert!(!query_param(&url, "code_challenge").is_empty(), "{url}");
assert!(!query_param(&url, "state").is_empty(), "{url}");
// The scope's space must survive encoding or the AS sees one scope.
let (u2, _) = begin("https://a.example", "ruview", "sensing:read sensing:admin", false).unwrap();
assert!(u2.contains("sensing%3Aread%20sensing%3Aadmin"), "{u2}");
// The verifier must never be in the URL — only its S256 hash.
assert!(set_cookie.starts_with(TXN_COOKIE), "{set_cookie}");
assert!(set_cookie.contains("HttpOnly"), "the verifier must not be script-readable");
}
#[test]
fn the_callback_returns_the_verifier_when_the_state_matches() {
init_secret_for_tests();
let (url, set_cookie) = begin("https://a.example", "ruview", "sensing:read", false).unwrap();
let state = query_param(&url, "state");
let header = format!("{TXN_COOKIE}={}", value_of(&set_cookie));
let verifier = verifier_for_callback(&header, &state).expect("matching state");
assert!(verifier.len() >= 43, "PKCE verifier looks too short: {}", verifier.len());
}
#[test]
fn a_callback_whose_state_does_not_match_is_refused() {
// MUTANT THIS KILLS: deleting the `state` comparison in
// `verifier_for_callback`. Without it the callback accepts a code from
// a flow the user never started — login CSRF: an attacker completes
// their own authorization, feeds the victim the resulting callback URL,
// and the victim's browser silently ends up in the ATTACKER's session.
init_secret_for_tests();
let (_url, set_cookie) = begin("https://a.example", "ruview", "sensing:read", false).unwrap();
let header = format!("{TXN_COOKIE}={}", value_of(&set_cookie));
assert!(matches!(
verifier_for_callback(&header, "state-from-a-different-flow"),
Err(SessionError::StateMismatch)
));
// Empty is the degenerate case a naive comparison lets through.
assert!(matches!(
verifier_for_callback(&header, ""),
Err(SessionError::StateMismatch)
));
}
#[test]
fn a_callback_with_no_transaction_or_a_forged_one_is_refused() {
init_secret_for_tests();
// No cookie at all.
assert!(matches!(
verifier_for_callback("other=1", "any"),
Err(SessionError::InvalidTransaction)
));
// Present but not signed by us: an attacker choosing their own verifier
// would defeat PKCE entirely.
assert!(matches!(
verifier_for_callback(&format!("{TXN_COOKIE}=bm90LXNpZ25lZA.deadbeef"), "any"),
Err(SessionError::InvalidTransaction)
));
}
#[test]
fn an_expired_transaction_is_refused_even_with_the_right_state() {
init_secret_for_tests();
let secret = secret().unwrap();
let txn = Transaction {
state: "s".into(),
verifier: "v".into(),
exp: now() - 1,
};
let header = format!(
"{TXN_COOKIE}={}",
sign(&serde_json::to_vec(&txn).unwrap(), &secret)
);
assert!(matches!(
verifier_for_callback(&header, "s"),
Err(SessionError::InvalidTransaction)
));
}
#[test]
fn an_issued_session_round_trips_with_its_subject_account_and_scope() {
init_secret_for_tests();
let raw = test_cookie_value("sub-1", "acct-1", "sensing:read", 3600);
let session = from_cookie_header(&format!("{SESSION_COOKIE}={raw}"))
.expect("a freshly issued session must be recoverable");
assert_eq!(session.subject, "sub-1");
assert_eq!(session.account_id, "acct-1");
assert!(session.has_scope("sensing:read"));
assert!(!session.has_scope("sensing:admin"), "scope must not be widened in transit");
}
#[test]
fn an_expired_session_cookie_does_not_authenticate() {
// MUTANT THIS KILLS: `session.is_live().then_some(session)` ->
// `Some(session)` in `from_cookie_header`. `is_live` IS unit-tested,
// but nothing asserted that the caller consults it — the recurring
// "tested in isolation, call site untested" shape. Without this, a
// signed cookie authenticates forever and the session TTL is decorative.
init_secret_for_tests();
let raw = test_cookie_value("sub-1", "acct-1", "sensing:read", -1);
assert!(from_cookie_header(&format!("{SESSION_COOKIE}={raw}")).is_none());
}
#[test]
fn a_session_signed_with_another_secret_does_not_authenticate() {
init_secret_for_tests();
// Forged with a different key: the payload is well-formed and unexpired,
// so only the MAC stands between it and a valid session.
let forged = sign(
&serde_json::to_vec(&BrowserSession {
subject: "attacker".into(),
account_id: "acct-attacker".into(),
scope: "sensing:admin".into(),
exp: now() + 3600,
auth_time: now(),
})
.unwrap(),
"a-different-secret",
);
assert!(from_cookie_header(&format!("{SESSION_COOKIE}={forged}")).is_none());
}
// ── cookie shadowing (P3) ─────────────────────────────────────────
#[test]
fn every_value_sent_under_a_name_is_visible_not_just_the_first() {
let h = "ruview_session=attacker; other=x; ruview_session=victim";
assert_eq!(
read_all_cookies(h, "ruview_session"),
vec!["attacker".to_string(), "victim".to_string()]
);
}
#[test]
fn a_shadowing_cookie_cannot_silently_take_over_the_session() {
// THE ATTACK. Cookies are keyed by (name, domain, path) and RFC 6265
// §5.4 sends longer-`Path` matches FIRST. They are not isolated by port
// or scheme, so any other service on this host — or a plain-HTTP MITM
// injecting Set-Cookie — can plant one.
//
// The attacker signs in legitimately, captures their OWN validly-signed
// cookie, and gets it set with `Path=/ui` on the victim's browser. Under
// first-match the victim's browser sends the attacker's cookie first, it
// verifies (it IS genuinely signed), and the victim silently operates
// inside the attacker's session.
//
// The signature was never the problem, which is why "it's signed" does
// not answer this.
init_secret_for_tests();
let attacker = test_cookie_value("attacker", "acct-attacker", "sensing:read", 3600);
let victim = test_cookie_value("victim", "acct-victim", "sensing:read", 3600);
let header = format!("ruview_session={attacker}; ruview_session={victim}");
assert!(
from_cookie_header(&header).is_none(),
"two validly-signed sessions must be refused, not resolved by order"
);
// Order must not matter — the victim's cookie arriving first is the same
// ambiguity, not a pass.
let reversed = format!("ruview_session={victim}; ruview_session={attacker}");
assert!(from_cookie_header(&reversed).is_none());
}
#[test]
fn a_junk_shadow_cookie_does_not_lock_the_real_user_out() {
// Only ONE candidate verifies, so there is no ambiguity to refuse. This
// matters: if any duplicate name caused a refusal, planting garbage
// would be a trivial denial of service against every user.
init_secret_for_tests();
let real = test_cookie_value("victim", "acct-victim", "sensing:read", 3600);
for header in [
format!("ruview_session=not-even-signed; ruview_session={real}"),
format!("ruview_session={real}; ruview_session=bm9wZQ.deadbeef"),
] {
let s = from_cookie_header(&header).expect("the genuine cookie must still work");
assert_eq!(s.subject, "victim");
}
}
#[test]
fn a_shadowing_transaction_cookie_cannot_substitute_a_pkce_verifier() {
// Same attack against the sign-in transaction: a second validly-signed
// txn cookie would let an attacker supply their own verifier and state,
// which defeats the PKCE binding rather than merely confusing it.
init_secret_for_tests();
let (url_a, cookie_a) = begin("https://a.example", "ruview", "sensing:read", false).unwrap();
let (_url_b, cookie_b) = begin("https://a.example", "ruview", "sensing:read", false).unwrap();
let state_a = query_param(&url_a, "state");
let header = format!(
"{TXN_COOKIE}={}; {TXN_COOKIE}={}",
value_of(&cookie_a),
value_of(&cookie_b)
);
assert!(matches!(
verifier_for_callback(&header, &state_a),
Err(SessionError::InvalidTransaction)
));
}
#[test]
fn the_cookie_max_age_matches_the_session_expiry() {
// Two independent expressions of the same lifetime: the cookie's
// Max-Age (when the browser stops sending it) and the payload's `exp`
// (when we stop accepting it). If they drift, one silently wins —
// a longer Max-Age means the browser keeps presenting a session we
// reject, a shorter one means we hold authority the browser discards.
init_secret_for_tests();
let raw = test_cookie_value("s", "a", "sensing:read", SESSION_TTL_SECS);
let session = from_cookie_header(&format!("{SESSION_COOKIE}={raw}")).unwrap();
let set_cookie = cookie(SESSION_COOKIE, &raw, SESSION_TTL_SECS, false);
assert!(
set_cookie.contains(&format!("Max-Age={SESSION_TTL_SECS}")),
"{set_cookie}"
);
// Same lifetime, allowing a second for the clock ticking between them.
assert!(
(session.exp - now() - SESSION_TTL_SECS).abs() <= 1,
"cookie Max-Age and session exp disagree: exp-now={}, Max-Age={SESSION_TTL_SECS}",
session.exp - now()
);
}
#[test]
fn browser_sign_in_stays_read_only_until_someone_decides_otherwise() {
// Pins the decision documented on BROWSER_SIGNIN_SCOPE. Widening it is
// legitimate, but it must be a choice: it makes every browser sign-in
// consent to delete capability, and it activates the ADMIN_REVERIFY_SECS
// branch that is currently unreachable in production.
assert_eq!(BROWSER_SIGNIN_SCOPE, ruview_auth::scope::SENSING_READ);
assert!(
!BROWSER_SIGNIN_SCOPE.split_whitespace().any(|s| s == ruview_auth::scope::SENSING_ADMIN),
"browser sign-in must not silently request admin: {BROWSER_SIGNIN_SCOPE}"
);
}
#[test]
fn the_authorize_url_actually_carries_that_scope() {
// The constant is only worth pinning if it reaches the wire. Asserting
// on the constant alone would pass even if `begin` were called with
// something else — the same "tested in isolation, call site untested"
// shape that produced several defects in this branch.
init_secret_for_tests();
let (url, _) = begin("https://a.example", "ruview", BROWSER_SIGNIN_SCOPE, false).unwrap();
assert!(url.contains("scope=sensing%3Aread"), "{url}");
assert!(!url.contains("sensing%3Aadmin"), "{url}");
}
#[test]
fn clearing_cookies_expires_them_immediately() {
for c in [clear_session(false), clear_transaction(false)] {
assert!(c.contains("Max-Age=0"), "{c}");
}
}
fn session(exp: i64) -> BrowserSession {
BrowserSession {
subject: "user-1".into(),
account_id: "acct-1".into(),
scope: "sensing:read".into(),
exp,
auth_time: now(),
}
}
#[test]
fn a_signed_value_round_trips() {
let signed = sign(b"hello", SECRET);
assert_eq!(unsign(&signed, SECRET).as_deref(), Some(&b"hello"[..]));
}
#[test]
fn a_tampered_payload_is_rejected() {
// The whole point of signing: the browser holds this value and can edit
// it. Flipping a byte must invalidate the tag.
let signed = sign(b"hello", SECRET);
let (body, tag) = signed.split_once('.').unwrap();
let mut bad = body.to_string();
bad.push('x');
assert!(unsign(&format!("{bad}.{tag}"), SECRET).is_none());
}
#[test]
fn a_value_signed_with_another_secret_is_rejected() {
let signed = sign(b"hello", "a-different-secret");
assert!(unsign(&signed, SECRET).is_none());
}
#[test]
fn a_malformed_cookie_value_is_rejected_rather_than_panicking() {
for bad in ["", ".", "no-separator", "!!!.!!!", "a.b.c"] {
assert!(unsign(bad, SECRET).is_none(), "{bad:?} must not verify");
}
}
#[test]
fn cookies_are_httponly_and_samesite_lax() {
// HttpOnly is what keeps page JavaScript — and therefore an XSS — away
// from the session.
let c = cookie("n", "v", 600, false);
assert!(c.contains("HttpOnly"), "{c}");
assert!(c.contains("SameSite=Lax"), "{c}");
assert!(c.contains("Path=/"), "{c}");
assert!(!c.contains("Secure"), "plain HTTP must not set Secure: {c}");
}
#[test]
fn secure_is_set_only_over_tls() {
assert!(cookie("n", "v", 600, true).contains("; Secure"));
}
#[test]
fn a_session_cookie_never_contains_the_access_token() {
// The core property of this design: the browser holds an assertion,
// not a credential it could replay against Cognitum or another service.
let payload = serde_json::to_vec(&session(now() + 3600)).unwrap();
let rendered = sign(&payload, SECRET);
let decoded = String::from_utf8(unsign(&rendered, SECRET).unwrap()).unwrap();
assert!(!decoded.contains("eyJ"), "looks like a JWT: {decoded}");
assert!(decoded.contains("user-1") && decoded.contains("sensing:read"));
}
#[test]
fn an_expired_session_is_not_live() {
assert!(!session(now() - 1).is_live());
assert!(session(now() + 60).is_live());
}
#[test]
fn session_scope_matching_is_exact() {
let s = session(now() + 60);
assert!(s.has_scope("sensing:read"));
assert!(!s.has_scope("sensing:admin"), "no implied escalation");
assert!(!s.has_scope("sensing"), "prefixes must not match");
}
#[test]
fn reads_a_named_cookie_out_of_a_header() {
let h = "foo=1; ruview_session=abc.def; bar=2";
assert_eq!(read_cookie(h, "ruview_session").as_deref(), Some("abc.def"));
assert_eq!(read_cookie(h, "absent"), None);
}
#[test]
fn a_cookie_name_that_merely_ends_with_the_target_is_not_matched() {
// `xruview_session=` must not be read as `ruview_session=`.
assert_eq!(read_cookie("xruview_session=v", "ruview_session"), None);
}
#[test]
fn the_authorize_url_encodes_a_multi_scope_request() {
let u = url_encode_authorize(
"https://auth.cognitum.one",
"ruview",
"sensing:read sensing:admin",
"st",
"ch",
);
assert!(u.starts_with("https://auth.cognitum.one/oauth/authorize"));
assert!(u.contains("client_id=ruview"));
assert!(u.contains("code_challenge_method=S256"));
assert!(
u.contains("scope=sensing%3Aread%20sensing%3Aadmin"),
"space must be encoded, not truncated: {u}"
);
}
#[test]
fn a_trailing_slash_on_the_issuer_does_not_double_up() {
let u = url_encode_authorize("https://auth.cognitum.one/", "ruview", "s", "st", "ch");
assert!(!u.contains(".one//oauth"), "{u}");
}
}
/// Regression guard for a response-shape mistake that silently broke sign-in.
#[cfg(test)]
mod response_shape_tests {
use axum::response::IntoResponse;
/// Axum's array-of-tuples form REPLACES same-name headers. Two `Set-Cookie`
/// entries collapse to one — which, on the sign-in callback, dropped the
/// session cookie and made a successful OAuth round-trip a no-op. Only the
/// last cookie survived.
#[test]
fn an_array_of_headers_silently_drops_a_duplicate_set_cookie() {
let resp = (
axum::http::StatusCode::FOUND,
[
(axum::http::header::SET_COOKIE, "a=1".to_string()),
(axum::http::header::SET_COOKIE, "b=2".to_string()),
],
)
.into_response();
assert_eq!(
resp.headers()
.get_all(axum::http::header::SET_COOKIE)
.iter()
.count(),
1,
"documenting the footgun: the array form replaces, it does not append"
);
}
/// `AppendHeaders` is what actually emits both.
#[test]
fn append_headers_emits_every_set_cookie() {
let resp = (
axum::http::StatusCode::FOUND,
axum::response::AppendHeaders([
(axum::http::header::LOCATION, "/ui/".to_string()),
(axum::http::header::SET_COOKIE, "a=1".to_string()),
(axum::http::header::SET_COOKIE, "b=2".to_string()),
]),
)
.into_response();
let cookies: Vec<_> = resp
.headers()
.get_all(axum::http::header::SET_COOKIE)
.iter()
.filter_map(|v| v.to_str().ok())
.collect();
assert_eq!(cookies.len(), 2, "both cookies must reach the browser");
assert!(cookies.contains(&"a=1") && cookies.contains(&"b=2"));
assert!(resp.headers().get(axum::http::header::LOCATION).is_some());
}
}