mirror of
https://github.com/ruvnet/RuView
synced 2026-08-02 19:11:46 +00:00
347698b67c
Closes the gap adversarial review found: `wifi-densepose login` writes ~/.ruview/credentials.json, which a BROWSER CANNOT READ. The UI therefore had no way to obtain a Cognitum token at all, and the WebSocket ticket mechanism ADR-272 built "for browsers" was only exercisable with the legacy static shared secret OAuth was meant to replace. The ADRs described a browser story that did not exist. Ported from cognitum-one/freetokens (src/auth/oauth.ts, live at freetokens.cognitum.one), whose shape is not the obvious one and is the whole point: THE BROWSER NEVER HOLDS AN OAUTH TOKEN. The server generates the PKCE verifier and state, keeps them in an HMAC-signed cookie, performs the code exchange itself, verifies the token, and issues its OWN session cookie carrying an assertion — subject, account, scope, expiry — not a credential. So the access token cannot be read by an XSS, cannot sit in localStorage, and cannot leak through a URL. A stolen session cookie is useless against Cognitum or any sibling service. GET /oauth/start -> 302 to auth.cognitum.one + signed transaction cookie GET /oauth/callback -> constant-time state check, exchange, verify, session GET /oauth/logout -> clears the local session (not the Cognitum session) Verified against the running binary: /oauth/start returns the same 302 + HttpOnly/SameSite=Lax/Max-Age=600 shape freetokens does live; a forged `state` is refused 400; a forged session cookie is refused 401. DELIBERATE DEVIATION from freetokens: no `__Host-` cookie prefix. That prefix REQUIRES `Secure`, and RuView is routinely reached at http://localhost or over plain HTTP on a LAN, where such a cookie is never sent and sign-in would fail silently. `Secure` is set only when the request actually arrived over TLS (direct or via x-forwarded-proto). Every other attribute matches; the HMAC is what protects the value. Other decisions worth stating: - The callback verifies through the SAME `verify_access_token` every other request uses — signature, audience (client_id), typ, expiry, scope. A sign-in path must not be a softer path. - The session cookie is checked LAST in the middleware, after bearer and ticket: it is the weakest-bound credential, so a presented bearer should win. - A browser session requests `sensing:read` only. Admin work goes through the CLI's explicit `--admin`. - The token exchange runs in `spawn_blocking` — `ureq` is blocking, and parking an async worker is the mistake this codebase just had to fix in jwks.rs. - `/oauth/*` sits outside `/api/v1/*` on purpose: gating the routes you use to obtain a credential would deadlock. PKCE moved out from behind the `login` feature into its own light `pkce` feature (rand + sha2 + base64, no HTTP stack), so the server can build an authorize URL without pulling in the client-side login machinery. `login` now implies `pkce`. Tests: 13 new browser_session unit tests — signature round-trip, tampered payload, wrong secret, malformed cookie values, HttpOnly/SameSite/Secure attributes, exact scope matching with no implied escalation, a cookie name that merely ends with the target not matching, multi-scope URL encoding, and the core property that a session cookie never contains the access token. Totals: 547 sensing-server lib + 5 wiring integration, 87 ruview-auth. Co-Authored-By: Ruflo & AQE
408 lines
14 KiB
Rust
408 lines
14 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::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.
|
|
const SESSION_TTL_SECS: i64 = 12 * 3600;
|
|
|
|
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,
|
|
}
|
|
|
|
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)
|
|
}
|
|
}
|
|
|
|
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.
|
|
pub fn read_cookie(header: &str, name: &str) -> Option<String> {
|
|
header.split(';').find_map(|part| {
|
|
let (k, v) = part.split_once('=')?;
|
|
(k.trim() == name).then(|| v.trim().to_string())
|
|
})
|
|
}
|
|
|
|
#[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),
|
|
}
|
|
|
|
fn secret() -> Result<String, SessionError> {
|
|
std::env::var(SESSION_SECRET_ENV)
|
|
.ok()
|
|
.filter(|s| !s.trim().is_empty())
|
|
.ok_or(SessionError::NotConfigured)
|
|
}
|
|
|
|
/// 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()?;
|
|
let raw = read_cookie(cookie_header, TXN_COOKIE).ok_or(SessionError::InvalidTransaction)?;
|
|
let bytes = unsign(&raw, &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,
|
|
};
|
|
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 raw = read_cookie(cookie_header, SESSION_COOKIE)?;
|
|
let bytes = unsign(&raw, &secret)?;
|
|
let session: BrowserSession = serde_json::from_slice(&bytes).ok()?;
|
|
session.is_live().then_some(session)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
const SECRET: &str = "test-secret-value";
|
|
|
|
fn session(exp: i64) -> BrowserSession {
|
|
BrowserSession {
|
|
subject: "user-1".into(),
|
|
account_id: "acct-1".into(),
|
|
scope: "sensing:read".into(),
|
|
exp,
|
|
}
|
|
}
|
|
|
|
#[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}");
|
|
}
|
|
}
|