Files
ruvnet--RuView/v2/crates/ruview-auth/src/login/client.rs
T
Dragan Spiridonov 31fb3d53f6 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
2026-07-22 17:53:42 +02:00

252 lines
9.0 KiB
Rust

//! 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");
}
}