mirror of
https://github.com/ruvnet/RuView
synced 2026-07-31 18:51:42 +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
50 lines
2.0 KiB
Rust
50 lines
2.0 KiB
Rust
//! Interactive Cognitum sign-in (ADR-271 phase 2). Feature `login`.
|
|
//!
|
|
//! The counterpart to this crate's verifier: the verifier checks tokens a
|
|
//! server receives, this obtains one for a user to present.
|
|
//!
|
|
//! Ported from `cognitum-one/meta-proxy` `src/oauth/`, cross-checked against
|
|
//! `musica`'s `cognitum_provider.rs` — the two independent implementations
|
|
//! against this same authorization server. Where they agree (exact
|
|
//! `/oauth/callback` redirect path, 60-second refresh skew, OOB fallback on
|
|
//! SSH/container) this follows both.
|
|
//!
|
|
//! ```no_run
|
|
//! # async fn demo() -> Result<(), Box<dyn std::error::Error>> {
|
|
//! use ruview_auth::login::{login, LoginOptions};
|
|
//!
|
|
//! let opts = LoginOptions::default(); // requests sensing:read only
|
|
//! let mut out = std::io::stdout();
|
|
//! let mut input = std::io::stdin().lock();
|
|
//! let session = login(&opts, &mut out, &mut input).await?;
|
|
//!
|
|
//! // Always go through ensure_fresh — never read access_token directly.
|
|
//! let bearer = session.ensure_fresh().await?;
|
|
//! # let _ = bearer;
|
|
//! # Ok(())
|
|
//! # }
|
|
//! ```
|
|
//!
|
|
//! # Two things that will bite if ignored
|
|
//!
|
|
//! 1. **Refresh tokens rotate with reuse detection.** Presenting a spent one
|
|
//! revokes the session family, so refresh is serialised and never retried.
|
|
//! Use [`store::Session::ensure_fresh`]; do not call [`client::refresh`]
|
|
//! directly unless you are reimplementing that guarantee.
|
|
//! 2. **Least scope by default.** [`LoginOptions::default`] asks for
|
|
//! `sensing:read`. Requesting `sensing:admin` should be a deliberate act for
|
|
//! 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 store;
|
|
|
|
pub use client::{OAuthError, TokenResponse, CLIENT_ID, CLIENT_ID_ENV, OOB_REDIRECT_URI};
|
|
pub use flow::{login, logout, LoginError, LoginOptions};
|
|
pub use store::{
|
|
default_credentials_path, Session, StoreError, StoredCredentials, CREDENTIALS_PATH_ENV,
|
|
};
|