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:
parent
705a167ffe
commit
347698b67c
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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};
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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}");
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue