diff --git a/v2/Cargo.lock b/v2/Cargo.lock index e56013cb..0b219542 100644 --- a/v2/Cargo.lock +++ b/v2/Cargo.lock @@ -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", diff --git a/v2/crates/ruview-auth/Cargo.toml b/v2/crates/ruview-auth/Cargo.toml index 0f64ac57..6aadf138 100644 --- a/v2/crates/ruview-auth/Cargo.toml +++ b/v2/crates/ruview-auth/Cargo.toml @@ -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 diff --git a/v2/crates/ruview-auth/src/lib.rs b/v2/crates/ruview-auth/src/lib.rs index 7d887d33..0bbdbb0c 100644 --- a/v2/crates/ruview-auth/src/lib.rs +++ b/v2/crates/ruview-auth/src/lib.rs @@ -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; diff --git a/v2/crates/ruview-auth/src/login/callback.rs b/v2/crates/ruview-auth/src/login/callback.rs new file mode 100644 index 00000000..4b489140 --- /dev/null +++ b/v2/crates/ruview-auth/src/login/callback.rs @@ -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, + pub state: Option, + pub error: Option, +} + +const SUCCESS_PAGE: &str = r#" + +
+

✓ RuView sign-in complete

+

You can close this tab and return to your terminal.

+
+ +"#; + +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:/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 { + 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 { + 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"); + } +} diff --git a/v2/crates/ruview-auth/src/login/client.rs b/v2/crates/ruview-auth/src/login/client.rs new file mode 100644 index 00000000..e1af1669 --- /dev/null +++ b/v2/crates/ruview-auth/src/login/client.rs @@ -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, + #[serde(default)] + pub account_email: Option, + /// The **rotating** refresh token. Identity revokes the presented one and + /// returns a replacement; see [`refresh`]. + #[serde(default)] + pub refresh_token: Option, + /// 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, + #[serde(default)] + pub scope: Option, +} + +#[derive(Debug, Deserialize)] +struct ErrorBody { + #[serde(default)] + error: Option, + #[serde(default)] + error_description: Option, +} + +async fn parse_token_response(resp: reqwest::Response) -> Result { + let status = resp.status(); + let body = resp.text().await?; + if status.is_success() { + return serde_json::from_str::(&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::(&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 { + 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 { + 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 { + #[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"); + } +} diff --git a/v2/crates/ruview-auth/src/login/flow.rs b/v2/crates/ruview-auth/src/login/flow.rs new file mode 100644 index 00000000..721405a3 --- /dev/null +++ b/v2/crates/ruview-auth/src/login/flow.rs @@ -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( + opts: &LoginOptions, + out: &mut W, + input: &mut R, +) -> Result { + 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( + opts: &LoginOptions, + http: &reqwest::Client, + issuer: String, + out: &mut W, +) -> Result { + 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( + opts: &LoginOptions, + http: &reqwest::Client, + issuer: String, + out: &mut W, + input: &mut R, +) -> Result { + 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( + opts: &LoginOptions, + http: &reqwest::Client, + token: client::TokenResponse, + issuer: String, + out: &mut W, +) -> Result { + 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 { + 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}"); + } +} diff --git a/v2/crates/ruview-auth/src/login/mod.rs b/v2/crates/ruview-auth/src/login/mod.rs new file mode 100644 index 00000000..14dc6c0a --- /dev/null +++ b/v2/crates/ruview-auth/src/login/mod.rs @@ -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> { +//! 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, +}; diff --git a/v2/crates/ruview-auth/src/login/pkce.rs b/v2/crates/ruview-auth/src/login/pkce.rs new file mode 100644 index 00000000..a023b68c --- /dev/null +++ b/v2/crates/ruview-auth/src/login/pkce.rs @@ -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); + } +} diff --git a/v2/crates/ruview-auth/src/login/store.rs b/v2/crates/ruview-auth/src/login/store.rs new file mode 100644 index 00000000..71bf4a53 --- /dev/null +++ b/v2/crates/ruview-auth/src/login/store.rs @@ -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, + /// Unix seconds. Absent ⇒ treated as already expired, never as "valid". + pub expires_at: Option, + pub scope: Option, + pub account_email: Option, + 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 { + 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 { + 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>, +} + +impl Session { + pub fn load_from(path: PathBuf, http: reqwest::Client) -> Result { + 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 { + 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 { + 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) -> 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); + } +} diff --git a/v2/crates/wifi-densepose-cli/Cargo.toml b/v2/crates/wifi-densepose-cli/Cargo.toml index bc4b58d1..e87e0e9d 100644 --- a/v2/crates/wifi-densepose-cli/Cargo.toml +++ b/v2/crates/wifi-densepose-cli/Cargo.toml @@ -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 diff --git a/v2/crates/wifi-densepose-cli/src/auth.rs b/v2/crates/wifi-densepose-cli/src/auth.rs new file mode 100644 index 00000000..03cecb4c --- /dev/null +++ b/v2/crates/wifi-densepose-cli/src/auth.rs @@ -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, +} + +#[derive(Debug, Args)] +pub struct LogoutArgs { + #[arg(long, env = ruview_auth::login::CREDENTIALS_PATH_ENV)] + pub credentials_path: Option, +} + +#[derive(Debug, Args)] +pub struct WhoamiArgs { + #[arg(long, env = ruview_auth::login::CREDENTIALS_PATH_ENV)] + pub credentials_path: Option, +} + +fn path_or_default(p: Option) -> 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(()) +} diff --git a/v2/crates/wifi-densepose-cli/src/lib.rs b/v2/crates/wifi-densepose-cli/src/lib.rs index d2b73bd9..79366f04 100644 --- a/v2/crates/wifi-densepose-cli/src/lib.rs +++ b/v2/crates/wifi-densepose-cli/src/lib.rs @@ -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. diff --git a/v2/crates/wifi-densepose-cli/src/main.rs b/v2/crates/wifi-densepose-cli/src/main.rs index e4d48f8b..ad468586 100644 --- a/v2/crates/wifi-densepose-cli/src/main.rs +++ b/v2/crates/wifi-densepose-cli/src/main.rs @@ -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?; }