Skip to main content

camel_auth/
native_client_store.rs

1use camel_api::CamelError;
2use std::collections::HashSet;
3use std::fmt;
4use tracing::warn;
5
6pub struct M2mClient {
7    pub client_id: String,
8    pub secret: M2mClientSecret,
9    pub roles: Vec<String>,
10    pub scopes: Vec<String>,
11}
12
13#[derive(Clone)]
14pub enum M2mClientSecret {
15    Env { name: String },
16    Plaintext { value: String },
17}
18
19struct ResolvedM2mClient {
20    client_id: String,
21    secret_value: String,
22    roles: Vec<String>,
23    scopes: Vec<String>,
24}
25
26pub struct M2mClientStore {
27    clients: Vec<ResolvedM2mClient>,
28}
29
30pub struct M2mClientRef<'a> {
31    pub client_id: &'a str,
32    pub roles: &'a [String],
33    pub scopes: &'a [String],
34}
35
36impl fmt::Debug for M2mClientSecret {
37    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
38        match self {
39            M2mClientSecret::Env { name } => write!(f, "Env {{ name: \"{name}\" }}"), // allow-secret
40            M2mClientSecret::Plaintext { .. } => {
41                write!(f, "Plaintext {{ value: \"[REDACTED]\" }}") // allow-secret
42            }
43        }
44    }
45}
46
47impl fmt::Debug for M2mClient {
48    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
49        f.debug_struct("M2mClient")
50            .field("client_id", &self.client_id)
51            .field("secret", &self.secret)
52            .field("roles", &self.roles)
53            .field("scopes", &self.scopes)
54            .finish()
55    }
56}
57
58impl fmt::Debug for M2mClientStore {
59    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
60        f.debug_struct("M2mClientStore")
61            .field("client_count", &self.clients.len())
62            .field("secrets", &"[REDACTED]")
63            .finish()
64    }
65}
66
67impl M2mClientStore {
68    pub fn try_new(clients: Vec<M2mClient>) -> Result<Self, CamelError> {
69        let mut seen_ids = HashSet::new();
70
71        for c in &clients {
72            if !seen_ids.insert(c.client_id.clone()) {
73                return Err(CamelError::Config(format!(
74                    "duplicate client_id: '{}'",
75                    c.client_id
76                )));
77            }
78        }
79
80        let mut resolved = Vec::with_capacity(clients.len());
81        for c in clients {
82            let secret_value = match &c.secret {
83                M2mClientSecret::Env { name } => {
84                    let val = std::env::var(name).map_err(|_| {
85                        CamelError::Config(format!("M2M client env var not set: {name}"))
86                    })?;
87                    if val.is_empty() {
88                        return Err(CamelError::Config(format!(
89                            "M2M client env var is empty: {name}"
90                        )));
91                    }
92                    val
93                }
94                M2mClientSecret::Plaintext { value } => {
95                    if value.is_empty() {
96                        return Err(CamelError::Config(
97                            "M2M client plaintext secret is empty".into(),
98                        ));
99                    }
100                    warn!(
101                        "M2M client '{}' uses plaintext secret — use env vars in production",
102                        c.client_id
103                    );
104                    value.clone()
105                }
106            };
107            resolved.push(ResolvedM2mClient {
108                client_id: c.client_id,
109                secret_value,
110                roles: c.roles,
111                scopes: c.scopes,
112            });
113        }
114
115        Ok(Self { clients: resolved })
116    }
117
118    pub fn lookup(&self, client_id: &str, client_secret: &str) -> Option<M2mClientRef<'_>> {
119        use sha2::{Digest, Sha256};
120
121        let secret_hash = Sha256::digest(client_secret.as_bytes());
122        for c in &self.clients {
123            if c.client_id != client_id {
124                continue;
125            }
126            let stored_hash = Sha256::digest(c.secret_value.as_bytes());
127            let acc = secret_hash
128                .iter()
129                .zip(stored_hash.iter())
130                .fold(0u8, |acc, (a, b)| acc | (a ^ b));
131            if acc == 0 {
132                return Some(M2mClientRef {
133                    client_id: &c.client_id,
134                    roles: &c.roles,
135                    scopes: &c.scopes,
136                });
137            }
138        }
139        None
140    }
141
142    pub fn get(&self, client_id: &str) -> Option<M2mClientRef<'_>> {
143        self.clients
144            .iter()
145            .find(|c| c.client_id == client_id)
146            .map(|c| M2mClientRef {
147                client_id: &c.client_id,
148                roles: &c.roles,
149                scopes: &c.scopes,
150            })
151    }
152}
153
154#[cfg(test)]
155mod tests {
156    use super::*;
157
158    #[test]
159    fn store_rejects_duplicate_client_ids() {
160        let result = M2mClientStore::try_new(vec![
161            M2mClient {
162                client_id: "worker".into(),
163                secret: M2mClientSecret::Plaintext {
164                    value: "secret-a".into(),
165                },
166                roles: vec!["read".into()],
167                scopes: vec!["api:read".into()],
168            },
169            M2mClient {
170                client_id: "worker".into(),
171                secret: M2mClientSecret::Plaintext {
172                    value: "secret-b".into(),
173                },
174                roles: vec!["write".into()],
175                scopes: vec!["api:write".into()],
176            },
177        ]);
178        let err = result.unwrap_err();
179        assert!(format!("{err}").contains("duplicate client_id"));
180    }
181
182    #[test]
183    fn store_rejects_empty_secret() {
184        let result = M2mClientStore::try_new(vec![M2mClient {
185            client_id: "worker".into(),
186            secret: M2mClientSecret::Plaintext { value: "".into() },
187            roles: vec![],
188            scopes: vec![],
189        }]);
190        let err = result.unwrap_err();
191        assert!(format!("{err}").contains("empty"));
192    }
193
194    #[test]
195    fn store_lookup_valid_client_constant_time() {
196        let store = M2mClientStore::try_new(vec![M2mClient {
197            client_id: "billing".into(),
198            secret: M2mClientSecret::Plaintext {
199                value: "secret-123".into(),
200            },
201            roles: vec!["billing".into()],
202            scopes: vec!["orders:read".into(), "orders:write".into()],
203        }])
204        .unwrap();
205        let client = store.lookup("billing", "secret-123").unwrap();
206        assert_eq!(client.client_id, "billing");
207        assert_eq!(client.roles, vec!["billing"]);
208    }
209
210    #[test]
211    fn store_lookup_wrong_secret_returns_none() {
212        let store = M2mClientStore::try_new(vec![M2mClient {
213            client_id: "billing".into(),
214            secret: M2mClientSecret::Plaintext {
215                value: "secret-123".into(),
216            },
217            roles: vec![],
218            scopes: vec![],
219        }])
220        .unwrap();
221        assert!(store.lookup("billing", "wrong").is_none());
222    }
223
224    #[test]
225    fn store_lookup_unknown_client_returns_none() {
226        let store = M2mClientStore::try_new(vec![]).unwrap();
227        assert!(store.lookup("unknown", "secret").is_none());
228    }
229
230    #[test]
231    fn store_resolves_env_secret() {
232        // SAFETY: test-scoped env mutation used to verify env-backed secret resolution.
233        unsafe { std::env::set_var("TEST_M2M_SECRET", "env-secret-value") };
234        let store = M2mClientStore::try_new(vec![M2mClient {
235            client_id: "worker".into(),
236            secret: M2mClientSecret::Env {
237                name: "TEST_M2M_SECRET".into(),
238            },
239            roles: vec![],
240            scopes: vec![],
241        }])
242        .unwrap();
243        assert!(store.lookup("worker", "env-secret-value").is_some());
244        // SAFETY: revert test-scoped env mutation before test exits.
245        unsafe { std::env::remove_var("TEST_M2M_SECRET") };
246    }
247
248    #[test]
249    fn store_rejects_missing_env_var() {
250        let result = M2mClientStore::try_new(vec![M2mClient {
251            client_id: "worker".into(),
252            secret: M2mClientSecret::Env {
253                name: "NONEXISTENT_VAR_XYZ".into(),
254            },
255            roles: vec![],
256            scopes: vec![],
257        }]);
258        let err = result.unwrap_err();
259        assert!(format!("{err}").contains("NONEXISTENT_VAR_XYZ"));
260    }
261
262    #[test]
263    fn store_debug_redacts_secrets() {
264        let store = M2mClientStore::try_new(vec![M2mClient {
265            client_id: "worker".into(),
266            secret: M2mClientSecret::Plaintext {
267                value: "super-secret".into(),
268            },
269            roles: vec![],
270            scopes: vec![],
271        }])
272        .unwrap();
273        let debug = format!("{store:?}");
274        assert!(!debug.contains("super-secret"));
275        assert!(debug.contains("[REDACTED]"));
276    }
277}