mirror of
https://github.com/ruvnet/RuView
synced 2026-08-11 20:41:44 +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:
@@ -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