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:
Dragan Spiridonov
2026-07-22 17:53:42 +02:00
parent c2bd33e649
commit 31fb3d53f6
13 changed files with 1398 additions and 0 deletions
+116
View File
@@ -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(())
}
+11
View File
@@ -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.
+9
View File
@@ -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?;
}