greentic_distributor_client/
store_auth.rs1use greentic_secrets_lib::{DevStore, SecretFormat, SecretsStore};
2use serde::{Deserialize, Serialize};
3use std::fs;
4use std::path::{Path, PathBuf};
5
6#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
7pub struct StoreCredentials {
8 pub tenant: String,
9 pub username: String,
10 pub token: String,
11}
12
13#[derive(Clone, Debug, PartialEq, Eq)]
14pub struct StoreAuth {
15 auth_path: PathBuf,
16 state_path: PathBuf,
17}
18
19#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
20struct StoreAuthState {
21 logins: Vec<StoreCredentials>,
22}
23
24#[derive(Debug, thiserror::Error)]
25pub enum StoreAuthError {
26 #[error("{0}")]
27 Message(String),
28 #[error("io error at `{path}`: {source}")]
29 Io {
30 path: String,
31 #[source]
32 source: std::io::Error,
33 },
34 #[error("json error: {0}")]
35 Json(#[from] serde_json::Error),
36 #[error("secret store error: {0}")]
37 SecretStore(String),
38}
39
40pub fn default_store_auth_path() -> PathBuf {
41 if let Ok(path) = std::env::var("GREENTIC_DIST_STORE_SECRETS_PATH") {
42 return PathBuf::from(path);
43 }
44 default_store_auth_dir().join("store-auth.json")
45}
46
47pub fn default_store_state_path() -> PathBuf {
48 default_store_auth_path()
49}
50
51impl Default for StoreAuth {
52 fn default() -> Self {
53 Self::new(default_store_auth_path(), default_store_state_path())
54 }
55}
56
57impl StoreAuth {
58 pub fn new(auth_path: impl Into<PathBuf>, state_path: impl Into<PathBuf>) -> Self {
59 Self {
60 auth_path: auth_path.into(),
61 state_path: state_path.into(),
62 }
63 }
64
65 pub fn from_env() -> Self {
66 Self::default()
67 }
68
69 pub fn auth_path(&self) -> &Path {
70 &self.auth_path
71 }
72
73 pub fn state_path(&self) -> &Path {
74 &self.state_path
75 }
76
77 pub async fn save_login(&self, tenant: &str, token: &str) -> Result<(), StoreAuthError> {
78 save_login(&self.auth_path, &self.state_path, tenant, token).await
79 }
80
81 pub async fn load_login(&self, tenant: &str) -> Result<StoreCredentials, StoreAuthError> {
82 load_login(&self.auth_path, &self.state_path, tenant).await
83 }
84}
85
86pub async fn save_login_default(tenant: &str, token: &str) -> Result<(), StoreAuthError> {
87 StoreAuth::default().save_login(tenant, token).await
88}
89
90pub async fn load_login_default(tenant: &str) -> Result<StoreCredentials, StoreAuthError> {
91 StoreAuth::default().load_login(tenant).await
92}
93
94fn default_store_auth_dir() -> PathBuf {
95 if let Some(config) = dirs_next::config_dir() {
96 return config.join("greentic").join("dist");
97 }
98 if let Ok(root) = std::env::var("GREENTIC_HOME") {
99 return PathBuf::from(root).join("config").join("dist");
100 }
101 PathBuf::from(".greentic").join("config").join("dist")
102}
103
104pub async fn save_login(
105 auth_path: &Path,
106 _state_path: &Path,
107 tenant: &str,
108 token: &str,
109) -> Result<(), StoreAuthError> {
110 let tenant = tenant.trim();
111 if tenant.is_empty() {
112 return Err(StoreAuthError::Message("tenant cannot be empty".into()));
113 }
114 if token.is_empty() {
115 return Err(StoreAuthError::Message("token cannot be empty".into()));
116 }
117
118 let credentials = StoreCredentials {
119 tenant: tenant.to_string(),
120 username: tenant.to_string(),
121 token: token.to_string(),
122 };
123 let mut state = load_state(auth_path)
124 .await?
125 .unwrap_or(StoreAuthState { logins: Vec::new() });
126 state.logins.retain(|login| login.tenant != tenant);
127 state.logins.push(credentials);
128 write_state(auth_path, &state).await?;
129 Ok(())
130}
131
132pub async fn load_login(
133 auth_path: &Path,
134 _state_path: &Path,
135 tenant: &str,
136) -> Result<StoreCredentials, StoreAuthError> {
137 let state = load_state(auth_path).await?.ok_or_else(|| {
138 StoreAuthError::Message(format!(
139 "no saved store login found at `{}`; run `greentic-dist auth login <tenant>` first",
140 auth_path.display()
141 ))
142 })?;
143 let active = state
144 .logins
145 .into_iter()
146 .find(|login| login.tenant == tenant)
147 .ok_or_else(|| {
148 StoreAuthError::Message(format!(
149 "tenant `{tenant}` has no saved credentials; run `greentic-dist auth login {tenant}` first"
150 ))
151 })?;
152 if active.username.trim().is_empty() || active.token.is_empty() {
153 return Err(StoreAuthError::Message(format!(
154 "stored credentials for tenant `{}` are incomplete",
155 active.tenant
156 )));
157 }
158 Ok(active)
159}
160
161async fn load_state(path: &Path) -> Result<Option<StoreAuthState>, StoreAuthError> {
162 let store = open_store(path)?;
163 let bytes = match store.get("secrets://prod/dist/_/store/auth_state").await {
164 Ok(bytes) => bytes,
165 Err(err) if err.to_string().contains("not found") => return Ok(None),
166 Err(err) => return Err(StoreAuthError::SecretStore(err.to_string())),
167 };
168 let state: StoreAuthState = serde_json::from_slice(&bytes)?;
169 Ok(Some(state))
170}
171
172async fn write_state(path: &Path, state: &StoreAuthState) -> Result<(), StoreAuthError> {
173 ensure_parent_dir(path)?;
174 let store = open_store(path)?;
175 let bytes = serde_json::to_vec(state)?;
176 store
177 .put(
178 "secrets://prod/dist/_/store/auth_state",
179 SecretFormat::Json,
180 &bytes,
181 )
182 .await
183 .map_err(|err| StoreAuthError::SecretStore(err.to_string()))?;
184 Ok(())
185}
186
187fn open_store(path: &Path) -> Result<DevStore, StoreAuthError> {
188 DevStore::with_path(path).map_err(|err| {
189 StoreAuthError::SecretStore(format!(
190 "failed to open secrets store `{}`: {err}",
191 path.display()
192 ))
193 })
194}
195
196fn ensure_parent_dir(path: &Path) -> Result<(), StoreAuthError> {
197 if let Some(parent) = path.parent() {
198 fs::create_dir_all(parent).map_err(|source| StoreAuthError::Io {
199 path: parent.display().to_string(),
200 source,
201 })?;
202 }
203 Ok(())
204}
205
206#[cfg(test)]
207mod tests {
208 use super::*;
209
210 #[tokio::test]
211 async fn round_trips_login_credentials() {
212 let temp = tempfile::tempdir().unwrap();
213 let auth_path = temp.path().join("store-auth.json");
214 let state_path = auth_path.clone();
215
216 save_login(&auth_path, &state_path, "tenant-a", "secret-token")
217 .await
218 .unwrap();
219
220 let loaded = load_login(&auth_path, &state_path, "tenant-a")
221 .await
222 .unwrap();
223 assert_eq!(loaded.tenant, "tenant-a");
224 assert_eq!(loaded.username, "tenant-a");
225 assert_eq!(loaded.token, "secret-token");
226 }
227
228 #[tokio::test]
229 async fn store_auth_wrapper_round_trips_login_credentials() {
230 let temp = tempfile::tempdir().unwrap();
231 let auth = StoreAuth::new(
232 temp.path().join("store-auth.json"),
233 temp.path().join("store-auth.json"),
234 );
235
236 auth.save_login("tenant-b", "other-secret").await.unwrap();
237
238 let loaded = auth.load_login("tenant-b").await.unwrap();
239 assert_eq!(loaded.tenant, "tenant-b");
240 assert_eq!(loaded.username, "tenant-b");
241 assert_eq!(loaded.token, "other-secret");
242 }
243}