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}\" }}"), M2mClientSecret::Plaintext { .. } => {
41 write!(f, "Plaintext {{ value: \"[REDACTED]\" }}") }
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 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 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}