mirror of
https://github.com/ruvnet/RuView
synced 2026-07-28 18:21:42 +00:00
31fb3d53f6
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
252 lines
9.0 KiB
Rust
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");
|
|
}
|
|
}
|