1use async_trait::async_trait;
13use color_eyre::eyre::{Result, bail};
14
15use crate::server_bearer_tokens_from_env;
16
17pub const AWS_SECRET_ENV: &str = "OMNIGRAPH_SERVER_BEARER_TOKENS_AWS_SECRET";
21
22#[async_trait]
25pub trait TokenSource: Send + Sync {
26 async fn load(&self) -> Result<Vec<(String, String)>>;
31
32 fn supports_refresh(&self) -> bool {
35 false
36 }
37
38 fn name(&self) -> &'static str;
40}
41
42#[derive(Debug, Default, Clone)]
54pub struct EnvOrFileTokenSource;
55
56#[async_trait]
57impl TokenSource for EnvOrFileTokenSource {
58 async fn load(&self) -> Result<Vec<(String, String)>> {
59 server_bearer_tokens_from_env()
60 }
61
62 fn name(&self) -> &'static str {
63 "env-or-file"
64 }
65}
66
67pub async fn resolve_token_source() -> Result<Box<dyn TokenSource>> {
77 if let Ok(secret_id) = std::env::var(AWS_SECRET_ENV) {
78 let secret_id = secret_id.trim().to_string();
79 if !secret_id.is_empty() {
80 #[cfg(feature = "aws")]
81 {
82 let source = aws::SecretsManagerTokenSource::new(secret_id).await?;
83 return Ok(Box::new(source));
84 }
85 #[cfg(not(feature = "aws"))]
86 {
87 bail!(
88 "{} is set but this binary was not built with --features aws. \
89 Rebuild: cargo build --release --features aws",
90 AWS_SECRET_ENV
91 );
92 }
93 }
94 }
95 Ok(Box::new(EnvOrFileTokenSource))
96}
97
98#[cfg(any(test, feature = "aws"))]
104pub(crate) fn parse_json_secret_payload(payload: &str) -> Result<Vec<(String, String)>> {
105 use std::collections::HashMap;
106
107 let map: HashMap<String, String> = serde_json::from_str(payload).map_err(|err| {
108 color_eyre::eyre::eyre!(
109 "bearer-token secret payload is not a JSON object of actor→token: {}",
110 err
111 )
112 })?;
113
114 let mut pairs: Vec<(String, String)> = Vec::with_capacity(map.len());
115 for (actor, token) in map {
116 let actor = actor.trim().to_string();
117 let token = token.trim().to_string();
118 if actor.is_empty() {
119 bail!("bearer-token secret contains a blank actor id");
120 }
121 if token.is_empty() {
122 bail!(
123 "bearer-token secret has a blank token for actor '{}'",
124 actor
125 );
126 }
127 pairs.push((actor, token));
128 }
129 pairs.sort_by(|(a, _), (b, _)| a.cmp(b));
130 Ok(pairs)
131}
132
133#[cfg(feature = "aws")]
134pub mod aws {
135 use super::TokenSource;
144 use async_trait::async_trait;
145 use color_eyre::eyre::{Result, WrapErr, eyre};
146
147 pub struct SecretsManagerTokenSource {
149 client: aws_sdk_secretsmanager::Client,
150 secret_id: String,
151 }
152
153 impl SecretsManagerTokenSource {
154 pub async fn new(secret_id: impl Into<String>) -> Result<Self> {
157 let config = aws_config::load_defaults(aws_config::BehaviorVersion::latest()).await;
158 let client = aws_sdk_secretsmanager::Client::new(&config);
159 Ok(Self {
160 client,
161 secret_id: secret_id.into(),
162 })
163 }
164 }
165
166 #[async_trait]
167 impl TokenSource for SecretsManagerTokenSource {
168 async fn load(&self) -> Result<Vec<(String, String)>> {
169 let output = self
170 .client
171 .get_secret_value()
172 .secret_id(&self.secret_id)
173 .send()
174 .await
175 .wrap_err_with(|| {
176 format!("fetch AWS Secrets Manager secret '{}'", self.secret_id)
177 })?;
178
179 let payload = output.secret_string().ok_or_else(|| {
180 eyre!(
181 "secret '{}' has no SecretString — binary secrets are not supported",
182 self.secret_id
183 )
184 })?;
185
186 super::parse_json_secret_payload(payload)
187 }
188
189 fn supports_refresh(&self) -> bool {
190 true
191 }
192
193 fn name(&self) -> &'static str {
194 "aws-secrets-manager"
195 }
196 }
197}
198
199#[cfg(feature = "aws")]
200pub use aws::SecretsManagerTokenSource;
201
202#[cfg(test)]
203mod tests {
204 use super::*;
205 use serial_test::serial;
206 use std::env;
207
208 fn clear_env() {
209 unsafe {
210 env::remove_var("OMNIGRAPH_SERVER_BEARER_TOKEN");
211 env::remove_var("OMNIGRAPH_SERVER_BEARER_TOKENS_JSON");
212 env::remove_var("OMNIGRAPH_SERVER_BEARER_TOKENS_FILE");
213 }
214 }
215
216 #[tokio::test]
217 #[serial]
218 async fn env_or_file_source_returns_empty_when_nothing_configured() {
219 clear_env();
220 let source = EnvOrFileTokenSource;
221 let tokens = source.load().await.unwrap();
222 assert!(tokens.is_empty());
223 }
224
225 #[tokio::test]
226 #[serial]
227 async fn env_or_file_source_reads_single_token_as_default_actor() {
228 clear_env();
229 unsafe {
230 env::set_var("OMNIGRAPH_SERVER_BEARER_TOKEN", "some-token");
231 }
232 let source = EnvOrFileTokenSource;
233 let tokens = source.load().await.unwrap();
234 unsafe {
235 env::remove_var("OMNIGRAPH_SERVER_BEARER_TOKEN");
236 }
237 assert_eq!(
238 tokens,
239 vec![("default".to_string(), "some-token".to_string())]
240 );
241 }
242
243 #[tokio::test]
244 async fn env_or_file_source_does_not_support_refresh() {
245 let source = EnvOrFileTokenSource;
246 assert!(!source.supports_refresh());
247 assert_eq!(source.name(), "env-or-file");
248 }
249
250 #[test]
251 fn parse_json_secret_payload_reads_actor_token_map() {
252 let pairs = parse_json_secret_payload(r#"{"alice": "tok-a", "bob": "tok-b"}"#).unwrap();
253 assert_eq!(
254 pairs,
255 vec![
256 ("alice".to_string(), "tok-a".to_string()),
257 ("bob".to_string(), "tok-b".to_string()),
258 ]
259 );
260 }
261
262 #[test]
263 fn parse_json_secret_payload_trims_whitespace() {
264 let pairs = parse_json_secret_payload(r#"{" alice ": " tok-a "}"#).unwrap();
265 assert_eq!(pairs, vec![("alice".to_string(), "tok-a".to_string())]);
266 }
267
268 #[test]
269 fn parse_json_secret_payload_rejects_blank_actor() {
270 let err = parse_json_secret_payload(r#"{" ": "tok"}"#).unwrap_err();
271 assert!(err.to_string().contains("blank actor"));
272 }
273
274 #[test]
275 fn parse_json_secret_payload_rejects_blank_token() {
276 let err = parse_json_secret_payload(r#"{"alice": " "}"#).unwrap_err();
277 assert!(err.to_string().contains("blank token"));
278 }
279
280 #[test]
281 fn parse_json_secret_payload_rejects_non_object() {
282 let err = parse_json_secret_payload("[1, 2, 3]").unwrap_err();
283 assert!(err.to_string().contains("not a JSON object"));
284 }
285
286 #[tokio::test]
287 #[serial]
288 async fn resolve_token_source_falls_back_to_env_or_file_when_aws_var_unset() {
289 clear_env();
290 unsafe {
291 env::remove_var(AWS_SECRET_ENV);
292 }
293 let source = resolve_token_source().await.unwrap();
294 assert_eq!(source.name(), "env-or-file");
295 }
296
297 #[cfg(not(feature = "aws"))]
298 #[tokio::test]
299 #[serial]
300 async fn resolve_token_source_errors_when_aws_var_set_without_feature() {
301 clear_env();
302 unsafe {
303 env::set_var(AWS_SECRET_ENV, "some-secret-id");
304 }
305 let result = resolve_token_source().await;
306 unsafe {
307 env::remove_var(AWS_SECRET_ENV);
308 }
309 let err = match result {
310 Ok(_) => panic!("expected resolve_token_source to error without aws feature"),
311 Err(err) => err,
312 };
313 assert!(err.to_string().contains("--features aws"));
314 }
315}