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!("bearer-token secret has a blank token for actor '{}'", actor);
123 }
124 pairs.push((actor, token));
125 }
126 pairs.sort_by(|(a, _), (b, _)| a.cmp(b));
127 Ok(pairs)
128}
129
130#[cfg(feature = "aws")]
131pub mod aws {
132 use super::TokenSource;
141 use async_trait::async_trait;
142 use color_eyre::eyre::{Result, WrapErr, eyre};
143
144 pub struct SecretsManagerTokenSource {
146 client: aws_sdk_secretsmanager::Client,
147 secret_id: String,
148 }
149
150 impl SecretsManagerTokenSource {
151 pub async fn new(secret_id: impl Into<String>) -> Result<Self> {
154 let config =
155 aws_config::load_defaults(aws_config::BehaviorVersion::latest()).await;
156 let client = aws_sdk_secretsmanager::Client::new(&config);
157 Ok(Self {
158 client,
159 secret_id: secret_id.into(),
160 })
161 }
162 }
163
164 #[async_trait]
165 impl TokenSource for SecretsManagerTokenSource {
166 async fn load(&self) -> Result<Vec<(String, String)>> {
167 let output = self
168 .client
169 .get_secret_value()
170 .secret_id(&self.secret_id)
171 .send()
172 .await
173 .wrap_err_with(|| {
174 format!("fetch AWS Secrets Manager secret '{}'", self.secret_id)
175 })?;
176
177 let payload = output.secret_string().ok_or_else(|| {
178 eyre!(
179 "secret '{}' has no SecretString — binary secrets are not supported",
180 self.secret_id
181 )
182 })?;
183
184 super::parse_json_secret_payload(payload)
185 }
186
187 fn supports_refresh(&self) -> bool {
188 true
189 }
190
191 fn name(&self) -> &'static str {
192 "aws-secrets-manager"
193 }
194 }
195}
196
197#[cfg(feature = "aws")]
198pub use aws::SecretsManagerTokenSource;
199
200#[cfg(test)]
201mod tests {
202 use super::*;
203 use std::env;
204 use serial_test::serial;
205
206 fn clear_env() {
207 unsafe {
208 env::remove_var("OMNIGRAPH_SERVER_BEARER_TOKEN");
209 env::remove_var("OMNIGRAPH_SERVER_BEARER_TOKENS_JSON");
210 env::remove_var("OMNIGRAPH_SERVER_BEARER_TOKENS_FILE");
211 }
212 }
213
214 #[tokio::test]
215 #[serial]
216 async fn env_or_file_source_returns_empty_when_nothing_configured() {
217 clear_env();
218 let source = EnvOrFileTokenSource;
219 let tokens = source.load().await.unwrap();
220 assert!(tokens.is_empty());
221 }
222
223 #[tokio::test]
224 #[serial]
225 async fn env_or_file_source_reads_single_token_as_default_actor() {
226 clear_env();
227 unsafe {
228 env::set_var("OMNIGRAPH_SERVER_BEARER_TOKEN", "some-token");
229 }
230 let source = EnvOrFileTokenSource;
231 let tokens = source.load().await.unwrap();
232 unsafe {
233 env::remove_var("OMNIGRAPH_SERVER_BEARER_TOKEN");
234 }
235 assert_eq!(tokens, vec![("default".to_string(), "some-token".to_string())]);
236 }
237
238 #[tokio::test]
239 async fn env_or_file_source_does_not_support_refresh() {
240 let source = EnvOrFileTokenSource;
241 assert!(!source.supports_refresh());
242 assert_eq!(source.name(), "env-or-file");
243 }
244
245 #[test]
246 fn parse_json_secret_payload_reads_actor_token_map() {
247 let pairs = parse_json_secret_payload(r#"{"alice": "tok-a", "bob": "tok-b"}"#).unwrap();
248 assert_eq!(
249 pairs,
250 vec![
251 ("alice".to_string(), "tok-a".to_string()),
252 ("bob".to_string(), "tok-b".to_string()),
253 ]
254 );
255 }
256
257 #[test]
258 fn parse_json_secret_payload_trims_whitespace() {
259 let pairs = parse_json_secret_payload(r#"{" alice ": " tok-a "}"#).unwrap();
260 assert_eq!(pairs, vec![("alice".to_string(), "tok-a".to_string())]);
261 }
262
263 #[test]
264 fn parse_json_secret_payload_rejects_blank_actor() {
265 let err = parse_json_secret_payload(r#"{" ": "tok"}"#).unwrap_err();
266 assert!(err.to_string().contains("blank actor"));
267 }
268
269 #[test]
270 fn parse_json_secret_payload_rejects_blank_token() {
271 let err = parse_json_secret_payload(r#"{"alice": " "}"#).unwrap_err();
272 assert!(err.to_string().contains("blank token"));
273 }
274
275 #[test]
276 fn parse_json_secret_payload_rejects_non_object() {
277 let err = parse_json_secret_payload("[1, 2, 3]").unwrap_err();
278 assert!(err.to_string().contains("not a JSON object"));
279 }
280
281 #[tokio::test]
282 #[serial]
283 async fn resolve_token_source_falls_back_to_env_or_file_when_aws_var_unset() {
284 clear_env();
285 unsafe {
286 env::remove_var(AWS_SECRET_ENV);
287 }
288 let source = resolve_token_source().await.unwrap();
289 assert_eq!(source.name(), "env-or-file");
290 }
291
292 #[cfg(not(feature = "aws"))]
293 #[tokio::test]
294 #[serial]
295 async fn resolve_token_source_errors_when_aws_var_set_without_feature() {
296 clear_env();
297 unsafe {
298 env::set_var(AWS_SECRET_ENV, "some-secret-id");
299 }
300 let result = resolve_token_source().await;
301 unsafe {
302 env::remove_var(AWS_SECRET_ENV);
303 }
304 let err = match result {
305 Ok(_) => panic!("expected resolve_token_source to error without aws feature"),
306 Err(err) => err,
307 };
308 assert!(err.to_string().contains("--features aws"));
309 }
310}