Skip to main content

omnigraph_server/
auth.rs

1//! Bearer token sources.
2//!
3//! A `TokenSource` loads `(actor_id, token)` pairs that the server uses to
4//! authenticate incoming bearer tokens. Plaintext tokens returned here are
5//! hashed immediately by `AppState` on ingest — see `hash_bearer_token` —
6//! and never persist past startup/refresh.
7//!
8//! The trait exists so that additional backends (AWS Secrets Manager,
9//! HashiCorp Vault, etc.) can plug in behind feature flags without
10//! touching the server wiring.
11
12use async_trait::async_trait;
13use color_eyre::eyre::{Result, bail};
14
15use crate::server_bearer_tokens_from_env;
16
17/// Environment variable that, when set, selects AWS Secrets Manager as the
18/// token source. Its value is the secret ID or ARN. Only honored when the
19/// binary is compiled with `--features aws`.
20pub const AWS_SECRET_ENV: &str = "OMNIGRAPH_SERVER_BEARER_TOKENS_AWS_SECRET";
21
22/// A source of bearer tokens, returned as `(actor_id, token)` pairs in
23/// plaintext. The caller is expected to hash tokens before storing them.
24#[async_trait]
25pub trait TokenSource: Send + Sync {
26    /// Fetch the current set of actor → token pairs.
27    ///
28    /// Called once at startup. Implementations that support rotation may
29    /// also be polled periodically.
30    async fn load(&self) -> Result<Vec<(String, String)>>;
31
32    /// Whether this source can be re-fetched for rotation without restart.
33    /// Default: false (one-shot sources).
34    fn supports_refresh(&self) -> bool {
35        false
36    }
37
38    /// Human-readable name for logs and error messages.
39    fn name(&self) -> &'static str;
40}
41
42/// Reads bearer tokens from environment variables and / or files, matching
43/// the long-standing server configuration:
44///
45/// - `OMNIGRAPH_SERVER_BEARER_TOKEN` — a single token assigned to the
46///   implicit actor `default`.
47/// - `OMNIGRAPH_SERVER_BEARER_TOKENS_JSON` — a JSON object of
48///   `{"actor_id": "token", …}`.
49/// - `OMNIGRAPH_SERVER_BEARER_TOKENS_FILE` — a path to a JSON file of the
50///   same shape.
51///
52/// Does not support refresh — reloading means restarting the process.
53#[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
67/// Pick the token source based on configuration.
68///
69/// Preference order:
70/// 1. If `OMNIGRAPH_SERVER_BEARER_TOKENS_AWS_SECRET` is set AND the binary was
71///    built with `--features aws`, returns an AWS Secrets Manager source.
72/// 2. If that env var is set but the binary was built without the feature,
73///    errors with a clear rebuild instruction rather than silently falling
74///    back to the env/file source (which would hide the misconfiguration).
75/// 3. Otherwise, returns `EnvOrFileTokenSource`.
76pub 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/// Parse a JSON secret payload (from AWS Secrets Manager or any equivalent
99/// source) into actor → token pairs.
100///
101/// Payload shape: `{"actor_id_1": "token_1", "actor_id_2": "token_2", ...}`.
102/// Extracted as a free function so it can be unit-tested without the AWS SDK.
103#[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    //! AWS Secrets Manager bearer-token backend.
136    //!
137    //! Fetches a JSON payload from a named secret on startup. Credentials are
138    //! resolved via the AWS default chain — env vars, shared config, IMDSv2
139    //! instance role, or ECS task role — so no explicit credential plumbing
140    //! is needed when running under an IAM role.
141    //!
142    //! Background refresh for rotation is a follow-up.
143    use super::TokenSource;
144    use async_trait::async_trait;
145    use color_eyre::eyre::{Result, WrapErr, eyre};
146
147    /// Loads bearer tokens from a named AWS Secrets Manager secret.
148    pub struct SecretsManagerTokenSource {
149        client: aws_sdk_secretsmanager::Client,
150        secret_id: String,
151    }
152
153    impl SecretsManagerTokenSource {
154        /// Construct a new source. Resolves AWS credentials + region via the
155        /// default chain — no explicit configuration needed on EC2/ECS/EKS.
156        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}