mirror of
https://github.com/ruvnet/RuView
synced 2026-07-22 17:23:19 +00:00
feat(cli): wifi-densepose login — Cognitum sign-in (ADR-271 phase 2)
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
This commit is contained in:
Generated
+6
@@ -7638,11 +7638,16 @@ dependencies = [
|
||||
"base64 0.21.7",
|
||||
"jsonwebtoken",
|
||||
"p256",
|
||||
"rand 0.8.5",
|
||||
"reqwest 0.12.28",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"thiserror 2.0.18",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"ureq 2.12.1",
|
||||
"url",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -11055,6 +11060,7 @@ dependencies = [
|
||||
"ndarray 0.17.2",
|
||||
"num-complex",
|
||||
"predicates",
|
||||
"ruview-auth",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tabled",
|
||||
|
||||
@@ -23,9 +23,24 @@ 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 }
|
||||
|
||||
[features]
|
||||
default = ["ureq-transport"]
|
||||
ureq-transport = ["dep:ureq"]
|
||||
# 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"]
|
||||
|
||||
[dev-dependencies]
|
||||
# Test-only: sign real ES256 tokens so the negative matrix exercises the same
|
||||
|
||||
@@ -67,6 +67,12 @@ pub mod jwks;
|
||||
pub mod principal;
|
||||
pub mod verify;
|
||||
|
||||
/// 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.
|
||||
#[cfg(feature = "login")]
|
||||
pub mod login;
|
||||
|
||||
pub use jwks::{JwksCache, JwksError, JwksFetcher};
|
||||
#[cfg(feature = "ureq-transport")]
|
||||
pub use jwks::UreqFetcher;
|
||||
|
||||
@@ -0,0 +1,223 @@
|
||||
//! 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");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
//! The `auth.cognitum.one` OAuth surface: authorize URL, `POST /oauth/token`
|
||||
//! (`authorization_code` and `refresh_token` grants), and
|
||||
//! `POST /v1/oauth/code-exchange` (the OOB fallback).
|
||||
//!
|
||||
//! Ported from `cognitum-one/meta-proxy` `src/oauth/client.rs`, with the
|
||||
//! refresh grant kept — meta-proxy discards its access token after one use,
|
||||
//! but a RuView session is long-lived and must refresh.
|
||||
//!
|
||||
//! **Target the identity origin, not the console.** metaharness ADR-119 found
|
||||
//! `dashboard.cognitum.one` returns 405 for `POST /oauth/token` (the console
|
||||
//! SPA swallows the route). `auth.cognitum.one` is the correct direct target.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// RuView's registered client (identity migration `0017`).
|
||||
pub const CLIENT_ID: &str = "ruview";
|
||||
|
||||
/// RFC 8252 out-of-band sentinel. Must match
|
||||
/// `services/identity/src/oauth/client.rs::FALLBACK_REDIRECT_URI` exactly.
|
||||
pub const OOB_REDIRECT_URI: &str = "urn:ietf:wg:oauth:2.0:oob";
|
||||
|
||||
pub const DEFAULT_AUTH_BASE_URL: &str = "https://auth.cognitum.one";
|
||||
|
||||
/// Override the issuer origin (staging, a local identity, a mirror).
|
||||
pub const AUTH_URL_ENV: &str = "RUVIEW_COGNITUM_AUTH_URL";
|
||||
|
||||
/// Override the client id.
|
||||
///
|
||||
/// Exists because Cognitum has no dynamic client registration, and products
|
||||
/// have historically borrowed a registered id while their own was pending —
|
||||
/// musica shipped as `meta-proxy` for exactly this reason. RuView has its own
|
||||
/// row now, so this is an escape hatch, not the normal path.
|
||||
pub const CLIENT_ID_ENV: &str = "RUVIEW_COGNITUM_CLIENT_ID";
|
||||
|
||||
pub fn auth_base_url() -> String {
|
||||
std::env::var(AUTH_URL_ENV)
|
||||
.ok()
|
||||
.filter(|v| !v.trim().is_empty())
|
||||
.map(|v| v.trim().trim_end_matches('/').to_string())
|
||||
.unwrap_or_else(|| DEFAULT_AUTH_BASE_URL.to_string())
|
||||
}
|
||||
|
||||
pub fn client_id() -> String {
|
||||
std::env::var(CLIENT_ID_ENV)
|
||||
.ok()
|
||||
.filter(|v| !v.trim().is_empty())
|
||||
.unwrap_or_else(|| CLIENT_ID.to_string())
|
||||
}
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum OAuthError {
|
||||
#[error("network error talking to the authorization server: {0}")]
|
||||
Network(#[from] reqwest::Error),
|
||||
#[error("authorization server rejected the request: {error} — {description}")]
|
||||
Protocol { error: String, description: String },
|
||||
#[error("unexpected response shape from the authorization server")]
|
||||
UnexpectedShape,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct TokenResponse {
|
||||
pub access_token: String,
|
||||
#[serde(default)]
|
||||
pub token_type: Option<String>,
|
||||
#[serde(default)]
|
||||
pub account_email: Option<String>,
|
||||
/// The **rotating** refresh token. Identity revokes the presented one and
|
||||
/// returns a replacement; see [`refresh`].
|
||||
#[serde(default)]
|
||||
pub refresh_token: Option<String>,
|
||||
/// Access-token lifetime in seconds (identity issues 900). Absent ⇒ treat
|
||||
/// the token as already needing refresh rather than assuming a default.
|
||||
#[serde(default)]
|
||||
pub expires_in: Option<i64>,
|
||||
#[serde(default)]
|
||||
pub scope: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct ErrorBody {
|
||||
#[serde(default)]
|
||||
error: Option<String>,
|
||||
#[serde(default)]
|
||||
error_description: Option<String>,
|
||||
}
|
||||
|
||||
async fn parse_token_response(resp: reqwest::Response) -> Result<TokenResponse, OAuthError> {
|
||||
let status = resp.status();
|
||||
let body = resp.text().await?;
|
||||
if status.is_success() {
|
||||
return serde_json::from_str::<TokenResponse>(&body)
|
||||
.map_err(|_| OAuthError::UnexpectedShape);
|
||||
}
|
||||
// A non-JSON error body (an HTML error page, a proxy timeout) must not
|
||||
// panic or masquerade as a protocol error we understand.
|
||||
match serde_json::from_str::<ErrorBody>(&body) {
|
||||
Ok(e) => Err(OAuthError::Protocol {
|
||||
error: e.error.unwrap_or_else(|| status.to_string()),
|
||||
description: e
|
||||
.error_description
|
||||
.unwrap_or_else(|| "no description supplied".into()),
|
||||
}),
|
||||
Err(_) => Err(OAuthError::UnexpectedShape),
|
||||
}
|
||||
}
|
||||
|
||||
/// Build the `/oauth/authorize` URL.
|
||||
///
|
||||
/// Uses a real URL encoder rather than `format!` so a scope containing a space
|
||||
/// (`"sensing:read sensing:admin"`) is encoded correctly — hand-formatting this
|
||||
/// is how a client ends up sending a truncated scope and getting a baffling
|
||||
/// `Unknown scope`.
|
||||
pub fn authorize_url(redirect_uri: &str, state: &str, code_challenge: &str, scope: &str) -> String {
|
||||
let mut url = url::Url::parse(&format!("{}/oauth/authorize", auth_base_url()))
|
||||
.expect("auth base URL is a valid URL");
|
||||
url.query_pairs_mut()
|
||||
.append_pair("response_type", "code")
|
||||
.append_pair("client_id", &client_id())
|
||||
.append_pair("redirect_uri", redirect_uri)
|
||||
.append_pair("code_challenge", code_challenge)
|
||||
.append_pair("code_challenge_method", "S256")
|
||||
.append_pair("state", state)
|
||||
.append_pair("scope", scope);
|
||||
url.to_string()
|
||||
}
|
||||
|
||||
/// `POST /oauth/token`, `grant_type=authorization_code`.
|
||||
pub async fn exchange_code(
|
||||
http: &reqwest::Client,
|
||||
code: &str,
|
||||
code_verifier: &str,
|
||||
redirect_uri: &str,
|
||||
) -> Result<TokenResponse, OAuthError> {
|
||||
let resp = http
|
||||
.post(format!("{}/oauth/token", auth_base_url()))
|
||||
.form(&[
|
||||
("grant_type", "authorization_code"),
|
||||
("code", code),
|
||||
("code_verifier", code_verifier),
|
||||
("client_id", &client_id()),
|
||||
("redirect_uri", redirect_uri),
|
||||
])
|
||||
.send()
|
||||
.await?;
|
||||
parse_token_response(resp).await
|
||||
}
|
||||
|
||||
/// `POST /oauth/token`, `grant_type=refresh_token`.
|
||||
///
|
||||
/// **Identity rotates refresh tokens with reuse detection.** The response
|
||||
/// carries a NEW refresh token and spends the old one; presenting a spent token
|
||||
/// revokes the entire session family. Two consequences the caller must honour:
|
||||
///
|
||||
/// 1. Persist the returned `refresh_token` **before** using the new access
|
||||
/// token — a crash in between otherwise strands the session.
|
||||
/// 2. Never retry a failed refresh with the same token. A timeout is not proof
|
||||
/// the server did not consume it.
|
||||
///
|
||||
/// [`super::store::Session::ensure_fresh`] does both; prefer it to calling this
|
||||
/// directly.
|
||||
pub async fn refresh(
|
||||
http: &reqwest::Client,
|
||||
refresh_token: &str,
|
||||
) -> Result<TokenResponse, OAuthError> {
|
||||
let resp = http
|
||||
.post(format!("{}/oauth/token", auth_base_url()))
|
||||
.form(&[
|
||||
("grant_type", "refresh_token"),
|
||||
("refresh_token", refresh_token),
|
||||
("client_id", &client_id()),
|
||||
])
|
||||
.send()
|
||||
.await?;
|
||||
parse_token_response(resp).await
|
||||
}
|
||||
|
||||
/// `POST /v1/oauth/code-exchange` — the OOB manual-paste fallback for hosts
|
||||
/// with no browser and no reachable loopback (SSH into a Pi, a container).
|
||||
pub async fn exchange_manual_code(
|
||||
http: &reqwest::Client,
|
||||
code: &str,
|
||||
code_verifier: &str,
|
||||
) -> Result<TokenResponse, OAuthError> {
|
||||
#[derive(Serialize)]
|
||||
struct Req<'a> {
|
||||
code: &'a str,
|
||||
code_verifier: &'a str,
|
||||
client_id: &'a str,
|
||||
}
|
||||
let resp = http
|
||||
.post(format!("{}/v1/oauth/code-exchange", auth_base_url()))
|
||||
.json(&Req {
|
||||
code,
|
||||
code_verifier,
|
||||
client_id: &client_id(),
|
||||
})
|
||||
.send()
|
||||
.await?;
|
||||
parse_token_response(resp).await
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn authorize_url_targets_the_identity_origin_not_the_console() {
|
||||
// The console origin 405s POST /oauth/token (metaharness ADR-119).
|
||||
let u = authorize_url("http://127.0.0.1:1/oauth/callback", "s", "c", "sensing:read");
|
||||
assert!(u.starts_with("https://auth.cognitum.one/oauth/authorize"), "{u}");
|
||||
assert!(!u.contains("dashboard.cognitum.one"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn authorize_url_carries_every_required_parameter() {
|
||||
let u = authorize_url("http://127.0.0.1:1/oauth/callback", "st8", "chal", "sensing:read");
|
||||
for expected in [
|
||||
"response_type=code",
|
||||
"client_id=ruview",
|
||||
"code_challenge=chal",
|
||||
"code_challenge_method=S256",
|
||||
"state=st8",
|
||||
] {
|
||||
assert!(u.contains(expected), "missing {expected} in {u}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_multi_scope_request_is_url_encoded_not_truncated() {
|
||||
// The space in "sensing:read sensing:admin" must survive as %20/+.
|
||||
// Hand-formatting this is how a client silently requests one scope.
|
||||
let u = authorize_url("http://127.0.0.1:1/oauth/callback", "s", "c", "sensing:read sensing:admin");
|
||||
assert!(
|
||||
u.contains("scope=sensing%3Aread+sensing%3Aadmin")
|
||||
|| u.contains("scope=sensing%3Aread%20sensing%3Aadmin"),
|
||||
"scope not encoded correctly: {u}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_oob_sentinel_matches_the_servers_constant_exactly() {
|
||||
// Any drift here fails the headless path with an opaque redirect_uri
|
||||
// mismatch.
|
||||
assert_eq!(OOB_REDIRECT_URI, "urn:ietf:wg:oauth:2.0:oob");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_default_client_id_is_ruviews_own_registration() {
|
||||
assert_eq!(CLIENT_ID, "ruview");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
//! 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 super::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}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
//! 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;
|
||||
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};
|
||||
pub use flow::{login, logout, LoginError, LoginOptions};
|
||||
pub use store::{
|
||||
default_credentials_path, Session, StoreError, StoredCredentials, CREDENTIALS_PATH_ENV,
|
||||
};
|
||||
@@ -0,0 +1,83 @@
|
||||
//! OAuth 2.0 PKCE (RFC 7636) generation.
|
||||
//!
|
||||
//! Ported from `cognitum-one/meta-proxy` `src/oauth/pkce.rs`, itself ported from
|
||||
//! `dashboard/apps/cli`. Kept byte-compatible on purpose: a verifier generated
|
||||
//! here has to validate against the same `services/identity` code every other
|
||||
//! Cognitum client already talks to.
|
||||
|
||||
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
|
||||
use base64::Engine;
|
||||
use rand::RngCore;
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
/// One login attempt's PKCE pair plus its CSRF `state`.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct PkceRequest {
|
||||
pub state: String,
|
||||
pub code_verifier: String,
|
||||
pub code_challenge: String,
|
||||
}
|
||||
|
||||
fn random_url_safe_token(byte_len: usize) -> String {
|
||||
let mut bytes = vec![0u8; byte_len];
|
||||
rand::rngs::OsRng.fill_bytes(&mut bytes);
|
||||
URL_SAFE_NO_PAD.encode(bytes)
|
||||
}
|
||||
|
||||
pub fn challenge_from_verifier(verifier: &str) -> String {
|
||||
URL_SAFE_NO_PAD.encode(Sha256::digest(verifier.as_bytes()))
|
||||
}
|
||||
|
||||
/// Fresh `state` + verifier/challenge for one login attempt.
|
||||
///
|
||||
/// 32 random bytes each: base64url-encodes to 43 characters, comfortably inside
|
||||
/// RFC 7636 §4.1's 43–128 range without padding.
|
||||
pub fn generate() -> PkceRequest {
|
||||
let state = random_url_safe_token(32);
|
||||
let code_verifier = random_url_safe_token(32);
|
||||
let code_challenge = challenge_from_verifier(&code_verifier);
|
||||
PkceRequest {
|
||||
state,
|
||||
code_verifier,
|
||||
code_challenge,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn matches_the_rfc7636_appendix_b_worked_example() {
|
||||
// The spec's own vector. If this drifts, our S256 is not S256 and the
|
||||
// server will reject every exchange — worth pinning to the standard
|
||||
// rather than to our own output.
|
||||
assert_eq!(
|
||||
challenge_from_verifier("dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk"),
|
||||
"E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn verifier_length_is_within_rfc7636_bounds() {
|
||||
let r = generate();
|
||||
assert!(
|
||||
r.code_verifier.len() >= 43 && r.code_verifier.len() <= 128,
|
||||
"len {}",
|
||||
r.code_verifier.len()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn challenge_is_derived_from_the_verifier_it_ships_with() {
|
||||
let r = generate();
|
||||
assert_eq!(challenge_from_verifier(&r.code_verifier), r.code_challenge);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn separate_attempts_share_nothing() {
|
||||
let (a, b) = (generate(), generate());
|
||||
assert_ne!(a.state, b.state);
|
||||
assert_ne!(a.code_verifier, b.code_verifier);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,383 @@
|
||||
//! Stored credentials and the refresh critical section.
|
||||
//!
|
||||
//! # Why refresh is the dangerous part
|
||||
//!
|
||||
//! Identity **rotates refresh tokens with reuse detection**: presenting one
|
||||
//! returns a replacement and spends the original, and presenting a spent token
|
||||
//! revokes the whole session family. So the two obvious implementations are
|
||||
//! both wrong:
|
||||
//!
|
||||
//! * *Refresh concurrently* — two tasks present the same token, the second
|
||||
//! looks like replay, and the user is logged out.
|
||||
//! * *Retry a failed refresh with the same token* — a timeout is not evidence
|
||||
//! the server didn't consume it. Retrying is precisely the replay the server
|
||||
//! is watching for.
|
||||
//!
|
||||
//! [`Session::ensure_fresh`] therefore holds an async mutex **across the
|
||||
//! await**, re-checks expiry after acquiring it (the task that waited may find
|
||||
//! the work already done), persists the rotated token **before** returning, and
|
||||
//! never retries.
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::Arc;
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
use super::client::{self, OAuthError, TokenResponse};
|
||||
|
||||
/// Refresh this many seconds before `exp`. Matches the figure meta-proxy and
|
||||
/// musica independently arrived at against the same 15-minute token.
|
||||
const REFRESH_SKEW_SECS: i64 = 60;
|
||||
|
||||
#[derive(Debug, thiserror::Error)]
|
||||
pub enum StoreError {
|
||||
#[error("no stored credentials — run `wifi-densepose login` first")]
|
||||
NotLoggedIn,
|
||||
#[error("credential file {path} is unreadable: {source}")]
|
||||
Unreadable {
|
||||
path: PathBuf,
|
||||
#[source]
|
||||
source: std::io::Error,
|
||||
},
|
||||
#[error("credential file {path} is malformed; run `wifi-densepose login` again")]
|
||||
Malformed { path: PathBuf },
|
||||
#[error("could not write credentials to {path}: {source}")]
|
||||
Unwritable {
|
||||
path: PathBuf,
|
||||
#[source]
|
||||
source: std::io::Error,
|
||||
},
|
||||
#[error("session expired and could not be refreshed — run `wifi-densepose login` again: {0}")]
|
||||
RefreshFailed(#[from] OAuthError),
|
||||
#[error("the authorization server returned no refresh token; re-login is required")]
|
||||
NoRefreshToken,
|
||||
}
|
||||
|
||||
/// The persisted session. Deliberately small: this file holds live credentials.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct StoredCredentials {
|
||||
pub schema_version: u8,
|
||||
pub access_token: String,
|
||||
pub refresh_token: Option<String>,
|
||||
/// Unix seconds. Absent ⇒ treated as already expired, never as "valid".
|
||||
pub expires_at: Option<i64>,
|
||||
pub scope: Option<String>,
|
||||
pub account_email: Option<String>,
|
||||
pub issuer: String,
|
||||
}
|
||||
|
||||
impl StoredCredentials {
|
||||
pub const SCHEMA_VERSION: u8 = 1;
|
||||
|
||||
fn from_response(t: TokenResponse, issuer: String) -> Self {
|
||||
let expires_at = t.expires_in.map(|s| now_unix() + s);
|
||||
Self {
|
||||
schema_version: Self::SCHEMA_VERSION,
|
||||
access_token: t.access_token,
|
||||
refresh_token: t.refresh_token,
|
||||
expires_at,
|
||||
scope: t.scope,
|
||||
account_email: t.account_email,
|
||||
issuer,
|
||||
}
|
||||
}
|
||||
|
||||
/// Does the access token need replacing?
|
||||
///
|
||||
/// A missing `expires_at` counts as expired. Guessing a lifetime here would
|
||||
/// mean confidently sending a token the server may have expired minutes ago.
|
||||
pub fn needs_refresh(&self) -> bool {
|
||||
match self.expires_at {
|
||||
None => true,
|
||||
Some(exp) => now_unix() + REFRESH_SKEW_SECS >= exp,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn now_unix() -> i64 {
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_secs() as i64)
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
/// Default credential path: `~/.ruview/credentials.json`, overridable.
|
||||
pub const CREDENTIALS_PATH_ENV: &str = "RUVIEW_CREDENTIALS_PATH";
|
||||
|
||||
pub fn default_credentials_path() -> PathBuf {
|
||||
if let Ok(p) = std::env::var(CREDENTIALS_PATH_ENV) {
|
||||
if !p.trim().is_empty() {
|
||||
return PathBuf::from(p);
|
||||
}
|
||||
}
|
||||
let home = std::env::var("HOME")
|
||||
.or_else(|_| std::env::var("USERPROFILE"))
|
||||
.unwrap_or_else(|_| ".".to_string());
|
||||
Path::new(&home).join(".ruview").join("credentials.json")
|
||||
}
|
||||
|
||||
/// Write credentials atomically and `0600`.
|
||||
///
|
||||
/// Same discipline the seed applies to its cloud key and meta-proxy to its
|
||||
/// config: temp file in the destination directory, restrict the mode *before*
|
||||
/// the rename, then rename. A partial credential file is worse than none, and a
|
||||
/// world-readable one is a live session anyone on the box can steal.
|
||||
pub fn save(path: &Path, creds: &StoredCredentials) -> Result<(), StoreError> {
|
||||
if let Some(dir) = path.parent() {
|
||||
std::fs::create_dir_all(dir).map_err(|source| StoreError::Unwritable {
|
||||
path: path.to_path_buf(),
|
||||
source,
|
||||
})?;
|
||||
}
|
||||
let json = serde_json::to_vec_pretty(creds).expect("credentials serialize");
|
||||
let tmp = path.with_extension("tmp");
|
||||
|
||||
std::fs::write(&tmp, &json).map_err(|source| StoreError::Unwritable {
|
||||
path: tmp.clone(),
|
||||
source,
|
||||
})?;
|
||||
restrict_permissions(&tmp)?;
|
||||
std::fs::rename(&tmp, path).map_err(|source| StoreError::Unwritable {
|
||||
path: path.to_path_buf(),
|
||||
source,
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
fn restrict_permissions(path: &Path) -> Result<(), StoreError> {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600)).map_err(|source| {
|
||||
StoreError::Unwritable {
|
||||
path: path.to_path_buf(),
|
||||
source,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(not(unix))]
|
||||
fn restrict_permissions(path: &Path) -> Result<(), StoreError> {
|
||||
// Windows: inherit the user profile directory's ACL. `icacls` would be the
|
||||
// stricter equivalent; noted rather than silently pretended.
|
||||
let _ = path;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn load(path: &Path) -> Result<StoredCredentials, StoreError> {
|
||||
let bytes = match std::fs::read(path) {
|
||||
Ok(b) => b,
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => return Err(StoreError::NotLoggedIn),
|
||||
Err(source) => {
|
||||
return Err(StoreError::Unreadable {
|
||||
path: path.to_path_buf(),
|
||||
source,
|
||||
})
|
||||
}
|
||||
};
|
||||
serde_json::from_slice(&bytes).map_err(|_| StoreError::Malformed {
|
||||
path: path.to_path_buf(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Remove stored credentials. Idempotent.
|
||||
///
|
||||
/// This forgets the local copy; it does not revoke server-side. That is a
|
||||
/// deliberate split (meta-proxy makes the same one): "this machine can no
|
||||
/// longer act as me" is the fail-secure local action, and revocation is a
|
||||
/// separate, account-level decision.
|
||||
pub fn clear(path: &Path) -> Result<bool, StoreError> {
|
||||
match std::fs::remove_file(path) {
|
||||
Ok(()) => Ok(true),
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(false),
|
||||
Err(source) => Err(StoreError::Unwritable {
|
||||
path: path.to_path_buf(),
|
||||
source,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
/// A live session that refreshes itself, safely, at most once at a time.
|
||||
#[derive(Clone)]
|
||||
pub struct Session {
|
||||
path: PathBuf,
|
||||
http: reqwest::Client,
|
||||
inner: Arc<Mutex<StoredCredentials>>,
|
||||
}
|
||||
|
||||
impl Session {
|
||||
pub fn load_from(path: PathBuf, http: reqwest::Client) -> Result<Self, StoreError> {
|
||||
let creds = load(&path)?;
|
||||
Ok(Self {
|
||||
path,
|
||||
http,
|
||||
inner: Arc::new(Mutex::new(creds)),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn from_response(
|
||||
path: PathBuf,
|
||||
http: reqwest::Client,
|
||||
token: TokenResponse,
|
||||
issuer: String,
|
||||
) -> Result<Self, StoreError> {
|
||||
let creds = StoredCredentials::from_response(token, issuer);
|
||||
save(&path, &creds)?;
|
||||
Ok(Self {
|
||||
path,
|
||||
http,
|
||||
inner: Arc::new(Mutex::new(creds)),
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn snapshot(&self) -> StoredCredentials {
|
||||
self.inner.lock().await.clone()
|
||||
}
|
||||
|
||||
/// Return a non-expired access token, refreshing if needed.
|
||||
///
|
||||
/// The mutex is held **across the network call** on purpose. That
|
||||
/// serialises refreshes, which is the entire point: identity's reuse
|
||||
/// detection turns a concurrent second refresh into a session revocation.
|
||||
/// The re-check after acquiring means a task that queued behind another's
|
||||
/// refresh returns the fresh token instead of spending the rotated one.
|
||||
pub async fn ensure_fresh(&self) -> Result<String, StoreError> {
|
||||
let mut guard = self.inner.lock().await;
|
||||
|
||||
if !guard.needs_refresh() {
|
||||
return Ok(guard.access_token.clone());
|
||||
}
|
||||
|
||||
let Some(refresh_token) = guard.refresh_token.clone() else {
|
||||
return Err(StoreError::NoRefreshToken);
|
||||
};
|
||||
|
||||
// Deliberately not retried. A timeout is not evidence the server did
|
||||
// not consume the token, and re-presenting it is exactly the replay
|
||||
// that revokes the session.
|
||||
let refreshed = client::refresh(&self.http, &refresh_token).await?;
|
||||
|
||||
let issuer = guard.issuer.clone();
|
||||
let mut next = StoredCredentials::from_response(refreshed, issuer);
|
||||
// Identity always returns a replacement, but if it ever omitted one,
|
||||
// dropping the old token would strand the session with no way back.
|
||||
if next.refresh_token.is_none() {
|
||||
next.refresh_token = Some(refresh_token);
|
||||
}
|
||||
|
||||
// Persist BEFORE handing the new access token out: a crash between the
|
||||
// two otherwise leaves a rotated-away token on disk and a live one only
|
||||
// in memory.
|
||||
save(&self.path, &next)?;
|
||||
let token = next.access_token.clone();
|
||||
*guard = next;
|
||||
Ok(token)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn creds(expires_at: Option<i64>) -> StoredCredentials {
|
||||
StoredCredentials {
|
||||
schema_version: StoredCredentials::SCHEMA_VERSION,
|
||||
access_token: "at".into(),
|
||||
refresh_token: Some("rt".into()),
|
||||
expires_at,
|
||||
scope: Some("sensing:read".into()),
|
||||
account_email: Some("a@b.c".into()),
|
||||
issuer: "https://auth.test".into(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_token_with_no_expiry_is_treated_as_expired() {
|
||||
// Guessing a lifetime would mean confidently sending a token the
|
||||
// server may have expired minutes ago.
|
||||
assert!(creds(None).needs_refresh());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_freshly_issued_token_does_not_need_refreshing() {
|
||||
assert!(!creds(Some(now_unix() + 900)).needs_refresh());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn refresh_is_triggered_inside_the_skew_window() {
|
||||
// 30s left, 60s skew — refresh now rather than racing expiry mid-request.
|
||||
assert!(creds(Some(now_unix() + 30)).needs_refresh());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_already_expired_token_needs_refreshing() {
|
||||
assert!(creds(Some(now_unix() - 1)).needs_refresh());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn save_then_load_round_trips() {
|
||||
let dir = std::env::temp_dir().join(format!("ruview-auth-test-{}", std::process::id()));
|
||||
let path = dir.join("credentials.json");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
|
||||
save(&path, &creds(Some(123))).unwrap();
|
||||
let back = load(&path).unwrap();
|
||||
assert_eq!(back.access_token, "at");
|
||||
assert_eq!(back.expires_at, Some(123));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[cfg(unix)]
|
||||
#[test]
|
||||
fn a_saved_credential_file_is_not_readable_by_anyone_else() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let dir = std::env::temp_dir().join(format!("ruview-auth-perm-{}", std::process::id()));
|
||||
let path = dir.join("credentials.json");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
|
||||
save(&path, &creds(Some(1))).unwrap();
|
||||
let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777;
|
||||
assert_eq!(mode, 0o600, "credentials must be 0600, got {mode:o}");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn loading_a_missing_file_says_not_logged_in_rather_than_erroring_obscurely() {
|
||||
let path = std::env::temp_dir().join("ruview-auth-definitely-absent.json");
|
||||
let _ = std::fs::remove_file(&path);
|
||||
assert!(matches!(load(&path), Err(StoreError::NotLoggedIn)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_corrupt_credential_file_is_reported_as_malformed_not_as_absent() {
|
||||
let dir = std::env::temp_dir().join(format!("ruview-auth-bad-{}", std::process::id()));
|
||||
let path = dir.join("credentials.json");
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
std::fs::write(&path, b"{not json").unwrap();
|
||||
assert!(matches!(load(&path), Err(StoreError::Malformed { .. })));
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn clearing_is_idempotent() {
|
||||
let dir = std::env::temp_dir().join(format!("ruview-auth-clear-{}", std::process::id()));
|
||||
let path = dir.join("credentials.json");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
|
||||
save(&path, &creds(Some(1))).unwrap();
|
||||
assert!(clear(&path).unwrap(), "first clear removes the file");
|
||||
assert!(!clear(&path).unwrap(), "second clear is a no-op, not an error");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn saving_leaves_no_temp_file_behind() {
|
||||
let dir = std::env::temp_dir().join(format!("ruview-auth-tmp-{}", std::process::id()));
|
||||
let path = dir.join("credentials.json");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
|
||||
save(&path, &creds(Some(1))).unwrap();
|
||||
assert!(!path.with_extension("tmp").exists(), "temp file must be renamed away");
|
||||
let _ = std::fs::remove_dir_all(&dir);
|
||||
}
|
||||
}
|
||||
@@ -56,6 +56,12 @@ csv = "1.3"
|
||||
|
||||
# Error handling
|
||||
anyhow = "1.0"
|
||||
|
||||
# ADR-271 phase 2 — `login`/`logout`/`whoami`. The `login` feature carries the
|
||||
# interactive half (PKCE, loopback, OOB paste, credential store, refresh);
|
||||
# the sensing server depends on this same crate with default features and
|
||||
# gets only the verifier.
|
||||
ruview-auth = { path = "../ruview-auth", features = ["login"] }
|
||||
thiserror = "2.0"
|
||||
|
||||
# Time
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
//! `wifi-densepose login` / `logout` / `whoami` — Cognitum sign-in (ADR-271).
|
||||
//!
|
||||
//! Signing in yields a Cognitum access token that a RuView sensing server
|
||||
//! verifies offline against `auth.cognitum.one`'s published JWKS. It replaces
|
||||
//! sharing one `RUVIEW_API_TOKEN` string between everyone who needs access:
|
||||
//! requests become attributable to a person, and destructive routes can be
|
||||
//! separated from read-only ones by scope.
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
use clap::Args;
|
||||
use ruview_auth::login::{self, LoginOptions};
|
||||
use ruview_auth::scope;
|
||||
|
||||
#[derive(Debug, Args)]
|
||||
pub struct LoginArgs {
|
||||
/// Also request `sensing:admin` — the capability to train models and delete
|
||||
/// models and recordings.
|
||||
///
|
||||
/// Off by default on purpose. A session that only streams poses has no
|
||||
/// business holding delete capability, and a token that carries it is a
|
||||
/// bigger loss if it leaks. Ask for it when you are about to do
|
||||
/// administrative work, not as a matter of habit.
|
||||
#[arg(long)]
|
||||
pub admin: bool,
|
||||
|
||||
/// Skip the browser and use the paste-a-code flow.
|
||||
///
|
||||
/// Detected automatically over SSH and inside containers; this forces it.
|
||||
#[arg(long)]
|
||||
pub no_browser: bool,
|
||||
|
||||
/// Where to store credentials. Defaults to `~/.ruview/credentials.json`.
|
||||
#[arg(long, env = ruview_auth::login::CREDENTIALS_PATH_ENV)]
|
||||
pub credentials_path: Option<PathBuf>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Args)]
|
||||
pub struct LogoutArgs {
|
||||
#[arg(long, env = ruview_auth::login::CREDENTIALS_PATH_ENV)]
|
||||
pub credentials_path: Option<PathBuf>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Args)]
|
||||
pub struct WhoamiArgs {
|
||||
#[arg(long, env = ruview_auth::login::CREDENTIALS_PATH_ENV)]
|
||||
pub credentials_path: Option<PathBuf>,
|
||||
}
|
||||
|
||||
fn path_or_default(p: Option<PathBuf>) -> PathBuf {
|
||||
p.unwrap_or_else(login::default_credentials_path)
|
||||
}
|
||||
|
||||
pub async fn login_cmd(args: LoginArgs) -> anyhow::Result<()> {
|
||||
let scope = if args.admin {
|
||||
// Admin implies read: there is no scope hierarchy server-side, so a
|
||||
// session that needs both must consent to both explicitly.
|
||||
format!("{} {}", scope::SENSING_READ, scope::SENSING_ADMIN)
|
||||
} else {
|
||||
scope::SENSING_READ.to_string()
|
||||
};
|
||||
|
||||
let opts = LoginOptions {
|
||||
credentials_path: path_or_default(args.credentials_path),
|
||||
scope,
|
||||
no_browser: args.no_browser,
|
||||
};
|
||||
|
||||
let mut out = std::io::stdout();
|
||||
let stdin = std::io::stdin();
|
||||
let mut input = stdin.lock();
|
||||
|
||||
login::login(&opts, &mut out, &mut input).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn logout_cmd(args: LogoutArgs) -> anyhow::Result<()> {
|
||||
let path = path_or_default(args.credentials_path);
|
||||
if login::logout(&path)? {
|
||||
println!("Signed out — {} removed.", path.display());
|
||||
} else {
|
||||
println!("Not signed in; nothing to remove.");
|
||||
}
|
||||
// Deliberately local-only. This makes the machine unable to act as you;
|
||||
// revoking the session for every device is an account-level action.
|
||||
println!("Note: this forgets the local credential only. It does not revoke the session server-side.");
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn whoami_cmd(args: WhoamiArgs) -> anyhow::Result<()> {
|
||||
let path = path_or_default(args.credentials_path);
|
||||
let creds = ruview_auth::login::store::load(&path)?;
|
||||
|
||||
println!("Credentials: {}", path.display());
|
||||
println!("Issuer: {}", creds.issuer);
|
||||
match &creds.account_email {
|
||||
Some(e) => println!("Account: {e}"),
|
||||
None => println!("Account: (not reported)"),
|
||||
}
|
||||
match &creds.scope {
|
||||
Some(s) => println!("Scope: {s}"),
|
||||
None => println!("Scope: (not reported)"),
|
||||
}
|
||||
// State, not just contents: an expired-looking session is the single most
|
||||
// common reason a command starts 401ing, so say it plainly here rather than
|
||||
// letting the user infer it from a failure elsewhere.
|
||||
if creds.needs_refresh() {
|
||||
println!("Status: access token expired or expiring — it will refresh on next use");
|
||||
} else {
|
||||
println!("Status: access token valid");
|
||||
}
|
||||
if creds.refresh_token.is_none() {
|
||||
println!("Warning: no refresh token stored; you will need to sign in again when this expires");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
@@ -26,6 +26,7 @@
|
||||
|
||||
use clap::{Parser, Subcommand};
|
||||
|
||||
pub mod auth;
|
||||
pub mod calibrate;
|
||||
pub mod calibrate_api;
|
||||
pub mod room;
|
||||
@@ -50,6 +51,16 @@ pub struct Cli {
|
||||
/// Top-level commands
|
||||
#[derive(Subcommand, Debug)]
|
||||
pub enum Commands {
|
||||
/// Sign in to Cognitum (ADR-271). Stores a token this machine can present
|
||||
/// to a RuView sensing server instead of sharing one static API token.
|
||||
Login(auth::LoginArgs),
|
||||
|
||||
/// Forget the locally stored Cognitum credentials.
|
||||
Logout(auth::LogoutArgs),
|
||||
|
||||
/// Show the stored Cognitum session: account, scope, and whether it is live.
|
||||
Whoami(auth::WhoamiArgs),
|
||||
|
||||
/// Empty-room baseline calibration (ADR-135).
|
||||
/// Captures CSI frames via UDP and saves a per-subcarrier statistical
|
||||
/// baseline used for real-time motion z-scoring and CIR reference.
|
||||
|
||||
@@ -18,6 +18,15 @@ async fn main() -> anyhow::Result<()> {
|
||||
let cli = Cli::parse();
|
||||
|
||||
match cli.command {
|
||||
Commands::Login(args) => {
|
||||
wifi_densepose_cli::auth::login_cmd(args).await?;
|
||||
}
|
||||
Commands::Logout(args) => {
|
||||
wifi_densepose_cli::auth::logout_cmd(args).await?;
|
||||
}
|
||||
Commands::Whoami(args) => {
|
||||
wifi_densepose_cli::auth::whoami_cmd(args).await?;
|
||||
}
|
||||
Commands::Calibrate(args) => {
|
||||
wifi_densepose_cli::calibrate::execute(args).await?;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user