feat(auth): browser sign-in — /oauth/start, /oauth/callback, session cookie

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
This commit is contained in:
Dragan Spiridonov 2026-07-23 09:57:15 +02:00
parent 705a167ffe
commit 347698b67c
11 changed files with 690 additions and 5 deletions

2
v2/Cargo.lock generated
View File

@ -11301,6 +11301,7 @@ dependencies = [
"clap",
"criterion",
"futures-util",
"hmac",
"jsonwebtoken",
"midstreamer-attractor",
"midstreamer-temporal-compare",
@ -11313,6 +11314,7 @@ dependencies = [
"serde",
"serde_json",
"sha2",
"subtle",
"tempfile",
"thiserror 1.0.69",
"tokio",

View File

@ -40,9 +40,13 @@ libc = { version = "0.2", optional = true }
[features]
default = ["ureq-transport"]
ureq-transport = ["dep:ureq"]
# PKCE generation only (RFC 7636). Light: rand + sha2 + base64, no HTTP stack.
# A resource server that runs its own browser sign-in redirect needs this
# WITHOUT the client-side login machinery.
pkce = ["dep:rand", "dep:sha2", "dep:base64"]
# Interactive OAuth login: PKCE, loopback callback, OOB paste fallback,
# credential storage, single-flight refresh. Opt in from a CLI or desktop app.
login = ["dep:reqwest", "dep:tokio", "dep:rand", "dep:sha2", "dep:base64", "dep:url", "dep:libc"]
login = ["pkce", "dep:reqwest", "dep:tokio", "dep:url", "dep:libc"]
[dev-dependencies]
# Test-only: sign real ES256 tokens so the negative matrix exercises the same

View File

@ -71,6 +71,11 @@ pub mod jwks;
pub mod principal;
pub mod verify;
/// PKCE generation (RFC 7636). Available without the full `login` stack so a
/// resource server can drive its own browser redirect.
#[cfg(feature = "pkce")]
pub mod pkce;
/// Interactive sign-in (PKCE, loopback, OOB paste, credential storage,
/// single-flight refresh). Off by default — a sensing server verifies tokens
/// and never obtains them, so it must not pay for the HTTP client this needs.

View File

@ -6,7 +6,7 @@ use std::time::Duration;
use super::callback::{looks_headless, open_browser, CallbackServer};
use super::client::{self, OAuthError};
use super::pkce;
use crate::pkce;
use super::store::{self, Session, StoreError};
use crate::scope;

View File

@ -36,9 +36,10 @@
//! an administrative operation, not the standing state of every session.
pub mod callback;
/// Re-exported from the crate root; PKCE is usable without this feature.
pub use crate::pkce;
pub mod client;
pub mod flow;
pub mod pkce;
pub mod store;
pub use client::{OAuthError, TokenResponse, CLIENT_ID, CLIENT_ID_ENV, OOB_REDIRECT_URI};

View File

@ -88,7 +88,11 @@ thiserror = "1"
# ADR-271 — Cognitum OAuth access-token verification. Reuses the `ureq`
# transport above rather than pulling a second HTTP stack: `ruview-auth`'s
# JWKS fetch sits behind a trait, and its default feature is the ureq one.
ruview-auth = { path = "../ruview-auth" }
ruview-auth = { path = "../ruview-auth", features = ["pkce"] }
# ADR-271 browser sign-in: signed transaction + session cookies.
hmac = "0.12"
subtle = "2"
base64 = "0.21"
# ADR-272 — unpredictable single-use WebSocket tickets.
rand = "0.8"

View File

@ -315,6 +315,40 @@ impl AuthState {
&self.tickets
}
/// Issuer origin, when OAuth is enabled.
pub fn oauth_issuer(&self) -> Option<String> {
self.oauth.as_ref().map(|o| o.issuer.clone())
}
/// The client id to present when starting a browser sign-in.
pub fn primary_client_id(&self) -> String {
self.oauth
.as_ref()
.and_then(|o| o.allowed_client_ids.first().cloned())
.unwrap_or_else(|| DEFAULT_CLIENT_ID.to_string())
}
/// Verify a token obtained through the browser flow, using the SAME rules
/// as every other request — a sign-in path must not be a softer one.
pub fn verify_for_browser(
&self,
token: &str,
) -> Result<ruview_auth::Principal, ruview_auth::VerifyError> {
let oauth = self
.oauth
.as_ref()
.ok_or(ruview_auth::VerifyError::MissingBearer)?;
verify_access_token(
token,
&oauth.jwks,
&VerifierConfig {
issuer: oauth.issuer.clone(),
required_scope: scope::SENSING_READ.to_string(),
allowed_client_ids: oauth.allowed_client_ids.clone(),
},
)
}
/// Whether the legacy unauthenticated-WebSocket escape hatch is active.
pub fn legacy_ws_enabled(&self) -> bool {
self.legacy_ws
@ -475,7 +509,8 @@ pub async fn require_bearer(
.then(|| token.trim_start())
})
else {
return unauthorized(&auth);
// No bearer header at all — a browser session may still authorize this.
return session_or_unauthorized(&auth, request, next).await;
};
// 1. Legacy static token. Unchanged, and tried first so an existing
@ -525,9 +560,67 @@ pub async fn require_bearer(
}
}
// 3. Browser session cookie (ADR-271 browser half). Checked last: it is the
// weakest-bound credential (host-only, no proof-of-possession), so a
// presented bearer or ticket should win.
if auth.oauth.is_some() {
if let Some(cookie_header) = request
.headers()
.get(axum::http::header::COOKIE)
.and_then(|v| v.to_str().ok())
{
if let Some(session) = crate::browser_session::from_cookie_header(cookie_header) {
let required = if is_ws_path(&path) {
scope::SENSING_READ
} else {
required_scope_for(request.method(), &path)
};
if session.has_scope(required) {
tracing::debug!(
sub = %session.subject,
scope = %required,
path = %path.as_str(),
"request authorized by browser session"
);
return next.run(request).await;
}
tracing::debug!(
path = %path.as_str(),
required_scope = %required,
"browser session lacks the scope this route requires"
);
}
}
}
unauthorized(&auth)
}
/// The no-bearer path: try a browser session cookie, else 401.
async fn session_or_unauthorized(auth: &AuthState, request: Request, next: Next) -> Response {
let path = request.uri().path().to_string();
if auth.oauth.is_some() {
if let Some(h) = request
.headers()
.get(axum::http::header::COOKIE)
.and_then(|v| v.to_str().ok())
{
if let Some(session) = crate::browser_session::from_cookie_header(h) {
let required = if is_ws_path(&path) {
scope::SENSING_READ
} else {
required_scope_for(request.method(), &path)
};
if session.has_scope(required) {
tracing::debug!(sub = %session.subject, path = %path.as_str(), "browser session authorized");
return next.run(request).await;
}
}
}
}
unauthorized(auth)
}
/// A uniform 401. The hint names whichever credentials are actually accepted,
/// so an operator is not told to set a variable this server ignores — but it
/// never says *why* a presented token failed.

