diff --git a/v2/Cargo.lock b/v2/Cargo.lock index 0b219542..370fc26b 100644 --- a/v2/Cargo.lock +++ b/v2/Cargo.lock @@ -11060,6 +11060,7 @@ dependencies = [ "ndarray 0.17.2", "num-complex", "predicates", + "reqwest 0.12.28", "ruview-auth", "serde", "serde_json", diff --git a/v2/crates/ruview-auth/src/login/store.rs b/v2/crates/ruview-auth/src/login/store.rs index c7990365..ac9d2b62 100644 --- a/v2/crates/ruview-auth/src/login/store.rs +++ b/v2/crates/ruview-auth/src/login/store.rs @@ -93,6 +93,20 @@ impl StoredCredentials { } } + /// 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 { + 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 diff --git a/v2/crates/wifi-densepose-cli/Cargo.toml b/v2/crates/wifi-densepose-cli/Cargo.toml index e87e0e9d..f5082939 100644 --- a/v2/crates/wifi-densepose-cli/Cargo.toml +++ b/v2/crates/wifi-densepose-cli/Cargo.toml @@ -62,6 +62,8 @@ anyhow = "1.0" # the sensing server depends on this same crate with default features and # gets only the verifier. ruview-auth = { path = "../ruview-auth", features = ["login"] } +# Only for constructing the HTTP client hands to Session. +reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] } thiserror = "2.0" # Time diff --git a/v2/crates/wifi-densepose-cli/src/auth.rs b/v2/crates/wifi-densepose-cli/src/auth.rs index 03cecb4c..080cb6c2 100644 --- a/v2/crates/wifi-densepose-cli/src/auth.rs +++ b/v2/crates/wifi-densepose-cli/src/auth.rs @@ -45,6 +45,15 @@ pub struct LogoutArgs { pub struct WhoamiArgs { #[arg(long, env = ruview_auth::login::CREDENTIALS_PATH_ENV)] pub credentials_path: Option, + + /// Refresh the access token now if it has expired, instead of only + /// reporting that it will be refreshed on next use. + /// + /// Refreshing rotates the stored refresh token — identity spends the old + /// one — so this is a real state change, not a read. That is why it is a + /// flag rather than something `whoami` does silently. + #[arg(long)] + pub refresh: bool, } fn path_or_default(p: Option) -> PathBuf { @@ -89,7 +98,18 @@ pub async fn logout_cmd(args: LogoutArgs) -> anyhow::Result<()> { 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)?; + let mut creds = ruview_auth::login::store::load(&path)?; + + if args.refresh && creds.needs_refresh() { + println!("Access token expired — refreshing…"); + let session = ruview_auth::login::Session::load_from(path.clone(), reqwest::Client::new())?; + // Goes through ensure_fresh, so it inherits the single-flight guarantee + // and the persist-before-return ordering rather than reimplementing a + // second, subtly different refresh path. + session.ensure_fresh().await?; + creds = session.snapshot().await; + println!("Refreshed.\n"); + } println!("Credentials: {}", path.display()); println!("Issuer: {}", creds.issuer); @@ -97,7 +117,9 @@ pub async fn whoami_cmd(args: WhoamiArgs) -> anyhow::Result<()> { Some(e) => println!("Account: {e}"), None => println!("Account: (not reported)"), } - match &creds.scope { + // Falls back to the token's own claim, so a file written before that + // fallback existed still reports its real scope. + match creds.effective_scope() { Some(s) => println!("Scope: {s}"), None => println!("Scope: (not reported)"), } @@ -105,7 +127,7 @@ pub async fn whoami_cmd(args: WhoamiArgs) -> anyhow::Result<()> { // 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"); + println!("Status: access token expired or expiring — pass --refresh to renew it now"); } else { println!("Status: access token valid"); }