mirror of
https://github.com/ruvnet/RuView
synced 2026-07-30 18:41:42 +00:00
31fb3d53f6
Phase 1 could verify a token and phase 3 could gate on one, but there was no way for a user to OBTAIN one. This closes that: sign in with a Cognitum account and get a token a RuView sensing server accepts, instead of everyone sharing one static RUVIEW_API_TOKEN string. Lives in `ruview-auth` behind a non-default `login` feature rather than in the CLI, so the Tauri desktop app can reuse it instead of growing a second copy. A server built with default features still gets the verifier and nothing else — no reqwest, no tokio net, no browser launcher. (This amends ADR-271's "no login flow in this crate" note; the reason for that line was to keep the server lean, and a feature gate achieves it without duplication.) Ported from meta-proxy's src/oauth/, cross-checked against musica's cognitum_provider.rs — two independent implementations against this same AS. Where they agree, this follows both: redirect path EXACTLY /oauth/callback, 60-second refresh skew, OOB fallback on SSH/CONTAINER//.dockerenv. Refresh is the part with teeth. Identity rotates refresh tokens with reuse detection, so presenting a spent one revokes the whole session family. Both obvious implementations are wrong: refreshing concurrently looks like replay, and retrying a failed refresh with the same token IS the replay. So `Session::ensure_fresh` holds an async mutex across the await, re-checks expiry after acquiring it (the waiter usually finds the work already done), persists the rotated token BEFORE returning it, and never retries. A missing expires_at counts as expired rather than being given a guessed default. Least scope by default: `login` requests `sensing:read`. `--admin` adds `sensing:admin` explicitly, and requests both because there is no scope hierarchy server-side. A session that streams poses should not casually hold the capability to delete the model it streams through. Credentials are written atomically and 0600 (temp file, chmod BEFORE rename) — the same discipline the seed applies to its cloud key. `logout` is local-only and says so: it makes this machine unable to act as you, but revoking the session everywhere is an account-level action. Also `whoami`, which reports whether the stored token is live — an expired-looking session is the most common reason a command starts 401ing, and it should be visible directly rather than inferred from a failure elsewhere. Verified against PRODUCTION, not just locally: authorize URLs built by this exact code path return HTTP 200 from auth.cognitum.one for both `sensing:read` and `sensing:read sensing:admin`, which exercises the real client_id, scope encoding, PKCE parameters and redirect_uri shape. Tests: 74 with --features login (51 unit + 21 verifier matrix + 2 doctests), including the RFC 7636 Appendix B vector, multi-scope URL encoding (a space that is hand-formatted rather than encoded silently truncates the request), a real TCP callback round-trip, callback timeout, 0600 permissions asserted on disk, atomic-save leaving no temp file, and refresh-window boundaries. Unchanged: 43 with default features, 501 in the sensing server. Co-Authored-By: Ruflo & AQE
224 lines
7.7 KiB
Rust
224 lines
7.7 KiB
Rust
//! The ephemeral loopback listener the browser redirects back to, plus opening
|
|
//! the system browser.
|
|
//!
|
|
//! A hand-rolled HTTP/1.1 responder rather than a second axum server: it serves
|
|
//! exactly one GET, then shuts down. Ported from `meta-proxy`
|
|
//! `src/oauth/{callback_server,browser}.rs`.
|
|
|
|
use std::net::SocketAddr;
|
|
use std::process::Stdio;
|
|
|
|
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
|
use tokio::net::TcpListener;
|
|
use tokio::time::{timeout, Duration};
|
|
|
|
pub struct CallbackServer {
|
|
listener: TcpListener,
|
|
/// The exact value to send as `redirect_uri`.
|
|
pub redirect_uri: String,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Default)]
|
|
pub struct CallbackResult {
|
|
pub code: Option<String>,
|
|
pub state: Option<String>,
|
|
pub error: Option<String>,
|
|
}
|
|
|
|
const SUCCESS_PAGE: &str = r#"<html>
|
|
<body style="background:#0a0a0a; color:#f5f5f5; font-family:system-ui,sans-serif;
|
|
display:flex; align-items:center; justify-content:center; height:100vh; margin:0;">
|
|
<div style="text-align:center;">
|
|
<h1>✓ RuView sign-in complete</h1>
|
|
<p>You can close this tab and return to your terminal.</p>
|
|
</div>
|
|
</body>
|
|
</html>"#;
|
|
|
|
impl CallbackServer {
|
|
/// Bind `127.0.0.1:0` and derive the redirect URI.
|
|
///
|
|
/// The path must be **exactly** `/oauth/callback`: identity's
|
|
/// `client::validate_redirect_uri` accepts `http://127.0.0.1:<any-port>/oauth/callback`
|
|
/// and nothing else, so a different path fails the authorize request with a
|
|
/// redirect-URI mismatch rather than anything that names the real problem.
|
|
pub async fn bind() -> std::io::Result<Self> {
|
|
let listener = TcpListener::bind(("127.0.0.1", 0)).await?;
|
|
let addr: SocketAddr = listener.local_addr()?;
|
|
Ok(Self {
|
|
redirect_uri: format!("http://127.0.0.1:{}/oauth/callback", addr.port()),
|
|
listener,
|
|
})
|
|
}
|
|
|
|
pub fn port(&self) -> u16 {
|
|
self.listener.local_addr().map(|a| a.port()).unwrap_or(0)
|
|
}
|
|
|
|
/// Serve exactly one callback, reply with the success page, return the
|
|
/// parsed query. Times out so an abandoned browser tab does not hang the
|
|
/// CLI forever.
|
|
pub async fn await_callback(&self, wait_for: Duration) -> std::io::Result<CallbackResult> {
|
|
let (mut stream, _) = timeout(wait_for, self.listener.accept())
|
|
.await
|
|
.map_err(|_| {
|
|
std::io::Error::new(
|
|
std::io::ErrorKind::TimedOut,
|
|
"timed out waiting for the OAuth callback — was the browser window closed?",
|
|
)
|
|
})??;
|
|
|
|
let mut buf = vec![0u8; 8192];
|
|
let n = stream.read(&mut buf).await?;
|
|
let text = String::from_utf8_lossy(&buf[..n]);
|
|
let target = text
|
|
.lines()
|
|
.next()
|
|
.unwrap_or("")
|
|
.split_whitespace()
|
|
.nth(1)
|
|
.unwrap_or("/oauth/callback")
|
|
.to_string();
|
|
|
|
let response = format!(
|
|
"HTTP/1.1 200 OK\r\nContent-Type: text/html; charset=utf-8\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
|
|
SUCCESS_PAGE.len(),
|
|
SUCCESS_PAGE
|
|
);
|
|
stream.write_all(response.as_bytes()).await?;
|
|
stream.shutdown().await?;
|
|
|
|
Ok(parse_callback_query(&target))
|
|
}
|
|
}
|
|
|
|
fn parse_callback_query(path_and_query: &str) -> CallbackResult {
|
|
let query = path_and_query.split_once('?').map(|(_, q)| q).unwrap_or("");
|
|
let mut out = CallbackResult::default();
|
|
for (k, v) in url::form_urlencoded::parse(query.as_bytes()) {
|
|
match k.as_ref() {
|
|
"code" => out.code = Some(v.into_owned()),
|
|
"state" => out.state = Some(v.into_owned()),
|
|
"error" => out.error = Some(v.into_owned()),
|
|
_ => {}
|
|
}
|
|
}
|
|
out
|
|
}
|
|
|
|
/// Open `url` in the system browser.
|
|
///
|
|
/// Success means the launcher was spawned, not that a window appeared — which
|
|
/// cannot be determined in general. Callers must print the URL regardless.
|
|
pub fn open_browser(url: &str) -> std::io::Result<()> {
|
|
let (cmd, args): (&str, Vec<&str>) = if cfg!(target_os = "macos") {
|
|
("open", vec![url])
|
|
} else if cfg!(target_os = "windows") {
|
|
// The empty title argument stops `start` treating a quoted URL as the
|
|
// window title.
|
|
("cmd", vec!["/c", "start", "", url])
|
|
} else {
|
|
("xdg-open", vec![url])
|
|
};
|
|
std::process::Command::new(cmd)
|
|
.args(args)
|
|
.stdin(Stdio::null())
|
|
.stdout(Stdio::null())
|
|
.stderr(Stdio::null())
|
|
.spawn()
|
|
.map(|_| ())
|
|
}
|
|
|
|
/// Does this process look like it has no usable browser?
|
|
///
|
|
/// Mirrors meta-proxy's detection exactly (`login.rs:105-108`). It is a
|
|
/// heuristic, which is why `--no-browser` exists: a wrong guess costs the user
|
|
/// one flag, not a failed login.
|
|
pub fn looks_headless() -> bool {
|
|
std::env::var("SSH_CONNECTION").is_ok()
|
|
|| std::env::var("SSH_TTY").is_ok()
|
|
|| std::env::var("CONTAINER").is_ok()
|
|
|| std::path::Path::new("/.dockerenv").exists()
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn parses_code_and_state() {
|
|
let r = parse_callback_query("/oauth/callback?code=abc&state=xyz");
|
|
assert_eq!(r.code.as_deref(), Some("abc"));
|
|
assert_eq!(r.state.as_deref(), Some("xyz"));
|
|
assert!(r.error.is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn parses_a_denial() {
|
|
let r = parse_callback_query("/oauth/callback?error=access_denied&state=xyz");
|
|
assert_eq!(r.error.as_deref(), Some("access_denied"));
|
|
assert!(r.code.is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn percent_encoded_values_are_decoded() {
|
|
let r = parse_callback_query("/oauth/callback?code=a%2Bb%2Fc&state=s");
|
|
assert_eq!(r.code.as_deref(), Some("a+b/c"));
|
|
}
|
|
|
|
#[test]
|
|
fn a_query_less_callback_yields_nothing_rather_than_panicking() {
|
|
let r = parse_callback_query("/oauth/callback");
|
|
assert!(r.code.is_none() && r.state.is_none() && r.error.is_none());
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn the_redirect_uri_has_the_exact_shape_identity_requires() {
|
|
let s = CallbackServer::bind().await.unwrap();
|
|
assert!(s.redirect_uri.starts_with("http://127.0.0.1:"));
|
|
assert!(s.redirect_uri.ends_with("/oauth/callback"));
|
|
assert_ne!(s.port(), 0, "must bind a real ephemeral port");
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn a_real_tcp_callback_round_trips() {
|
|
let server = CallbackServer::bind().await.unwrap();
|
|
let port = server.port();
|
|
let client = tokio::spawn(async move {
|
|
let mut s = tokio::net::TcpStream::connect(("127.0.0.1", port))
|
|
.await
|
|
.unwrap();
|
|
s.write_all(b"GET /oauth/callback?code=real&state=st HTTP/1.1\r\nHost: 127.0.0.1\r\n\r\n")
|
|
.await
|
|
.unwrap();
|
|
let mut buf = vec![0u8; 4096];
|
|
let n = s.read(&mut buf).await.unwrap();
|
|
String::from_utf8_lossy(&buf[..n]).to_string()
|
|
});
|
|
|
|
let r = server.await_callback(Duration::from_secs(5)).await.unwrap();
|
|
assert_eq!(r.code.as_deref(), Some("real"));
|
|
assert_eq!(r.state.as_deref(), Some("st"));
|
|
|
|
let page = client.await.unwrap();
|
|
assert!(page.contains("200 OK"));
|
|
assert!(page.contains("RuView sign-in complete"));
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn an_abandoned_login_times_out_instead_of_hanging() {
|
|
let server = CallbackServer::bind().await.unwrap();
|
|
assert!(server
|
|
.await_callback(Duration::from_millis(50))
|
|
.await
|
|
.is_err());
|
|
}
|
|
|
|
#[test]
|
|
fn opening_a_browser_never_panics_even_with_no_launcher_present() {
|
|
// CI containers have no xdg-open; that is a handled condition, not a
|
|
// failure — the caller prints the URL either way.
|
|
let _ = open_browser("http://127.0.0.1:1/nope");
|
|
}
|
|
}
|