use async_trait::async_trait;
use color_eyre::eyre::{Result, bail};
use crate::server_bearer_tokens_from_env;
pub const AWS_SECRET_ENV: &str = "OMNIGRAPH_SERVER_BEARER_TOKENS_AWS_SECRET";
#[async_trait]
pub trait TokenSource: Send + Sync {
async fn load(&self) -> Result<Vec<(String, String)>>;
fn supports_refresh(&self) -> bool {
false
}
fn name(&self) -> &'static str;
}
#[derive(Debug, Default, Clone)]
pub struct EnvOrFileTokenSource;
#[async_trait]
impl TokenSource for EnvOrFileTokenSource {
async fn load(&self) -> Result<Vec<(String, String)>> {
server_bearer_tokens_from_env()
}
fn name(&self) -> &'static str {
"env-or-file"
}
}
pub async fn resolve_token_source() -> Result<Box<dyn TokenSource>> {
if let Ok(secret_id) = std::env::var(AWS_SECRET_ENV) {
let secret_id = secret_id.trim().to_string();
if !secret_id.is_empty() {
#[cfg(feature = "aws")]
{
let source = aws::SecretsManagerTokenSource::new(secret_id).await?;
return Ok(Box::new(source));
}
#[cfg(not(feature = "aws"))]
{
bail!(
"{} is set but this binary was not built with --features aws. \
Rebuild: cargo build --release --features aws",
AWS_SECRET_ENV
);
}
}
}
Ok(Box::new(EnvOrFileTokenSource))
}
#[cfg(any(test, feature = "aws"))]
pub(crate) fn parse_json_secret_payload(payload: &str) -> Result<Vec<(String, String)>> {
use std::collections::HashMap;
let map: HashMap<String, String> = serde_json::from_str(payload).map_err(|err| {
color_eyre::eyre::eyre!(
"bearer-token secret payload is not a JSON object of actor→token: {}",
err
)
})?;
let mut pairs: Vec<(String, String)> = Vec::with_capacity(map.len());
for (actor, token) in map {
let actor = actor.trim().to_string();
let token = token.trim().to_string();
if actor.is_empty() {
bail!("bearer-token secret contains a blank actor id");
}
if token.is_empty() {
bail!(
"bearer-token secret has a blank token for actor '{}'",
actor
);
}
pairs.push((actor, token));
}
pairs.sort_by(|(a, _), (b, _)| a.cmp(b));
Ok(pairs)
}
#[cfg(feature = "aws")]
pub mod aws {
use super::TokenSource;
use async_trait::async_trait;
use color_eyre::eyre::{Result, WrapErr, eyre};
pub struct SecretsManagerTokenSource {
client: aws_sdk_secretsmanager::Client,
secret_id: String,
}
impl SecretsManagerTokenSource {
pub async fn new(secret_id: impl Into<String>) -> Result<Self> {
let config = aws_config::load_defaults(aws_config::BehaviorVersion::latest()).await;
let client = aws_sdk_secretsmanager::Client::new(&config);
Ok(Self {
client,
secret_id: secret_id.into(),
})
}
}
#[async_trait]
impl TokenSource for SecretsManagerTokenSource {
async fn load(&self) -> Result<Vec<(String, String)>> {
let output = self
.client
.get_secret_value()
.secret_id(&self.secret_id)
.send()
.await
.wrap_err_with(|| {
format!("fetch AWS Secrets Manager secret '{}'", self.secret_id)
})?;
let payload = output.secret_string().ok_or_else(|| {
eyre!(
"secret '{}' has no SecretString — binary secrets are not supported",
self.secret_id
)
})?;
super::parse_json_secret_payload(payload)
}
fn supports_refresh(&self) -> bool {
true
}
fn name(&self) -> &'static str {
"aws-secrets-manager"
}
}
}
#[cfg(feature = "aws")]
pub use aws::SecretsManagerTokenSource;
#[cfg(test)]
mod tests {
use super::*;
use serial_test::serial;
use std::env;
fn clear_env() {
unsafe {
env::remove_var("OMNIGRAPH_SERVER_BEARER_TOKEN");
env::remove_var("OMNIGRAPH_SERVER_BEARER_TOKENS_JSON");
env::remove_var("OMNIGRAPH_SERVER_BEARER_TOKENS_FILE");
}
}
#[tokio::test]
#[serial]
async fn env_or_file_source_returns_empty_when_nothing_configured() {
clear_env();
let source = EnvOrFileTokenSource;
let tokens = source.load().await.unwrap();
assert!(tokens.is_empty());
}
#[tokio::test]
#[serial]
async fn env_or_file_source_reads_single_token_as_default_actor() {
clear_env();
unsafe {
env::set_var("OMNIGRAPH_SERVER_BEARER_TOKEN", "some-token");
}
let source = EnvOrFileTokenSource;
let tokens = source.load().await.unwrap();
unsafe {
env::remove_var("OMNIGRAPH_SERVER_BEARER_TOKEN");
}
assert_eq!(
tokens,
vec![("default".to_string(), "some-token".to_string())]
);
}
#[tokio::test]
async fn env_or_file_source_does_not_support_refresh() {
let source = EnvOrFileTokenSource;
assert!(!source.supports_refresh());
assert_eq!(source.name(), "env-or-file");
}
#[test]
fn parse_json_secret_payload_reads_actor_token_map() {
let pairs = parse_json_secret_payload(r#"{"alice": "tok-a", "bob": "tok-b"}"#).unwrap();
assert_eq!(
pairs,
vec![
("alice".to_string(), "tok-a".to_string()),
("bob".to_string(), "tok-b".to_string()),
]
);
}
#[test]
fn parse_json_secret_payload_trims_whitespace() {
let pairs = parse_json_secret_payload(r#"{" alice ": " tok-a "}"#).unwrap();
assert_eq!(pairs, vec![("alice".to_string(), "tok-a".to_string())]);
}
#[test]
fn parse_json_secret_payload_rejects_blank_actor() {
let err = parse_json_secret_payload(r#"{" ": "tok"}"#).unwrap_err();
assert!(err.to_string().contains("blank actor"));
}
#[test]
fn parse_json_secret_payload_rejects_blank_token() {
let err = parse_json_secret_payload(r#"{"alice": " "}"#).unwrap_err();
assert!(err.to_string().contains("blank token"));
}
#[test]
fn parse_json_secret_payload_rejects_non_object() {
let err = parse_json_secret_payload("[1, 2, 3]").unwrap_err();
assert!(err.to_string().contains("not a JSON object"));
}
#[tokio::test]
#[serial]
async fn resolve_token_source_falls_back_to_env_or_file_when_aws_var_unset() {
clear_env();
unsafe {
env::remove_var(AWS_SECRET_ENV);
}
let source = resolve_token_source().await.unwrap();
assert_eq!(source.name(), "env-or-file");
}
#[cfg(not(feature = "aws"))]
#[tokio::test]
#[serial]
async fn resolve_token_source_errors_when_aws_var_set_without_feature() {
clear_env();
unsafe {
env::set_var(AWS_SECRET_ENV, "some-secret-id");
}
let result = resolve_token_source().await;
unsafe {
env::remove_var(AWS_SECRET_ENV);
}
let err = match result {
Ok(_) => panic!("expected resolve_token_source to error without aws feature"),
Err(err) => err,
};
assert!(err.to_string().contains("--features aws"));
}
}