View File

@ -0,0 +1,407 @@
//! 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}");
}
}

View File

@ -9,6 +9,7 @@
//! - Real-time CSI introspection / low-latency tap (`introspection`, ADR-099)
pub mod bearer_auth;
pub mod browser_session;
pub mod ws_ticket;
pub mod cli;
pub mod dataset;

View File

@ -8082,6 +8082,11 @@ async fn main() {
// ADR-272 — browsers cannot set Authorization on a WebSocket upgrade,
// so they exchange their credential here for a 30s single-use ticket.
.route("/api/v1/ws-ticket", axum::routing::post(ws_ticket_handler))
// ADR-271 browser sign-in. Deliberately NOT under /api/v1/*: these are
// how a browser obtains a credential, so gating them would deadlock.
.route("/oauth/start", get(oauth_start))
.route("/oauth/callback", get(oauth_callback))
.route("/oauth/logout", get(oauth_logout))
.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))
@ -9153,3 +9158,166 @@ async fn ws_ticket_handler(
.into_response(),
}
}
// ---- ADR-271 browser sign-in ------------------------------------------------
//
// Ported from cognitum-one/freetokens (`src/auth/oauth.ts`, live). The browser
// never holds an OAuth token: this server does the exchange and issues its own
// signed session cookie. Closes the gap where `wifi-densepose login` wrote a
// file no browser could read.
fn request_is_tls(headers: &axum::http::HeaderMap) -> bool {
// Behind a reverse proxy the TLS terminates upstream, so trust the standard
// forwarding header when present. Conservative default: not TLS, which only
// ever omits `Secure` — it never adds a cookie where it shouldn't be.
headers
.get("x-forwarded-proto")
.and_then(|v| v.to_str().ok())
.map(|p| p.eq_ignore_ascii_case("https"))
.unwrap_or(false)
|| wifi_densepose_sensing_server::browser_session::public_base_url().starts_with("https://")
}
async fn oauth_start(
axum::Extension(auth): axum::Extension<wifi_densepose_sensing_server::bearer_auth::AuthState>,
headers: axum::http::HeaderMap,
) -> axum::response::Response {
use axum::response::IntoResponse;
use wifi_densepose_sensing_server::browser_session as bs;
let Some(issuer) = auth.oauth_issuer() else {
return (
axum::http::StatusCode::SERVICE_UNAVAILABLE,
"OAuth is not enabled on this server (set RUVIEW_OAUTH_ISSUER)\n",
)
.into_response();
};
let secure = request_is_tls(&headers);
// Least privilege: a browser session asks for read. Admin work goes through
// the CLI, which requires an explicit --admin.
match bs::begin(&issuer, &auth.primary_client_id(), ruview_auth::scope::SENSING_READ, secure) {
Ok((location, cookie)) => (
axum::http::StatusCode::FOUND,
[
(axum::http::header::LOCATION, location),
(axum::http::header::SET_COOKIE, cookie),
],
)
.into_response(),
Err(e) => (axum::http::StatusCode::SERVICE_UNAVAILABLE, format!("{e}\n")).into_response(),
}
}
#[derive(serde::Deserialize)]
struct OAuthCallbackQuery {
code: Option<String>,
state: Option<String>,
error: Option<String>,
}
async fn oauth_callback(
axum::Extension(auth): axum::Extension<wifi_densepose_sensing_server::bearer_auth::AuthState>,
headers: axum::http::HeaderMap,
axum::extract::Query(q): axum::extract::Query<OAuthCallbackQuery>,
) -> axum::response::Response {
use axum::response::IntoResponse;
use wifi_densepose_sensing_server::browser_session as bs;
let secure = request_is_tls(&headers);
let bad = |code: axum::http::StatusCode, msg: String| {
(code, [(axum::http::header::SET_COOKIE, bs::clear_transaction(secure))], msg)
.into_response()
};
if let Some(err) = q.error {
return bad(axum::http::StatusCode::BAD_REQUEST, format!("Cognitum declined the sign-in: {err}\n"));
}
let (Some(code), Some(state)) = (q.code, q.state) else {
return bad(axum::http::StatusCode::BAD_REQUEST, "Incomplete sign-in response\n".into());
};
let cookie_header = headers
.get(axum::http::header::COOKIE)
.and_then(|v| v.to_str().ok())
.unwrap_or_default()
.to_string();
// CSRF check BEFORE the single-use code is spent.
let verifier = match bs::verifier_for_callback(&cookie_header, &state) {
Ok(v) => v,
Err(e) => return bad(axum::http::StatusCode::BAD_REQUEST, format!("{e}\n")),
};
let Some(issuer) = auth.oauth_issuer() else {
return bad(axum::http::StatusCode::SERVICE_UNAVAILABLE, "OAuth is not enabled\n".into());
};
let client_id = auth.primary_client_id();
// `ureq` is blocking; spawn_blocking so a slow token endpoint cannot park an
// async worker (the same mistake this codebase had to fix in jwks.rs).
let exchange = tokio::task::spawn_blocking(move || {
ureq::post(&format!("{issuer}/oauth/token"))
.send_form(&[
("grant_type", "authorization_code"),
("code", &code),
("code_verifier", &verifier),
("client_id", &client_id),
("redirect_uri", &bs::redirect_uri()),
])
.map_err(|e| e.to_string())
.and_then(|r| r.into_string().map_err(|e| e.to_string()))
})
.await;
let body = match exchange {
Ok(Ok(b)) => b,
Ok(Err(e)) => return bad(axum::http::StatusCode::BAD_GATEWAY, format!("token exchange failed: {e}\n")),
Err(e) => return bad(axum::http::StatusCode::INTERNAL_SERVER_ERROR, format!("token exchange task failed: {e}\n")),
};
let access_token = match serde_json::from_str::<serde_json::Value>(&body)
.ok()
.and_then(|v| v.get("access_token")?.as_str().map(str::to_owned))
{
Some(t) => t,
None => return bad(axum::http::StatusCode::BAD_GATEWAY, "token endpoint returned no access_token\n".into()),
};
// Verify with the SAME verifier that gates every other request — signature,
// audience, typ, expiry, scope. A browser sign-in must not be a softer path.
let principal = match auth.verify_for_browser(&access_token) {
Ok(p) => p,
Err(e) => return bad(axum::http::StatusCode::UNAUTHORIZED, format!("{e}\n")),
};
let session_cookie = match bs::issue(&principal, secure) {
Ok(c) => c,
Err(e) => return bad(axum::http::StatusCode::SERVICE_UNAVAILABLE, format!("{e}\n")),
};
tracing::info!(sub = %principal.subject, "browser sign-in complete");
(
axum::http::StatusCode::FOUND,
[
(axum::http::header::LOCATION, "/ui/".to_string()),
(axum::http::header::SET_COOKIE, session_cookie),
],
)
.into_response()
}
async fn oauth_logout(headers: axum::http::HeaderMap) -> axum::response::Response {
use axum::response::IntoResponse;
// Local only: forgets this browser's session. Revoking the Cognitum session
// for every device is an account-level action at auth.cognitum.one.
let secure = request_is_tls(&headers);
(
axum::http::StatusCode::FOUND,
[
(axum::http::header::LOCATION, "/ui/".to_string()),
(
axum::http::header::SET_COOKIE,
wifi_densepose_sensing_server::browser_session::clear_session(secure),
),
],
)
.into_response()
}