mirror of
https://github.com/ruvnet/RuView
synced 2026-07-28 18:21:42 +00:00
2e018f4f19
Native frame contract, universal RF encoder, RF-aware Gaussian spatial memory, physics-guided synthetic RF worlds, edge sensing control plane, BLE-CS + factorized pose. All 10 ADRs (273-282) fully implemented and tested (99 tests); ADR-278 (radar inverse rendering) honestly gated with zero code as a future research program. Deep-reviewed and hardware-tested against a live ESP32-C6 CSI node before merge: fixed a reachable panic, a silent NaN-corruption path, a cross-entity Gaussian conflation bug, and a wrong-center-frequency bug in the WiFi adapter (confirmed live: was misreporting channel 4 as 2437 MHz, now correctly reports 2427 MHz matching the hardware parser exactly). Added a standing hardware-in-the-loop test (examples/esp32_live_hardware_test.rs). Also fixed unrelated pre-existing issues surfaced during validation (wifi-densepose-core clippy warnings, a ruview-auth Windows build break, a sensing-server test flake). Full review: https://gist.github.com/ruvnet/89795f3c4b8ea166cff5ac35ae4c7651
746 lines
28 KiB
Rust
746 lines
28 KiB
Rust
//! 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.
|
|
//!
|
|
//! ## The in-process mutex is not enough
|
|
//!
|
|
//! Every CLI invocation is a NEW process with its own `Session` and its own
|
|
//! mutex, all sharing one credential file. Two `wifi-densepose` commands run
|
|
//! close together inside the refresh window would each load the same refresh
|
|
//! token and each present it — and the second is replay, so the user is logged
|
|
//! out for running two commands at once.
|
|
//!
|
|
//! So the critical section is also guarded by an advisory **file lock**, taken
|
|
//! NON-BLOCKING. If another process holds it, that process is already
|
|
//! refreshing: we wait briefly and re-read the file rather than queue up to do
|
|
//! the same work with a token that is about to be spent. Blocking on the lock
|
|
//! would also park the async executor — the same mistake this crate had to fix
|
|
//! in `jwks.rs`.
|
|
|
|
use std::path::{Path, PathBuf};
|
|
use std::sync::Arc;
|
|
|
|
use serde::{Deserialize, Serialize};
|
|
use tokio::sync::Mutex;
|
|
|
|
use super::client::{self, OAuthError, TokenResponse};
|
|
|
|
/// How many times to re-read the credential file while another process holds
|
|
/// the refresh lock, before giving up on it and refreshing ourselves.
|
|
const RELOAD_ATTEMPTS: usize = 20;
|
|
/// Gap between those re-reads. 20 x 150ms = 3s, comfortably longer than a
|
|
/// healthy token exchange and shorter than a user notices.
|
|
const RELOAD_INTERVAL: std::time::Duration = std::time::Duration::from_millis(150);
|
|
|
|
/// 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.
|
|
///
|
|
/// `Debug` is hand-written and REDACTING — a derived impl prints both tokens in
|
|
/// full, and this type is the obvious thing to log when a session misbehaves.
|
|
#[derive(Clone, Serialize, Deserialize)]
|
|
pub struct StoredCredentials {
|
|
pub schema_version: u8,
|
|
pub access_token: String,
|
|
pub refresh_token: Option<String>,
|
|
/// Unix seconds. Absent ⇒ treated as already expired, never as "valid".
|
|
pub expires_at: Option<i64>,
|
|
pub scope: Option<String>,
|
|
pub account_email: Option<String>,
|
|
pub issuer: String,
|
|
}
|
|
|
|
impl std::fmt::Debug for StoredCredentials {
|
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
f.debug_struct("StoredCredentials")
|
|
.field("schema_version", &self.schema_version)
|
|
.field("access_token", &"<redacted>")
|
|
.field("refresh_token", &self.refresh_token.as_ref().map(|_| "<redacted>"))
|
|
.field("expires_at", &self.expires_at)
|
|
.field("scope", &self.scope)
|
|
.field("account_email", &self.account_email)
|
|
.field("issuer", &self.issuer)
|
|
.finish()
|
|
}
|
|
}
|
|
|
|
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);
|
|
// Identity's /oauth/token response has no top-level `scope` field, but
|
|
// the access token itself carries a `scope` claim — and that claim is
|
|
// the authoritative one, since it is what a resource server actually
|
|
// gates on. Falling back to it turns "(not reported by the server)"
|
|
// into the real answer. Envelope first on the off chance a future
|
|
// response does carry one.
|
|
let scope = t
|
|
.scope
|
|
.clone()
|
|
.or_else(|| scope_from_access_token(&t.access_token));
|
|
Self {
|
|
schema_version: Self::SCHEMA_VERSION,
|
|
access_token: t.access_token,
|
|
refresh_token: t.refresh_token,
|
|
expires_at,
|
|
scope,
|
|
account_email: t.account_email,
|
|
issuer,
|
|
}
|
|
}
|
|
|
|
/// The granted scope, falling back to the access token's own claim.
|
|
///
|
|
/// Resolved at *read* time, not just at write time, so credential files
|
|
/// written before the fallback existed — or by any client that stores only
|
|
/// what the token response carried — still report correctly instead of
|
|
/// showing "(not reported)" forever. The token is the authoritative source
|
|
/// either way; the stored field is a convenience copy.
|
|
pub fn effective_scope(&self) -> Option<String> {
|
|
self.scope
|
|
.clone()
|
|
.filter(|s| !s.is_empty())
|
|
.or_else(|| scope_from_access_token(&self.access_token))
|
|
}
|
|
|
|
/// 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,
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Read the `scope` claim out of an access token **for display only**.
|
|
///
|
|
/// # This is NOT verification
|
|
///
|
|
/// It base64-decodes the JWT payload and does not check the signature, the
|
|
/// issuer, `exp`, `typ`, or anything else. Its only legitimate use is telling a
|
|
/// user what they just consented to, for a token this process received over TLS
|
|
/// directly from the issuer moments ago.
|
|
///
|
|
/// Never use it to make an authorization decision. Anything that gates access
|
|
/// must go through [`crate::verify::verify_access_token`], which checks the
|
|
/// signature against identity's published JWKS. A client reading its own freshly
|
|
/// issued token is a fundamentally different situation from a server reading a
|
|
/// token a stranger handed it.
|
|
///
|
|
/// Returns `None` rather than guessing if the token is not a well-formed JWT —
|
|
/// an unreadable scope must present as unknown, never as empty (which would
|
|
/// read as "you were granted nothing").
|
|
fn scope_from_access_token(jwt: &str) -> Option<String> {
|
|
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
|
|
use base64::Engine;
|
|
|
|
let payload_b64 = jwt.split('.').nth(1)?;
|
|
let bytes = URL_SAFE_NO_PAD.decode(payload_b64).ok()?;
|
|
let claims: serde_json::Value = serde_json::from_slice(&bytes).ok()?;
|
|
claims
|
|
.get("scope")?
|
|
.as_str()
|
|
.filter(|s| !s.is_empty())
|
|
.map(str::to_owned)
|
|
}
|
|
|
|
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");
|
|
|
|
// Create with 0600 ALREADY SET, rather than write-then-chmod.
|
|
//
|
|
// `fs::write` creates at `0666 & !umask` — 0644 on a default umask — so the
|
|
// refresh token was world-readable at a predictable path for the window
|
|
// between the write and the chmod. `save` runs on every silent refresh
|
|
// (REFRESH_SKEW_SECS against a 15-minute token), so that window recurred
|
|
// every few minutes, and the refresh token is the highest-value credential
|
|
// here: identity rotates with reuse detection, so a thief who presents it
|
|
// first takes the session family and logs the real user out.
|
|
write_private(&tmp, &json).map_err(|source| StoreError::Unwritable {
|
|
path: tmp.clone(),
|
|
source,
|
|
})?;
|
|
std::fs::rename(&tmp, path).map_err(|source| StoreError::Unwritable {
|
|
path: path.to_path_buf(),
|
|
source,
|
|
})?;
|
|
Ok(())
|
|
}
|
|
|
|
/// Write `bytes` to a file that is never readable by anyone else, at any point.
|
|
///
|
|
/// `create_new` also means a pre-existing `.tmp` — a symlink planted by a local
|
|
/// attacker, or a leftover from a crash — is an error rather than a target.
|
|
#[cfg(unix)]
|
|
fn write_private(path: &Path, bytes: &[u8]) -> std::io::Result<()> {
|
|
use std::io::Write;
|
|
use std::os::unix::fs::OpenOptionsExt;
|
|
let _ = std::fs::remove_file(path); // clear our own leftover, not a race
|
|
let mut f = std::fs::OpenOptions::new()
|
|
.write(true)
|
|
.create_new(true)
|
|
.mode(0o600)
|
|
.open(path)?;
|
|
f.write_all(bytes)?;
|
|
f.sync_all()
|
|
}
|
|
|
|
#[cfg(not(unix))]
|
|
fn write_private(path: &Path, bytes: &[u8]) -> std::io::Result<()> {
|
|
std::fs::write(path, bytes)
|
|
}
|
|
|
|
#[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<StoredCredentials, StoreError> {
|
|
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<bool, StoreError> {
|
|
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,
|
|
}),
|
|
}
|
|
}
|
|
|
|
/// An advisory, cross-process exclusive lock on the credential file.
|
|
///
|
|
/// Unix only. On other platforms this is a no-op and the cross-process race
|
|
/// remains — stated rather than silently pretended, since a lock that does
|
|
/// nothing while claiming to protect is worse than none.
|
|
struct FileLock {
|
|
#[cfg(unix)]
|
|
file: std::fs::File,
|
|
}
|
|
|
|
impl FileLock {
|
|
/// `None` if another process holds it. Never blocks.
|
|
#[cfg(unix)]
|
|
fn try_acquire(credentials_path: &Path) -> Option<Self> {
|
|
use std::os::unix::io::AsRawFd;
|
|
let path = credentials_path.with_extension("lock");
|
|
if let Some(dir) = path.parent() {
|
|
let _ = std::fs::create_dir_all(dir);
|
|
}
|
|
let file = std::fs::OpenOptions::new()
|
|
.create(true)
|
|
.truncate(false)
|
|
.write(true)
|
|
.open(&path)
|
|
.ok()?;
|
|
// LOCK_EX | LOCK_NB
|
|
let rc = unsafe { libc::flock(file.as_raw_fd(), libc::LOCK_EX | libc::LOCK_NB) };
|
|
if rc == 0 {
|
|
Some(Self { file })
|
|
} else {
|
|
None
|
|
}
|
|
}
|
|
|
|
#[cfg(not(unix))]
|
|
fn try_acquire(_credentials_path: &Path) -> Option<Self> {
|
|
Some(Self {})
|
|
}
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
impl Drop for FileLock {
|
|
fn drop(&mut self) {
|
|
use std::os::unix::io::AsRawFd;
|
|
// Released on close anyway; explicit so the intent is legible.
|
|
unsafe { libc::flock(self.file.as_raw_fd(), libc::LOCK_UN) };
|
|
}
|
|
}
|
|
|
|
/// 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<Mutex<StoredCredentials>>,
|
|
}
|
|
|
|
impl Session {
|
|
pub fn load_from(path: PathBuf, http: reqwest::Client) -> Result<Self, StoreError> {
|
|
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<Self, StoreError> {
|
|
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<String, StoreError> {
|
|
let mut guard = self.inner.lock().await;
|
|
|
|
if !guard.needs_refresh() {
|
|
return Ok(guard.access_token.clone());
|
|
}
|
|
|
|
// Cross-process guard. Non-blocking on purpose: a busy lock means
|
|
// another process is mid-refresh, so the useful move is to wait for its
|
|
// result rather than race it with a token it is about to spend.
|
|
let _file_lock: Option<FileLock> = match FileLock::try_acquire(&self.path) {
|
|
Some(lock) => Some(lock),
|
|
None => {
|
|
for _ in 0..RELOAD_ATTEMPTS {
|
|
tokio::time::sleep(RELOAD_INTERVAL).await;
|
|
if let Ok(fresh) = load(&self.path) {
|
|
if !fresh.needs_refresh() {
|
|
let token = fresh.access_token.clone();
|
|
*guard = fresh;
|
|
return Ok(token);
|
|
}
|
|
}
|
|
}
|
|
// The other process died or is wedged. Fall through and refresh
|
|
// ourselves — the lock is advisory, not a correctness barrier.
|
|
tracing::warn!(
|
|
"another process held the credential lock without completing a refresh; \
|
|
proceeding"
|
|
);
|
|
None
|
|
}
|
|
};
|
|
|
|
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<i64>) -> 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());
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
#[test]
|
|
fn the_credential_lock_is_exclusive_and_non_blocking() {
|
|
// Guards the cross-process race: two CLI invocations inside the refresh
|
|
// window used to be able to present the same rotating refresh token,
|
|
// which identity treats as replay and answers by revoking the session.
|
|
let dir = std::env::temp_dir().join(format!("ruview-auth-lock-{}", std::process::id()));
|
|
let path = dir.join("credentials.json");
|
|
std::fs::create_dir_all(&dir).unwrap();
|
|
|
|
let first = FileLock::try_acquire(&path).expect("first acquire succeeds");
|
|
assert!(
|
|
FileLock::try_acquire(&path).is_none(),
|
|
"a second holder must be refused, and refused WITHOUT blocking"
|
|
);
|
|
drop(first);
|
|
assert!(
|
|
FileLock::try_acquire(&path).is_some(),
|
|
"the lock must be released on drop"
|
|
);
|
|
let _ = std::fs::remove_dir_all(&dir);
|
|
}
|
|
|
|
#[test]
|
|
fn redacted_debug_never_prints_token_material() {
|
|
// A derived Debug prints both tokens in full, and this is the obvious
|
|
// type to log when a session misbehaves.
|
|
// Distinctive values — an earlier version of this test used "at"/"rt",
|
|
// which collide with `expires_at` and produce a false failure.
|
|
let mut c = creds(Some(1));
|
|
c.access_token = "SECRET-ACCESS-VALUE".into();
|
|
c.refresh_token = Some("SECRET-REFRESH-VALUE".into());
|
|
let rendered = format!("{c:?}");
|
|
assert!(
|
|
!rendered.contains("SECRET-ACCESS-VALUE"),
|
|
"access token leaked: {rendered}"
|
|
);
|
|
assert!(
|
|
!rendered.contains("SECRET-REFRESH-VALUE"),
|
|
"refresh token leaked: {rendered}"
|
|
);
|
|
assert!(rendered.contains("<redacted>"));
|
|
// Non-secret fields stay visible or the type is useless for debugging.
|
|
assert!(rendered.contains("https://auth.test"));
|
|
}
|
|
|
|
#[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);
|
|
}
|
|
|
|
#[cfg(unix)]
|
|
#[test]
|
|
fn the_temp_file_is_never_world_readable_even_for_an_instant() {
|
|
// The test above checks the FINAL file. It passed while `save` wrote via
|
|
// `fs::write` (0644 under a default umask) and chmodded afterwards — so
|
|
// the refresh token sat world-readable at a predictable path in between,
|
|
// on every silent refresh. Asserting on the destination could never see
|
|
// that; this asserts on the temp file `save` actually creates.
|
|
use std::os::unix::fs::PermissionsExt;
|
|
let dir = std::env::temp_dir().join(format!("ruview-auth-tmpperm-{}", std::process::id()));
|
|
let path = dir.join("credentials.json");
|
|
let tmp = path.with_extension("tmp");
|
|
let _ = std::fs::remove_dir_all(&dir);
|
|
std::fs::create_dir_all(&dir).unwrap();
|
|
|
|
write_private(&tmp, b"secret").unwrap();
|
|
let mode = std::fs::metadata(&tmp).unwrap().permissions().mode() & 0o777;
|
|
assert_eq!(mode, 0o600, "temp file must be created 0600, got {mode:o}");
|
|
assert_eq!(mode & 0o077, 0, "group/other must have no access at all");
|
|
|
|
// A leftover from a crashed run is cleared and replaced, and the
|
|
// replacement is 0600 too — the mode must not be inherited from
|
|
// whatever was there before.
|
|
let _ = std::fs::set_permissions(&tmp, std::fs::Permissions::from_mode(0o666));
|
|
write_private(&tmp, b"replacement").unwrap();
|
|
let mode = std::fs::metadata(&tmp).unwrap().permissions().mode() & 0o777;
|
|
assert_eq!(mode, 0o600, "a replaced temp file must also be 0600, got {mode:o}");
|
|
assert_eq!(std::fs::read(&tmp).unwrap(), b"replacement", "must replace, not append");
|
|
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);
|
|
}
|
|
}
|
|
|
|
/// The scope-from-token fallback. Split out so its "display only, never an
|
|
/// authorization input" contract is pinned by name.
|
|
#[cfg(test)]
|
|
mod scope_display_tests {
|
|
use super::*;
|
|
use base64::engine::general_purpose::URL_SAFE_NO_PAD;
|
|
use base64::Engine;
|
|
|
|
fn jwt_with_payload(payload: serde_json::Value) -> String {
|
|
// Header and signature are irrelevant here — that is the whole point:
|
|
// this path never inspects them, so the test must not imply it does.
|
|
format!(
|
|
"eyJhbGciOiJFUzI1NiJ9.{}.not-a-real-signature",
|
|
URL_SAFE_NO_PAD.encode(serde_json::to_vec(&payload).unwrap())
|
|
)
|
|
}
|
|
|
|
#[test]
|
|
fn reads_the_scope_claim_when_the_envelope_omits_it() {
|
|
// The live behaviour that motivated this: identity's /oauth/token
|
|
// response carries no top-level `scope`, but the token does.
|
|
let t = jwt_with_payload(serde_json::json!({"scope": "sensing:read"}));
|
|
assert_eq!(scope_from_access_token(&t).as_deref(), Some("sensing:read"));
|
|
}
|
|
|
|
#[test]
|
|
fn reads_a_multi_scope_claim_intact() {
|
|
let t = jwt_with_payload(serde_json::json!({"scope": "sensing:read sensing:admin"}));
|
|
assert_eq!(
|
|
scope_from_access_token(&t).as_deref(),
|
|
Some("sensing:read sensing:admin")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn an_unparseable_token_reads_as_unknown_not_as_empty() {
|
|
// "" would render as "you were granted nothing", which is a different
|
|
// and wrong claim.
|
|
assert_eq!(scope_from_access_token("not-a-jwt"), None);
|
|
assert_eq!(scope_from_access_token(""), None);
|
|
assert_eq!(scope_from_access_token("a.!!!not-base64!!!.c"), None);
|
|
}
|
|
|
|
#[test]
|
|
fn an_empty_scope_claim_reads_as_unknown() {
|
|
let t = jwt_with_payload(serde_json::json!({"scope": ""}));
|
|
assert_eq!(scope_from_access_token(&t), None);
|
|
}
|
|
|
|
#[test]
|
|
fn a_token_with_no_scope_claim_reads_as_unknown() {
|
|
let t = jwt_with_payload(serde_json::json!({"sub": "u1"}));
|
|
assert_eq!(scope_from_access_token(&t), None);
|
|
}
|
|
|
|
#[test]
|
|
fn the_response_envelope_wins_when_it_does_carry_a_scope() {
|
|
let token = TokenResponse {
|
|
access_token: jwt_with_payload(serde_json::json!({"scope": "from:token"})),
|
|
token_type: None,
|
|
account_email: None,
|
|
refresh_token: None,
|
|
expires_in: Some(900),
|
|
scope: Some("from:envelope".into()),
|
|
};
|
|
let c = StoredCredentials::from_response(token, "https://auth.test".into());
|
|
assert_eq!(c.scope.as_deref(), Some("from:envelope"));
|
|
}
|
|
|
|
#[test]
|
|
fn the_token_claim_is_used_when_the_envelope_is_silent() {
|
|
let token = TokenResponse {
|
|
access_token: jwt_with_payload(serde_json::json!({"scope": "sensing:read"})),
|
|
token_type: None,
|
|
account_email: None,
|
|
refresh_token: None,
|
|
expires_in: Some(900),
|
|
scope: None,
|
|
};
|
|
let c = StoredCredentials::from_response(token, "https://auth.test".into());
|
|
assert_eq!(c.scope.as_deref(), Some("sensing:read"));
|
|
}
|
|
}
|