mirror of
https://github.com/ruvnet/RuView
synced 2026-07-24 17:43:20 +00:00
347698b67c
Closes the gap adversarial review found: `wifi-densepose login` writes ~/.ruview/credentials.json, which a BROWSER CANNOT READ. The UI therefore had no way to obtain a Cognitum token at all, and the WebSocket ticket mechanism ADR-272 built "for browsers" was only exercisable with the legacy static shared secret OAuth was meant to replace. The ADRs described a browser story that did not exist. Ported from cognitum-one/freetokens (src/auth/oauth.ts, live at freetokens.cognitum.one), whose shape is not the obvious one and is the whole point: THE BROWSER NEVER HOLDS AN OAUTH TOKEN. The server generates the PKCE verifier and state, keeps them in an HMAC-signed cookie, performs the code exchange itself, verifies the token, and issues its OWN session cookie carrying an assertion — subject, account, scope, expiry — not a credential. So the access token cannot be read by an XSS, cannot sit in localStorage, and cannot leak through a URL. A stolen session cookie is useless against Cognitum or any sibling service. GET /oauth/start -> 302 to auth.cognitum.one + signed transaction cookie GET /oauth/callback -> constant-time state check, exchange, verify, session GET /oauth/logout -> clears the local session (not the Cognitum session) Verified against the running binary: /oauth/start returns the same 302 + HttpOnly/SameSite=Lax/Max-Age=600 shape freetokens does live; a forged `state` is refused 400; a forged session cookie is refused 401. DELIBERATE DEVIATION from freetokens: no `__Host-` cookie prefix. That prefix REQUIRES `Secure`, and RuView is routinely reached at http://localhost or over plain HTTP on a LAN, where such a cookie is never sent and sign-in would fail silently. `Secure` is set only when the request actually arrived over TLS (direct or via x-forwarded-proto). Every other attribute matches; the HMAC is what protects the value. Other decisions worth stating: - The callback verifies through the SAME `verify_access_token` every other request uses — signature, audience (client_id), typ, expiry, scope. A sign-in path must not be a softer path. - The session cookie is checked LAST in the middleware, after bearer and ticket: it is the weakest-bound credential, so a presented bearer should win. - A browser session requests `sensing:read` only. Admin work goes through the CLI's explicit `--admin`. - The token exchange runs in `spawn_blocking` — `ureq` is blocking, and parking an async worker is the mistake this codebase just had to fix in jwks.rs. - `/oauth/*` sits outside `/api/v1/*` on purpose: gating the routes you use to obtain a credential would deadlock. PKCE moved out from behind the `login` feature into its own light `pkce` feature (rand + sha2 + base64, no HTTP stack), so the server can build an authorize URL without pulling in the client-side login machinery. `login` now implies `pkce`. Tests: 13 new browser_session unit tests — signature round-trip, tampered payload, wrong secret, malformed cookie values, HttpOnly/SameSite/Secure attributes, exact scope matching with no implied escalation, a cookie name that merely ends with the target not matching, multi-scope URL encoding, and the core property that a session cookie never contains the access token. Totals: 547 sensing-server lib + 5 wiring integration, 87 ruview-auth. Co-Authored-By: Ruflo & AQE
64 lines
3.0 KiB
TOML
64 lines
3.0 KiB
TOML
[package]
|
|
name = "ruview-auth"
|
|
version = "0.1.0"
|
|
edition = "2021"
|
|
description = "Cognitum OAuth access-token verification for RuView (ADR-271)"
|
|
publish = false
|
|
|
|
[dependencies]
|
|
# Same major as the service that ISSUES these tokens
|
|
# (cognitum-one/dashboard `services/identity`, workspace `jsonwebtoken = "9"`).
|
|
# Signature math is delegated to this crate; nothing here hand-rolls crypto.
|
|
jsonwebtoken = "9"
|
|
|
|
# `ureq`, not `reqwest`: `wifi-densepose-sensing-server` — the first consumer —
|
|
# deliberately chose ureq as "the smallest" HTTP client (see its Cargo.toml).
|
|
# Adding reqwest here would silently reverse that decision for the whole
|
|
# dependency graph. Optional so a caller can supply its own transport via
|
|
# `JwksFetcher` and take no HTTP dependency at all.
|
|
ureq = { version = "2", default-features = false, features = ["tls", "json"], optional = true }
|
|
|
|
serde = { workspace = true }
|
|
serde_json = { workspace = true }
|
|
thiserror = { workspace = true }
|
|
tracing = { workspace = true }
|
|
|
|
# --- `login` feature only (ADR-271 phase 2) -------------------------------
|
|
# The login flow is an interactive client concern: a browser, a loopback
|
|
# listener, a token exchange. The sensing server needs none of it and must not
|
|
# pay for it, so every dependency here is optional and off by default. A server
|
|
# built with default features gets the verifier and nothing more.
|
|
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"], optional = true }
|
|
tokio = { workspace = true, optional = true }
|
|
rand = { version = "0.8", optional = true }
|
|
sha2 = { workspace = true, optional = true }
|
|
base64 = { version = "0.21", optional = true }
|
|
url = { version = "2", optional = true }
|
|
# Advisory cross-process file lock around the refresh critical section (Unix).
|
|
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 = ["pkce", "dep:reqwest", "dep:tokio", "dep:url", "dep:libc"]
|
|
|
|
[dev-dependencies]
|
|
# Test-only: sign real ES256 tokens so the negative matrix exercises the same
|
|
# code path production does, rather than asserting against hand-built strings.
|
|
jsonwebtoken = "9"
|
|
serde_json = { workspace = true }
|
|
|
|
# Keypairs are GENERATED AT TEST RUNTIME, never committed. A checked-in
|
|
# `-----BEGIN PRIVATE KEY-----` is inert here but it trains scanners and readers
|
|
# to treat committed key material as normal, and this repo has no such
|
|
# precedent (zero tracked `.pem` files). Generating also makes the matrix
|
|
# self-contained: no fixture can drift out of sync with the JWKS it is served by.
|
|
p256 = { version = "0.13", features = ["ecdsa", "pkcs8"] }
|
|
base64 = "0.21"
|