test(auth): cover browser sign-in; ADR-271/272 corrections and remediation plans

Browser sign-in was the newest security surface in this PR and had no
executable evidence behind it: browser_session.rs was 534 lines with 13 tests,
every one of which hit a private helper (sign, unsign, cookie, read_cookie,
is_live, has_scope). No test called issue, from_cookie_header, begin,
verifier_for_callback or is_configured, and no test anywhere presented a session
cookie to the gate.

+10 tests in browser_session, +6 in bearer_auth. Three mutants the adversarial
review named, each now verified dead by actually applying the mutation:

  (a) delete the `state` comparison in verifier_for_callback
      -> a_callback_whose_state_does_not_match_is_refused FAILED
      Without it the callback accepts a code from a flow the user never
      started: login CSRF, victim silently lands in the attacker's session.

  (b) `session.is_live().then_some(session)` -> `Some(session)`
      -> an_expired_session_cookie_does_not_authenticate FAILED
      -> an_expired_browser_session_is_refused FAILED
      `is_live` was already unit-tested; nothing asserted the CALLER consults
      it. Same "tested in isolation, call site untested" shape as the earlier
      refresh-never-invoked defect.

  (c) `session.has_scope(required)` -> `true`
      -> a_read_scoped_browser_session_cannot_delete_or_train FAILED
      Without it any browser session could delete models and start training.

Mutant (c) initially appeared to SURVIVE. It did not — there are two
has_scope call sites and the first substitution only hit one. Mutating the
one in `session_or_unauthorized` kills the test. That accident confirmed a
separate finding: the cookie branch inside require_bearer is unreachable when
an Authorization header is present, because the OAuth step returns on both
arms. It fails closed, so it is not a hole, but "try the next credential" is
what the code reads like. Pinned by
a_bad_bearer_beats_a_good_cookie_rather_than_falling_back.

Adds two crate-internal test seams (init_secret_for_tests, test_cookie_value).
test_cookie_value signs through the same path as `issue`, so tests presenting a
cookie exercise real verification rather than a test-only bypass.

ADR-271:
- The "browser cannot obtain an OAuth token" section asserted
  `grep -ril "oauth|cognitum|pkce" ui/` returns nothing. It now returns three
  files, invalidated by commits in this same PR. Marked superseded, original
  retained under a fold, replaced with what actually ships.
- Records the two deferred decisions with designs rather than patches: P1 the
  blocking JWKS fetch on a tokio worker (whose rate limiter is bypassed on
  exactly the stale path that matters, because fetched_at updates only on
  success — so after the TTL every request fetches, and a Pi that loses WAN
  stalls itself with no attacker present); P2 the 12-hour session from a
  15-minute token, with three costed options. Capping the session to
  sensing:read was considered and rejected: the dashboard genuinely issues
  DELETE /api/v1/models/{id}.
- P3 records that dropping `__Host-` costs origin-integrity, not just Secure —
  read_cookie takes the FIRST match and cookies are not port-scoped, so a
  same-host writer can shadow a session. Forgery was never the threat that
  prefix addresses.
- Documents redirect_uri's hardcoded default and the unconsumed CLI credential
  as known-incomplete, per decision to leave both as-is.

ADR-272: corrected a claim that would mislead users into a 401. It stated the
Python client DOES send Authorization: Bearer on the handshake; ws.py passes no
headers at all (zero occurrences of extra_headers or Authorization), so every
published client 401s once auth is enabled. Server-side decision unchanged.

Verified: sensing-server 566 + 179 + 7 + 5 + 8 + 4 + 16 pass under CI flags,
ruview-auth 61 + 25 + 2 with --all-features, auth_wiring 7.

