use crate::credential::providers;
use crate::gateway::proxy::BErr;
use crate::paths;
use crate::util::now_ms;
use serde_json::{json, Map, Value};
use std::collections::HashMap;
use std::fs::OpenOptions;
use std::io::Write;
use std::path::PathBuf;
use std::sync::{Arc, OnceLock};
pub type Credential = Map<String, Value>;
const REFRESH_SKEW_MS: i64 = 60_000;
pub fn vault_dir() -> PathBuf {
crate::paths::vault_dir()
}
pub fn get_credential(name: &str) -> Option<Credential> {
let path = vault_dir().join(format!("{name}.json"));
let text = std::fs::read_to_string(path).ok()?;
serde_json::from_str::<Value>(&text)
.ok()?
.as_object()
.cloned()
}
fn expires(cred: &Credential) -> Option<i64> {
cred.get("expires").and_then(|v| v.as_i64())
}
pub fn needs_refresh(cred: &Credential, now: i64) -> bool {
match expires(cred) {
Some(exp) => now >= exp - REFRESH_SKEW_MS,
None => false,
}
}
fn is_oauth(cred: &Credential) -> bool {
cred.get("type").and_then(|v| v.as_str()) == Some("oauth")
}
pub fn render_injection(cred: &Credential, provider_name: &str) -> Vec<(String, String)> {
let Some(p) = crate::credential::registry::provider(provider_name) else {
return Vec::new();
};
let mut out = Vec::new();
for h in &p.inject {
if let Some(value) = substitute(&h.value, cred) {
out.push((h.header.clone(), value));
}
}
out
}
fn substitute(template: &str, cred: &Credential) -> Option<String> {
let mut result = String::new();
let mut rest = template;
while let Some(open) = rest.find('{') {
result.push_str(&rest[..open]);
let close = rest[open..].find('}')? + open;
let field = &rest[open + 1..close];
result.push_str(cred.get(field).and_then(|v| v.as_str())?);
rest = &rest[close + 1..];
}
result.push_str(rest);
Some(result)
}
fn write_credential(name: &str, cred: &Credential) -> Result<(), BErr> {
let dir = vault_dir();
std::fs::create_dir_all(&dir)?;
let path = dir.join(format!("{name}.json"));
let tmp = dir.join(format!("{name}.json.tmp"));
std::fs::write(&tmp, format!("{}\n", serde_json::to_string_pretty(cred)?))?;
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
std::fs::set_permissions(&tmp, std::fs::Permissions::from_mode(0o600))?;
}
std::fs::rename(&tmp, &path)?;
Ok(())
}
fn log_refresh(event: Value) {
let path = paths::credentials_log();
if let Some(dir) = path.parent() {
let _ = std::fs::create_dir_all(dir);
}
if let Ok(mut f) = OpenOptions::new().create(true).append(true).open(&path) {
let _ = writeln!(f, "{event}");
}
}
fn name_lock(name: &str) -> Arc<tokio::sync::Mutex<()>> {
static LOCKS: OnceLock<std::sync::Mutex<HashMap<String, Arc<tokio::sync::Mutex<()>>>>> =
OnceLock::new();
let map = LOCKS.get_or_init(|| std::sync::Mutex::new(HashMap::new()));
let mut m = map.lock().unwrap();
m.entry(name.to_string())
.or_insert_with(|| Arc::new(tokio::sync::Mutex::new(())))
.clone()
}
pub async fn get_fresh_credential(name: &str) -> Result<Option<Credential>, BErr> {
let cred = match get_credential(name) {
Some(c) => c,
None => return Ok(None),
};
if !is_oauth(&cred)
|| cred.get("refresh").and_then(|v| v.as_str()).is_none()
|| !needs_refresh(&cred, now_ms())
{
return Ok(Some(cred));
}
let lock = name_lock(name);
let _guard = lock.lock().await;
let cred = get_credential(name).unwrap_or(cred);
if !needs_refresh(&cred, now_ms()) {
return Ok(Some(cred));
}
let refresh_token = cred
.get("refresh")
.and_then(|v| v.as_str())
.unwrap_or_default()
.to_string();
match providers::refresh(name, &refresh_token).await {
Ok(fresh) => {
let mut merged = cred.clone();
merged.insert("access".into(), json!(fresh.access));
merged.insert("refresh".into(), json!(fresh.refresh));
merged.insert("expires".into(), json!(fresh.expires));
write_credential(name, &merged)?;
log_refresh(
json!({"ts": crate::util::now_rfc3339(), "event":"refresh", "credential": name, "ok": true, "expires": fresh.expires}),
);
Ok(Some(merged))
}
Err(e) => {
log_refresh(
json!({"ts": crate::util::now_rfc3339(), "event":"refresh", "credential": name, "ok": false, "error": e.to_string()}),
);
Err(e)
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn oauth(expires: i64) -> Credential {
serde_json::json!({"type":"oauth","access":"a","refresh":"r","expires":expires})
.as_object()
.unwrap()
.clone()
}
#[test]
fn needs_refresh_semantics() {
assert!(needs_refresh(&oauth(now_ms() - 1000), now_ms()));
assert!(!needs_refresh(&oauth(now_ms() + 3_600_000), now_ms()));
assert!(needs_refresh(&oauth(now_ms() + 30_000), now_ms())); let no_exp = serde_json::json!({"type":"oauth","access":"a","refresh":"r"})
.as_object()
.unwrap()
.clone();
assert!(!needs_refresh(&no_exp, now_ms()));
}
#[test]
fn substitute_fills_fields_and_skips_missing() {
let cred = serde_json::json!({"access":"tok","accountId":"acc","key":"sk-1"})
.as_object()
.unwrap()
.clone();
assert_eq!(
substitute("Bearer {access}", &cred),
Some("Bearer tok".to_string())
);
assert_eq!(substitute("{accountId}", &cred), Some("acc".to_string()));
assert_eq!(substitute("{key}", &cred), Some("sk-1".to_string()));
assert_eq!(substitute("Bearer {missing}", &cred), None); }
}