Files
ruvnet--RuView/v2/crates/ruview-auth/src/login/store.rs
T
rUv 2e018f4f19 feat(ruview-unified): Unified RF spatial world model — ADR-273..282 (#1437)
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
2026-07-26 14:37:56 -07:00

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