Co-Authored-By: Ruflo & AQE
This commit is contained in:
Dragan Spiridonov
2026-07-23 12:08:13 +02:00
parent c72bbc15dd
commit 6ce50d5158
4 changed files with 502 additions and 7 deletions
@@ -170,7 +170,18 @@ take no HTTP dependency at all.
- A new dependency, `jsonwebtoken` — the same crate, same major version, that
identity itself uses to sign these tokens.
## Known incomplete: the browser cannot obtain an OAuth token
## ~~Known incomplete: the browser cannot obtain an OAuth token~~ — CLOSED 2026-07-23
> **Superseded within this same PR.** The text below described the state when
> this ADR was first written. It is retained because the reasoning still
> explains *why* the browser half was built, but every factual claim in it is
> now false — in particular `grep -ril "oauth|cognitum|pkce" ui/` now returns
> `ui/sw.js`, `ui/sw.test.mjs` and `ui/utils/quick-settings.js`. An adversarial
> review caught the ADR still asserting the old state; see "Browser sign-in"
> below for what actually ships.
<details>
<summary>Original text (no longer accurate)</summary>
`wifi-densepose login` writes to `~/.ruview/credentials.json` — a file a browser
cannot read. The UI's `ws-ticket.js` reads a bearer from
@@ -184,9 +195,152 @@ browsers" is today only exercisable with the legacy static shared secret that
OAuth was meant to replace. The server-side gating is correct and complete; the
browser half of the story these ADRs tell is not built.
Deliberately recorded rather than left implied, because the ADRs read as though
the browser path exists. Closing it needs a UI sign-in flow that puts an OAuth
access token where the page can reach it — a separate piece of work.
</details>
## Browser sign-in
`/oauth/start`, `/oauth/callback`, `/oauth/logout` and `/oauth/status`, plus a
"Cognitum Account" panel in QuickSettings. The server runs the authorization
code + PKCE flow itself and hands the browser a **signed session cookie**
never the access token. The browser gets an assertion that this server already
verified a token, which is nothing replayable anywhere else.
Three things about it are load-bearing and were each found the hard way:
- **The cookie carries the granted scope**, and the gate re-checks it per
request. A `sensing:read` session cannot delete a model.
- **`__Host-` is deliberately NOT used.** That prefix requires `Secure`, and
RuView is routinely reached over plain HTTP on a LAN; a cookie the browser
refuses to set is worse than one without the prefix. The cost is real and is
recorded as P3 under "Open problems" below.
- **The service worker must never cache `/oauth/*` or authenticated `/api/*`.**
The Cache API is not the HTTP cache and ignores `Cache-Control` entirely, so
a cached `/oauth/status` froze sign-in until a hard reload, and cached API
responses could be replayed to a different user after sign-out. `ui/sw.js` is
now deny-by-default with an allowlist.
### Still incomplete
`redirect_uri` defaults to `http://127.0.0.1:8080/oauth/callback` and is
overridden only by `RUVIEW_PUBLIC_BASE_URL`. Browser sign-in therefore works
only on a host reached at exactly that origin: an operator browsing
`http://localhost:8080` or `http://192.168.1.50:8080` cannot complete the flow
(PKCE keeps the code unexchangeable, so this is a broken flow, not a token
leak). Deriving it from the request is the fix; deferred deliberately, since
deriving a redirect URI from attacker-controllable headers is its own class of
bug and deserves its own decision.
The credential `wifi-densepose login` stores is also **not yet consumed by any
shipped client** — no CLI subcommand, MCP server or Python client reads
`~/.ruview/credentials.json`. The token is obtainable and verifiable; wiring the
clients to send it is separate work.
## Open problems and proposed remediation
Two findings from the 2026-07-23 adversarial review are **not fixed in this
work**. Both are recorded here with a proposed design rather than patched in a
hurry, because each changes a runtime property that deserves its own decision.
### P1 — the JWKS fetch blocks a tokio worker, and the stale path is unbounded
`verify.rs:182` calls `JwksCache::decoding_key_for`, which performs a blocking
`ureq` request (`jwks.rs:181`, 3s connect + 3s read) directly on the async
worker running `require_bearer`. The same codebase already knows this is wrong:
`main.rs:9265` wraps the token exchange in `spawn_blocking`, commenting "the
same mistake this codebase had to fix in `jwks.rs`". The hot verification path
did not get the same treatment.
Worse, the rate limiter does not cover the case that matters.
`state.fetched_at` is updated **only on success** (`jwks.rs:188`); the error arm
leaves it untouched. So once the TTL elapses after the last *successful* fetch,
`fresh` is permanently `false`, the `may_force` guard at `:170` is never
consulted, and **every** request performs its own blocking fetch attempt.
This fires with no attacker present. On a Pi that loses WAN — the documented
deployment reality — 300 seconds later every API call and every UI poll starts a
blocking outbound attempt, and with few tokio workers the whole server stalls,
including `/health`. An attacker can reach the same state deliberately by
flooding tokens carrying an unknown `kid`.
**Proposed fix, in dependency order:**
1. **Rate-limit attempts, not successes.** Add `last_attempt_at`, recorded
before the fetch regardless of outcome, and consult it on the stale path too.
This alone converts "every request fetches" into "one request per interval".
2. **Get the blocking call off the runtime.** Either wrap the call in
`spawn_blocking` at the `verify` boundary, or give `JwksCache` an async
transport behind the existing transport seam. The seam already exists —
`JwksCache::new` takes a boxed transport — so this is an added
implementation, not a redesign.
3. **Single-flight the refresh.** Concurrent misses for the same `kid` should
await one shared fetch rather than each issuing their own.
4. **Refresh ahead of expiry** from a background task, so the request path
normally never fetches at all.
Steps 1 and 2 are the ones that remove the stall; 3 and 4 are optimisations.
The test that must accompany this: a transport whose fetch blocks on a barrier,
asserting that a second concurrent verification is not serialised behind it —
the current suite is entirely single-threaded and could not observe a
reintroduction (`jwks::tests` contains no concurrency primitive at all).
### P2 — a 15-minute access token becomes a 12-hour session
`issue()` sets `exp: now() + SESSION_TTL_SECS` with `SESSION_TTL_SECS = 12 *
3600`, deliberately not inheriting the access token's ~15-minute lifetime. The
session cookie is an assertion that this server verified a token, so it is not
*wrong* for it to outlive the token — but 12 hours is a long time to hold an
authority that cannot be revoked. Cognitum publishes no introspection endpoint
(see "Facts about the tokens"), so RuView has no way to ask whether the grant
behind a session still stands. A disabled account keeps sensing access, and
`sensing:admin` if it had it, until the cookie expires on its own.
Capping the session at `sensing:read` was considered and **rejected**: the
dashboard genuinely performs admin operations (`model.service.js:136` issues
`DELETE /api/v1/models/{id}`), so that would break shipped functionality.
**Three options, with the tradeoff each carries:**
| Option | Effect | Cost |
|---|---|---|
| **A. Shorten the TTL** (e.g. 12h → 4h) | Bounds exposure by a factor of 3, one constant | Re-auth is a full-page navigation, which interrupts a live streaming dashboard. Mostly silent while the Cognitum session is alive, but not free. |
| **B. Server-side session store** with the refresh token, revalidated periodically | Real revocation: a disabled grant fails at the next refresh | The server now stores refresh tokens — a new and higher-value secret at rest — and refresh rotates with reuse detection, so a bug logs users out. |
| **C. Re-verify on privileged operations only** | `sensing:admin` requires a fresh token; reads keep the long session | Best blast-radius-per-unit-cost, but needs a UI affordance for step-up auth that does not exist. |
**Recommendation: A now, C next.** A is a one-line change that bounds the
window immediately; C is the design that actually matches the risk, since the
damage a stale session can do is concentrated in the mutating routes. B is only
worth it if RuView later needs true cross-device sign-out.
Whichever is chosen, `SESSION_TTL_SECS` should be pinned by a test asserting the
issued cookie's `Max-Age` matches the session's `exp`, so the two cannot drift.
### P3 — dropping `__Host-` costs cookie origin-integrity, not just `Secure`
The decision above frames omitting `__Host-` as trading away a `Secure`
requirement that RuView cannot meet on a plain-HTTP LAN. That framing is
incomplete: `__Host-` also guarantees the cookie was set by *this* origin with
`Path=/` and no `Domain`. Without it, cookies are not port-scoped and are not
integrity-protected against a same-host writer.
`read_cookie` returns the **first** match in the header, and RFC 6265 §5.4 sends
longer-`Path` cookies first. So an attacker who can set a cookie on the same
host — any other service on any port on that appliance, or a plain-HTTP MITM
injecting `Set-Cookie` — can plant `ruview_session=<their own validly signed
session>; Path=/ui`. The victim's browser then sends both, the attacker's first,
and it verifies correctly because it *is* genuinely signed. The victim ends up
operating inside the attacker's session; `/oauth/status` reports the attacker's
account, and anything the victim records is attributed to them.
Note the shape: the signature is doing its job. Forgery was never the threat
`__Host-` addresses, so "the signature is what protects the value" does not
answer this.
**Proposed fix (cheap, no prefix needed):** have `read_cookie` collect *all*
values for the name and accept only if exactly one verifies — or, more strictly,
reject outright when more than one `ruview_session` is present, since a browser
should never legitimately send two. Add `Secure` and the `__Host-` prefix
conditionally when the server knows it is behind TLS, keeping the plain-HTTP LAN
case working.
## Alternatives considered
@@ -50,9 +50,25 @@ match what each kind of client can actually do.
### 1. Native clients send a bearer on the upgrade
The Python client, the Rust CLI and the TypeScript MCP client are not browsers
and have never been subject to the header limitation. They send a normal
`Authorization: Bearer` on the handshake. Routing them through a ticket would
add a round-trip and a second credential path for no benefit.
and have never been subject to the header limitation. They **can** send a normal
`Authorization: Bearer` on the handshake, so the server accepts one there;
routing them through a ticket would add a round-trip and a second credential
path for no benefit.
> **Correction, 2026-07-23.** This section previously stated that those clients
> **do** send a bearer. The published Python client does not:
> `python/wifi_densepose/client/ws.py` calls `websockets.connect(url,
> ping_interval, ping_timeout, max_size)` and passes no headers at all — the
> file contains zero occurrences of `extra_headers` or `Authorization`. So every
> `wifi-densepose[client]` consumer **401s the moment an operator enables
> auth**, and this ADR told them they would be fine.
>
> The server side of the decision stands — a bearer on the upgrade is accepted,
> and that is the right contract for a non-browser client. What is missing is
> the client implementing it, tracked as ruvnet/RuView#1395. Until then the only
> remedy available to those users is
> `RUVIEW_WS_LEGACY_UNAUTHENTICATED=1`, which reopens the exposure this ADR
> exists to close — so it is a migration aid with a deadline, not an answer.
### 2. Browsers exchange their credential for a single-use ticket
@@ -1155,6 +1155,129 @@ mod oauth_tests {
.status()
}
/// Same as [`call`] but presents a browser session cookie instead of a
/// bearer. The cookie is minted through `browser_session`'s real signing
/// path, so this exercises genuine verification.
async fn call_with_session(auth: AuthState, method: &str, path: &str, cookie: &str) -> StatusCode {
let req = Request::builder()
.method(method)
.uri(path)
.header(axum::http::header::COOKIE, cookie);
app(auth)
.oneshot(req.body(Body::empty()).unwrap())
.await
.unwrap()
.status()
}
fn session_cookie(scope_claim: &str, ttl: i64) -> String {
format!(
"ruview_session={}",
crate::browser_session::test_cookie_value("sub-b", "acct-b", scope_claim, ttl)
)
}
// ── browser session cookie as a credential ────────────────────────
//
// The session cookie authorizes /api/v1/* and WebSocket upgrades, and had
// no test presenting one at any level — the newest credential in the system
// was the one with no executable evidence behind it.
#[tokio::test]
async fn a_read_scoped_browser_session_can_read() {
let c = session_cookie(scope::SENSING_READ, 3600);
assert_eq!(
call_with_session(oauth_only(), "GET", "/api/v1/models", &c).await,
StatusCode::OK
);
}
#[tokio::test]
async fn a_read_scoped_browser_session_cannot_delete_or_train() {
// MUTANT THIS KILLS: replacing `session.has_scope(required)` with
// `true` in the cookie branch. Without this, anyone signed in through
// the browser — the least-bound credential in the system, host-only
// with no proof-of-possession — could delete models and recordings and
// start training, regardless of what they consented to.
let c = session_cookie(scope::SENSING_READ, 3600);
for (method, path) in [
("DELETE", "/api/v1/models/m1"),
("DELETE", "/api/v1/recording/r1"),
("POST", "/api/v1/train/start"),
] {
assert_eq!(
call_with_session(oauth_only(), method, path, &c).await,
StatusCode::UNAUTHORIZED,
"{method} {path} accepted a read-only browser session"
);
}
}
#[tokio::test]
async fn an_admin_scoped_browser_session_can_delete() {
// The negative above must not pass merely because cookies never work.
let c = session_cookie(
&format!("{} {}", scope::SENSING_READ, scope::SENSING_ADMIN),
3600,
);
assert_eq!(
call_with_session(oauth_only(), "DELETE", "/api/v1/models/m1", &c).await,
StatusCode::OK
);
}
#[tokio::test]
async fn an_expired_browser_session_is_refused() {
let c = session_cookie(scope::SENSING_READ, -1);
assert_eq!(
call_with_session(oauth_only(), "GET", "/api/v1/models", &c).await,
StatusCode::UNAUTHORIZED
);
}
#[tokio::test]
async fn a_bad_bearer_beats_a_good_cookie_rather_than_falling_back() {
// Pins REAL precedence, which is not what the ordering comment in
// `require_bearer` implies. When an Authorization header is present the
// OAuth step returns on BOTH arms, so the cookie branch that follows it
// is unreachable — a browser holding a valid session that also sends a
// stale bearer gets 401 rather than falling back to its cookie.
//
// Verified empirically: mutating that branch's `has_scope` to `true`
// changes no test outcome, while mutating the one in
// `session_or_unauthorized` fails `a_read_scoped_browser_session_
// cannot_delete_or_train`.
//
// This fails CLOSED, so it is not a hole — but it was undocumented and
// untested, and "try the next credential" is what the code reads like.
// Pinned here so changing it is a decision rather than an accident.
let c = session_cookie(scope::SENSING_READ, 3600);
let req = Request::builder()
.method("GET")
.uri("/api/v1/models")
.header(AUTHORIZATION, "Bearer not-a-valid-token")
.header(axum::http::header::COOKIE, &c);
let status = app(oauth_only())
.oneshot(req.body(Body::empty()).unwrap())
.await
.unwrap()
.status();
assert_eq!(
status,
StatusCode::UNAUTHORIZED,
"a presented bearer is authoritative; the cookie is not consulted after it fails"
);
}
#[tokio::test]
async fn a_forged_browser_session_is_refused() {
let c = "ruview_session=Zm9yZ2Vk.bm90LWEtdmFsaWQtbWFj";
assert_eq!(
call_with_session(oauth_only(), "GET", "/api/v1/models", c).await,
StatusCode::UNAUTHORIZED
);
}
fn oauth_only() -> AuthState {
AuthState {
token: None,
@@ -369,12 +369,214 @@ pub fn from_cookie_header(cookie_header: &str) -> Option<BrowserSession> {
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 {
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,
};
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,
})
.unwrap(),
"a-different-secret",
);
assert!(from_cookie_header(&format!("{SESSION_COOKIE}={forged}")).is_none());
}
#[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(),