Skip to main content

camel_auth/
native_client_store.rs

1use camel_api::CamelError;
2use std::collections::HashSet;
3use std::fmt;
4use tracing::warn;
5use zeroize::Zeroizing;
6
7/// Compare two byte slices without short-circuiting on early mismatch.
8/// Returns `true` if both slices have equal length and identical bytes.
9///
10/// The length check leaks the length of the inputs, which is acceptable
11/// because client IDs are typically fixed-length or their length is not
12/// secret. The byte-by-byte comparison runs in constant time relative to
13/// the length of the inputs.
14fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
15    if a.len() != b.len() {
16        return false;
17    }
18    let mut diff = 0u8;
19    for (x, y) in a.iter().zip(b.iter()) {
20        diff |= x ^ y;
21    }
22    diff == 0
23}
24
25pub struct M2mClient {
26    pub client_id: String,
27    pub secret: M2mClientSecret,
28    pub roles: Vec<String>,
29    pub scopes: Vec<String>,
30}
31
32#[derive(Clone)]
33pub enum M2mClientSecret {
34    Env { name: String },
35    Plaintext { value: Zeroizing<String> },
36}
37
38struct ResolvedM2mClient {
39    client_id: String,
40    secret_value: Zeroizing<String>,
41    roles: Vec<String>,
42    scopes: Vec<String>,
43}
44
45pub struct M2mClientStore {
46    clients: Vec<ResolvedM2mClient>,
47}
48
49pub struct M2mClientRef<'a> {
50    pub client_id: &'a str,
51    pub roles: &'a [String],
52    pub scopes: &'a [String],
53}
54
55impl fmt::Debug for M2mClientSecret {
56    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
57        match self {
58            M2mClientSecret::Env { name } => write!(f, "Env {{ name: \"{name}\" }}"), // allow-secret
59            M2mClientSecret::Plaintext { .. } => {
60                write!(f, "Plaintext {{ value: \"[REDACTED]\" }}") // allow-secret
61            }
62        }
63    }
64}
65
66impl fmt::Debug for M2mClient {
67    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
68        f.debug_struct("M2mClient")
69            .field("client_id", &self.client_id)
70            .field("secret", &self.secret)
71            .field("roles", &self.roles)
72            .field("scopes", &self.scopes)
73            .finish()
74    }
75}
76
77impl fmt::Debug for M2mClientStore {
78    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
79        f.debug_struct("M2mClientStore")
80            .field("client_count", &self.clients.len())
81            .field("secrets", &"[REDACTED]")
82            .finish()
83    }
84}
85
86impl M2mClientStore {
87    pub fn try_new(clients: Vec<M2mClient>) -> Result<Self, CamelError> {
88        let mut seen_ids = HashSet::new();
89
90        for c in &clients {
91            if !seen_ids.insert(c.client_id.clone()) {
92                return Err(CamelError::Config(format!(
93                    "duplicate client_id: '{}'",
94                    c.client_id
95                )));
96            }
97        }
98
99        let mut resolved = Vec::with_capacity(clients.len());
100        for c in clients {
101            let secret_value = match &c.secret {
102                M2mClientSecret::Env { name } => {
103                    let val = std::env::var(name).map_err(|_| {
104                        CamelError::Config(format!("M2M client env var not set: {name}"))
105                    })?;
106                    if val.is_empty() {
107                        return Err(CamelError::Config(format!(
108                            "M2M client env var is empty: {name}"
109                        )));
110                    }
111                    Zeroizing::new(val)
112                }
113                M2mClientSecret::Plaintext { value } => {
114                    if value.is_empty() {
115                        return Err(CamelError::Config(
116                            "M2M client plaintext secret is empty".into(),
117                        ));
118                    }
119                    warn!(
120                        "M2M client '{}' uses plaintext secret — use env vars in production",
121                        c.client_id
122                    );
123                    value.clone()
124                }
125            };
126            resolved.push(ResolvedM2mClient {
127                client_id: c.client_id,
128                secret_value,
129                roles: c.roles,
130                scopes: c.scopes,
131            });
132        }
133
134        Ok(Self { clients: resolved })
135    }
136
137    pub fn lookup(&self, client_id: &str, client_secret: &str) -> Option<M2mClientRef<'_>> {
138        use sha2::{Digest, Sha256};
139
140        let secret_hash = Sha256::digest(client_secret.as_bytes());
141        for c in &self.clients {
142            if !constant_time_eq(c.client_id.as_bytes(), client_id.as_bytes()) {
143                continue;
144            }
145            let stored_hash = Sha256::digest(c.secret_value.as_bytes());
146            if constant_time_eq(&secret_hash, &stored_hash) {
147                return Some(M2mClientRef {
148                    client_id: &c.client_id,
149                    roles: &c.roles,
150                    scopes: &c.scopes,
151                });
152            }
153            // Client ID matched but secret didn't — no point scanning further
154            // (dedup guarantees at most one entry per client_id).
155            break;
156        }
157        None
158    }
159
160    pub fn get(&self, client_id: &str) -> Option<M2mClientRef<'_>> {
161        self.clients
162            .iter()
163            .find(|c| constant_time_eq(c.client_id.as_bytes(), client_id.as_bytes()))
164            .map(|c| M2mClientRef {
165                client_id: &c.client_id,
166                roles: &c.roles,
167                scopes: &c.scopes,
168            })
169    }
170}
171
172#[cfg(test)]
173mod tests {
174    use super::*;
175
176    #[test]
177    fn constant_time_eq_same_slice_returns_true() {
178        assert!(constant_time_eq(b"hello", b"hello"));
179        assert!(constant_time_eq(b"", b""));
180        assert!(constant_time_eq(b"a", b"a"));
181    }
182
183    #[test]
184    fn constant_time_eq_different_length_returns_false() {
185        assert!(!constant_time_eq(b"hello", b"world!"));
186        assert!(!constant_time_eq(b"a", b""));
187    }
188
189    #[test]
190    fn constant_time_eq_differs_in_last_byte_returns_false() {
191        assert!(!constant_time_eq(b"client-aaa1", b"client-aaa2"));
192        assert!(!constant_time_eq(b"aaa", b"aab"));
193    }
194
195    #[test]
196    fn constant_time_eq_differs_in_first_byte_returns_false() {
197        assert!(!constant_time_eq(b"xabc", b"yabc"));
198    }
199
200    #[test]
201    fn store_lookup_wrong_secret_for_similar_client_id() {
202        let store = M2mClientStore::try_new(vec![
203            M2mClient {
204                client_id: "client-aaa1".into(),
205                secret: M2mClientSecret::Plaintext {
206                    value: Zeroizing::new("secret-1".into()),
207                },
208                roles: vec![],
209                scopes: vec![],
210            },
211            M2mClient {
212                client_id: "client-aaa2".into(),
213                secret: M2mClientSecret::Plaintext {
214                    value: Zeroizing::new("secret-2".into()),
215                },
216                roles: vec![],
217                scopes: vec![],
218            },
219        ])
220        .unwrap();
221        // Wrong secret for client-aaa1 should return None
222        assert!(store.lookup("client-aaa1", "wrong-secret").is_none());
223        // Right secret should succeed
224        assert!(store.lookup("client-aaa1", "secret-1").is_some());
225        assert!(store.lookup("client-aaa2", "secret-2").is_some());
226    }
227
228    #[test]
229    fn store_rejects_duplicate_client_ids() {
230        let result = M2mClientStore::try_new(vec![
231            M2mClient {
232                client_id: "worker".into(),
233                secret: M2mClientSecret::Plaintext {
234                    value: Zeroizing::new("secret-a".into()),
235                },
236                roles: vec!["read".into()],
237                scopes: vec!["api:read".into()],
238            },
239            M2mClient {
240                client_id: "worker".into(),
241                secret: M2mClientSecret::Plaintext {
242                    value: Zeroizing::new("secret-b".into()),
243                },
244                roles: vec!["write".into()],
245                scopes: vec!["api:write".into()],
246            },
247        ]);
248        let err = result.unwrap_err();
249        assert!(format!("{err}").contains("duplicate client_id"));
250    }
251
252    #[test]
253    fn store_rejects_empty_secret() {
254        let result = M2mClientStore::try_new(vec![M2mClient {
255            client_id: "worker".into(),
256            secret: M2mClientSecret::Plaintext {
257                value: Zeroizing::new("".into()),
258            },
259            roles: vec![],
260            scopes: vec![],
261        }]);
262        let err = result.unwrap_err();
263        assert!(format!("{err}").contains("empty"));
264    }
265
266    #[test]
267    fn store_lookup_valid_client_constant_time() {
268        let store = M2mClientStore::try_new(vec![M2mClient {
269            client_id: "billing".into(),
270            secret: M2mClientSecret::Plaintext {
271                value: Zeroizing::new("secret-123".into()),
272            },
273            roles: vec!["billing".into()],
274            scopes: vec!["orders:read".into(), "orders:write".into()],
275        }])
276        .unwrap();
277        let client = store.lookup("billing", "secret-123").unwrap();
278        assert_eq!(client.client_id, "billing");
279        assert_eq!(client.roles, vec!["billing"]);
280    }
281
282    #[test]
283    fn store_lookup_wrong_secret_returns_none() {
284        let store = M2mClientStore::try_new(vec![M2mClient {
285            client_id: "billing".into(),
286            secret: M2mClientSecret::Plaintext {
287                value: Zeroizing::new("secret-123".into()),
288            },
289            roles: vec![],
290            scopes: vec![],
291        }])
292        .unwrap();
293        assert!(store.lookup("billing", "wrong").is_none());
294    }
295
296    #[test]
297    fn store_lookup_unknown_client_returns_none() {
298        let store = M2mClientStore::try_new(vec![]).unwrap();
299        assert!(store.lookup("unknown", "secret").is_none());
300    }
301
302    #[test]
303    fn store_resolves_env_secret() {
304        // SAFETY: test-scoped env mutation used to verify env-backed secret resolution.
305        unsafe { std::env::set_var("TEST_M2M_SECRET", "env-secret-value") };
306        let store = M2mClientStore::try_new(vec![M2mClient {
307            client_id: "worker".into(),
308            secret: M2mClientSecret::Env {
309                name: "TEST_M2M_SECRET".into(),
310            },
311            roles: vec![],
312            scopes: vec![],
313        }])
314        .unwrap();
315        assert!(store.lookup("worker", "env-secret-value").is_some());
316        // SAFETY: revert test-scoped env mutation before test exits.
317        unsafe { std::env::remove_var("TEST_M2M_SECRET") };
318    }
319
320    #[test]
321    fn store_rejects_missing_env_var() {
322        let result = M2mClientStore::try_new(vec![M2mClient {
323            client_id: "worker".into(),
324            secret: M2mClientSecret::Env {
325                name: "NONEXISTENT_VAR_XYZ".into(),
326            },
327            roles: vec![],
328            scopes: vec![],
329        }]);
330        let err = result.unwrap_err();
331        assert!(format!("{err}").contains("NONEXISTENT_VAR_XYZ"));
332    }
333
334    #[test]
335    fn store_debug_redacts_secrets() {
336        let store = M2mClientStore::try_new(vec![M2mClient {
337            client_id: "worker".into(),
338            secret: M2mClientSecret::Plaintext {
339                value: Zeroizing::new("super-secret".into()),
340            },
341            roles: vec![],
342            scopes: vec![],
343        }])
344        .unwrap();
345        let debug = format!("{store:?}");
346        assert!(!debug.contains("super-secret"));
347        assert!(debug.contains("[REDACTED]"));
348    }
349}