use crate::RuntimeError;
use std::io::Write as _;
const SESSION_FILE: &str = "session.json";
pub(super) struct StoredCredentials {
pub(super) access_token: String,
pub(super) refresh_token: String,
origin: String,
}
pub(super) struct CredentialStoreLock {
_file: std::fs::File,
auth_dir: std::path::PathBuf,
}
impl CredentialStoreLock {
pub(super) fn exclusive(home: &std::path::Path) -> Result<Self, RuntimeError> {
let auth_dir = auth_dir(home);
ensure_auth_dir(auth_dir.as_path())?;
let file = open_lock_file(auth_dir.as_path())?;
fs2::FileExt::lock_exclusive(&file)?;
Ok(Self {
_file: file,
auth_dir,
})
}
pub(super) fn exclusive_if_present(
home: &std::path::Path,
) -> Result<Option<Self>, RuntimeError> {
if !auth_dir(home).is_dir() {
return Ok(None);
}
Self::exclusive(home).map(Some)
}
pub(super) fn read_credentials(
&self,
expected_origin: &str,
) -> Result<Option<StoredCredentials>, RuntimeError> {
let credentials = read_session(self.auth_dir.as_path())?;
if let Some(credentials) = &credentials {
ensure_matching_origin(credentials.origin.as_str(), expected_origin)?;
}
Ok(credentials)
}
pub(super) fn has_refresh_backed_session(&self) -> Result<bool, RuntimeError> {
Ok(self.auth_dir.join(SESSION_FILE).try_exists()?)
}
pub(super) fn persist(
&self,
access_token: &str,
refresh_token: &str,
origin: &str,
) -> Result<(), RuntimeError> {
let session = serde_json::json!({
"access_token": access_token.trim(),
"refresh_token": refresh_token.trim(),
"origin": origin.trim(),
});
write_secret_atomically(
self.auth_dir.as_path(),
SESSION_FILE,
session.to_string().as_str(),
)
}
pub(super) fn remove_credentials(&self) -> Result<bool, RuntimeError> {
let session_removed = remove_if_present(self.auth_dir.join(SESSION_FILE).as_path())?;
let access_removed = remove_if_present(self.auth_dir.join("token").as_path())?;
let refresh_removed = remove_if_present(self.auth_dir.join("refresh-token").as_path())?;
let origin_removed = remove_if_present(self.auth_dir.join("auth-origin").as_path())?;
Ok(session_removed || access_removed || refresh_removed || origin_removed)
}
}
pub(super) fn read_access_token(
home: &std::path::Path,
expected_origin: &str,
) -> Result<String, RuntimeError> {
let auth_dir = auth_dir(home);
if !auth_dir.is_dir() {
return Err(RuntimeError::MissingToken);
}
ensure_auth_dir(auth_dir.as_path())?;
let lock_file = open_lock_file(auth_dir.as_path())?;
fs2::FileExt::lock_shared(&lock_file)?;
if let Some(credentials) = read_session(auth_dir.as_path())? {
ensure_matching_origin(credentials.origin.as_str(), expected_origin)?;
return Ok(credentials.access_token);
}
let access_token = read_secret(auth_dir.join("token").as_path())?;
let refresh_token = read_secret(auth_dir.join("refresh-token").as_path())?;
let origin = read_secret(auth_dir.join("auth-origin").as_path())?;
if refresh_token.is_some() || origin.is_some() {
return Err(unbound_refresh_credentials());
}
access_token.ok_or(RuntimeError::MissingToken)
}
fn auth_dir(home: &std::path::Path) -> std::path::PathBuf {
home.join(".logbrew")
}
fn ensure_auth_dir(path: &std::path::Path) -> Result<(), RuntimeError> {
std::fs::create_dir_all(path)?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt as _;
std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o700))?;
}
Ok(())
}
fn open_lock_file(auth_dir: &std::path::Path) -> Result<std::fs::File, RuntimeError> {
let mut options = std::fs::OpenOptions::new();
let _options = options.read(true).write(true).create(true);
#[cfg(unix)]
{
use std::os::unix::fs::OpenOptionsExt as _;
let _options = options.mode(0o600);
}
let file = options.open(auth_dir.join("credentials.lock"))?;
secure_file_permissions(&file)?;
Ok(file)
}
fn read_secret(path: &std::path::Path) -> Result<Option<String>, RuntimeError> {
let secret = match std::fs::read_to_string(path) {
Ok(secret) => secret,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
Err(error) => return Err(RuntimeError::Io(error)),
};
let secret = secret.trim();
secure_path_permissions(path)?;
Ok((!secret.is_empty()).then(|| secret.to_owned()))
}
fn read_session(auth_dir: &std::path::Path) -> Result<Option<StoredCredentials>, RuntimeError> {
let Some(session) = read_secret(auth_dir.join(SESSION_FILE).as_path())? else {
return Ok(None);
};
let value = serde_json::from_str::<serde_json::Value>(session.as_str())
.map_err(|_| invalid_credentials())?;
let Some(object) = value.as_object().filter(|object| object.len() == 3) else {
return Err(invalid_credentials());
};
let access_token = session_field(object, "access_token")?;
let refresh_token = session_field(object, "refresh_token")?;
let origin = session_field(object, "origin")?;
Ok(Some(StoredCredentials {
access_token,
refresh_token,
origin,
}))
}
fn session_field(
object: &serde_json::Map<String, serde_json::Value>,
field: &str,
) -> Result<String, RuntimeError> {
object
.get(field)
.and_then(serde_json::Value::as_str)
.map(str::trim)
.filter(|value| !value.is_empty())
.map(ToOwned::to_owned)
.ok_or_else(invalid_credentials)
}
fn secure_file_permissions(file: &std::fs::File) -> Result<(), RuntimeError> {
#[cfg(not(unix))]
let _ = file;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt as _;
file.set_permissions(std::fs::Permissions::from_mode(0o600))?;
}
Ok(())
}
fn secure_path_permissions(path: &std::path::Path) -> Result<(), RuntimeError> {
#[cfg(not(unix))]
let _ = path;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt as _;
std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600))?;
}
Ok(())
}
fn write_secret_atomically(
auth_dir: &std::path::Path,
name: &str,
secret: &str,
) -> Result<(), RuntimeError> {
if secret.trim().is_empty() {
return Err(invalid_credentials());
}
let mut file = atomic_write_file::AtomicWriteFile::open(auth_dir.join(name))?;
secure_file_permissions(file.as_file())?;
writeln!(file, "{}", secret.trim())?;
file.commit()?;
Ok(())
}
fn remove_if_present(path: &std::path::Path) -> Result<bool, RuntimeError> {
match std::fs::remove_file(path) {
Ok(()) => Ok(true),
Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(false),
Err(error) => Err(RuntimeError::Io(error)),
}
}
const fn invalid_credentials() -> RuntimeError {
RuntimeError::Unavailable {
message: "authentication credentials were invalid",
next: "run logbrew login",
}
}
fn ensure_matching_origin(origin: &str, expected_origin: &str) -> Result<(), RuntimeError> {
if origin == expected_origin {
Ok(())
} else {
Err(origin_mismatch())
}
}
const fn origin_mismatch() -> RuntimeError {
RuntimeError::Unavailable {
message: "local authentication belongs to a different API",
next: "run logbrew login for the configured API",
}
}
const fn unbound_refresh_credentials() -> RuntimeError {
RuntimeError::Unavailable {
message: "local refresh credentials are missing API ownership",
next: "run logbrew login",
}
}