mirror of
https://github.com/ruvnet/RuView
synced 2026-07-29 18:31:44 +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
242 lines
7.7 KiB
Rust
242 lines
7.7 KiB
Rust
//! Login orchestration: browser + loopback when possible, OOB paste when not.
|
|
|
|
use std::io::{BufRead, Write};
|
|
use std::path::PathBuf;
|
|
use std::time::Duration;
|
|
|
|
use super::callback::{looks_headless, open_browser, CallbackServer};
|
|
use super::client::{self, OAuthError};
|
|
use crate::pkce;
|
|
use super::store::{self, Session, StoreError};
|
|
use crate::scope;
|
|
|
|
/// How long to wait for the user to finish in the browser.
|
|
const CALLBACK_TIMEOUT: Duration = Duration::from_secs(300);
|
|
|
|
#[derive(Debug, thiserror::Error)]
|
|
pub enum LoginError {
|
|
#[error(transparent)]
|
|
OAuth(#[from] OAuthError),
|
|
#[error(transparent)]
|
|
Store(#[from] StoreError),
|
|
#[error("could not bind a loopback callback listener: {0}")]
|
|
Bind(#[source] std::io::Error),
|
|
#[error("waiting for the browser callback failed: {0}")]
|
|
Callback(#[source] std::io::Error),
|
|
#[error("the authorization server returned state {got:?}, expected {expected:?} — this login was not the one you started, so it was discarded")]
|
|
StateMismatch { expected: String, got: String },
|
|
#[error("the authorization server reported: {0}")]
|
|
Denied(String),
|
|
#[error("login cancelled")]
|
|
Cancelled,
|
|
#[error("could not read from the terminal: {0}")]
|
|
Io(#[from] std::io::Error),
|
|
}
|
|
|
|
pub struct LoginOptions {
|
|
/// Where to persist credentials.
|
|
pub credentials_path: PathBuf,
|
|
/// Scopes to request. Least privilege by default — `sensing:read` only.
|
|
pub scope: String,
|
|
/// Force the OOB paste flow even if a browser looks available.
|
|
pub no_browser: bool,
|
|
}
|
|
|
|
impl Default for LoginOptions {
|
|
fn default() -> Self {
|
|
Self {
|
|
credentials_path: store::default_credentials_path(),
|
|
// A client registration is a ceiling, not a default (ADR-060 §5).
|
|
// Routine use asks for read; admin is an explicit escalation.
|
|
scope: scope::SENSING_READ.to_string(),
|
|
no_browser: false,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Run the login flow and persist the resulting session.
|
|
///
|
|
/// `out` receives the human-facing prose (URLs, prompts) so a caller can
|
|
/// capture it in tests; `input` supplies the pasted code in the OOB path.
|
|
pub async fn login<W: Write, R: BufRead>(
|
|
opts: &LoginOptions,
|
|
out: &mut W,
|
|
input: &mut R,
|
|
) -> Result<Session, LoginError> {
|
|
let http = reqwest::Client::new();
|
|
let issuer = client::auth_base_url();
|
|
|
|
if opts.no_browser || looks_headless() {
|
|
return manual_login(opts, &http, issuer, out, input).await;
|
|
}
|
|
|
|
match browser_login(opts, &http, issuer.clone(), out).await {
|
|
Ok(s) => Ok(s),
|
|
// A loopback bind failure is environmental, not user error — fall back
|
|
// rather than dead-ending someone who is one paste away from success.
|
|
Err(LoginError::Bind(e)) => {
|
|
writeln!(
|
|
out,
|
|
"Could not open a local callback listener ({e}); falling back to paste-code sign-in.\n"
|
|
)?;
|
|
manual_login(opts, &http, issuer, out, input).await
|
|
}
|
|
Err(e) => Err(e),
|
|
}
|
|
}
|
|
|
|
async fn browser_login<W: Write>(
|
|
opts: &LoginOptions,
|
|
http: &reqwest::Client,
|
|
issuer: String,
|
|
out: &mut W,
|
|
) -> Result<Session, LoginError> {
|
|
let server = CallbackServer::bind().await.map_err(LoginError::Bind)?;
|
|
let req = pkce::generate();
|
|
let url = client::authorize_url(
|
|
&server.redirect_uri,
|
|
&req.state,
|
|
&req.code_challenge,
|
|
&opts.scope,
|
|
);
|
|
|
|
writeln!(out, "Opening your browser to sign in to Cognitum…")?;
|
|
writeln!(out, "If it doesn't open, visit:\n\n {url}\n")?;
|
|
// Best-effort: the URL is already printed, so a missing launcher is not fatal.
|
|
let _ = open_browser(&url);
|
|
|
|
let cb = server
|
|
.await_callback(CALLBACK_TIMEOUT)
|
|
.await
|
|
.map_err(LoginError::Callback)?;
|
|
|
|
if let Some(err) = cb.error {
|
|
return Err(LoginError::Denied(err));
|
|
}
|
|
// CSRF check before the code is spent: a code arriving with the wrong state
|
|
// did not come from the flow we started.
|
|
let got = cb.state.unwrap_or_default();
|
|
if got != req.state {
|
|
return Err(LoginError::StateMismatch {
|
|
expected: req.state,
|
|
got,
|
|
});
|
|
}
|
|
let code = cb.code.ok_or(LoginError::Cancelled)?;
|
|
|
|
let token = client::exchange_code(http, &code, &req.code_verifier, &server.redirect_uri).await?;
|
|
finish(opts, http, token, issuer, out)
|
|
}
|
|
|
|
async fn manual_login<W: Write, R: BufRead>(
|
|
opts: &LoginOptions,
|
|
http: &reqwest::Client,
|
|
issuer: String,
|
|
out: &mut W,
|
|
input: &mut R,
|
|
) -> Result<Session, LoginError> {
|
|
let req = pkce::generate();
|
|
let url = client::authorize_url(
|
|
client::OOB_REDIRECT_URI,
|
|
&req.state,
|
|
&req.code_challenge,
|
|
&opts.scope,
|
|
);
|
|
|
|
writeln!(
|
|
out,
|
|
"No local browser available (SSH/container detected, or --no-browser).\n"
|
|
)?;
|
|
writeln!(
|
|
out,
|
|
"Open this URL in a browser on any machine and authorize:\n\n {url}\n"
|
|
)?;
|
|
write!(out, "Paste the code shown after authorizing: ")?;
|
|
out.flush()?;
|
|
|
|
let mut line = String::new();
|
|
input.read_line(&mut line)?;
|
|
let code = line.trim();
|
|
if code.is_empty() {
|
|
return Err(LoginError::Cancelled);
|
|
}
|
|
|
|
let token = client::exchange_manual_code(http, code, &req.code_verifier).await?;
|
|
finish(opts, http, token, issuer, out)
|
|
}
|
|
|
|
fn finish<W: Write>(
|
|
opts: &LoginOptions,
|
|
http: &reqwest::Client,
|
|
token: client::TokenResponse,
|
|
issuer: String,
|
|
out: &mut W,
|
|
) -> Result<Session, LoginError> {
|
|
let granted = token.scope.clone();
|
|
let email = token.account_email.clone();
|
|
let session = Session::from_response(
|
|
opts.credentials_path.clone(),
|
|
http.clone(),
|
|
token,
|
|
issuer,
|
|
)?;
|
|
|
|
writeln!(out)?;
|
|
match email {
|
|
Some(e) => writeln!(out, "Signed in as {e}.")?,
|
|
None => writeln!(out, "Signed in.")?,
|
|
}
|
|
// Report what the server actually granted, not what we asked for. They can
|
|
// differ, and a user who thinks they hold `sensing:admin` when they don't
|
|
// will read the eventual 401 as a bug.
|
|
match granted {
|
|
Some(s) => writeln!(out, "Granted scope: {s}")?,
|
|
None => writeln!(out, "Granted scope: (not reported by the server)")?,
|
|
}
|
|
writeln!(
|
|
out,
|
|
"Credentials saved to {}",
|
|
opts.credentials_path.display()
|
|
)?;
|
|
Ok(session)
|
|
}
|
|
|
|
/// Forget the local session. Returns whether anything was removed.
|
|
///
|
|
/// Local-only by design: this makes the machine unable to act as you. Revoking
|
|
/// server-side is a separate, account-level action.
|
|
pub fn logout(credentials_path: &std::path::Path) -> Result<bool, StoreError> {
|
|
store::clear(credentials_path)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn the_default_scope_is_read_only() {
|
|
// ADR-060 §5: a registration is a ceiling, not a default. A session that
|
|
// streams poses must not casually hold delete capability.
|
|
assert_eq!(LoginOptions::default().scope, scope::SENSING_READ);
|
|
assert_ne!(LoginOptions::default().scope, scope::SENSING_ADMIN);
|
|
}
|
|
|
|
#[test]
|
|
fn logout_on_a_machine_that_never_logged_in_is_not_an_error() {
|
|
let p = std::env::temp_dir().join("ruview-flow-absent-credentials.json");
|
|
let _ = std::fs::remove_file(&p);
|
|
assert_eq!(logout(&p).unwrap(), false);
|
|
}
|
|
|
|
#[test]
|
|
fn a_state_mismatch_names_both_values_so_it_can_be_diagnosed() {
|
|
let e = LoginError::StateMismatch {
|
|
expected: "aaa".into(),
|
|
got: "bbb".into(),
|
|
};
|
|
let msg = e.to_string();
|
|
assert!(msg.contains("aaa") && msg.contains("bbb"), "{msg}");
|
|
assert!(msg.contains("discarded"), "must say the login was refused: {msg}");
|
|
}
|
|
}
|