diff --git a/v2/Cargo.lock b/v2/Cargo.lock index fc1218c9..26927517 100644 --- a/v2/Cargo.lock +++ b/v2/Cargo.lock @@ -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", diff --git a/v2/crates/ruview-auth/Cargo.toml b/v2/crates/ruview-auth/Cargo.toml index 2b2c5931..96df45b3 100644 --- a/v2/crates/ruview-auth/Cargo.toml +++ b/v2/crates/ruview-auth/Cargo.toml @@ -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 diff --git a/v2/crates/ruview-auth/src/lib.rs b/v2/crates/ruview-auth/src/lib.rs index be356502..821abed8 100644 --- a/v2/crates/ruview-auth/src/lib.rs +++ b/v2/crates/ruview-auth/src/lib.rs @@ -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. diff --git a/v2/crates/ruview-auth/src/login/flow.rs b/v2/crates/ruview-auth/src/login/flow.rs index 721405a3..ad46fb97 100644 --- a/v2/crates/ruview-auth/src/login/flow.rs +++ b/v2/crates/ruview-auth/src/login/flow.rs @@ -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; diff --git a/v2/crates/ruview-auth/src/login/mod.rs b/v2/crates/ruview-auth/src/login/mod.rs index 14dc6c0a..942f684e 100644 --- a/v2/crates/ruview-auth/src/login/mod.rs +++ b/v2/crates/ruview-auth/src/login/mod.rs @@ -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}; diff --git a/v2/crates/ruview-auth/src/login/pkce.rs b/v2/crates/ruview-auth/src/pkce.rs similarity index 100% rename from v2/crates/ruview-auth/src/login/pkce.rs rename to v2/crates/ruview-auth/src/pkce.rs diff --git a/v2/crates/wifi-densepose-sensing-server/Cargo.toml b/v2/crates/wifi-densepose-sensing-server/Cargo.toml index 5267e4d2..34347cde 100644 --- a/v2/crates/wifi-densepose-sensing-server/Cargo.toml +++ b/v2/crates/wifi-densepose-sensing-server/Cargo.toml @@ -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" diff --git a/v2/crates/wifi-densepose-sensing-server/src/bearer_auth.rs b/v2/crates/wifi-densepose-sensing-server/src/bearer_auth.rs index b86b7f39..dd4e5b7d 100644 --- a/v2/crates/wifi-densepose-sensing-server/src/bearer_auth.rs +++ b/v2/crates/wifi-densepose-sensing-server/src/bearer_auth.rs @@ -315,6 +315,40 @@ impl AuthState { &self.tickets } + /// Issuer origin, when OAuth is enabled. + pub fn oauth_issuer(&self) -> Option { + 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 { + 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. diff --git a/v2/crates/wifi-densepose-sensing-server/src/browser_session.rs b/v2/crates/wifi-densepose-sensing-server/src/browser_session.rs new file mode 100644 index 00000000..f5fe5ad1 --- /dev/null +++ b/v2/crates/wifi-densepose-sensing-server/src/browser_session.rs @@ -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; + +/// 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> { + use base64::engine::general_purpose::URL_SAFE_NO_PAD; + use base64::Engine; + URL_SAFE_NO_PAD.decode(s).ok() +} + +/// `.`. +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> { + 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 { + 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 { + 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 { + 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 { + let secret = secret()?; + let session = BrowserSession { + subject: principal.subject.clone(), + account_id: principal.account_id.clone(), + scope: principal.scopes().collect::>().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 { + 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}"); + } +} diff --git a/v2/crates/wifi-densepose-sensing-server/src/lib.rs b/v2/crates/wifi-densepose-sensing-server/src/lib.rs index 2156963a..b0d802ea 100644 --- a/v2/crates/wifi-densepose-sensing-server/src/lib.rs +++ b/v2/crates/wifi-densepose-sensing-server/src/lib.rs @@ -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; diff --git a/v2/crates/wifi-densepose-sensing-server/src/main.rs b/v2/crates/wifi-densepose-sensing-server/src/main.rs index 56cf8f81..7fdfd5e3 100644 --- a/v2/crates/wifi-densepose-sensing-server/src/main.rs +++ b/v2/crates/wifi-densepose-sensing-server/src/main.rs @@ -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, + 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, + state: Option, + error: Option, +} + +async fn oauth_callback( + axum::Extension(auth): axum::Extension, + headers: axum::http::HeaderMap, + axum::extract::Query(q): axum::extract::Query, +) -> 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::(&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() +}