use std::path::PathBuf;
use anyhow::{bail, Context, Result};
const SERVICE: &str = "app.portaki.cli";
const ACCESS_ENTRY: &str = "access-token";
const REFRESH_ENTRY: &str = "refresh-token";
pub fn explicit_token() -> Option<String> {
std::env::var("PORTAKI_DEV_TOKEN")
.ok()
.map(|token| token.trim().to_string())
.filter(|token| !token.is_empty())
}
pub fn access_token() -> Result<String> {
if let Some(token) = explicit_token() {
return Ok(token);
}
match read(ACCESS_ENTRY) {
Ok(Some(token)) => Ok(token),
Ok(None) => bail!("not signed in — run `portaki login`"),
Err(failure) => Err(failure),
}
}
pub async fn refresh() -> Result<String> {
let refresh_token = match read(REFRESH_ENTRY)? {
Some(token) => token,
None => bail!("no refresh token stored — run `portaki login`"),
};
let response = reqwest::Client::new()
.post(format!("{}/api/v1/auth/refresh", api_base_url(None)))
.json(&serde_json::json!({ "refreshToken": refresh_token }))
.send()
.await
.context("renew the access token")?;
let body = response.text().await.unwrap_or_default();
let renewed: RenewedTokens = crate::api::unwrap(&body)?;
store(&renewed.access_token, &renewed.refresh_token)?;
Ok(renewed.access_token)
}
#[derive(Debug, serde::Deserialize)]
#[serde(rename_all = "camelCase")]
struct RenewedTokens {
access_token: String,
refresh_token: String,
}
pub fn api_base_url(explicit: Option<&str>) -> String {
resolve_base_url(explicit, std::env::var("PORTAKI_API_URL").ok().as_deref())
}
fn resolve_base_url(explicit: Option<&str>, from_env: Option<&str>) -> String {
[explicit, from_env]
.into_iter()
.flatten()
.map(str::trim)
.find(|value| !value.is_empty())
.unwrap_or("https://api.portaki.app")
.trim_end_matches('/')
.to_string()
}
pub fn store(access_token: &str, refresh_token: &str) -> Result<()> {
write(ACCESS_ENTRY, access_token)?;
write(REFRESH_ENTRY, refresh_token)
}
pub fn refresh_token() -> Option<String> {
read(REFRESH_ENTRY).ok().flatten()
}
pub fn forget() -> Result<()> {
delete(ACCESS_ENTRY)?;
delete(REFRESH_ENTRY)
}
fn uses_keychain() -> bool {
std::env::var("PORTAKI_CREDENTIALS")
.map(|choice| choice.trim().eq_ignore_ascii_case("keychain"))
.unwrap_or(false)
}
fn credentials_path() -> Result<PathBuf> {
if let Ok(explicit) = std::env::var("PORTAKI_CREDENTIALS_FILE") {
if !explicit.trim().is_empty() {
return Ok(PathBuf::from(explicit));
}
}
Ok(config_dir()?.join("credentials.json"))
}
pub fn config_dir() -> Result<PathBuf> {
let base = match std::env::var("XDG_CONFIG_HOME") {
Ok(xdg) if !xdg.trim().is_empty() => PathBuf::from(xdg),
_ => {
let home = std::env::var("HOME").context("locate the home directory")?;
PathBuf::from(home).join(".config")
}
};
Ok(base.join("portaki"))
}
#[derive(Default, serde::Serialize, serde::Deserialize)]
#[serde(rename_all = "camelCase")]
struct StoredCredentials {
#[serde(default)]
access_token: String,
#[serde(default)]
refresh_token: String,
}
fn load() -> Result<StoredCredentials> {
load_from(&credentials_path()?)
}
fn load_from(path: &std::path::Path) -> Result<StoredCredentials> {
match std::fs::read_to_string(path) {
Ok(raw) => serde_json::from_str(&raw).with_context(|| {
format!(
"parse {} — delete it and run `portaki login`",
path.display()
)
}),
Err(missing) if missing.kind() == std::io::ErrorKind::NotFound => {
Ok(StoredCredentials::default())
}
Err(failure) => Err(failure).with_context(|| format!("read {}", path.display())),
}
}
fn save(credentials: &StoredCredentials) -> Result<()> {
save_to(&credentials_path()?, credentials)
}
fn save_to(path: &std::path::Path, credentials: &StoredCredentials) -> Result<()> {
let parent = path.parent().context("credentials directory")?;
std::fs::create_dir_all(parent).with_context(|| format!("create {}", parent.display()))?;
restrict(parent, 0o700)?;
let temporary = path.with_extension("json.tmp");
let body = serde_json::to_string_pretty(credentials).context("serialise credentials")?;
std::fs::write(&temporary, body).with_context(|| format!("write {}", temporary.display()))?;
restrict(&temporary, 0o600)?;
std::fs::rename(&temporary, path).with_context(|| format!("write {}", path.display()))
}
#[cfg(unix)]
fn restrict(path: &std::path::Path, mode: u32) -> Result<()> {
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(path, std::fs::Permissions::from_mode(mode))
.with_context(|| format!("restrict {}", path.display()))
}
#[cfg(not(unix))]
fn restrict(_path: &std::path::Path, _mode: u32) -> Result<()> {
Ok(())
}
fn entry(name: &str) -> Result<keyring::Entry> {
keyring::Entry::new(SERVICE, name).context("open the system keychain")
}
fn read(name: &str) -> Result<Option<String>> {
if !uses_keychain() {
let stored = load()?;
let value = if name == ACCESS_ENTRY {
stored.access_token
} else {
stored.refresh_token
};
return Ok(if value.trim().is_empty() {
None
} else {
Some(value)
});
}
match entry(name)?.get_password() {
Ok(value) => Ok(Some(value)),
Err(keyring::Error::NoEntry) => Ok(None),
Err(failure) => Err(failure).context("read from the system keychain"),
}
}
fn write(name: &str, value: &str) -> Result<()> {
if !uses_keychain() {
let mut stored = load()?;
if name == ACCESS_ENTRY {
stored.access_token = value.to_string();
} else {
stored.refresh_token = value.to_string();
}
return save(&stored);
}
entry(name)?
.set_password(value)
.context("write to the system keychain")
}
fn delete(name: &str) -> Result<()> {
if !uses_keychain() {
let path = credentials_path()?;
return match std::fs::remove_file(&path) {
Ok(()) => Ok(()),
Err(missing) if missing.kind() == std::io::ErrorKind::NotFound => Ok(()),
Err(failure) => Err(failure).with_context(|| format!("remove {}", path.display())),
};
}
match entry(name)?.delete_credential() {
Ok(()) | Err(keyring::Error::NoEntry) => Ok(()),
Err(failure) => Err(failure).context("clear the system keychain"),
}
}
#[cfg(test)]
mod tests {
use super::*;
const PROD: &str = "https://api.portaki.app";
#[test]
fn an_exported_but_empty_variable_means_unset() {
assert_eq!(resolve_base_url(None, Some("")), PROD);
assert_eq!(resolve_base_url(None, Some(" ")), PROD);
assert_eq!(resolve_base_url(Some(""), None), PROD);
}
#[test]
fn the_flag_wins_over_the_environment() {
assert_eq!(
resolve_base_url(
Some("https://explicit.example"),
Some("https://env.example")
),
"https://explicit.example"
);
}
#[test]
fn a_trailing_slash_never_doubles() {
assert_eq!(
resolve_base_url(None, Some("https://api.example/")),
"https://api.example"
);
}
#[test]
fn nothing_set_means_production() {
assert_eq!(resolve_base_url(None, None), PROD);
}
#[test]
fn credentials_round_trip_through_the_file() {
let directory = tempfile::tempdir().unwrap();
let path = directory.path().join("portaki").join("credentials.json");
let empty = load_from(&path).unwrap();
assert!(empty.access_token.is_empty());
save_to(
&path,
&StoredCredentials {
access_token: "acces".into(),
refresh_token: "renouvellement".into(),
},
)
.unwrap();
let stored = load_from(&path).unwrap();
assert_eq!(stored.access_token, "acces");
assert_eq!(stored.refresh_token, "renouvellement");
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mode = std::fs::metadata(&path).unwrap().permissions().mode() & 0o777;
assert_eq!(mode, 0o600, "le fichier de secrets doit être en 0600");
let parent = std::fs::metadata(path.parent().unwrap()).unwrap();
assert_eq!(parent.permissions().mode() & 0o777, 0o700);
}
}
#[test]
fn a_corrupt_file_says_what_to_do() {
let directory = tempfile::tempdir().unwrap();
let path = directory.path().join("credentials.json");
std::fs::write(&path, "{ pas du json").unwrap();
let failure = match load_from(&path) {
Ok(_) => panic!("un fichier illisible ne doit pas passer pour vide"),
Err(failure) => failure.to_string(),
};
assert!(failure.contains("portaki login"), "{failure}");
}
#[test]
fn the_default_path_is_outside_any_repository() {
let resolved = credentials_path().unwrap();
assert!(
resolved.ends_with("portaki/credentials.json"),
"{resolved:?}"
);
assert!(resolved.is_absolute(), "{resolved:?}");
}
#[test]
fn the_environment_is_the_way_in_when_there_is_no_keychain() {
std::env::set_var("PORTAKI_DEV_TOKEN", "injected");
assert_eq!(access_token().unwrap(), "injected");
std::env::set_var("PORTAKI_DEV_TOKEN", " ");
match access_token() {
Ok(from_keychain) => assert!(
!from_keychain.trim().is_empty(),
"une variable blanche ne doit pas devenir un jeton"
),
Err(failure) => {
let message = failure.to_string();
assert!(
message.contains("portaki login") || message.contains("keychain"),
"{message}"
);
}
}
std::env::remove_var("PORTAKI_DEV_TOKEN");
}
}