fix(auth): allow anonymous read-only API access
Match the Python client for read-only commands by falling back to anonymous API requests when no valid login cache is available. Keep mutating commands login-only while letting ls, labs, metadata, and file-metadata resolve laboratories and folders without a cached token. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
Vendored
+169
-4
@@ -185,6 +185,43 @@ fn load_cache_from_dir(remote: &str, config_dir: &Path) -> Result<Cache, anyhow:
|
||||
Ok(cache)
|
||||
}
|
||||
|
||||
fn load_cache_if_present_from_dir(
|
||||
remote: &str,
|
||||
config_dir: &Path,
|
||||
) -> Result<Option<Cache>, anyhow::Error> {
|
||||
let cache_path = cache_file_path_in(config_dir, remote);
|
||||
let snapshot = match read_cache_snapshot(&cache_path) {
|
||||
Ok(snapshot) => snapshot,
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
|
||||
invalidate_cached_entry(config_dir, remote);
|
||||
return Ok(None);
|
||||
}
|
||||
Err(e) => return Err(e.into()),
|
||||
};
|
||||
|
||||
if let Some(cache) = cached_entry(config_dir, remote, &snapshot) {
|
||||
return Ok(Some(cache));
|
||||
}
|
||||
|
||||
let data = match fs::read_to_string(&cache_path) {
|
||||
Ok(data) => data,
|
||||
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
|
||||
invalidate_cached_entry(config_dir, remote);
|
||||
return Ok(None);
|
||||
}
|
||||
Err(e) => return Err(e.into()),
|
||||
};
|
||||
let cache = match parse_cache(remote, &data) {
|
||||
Ok(cache) => cache,
|
||||
Err(_) => {
|
||||
remove_cache_in_dir(remote, config_dir)?;
|
||||
return Ok(None);
|
||||
}
|
||||
};
|
||||
update_cached_entry(config_dir, remote, snapshot, cache.clone());
|
||||
Ok(Some(cache))
|
||||
}
|
||||
|
||||
fn persist_cache_in_dir(
|
||||
remote: &str,
|
||||
config_dir: &Path,
|
||||
@@ -247,6 +284,7 @@ pub async fn load_cache_with_token_refresh(remote: &str) -> Result<Cache, anyhow
|
||||
let lock = get_remote_lock(remote);
|
||||
let _guard = lock.lock().await;
|
||||
|
||||
ensure_cache_dir(&cache_dir_path(&crate::settings::SETTINGS.config_dirname))?;
|
||||
let lock_path = cache_file_path(remote).with_extension("lock");
|
||||
use fs2::FileExt;
|
||||
let lock_file = fs::OpenOptions::new()
|
||||
@@ -280,9 +318,71 @@ pub async fn load_cache_with_token_refresh(remote: &str) -> Result<Cache, anyhow
|
||||
result
|
||||
}
|
||||
|
||||
async fn load_cache_with_token_refresh_optional_from_dir(
|
||||
remote: &str,
|
||||
config_dir: &Path,
|
||||
) -> Result<Option<Cache>, anyhow::Error> {
|
||||
let lock = get_remote_lock(remote);
|
||||
let _guard = lock.lock().await;
|
||||
|
||||
ensure_cache_dir(&cache_dir_path(config_dir))?;
|
||||
let lock_path = cache_file_path_in(config_dir, remote).with_extension("lock");
|
||||
use fs2::FileExt;
|
||||
let lock_file = fs::OpenOptions::new()
|
||||
.write(true)
|
||||
.create(true)
|
||||
.open(&lock_path)?;
|
||||
lock_file.lock_exclusive()?;
|
||||
|
||||
let result: Result<Option<Cache>, anyhow::Error> = async {
|
||||
let Some(mut cache) = load_cache_if_present_from_dir(remote, config_dir)? else {
|
||||
return Ok(None);
|
||||
};
|
||||
|
||||
if crate::token::is_expired(&cache.token.refresh) {
|
||||
remove_cache_in_dir(remote, config_dir)?;
|
||||
return Ok(None);
|
||||
}
|
||||
|
||||
if crate::token::is_refresh_required(&cache.token.access, &cache.token.refresh) {
|
||||
cache = refresh_and_persist_in_dir(remote, config_dir, &cache).await?;
|
||||
}
|
||||
|
||||
Ok(Some(cache))
|
||||
}
|
||||
.await;
|
||||
|
||||
lock_file.unlock()?;
|
||||
result
|
||||
}
|
||||
|
||||
/// Load cache when present and refresh its token if needed.
|
||||
///
|
||||
/// Unlike `load_cache_with_token_refresh`, this returns `Ok(None)` when the user
|
||||
/// is effectively anonymous: no cache file exists, the cache is invalid, or the
|
||||
/// refresh token has already expired. This mirrors the Python client behavior
|
||||
/// used by read-only commands.
|
||||
pub async fn load_cache_with_token_refresh_optional(
|
||||
remote: &str,
|
||||
) -> Result<Option<Cache>, anyhow::Error> {
|
||||
load_cache_with_token_refresh_optional_from_dir(
|
||||
remote,
|
||||
&crate::settings::SETTINGS.config_dirname,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Call the token-refresh endpoint and write the new access token back to the
|
||||
/// cache file. The caller must already hold the per-remote async mutex.
|
||||
async fn refresh_and_persist(remote: &str, cache: &Cache) -> Result<Cache, anyhow::Error> {
|
||||
refresh_and_persist_in_dir(remote, &crate::settings::SETTINGS.config_dirname, cache).await
|
||||
}
|
||||
|
||||
async fn refresh_and_persist_in_dir(
|
||||
remote: &str,
|
||||
config_dir: &Path,
|
||||
cache: &Cache,
|
||||
) -> Result<Cache, anyhow::Error> {
|
||||
let url = crate::commands::config::get_remote_url(remote)?
|
||||
.ok_or_else(|| anyhow!("Remote `{}` is not configured.", remote))?;
|
||||
let conn = MDRSConnection::new(&url);
|
||||
@@ -298,7 +398,7 @@ async fn refresh_and_persist(remote: &str, cache: &Cache) -> Result<Cache, anyho
|
||||
&updated_cache.laboratories,
|
||||
);
|
||||
|
||||
persist_cache(remote, &updated_cache)?;
|
||||
persist_cache_in_dir(remote, config_dir, &updated_cache)?;
|
||||
|
||||
Ok(updated_cache)
|
||||
}
|
||||
@@ -312,11 +412,26 @@ pub fn create_authenticated_conn(
|
||||
remote: &str,
|
||||
cache: &Cache,
|
||||
) -> Result<MDRSConnection, anyhow::Error> {
|
||||
Ok(create_remote_conn(remote)?.with_token(cache.token.access.clone()))
|
||||
}
|
||||
|
||||
/// Create an unauthenticated `MDRSConnection` for the given remote label.
|
||||
pub fn create_remote_conn(remote: &str) -> Result<MDRSConnection, anyhow::Error> {
|
||||
let url = crate::commands::config::get_remote_url(remote)?
|
||||
.ok_or_else(|| anyhow!("Remote `{}` is not configured.", remote))?;
|
||||
Ok(MDRSConnection::new(&url)
|
||||
.with_remote(remote)
|
||||
.with_token(cache.token.access.clone()))
|
||||
Ok(MDRSConnection::new(&url).with_remote(remote))
|
||||
}
|
||||
|
||||
/// Create a connection for read-only commands, attaching a bearer token only
|
||||
/// when a valid login cache is available.
|
||||
pub async fn create_readonly_conn(
|
||||
remote: &str,
|
||||
) -> Result<(MDRSConnection, Option<Cache>), anyhow::Error> {
|
||||
let conn = create_remote_conn(remote)?;
|
||||
match load_cache_with_token_refresh_optional(remote).await? {
|
||||
Some(cache) => Ok((conn.with_token(cache.token.access.clone()), Some(cache))),
|
||||
None => Ok((conn, None)),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -438,4 +553,54 @@ mod tests {
|
||||
.contains(&format!("Not logged in to `{remote}`"))
|
||||
);
|
||||
}
|
||||
|
||||
fn make_jwt_with_exp(exp: i64) -> String {
|
||||
use base64::{Engine, engine::general_purpose::URL_SAFE_NO_PAD};
|
||||
|
||||
let header = URL_SAFE_NO_PAD.encode(r#"{"alg":"none","typ":"JWT"}"#);
|
||||
let payload = URL_SAFE_NO_PAD.encode(format!(r#"{{"exp":{exp}}}"#));
|
||||
format!("{header}.{payload}.")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_cache_if_present_returns_none_when_cache_missing() {
|
||||
let dir = tempdir().unwrap();
|
||||
let remote = remote_name("missing", dir.path());
|
||||
|
||||
let loaded = load_cache_if_present_from_dir(&remote, dir.path()).unwrap();
|
||||
|
||||
assert!(loaded.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn load_cache_if_present_clears_invalid_cache() {
|
||||
let dir = tempdir().unwrap();
|
||||
let remote = remote_name("invalid", dir.path());
|
||||
let cache_dir = cache_dir_path(dir.path());
|
||||
ensure_cache_dir(&cache_dir).unwrap();
|
||||
let cache_path = cache_file_path_in(dir.path(), &remote);
|
||||
fs::write(&cache_path, b"{invalid json").unwrap();
|
||||
|
||||
let loaded = load_cache_if_present_from_dir(&remote, dir.path()).unwrap();
|
||||
|
||||
assert!(loaded.is_none());
|
||||
assert!(!cache_path.exists());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn optional_cache_load_treats_expired_session_as_anonymous() {
|
||||
let dir = tempdir().unwrap();
|
||||
let remote = remote_name("expired", dir.path());
|
||||
let mut cache = sample_cache("alice");
|
||||
cache.token.access = make_jwt_with_exp(0);
|
||||
cache.token.refresh = make_jwt_with_exp(0);
|
||||
persist_cache_in_dir(&remote, dir.path(), &cache).unwrap();
|
||||
|
||||
let loaded = load_cache_with_token_refresh_optional_from_dir(&remote, dir.path())
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert!(loaded.is_none());
|
||||
assert!(!cache_file_path_in(dir.path(), &remote).exists());
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user