ghost_io_api/auth/
admin.rs1use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine};
21use hmac::{Hmac, Mac};
22use sha2::Sha256;
23use std::fmt;
24use std::time::{SystemTime, UNIX_EPOCH};
25
26use crate::error::{GhostError, Result};
27
28type HmacSha256 = Hmac<Sha256>;
29
30const TOKEN_EXPIRY_SECS: u64 = 5 * 60;
32
33#[derive(Debug, Clone, PartialEq, Eq)]
54pub struct AdminApiKey {
55 key_id: String,
56 hex_secret: String,
57}
58
59impl AdminApiKey {
60 pub fn new(key: impl Into<String>) -> Result<Self> {
89 let raw = key.into();
90 let raw = raw.trim();
91
92 if raw.is_empty() {
93 return Err(GhostError::auth("Admin API key cannot be empty"));
94 }
95
96 let (key_id, hex_secret) = raw.split_once(':').ok_or_else(|| {
97 GhostError::auth("Admin API key must be in the format 'id:hex_secret'")
98 })?;
99
100 let key_id = key_id.trim().to_string();
101 let hex_secret = hex_secret.trim().to_lowercase();
102
103 if key_id.is_empty() {
104 return Err(GhostError::auth("Admin API key ID cannot be empty"));
105 }
106
107 if hex_secret.is_empty() {
108 return Err(GhostError::auth("Admin API key secret cannot be empty"));
109 }
110
111 if !hex_secret.chars().all(|c| c.is_ascii_hexdigit()) {
112 return Err(GhostError::auth(
113 "Admin API key secret must contain only hexadecimal characters (0-9, a-f)",
114 ));
115 }
116
117 if hex_secret.len() % 2 != 0 {
118 return Err(GhostError::auth(
119 "Admin API key secret must have an even number of hex characters",
120 ));
121 }
122
123 Ok(Self { key_id, hex_secret })
124 }
125
126 pub fn key_id(&self) -> &str {
128 &self.key_id
129 }
130
131 pub fn is_valid(&self) -> bool {
135 !self.key_id.is_empty()
136 && !self.hex_secret.is_empty()
137 && self.hex_secret.len() % 2 == 0
138 && self.hex_secret.chars().all(|c| c.is_ascii_hexdigit())
139 }
140
141 pub fn generate_jwt(&self) -> Result<String> {
168 let iat = SystemTime::now()
169 .duration_since(UNIX_EPOCH)
170 .map_err(|e| GhostError::auth(format!("System time error: {e}")))?
171 .as_secs();
172
173 self.sign_jwt(iat)
174 }
175
176 pub(crate) fn sign_jwt(&self, iat: u64) -> Result<String> {
178 let exp = iat + TOKEN_EXPIRY_SECS;
179
180 let header = serde_json::json!({
181 "alg": "HS256",
182 "kid": self.key_id,
183 "typ": "JWT"
184 });
185 let header_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_string(&header)?.as_bytes());
186
187 let payload = serde_json::json!({
188 "exp": exp,
189 "iat": iat
190 });
191 let payload_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_string(&payload)?.as_bytes());
192
193 let signing_input = format!("{header_b64}.{payload_b64}");
194 let secret_bytes = hex_decode(&self.hex_secret)?;
195
196 let mut mac = HmacSha256::new_from_slice(&secret_bytes)
197 .map_err(|e| GhostError::auth(format!("Invalid HMAC key length: {e}")))?;
198 mac.update(signing_input.as_bytes());
199 let signature_b64 = URL_SAFE_NO_PAD.encode(mac.finalize().into_bytes());
200
201 Ok(format!("{signing_input}.{signature_b64}"))
202 }
203}
204
205fn hex_decode(hex: &str) -> Result<Vec<u8>> {
206 (0..hex.len())
207 .step_by(2)
208 .map(|i| {
209 u8::from_str_radix(&hex[i..i + 2], 16)
210 .map_err(|e| GhostError::auth(format!("Invalid hex in Admin API key secret: {e}")))
211 })
212 .collect()
213}
214
215impl fmt::Display for AdminApiKey {
216 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
231 write!(f, "AdminApiKey({}:***)", self.key_id)
232 }
233}
234
235impl AsRef<str> for AdminApiKey {
236 fn as_ref(&self) -> &str {
237 &self.key_id
238 }
239}
240
241#[cfg(test)]
242mod tests {
243 use super::*;
244
245 const VALID_KID: &str = "6748592f4b9b7700010f6564";
246 const VALID_SECRET: &str = "b1b5b9c1d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1";
247 const VALID_KEY: &str =
248 "6748592f4b9b7700010f6564:b1b5b9c1d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1";
249
250 #[test]
253 fn test_valid_key() {
254 let key = AdminApiKey::new(VALID_KEY).unwrap();
255 assert_eq!(key.key_id(), VALID_KID);
256 assert!(key.is_valid());
257 }
258
259 #[test]
260 fn test_key_trims_whitespace() {
261 let key = AdminApiKey::new(format!(" {VALID_KEY} ")).unwrap();
262 assert_eq!(key.key_id(), VALID_KID);
263 }
264
265 #[test]
266 fn test_key_normalises_secret_to_lowercase() {
267 let upper = format!("{VALID_KID}:{}", VALID_SECRET.to_uppercase());
268 let key = AdminApiKey::new(upper).unwrap();
269 assert!(key.is_valid());
270 }
271
272 #[test]
273 fn test_empty_key() {
274 let err = AdminApiKey::new("").unwrap_err();
275 assert!(err.is_auth_error());
276 assert!(err.to_string().contains("cannot be empty"));
277 }
278
279 #[test]
280 fn test_missing_separator() {
281 let err = AdminApiKey::new("noseparatoratall").unwrap_err();
282 assert!(err.is_auth_error());
283 assert!(err.to_string().contains("format 'id:hex_secret'"));
284 }
285
286 #[test]
287 fn test_empty_key_id() {
288 let err = AdminApiKey::new(":abcdef12").unwrap_err();
289 assert!(err.is_auth_error());
290 assert!(err.to_string().contains("ID cannot be empty"));
291 }
292
293 #[test]
294 fn test_empty_secret() {
295 let err = AdminApiKey::new("somekid:").unwrap_err();
296 assert!(err.is_auth_error());
297 assert!(err.to_string().contains("secret cannot be empty"));
298 }
299
300 #[test]
301 fn test_non_hex_secret() {
302 let err = AdminApiKey::new("somekid:xyz!").unwrap_err();
303 assert!(err.is_auth_error());
304 assert!(err.to_string().contains("hexadecimal characters"));
305 }
306
307 #[test]
308 fn test_odd_length_secret() {
309 let err = AdminApiKey::new("somekid:abc").unwrap_err();
310 assert!(err.is_auth_error());
311 assert!(err.to_string().contains("even number of hex characters"));
312 }
313
314 #[test]
315 fn test_clone_and_eq() {
316 let key = AdminApiKey::new(VALID_KEY).unwrap();
317 assert_eq!(key.clone(), key);
318 }
319
320 #[test]
321 fn test_is_valid() {
322 let key = AdminApiKey::new(VALID_KEY).unwrap();
323 assert!(key.is_valid());
324 }
325
326 #[test]
329 fn test_jwt_has_three_parts() {
330 let key = AdminApiKey::new(VALID_KEY).unwrap();
331 let token = key.generate_jwt().unwrap();
332 assert_eq!(token.split('.').count(), 3);
333 }
334
335 #[test]
336 fn test_jwt_header_fields() {
337 let key = AdminApiKey::new(VALID_KEY).unwrap();
338 let token = key.generate_jwt().unwrap();
339
340 let header_b64 = token.split('.').next().unwrap();
341 let header_bytes = URL_SAFE_NO_PAD.decode(header_b64).unwrap();
342 let header: serde_json::Value = serde_json::from_slice(&header_bytes).unwrap();
343
344 assert_eq!(header["alg"], "HS256");
345 assert_eq!(header["typ"], "JWT");
346 assert_eq!(header["kid"], VALID_KID);
347 }
348
349 #[test]
350 fn test_jwt_payload_claims() {
351 let key = AdminApiKey::new(VALID_KEY).unwrap();
352 let iat: u64 = 1_700_000_000;
353 let token = key.sign_jwt(iat).unwrap();
354
355 let payload_b64 = token.split('.').nth(1).unwrap();
356 let payload_bytes = URL_SAFE_NO_PAD.decode(payload_b64).unwrap();
357 let payload: serde_json::Value = serde_json::from_slice(&payload_bytes).unwrap();
358
359 assert_eq!(payload["iat"], iat);
360 assert_eq!(payload["exp"], iat + TOKEN_EXPIRY_SECS);
361 }
362
363 #[test]
364 fn test_jwt_expiry_is_five_minutes() {
365 assert_eq!(TOKEN_EXPIRY_SECS, 300);
366 }
367
368 #[test]
369 fn test_jwt_signature_is_base64url_no_pad() {
370 let key = AdminApiKey::new(VALID_KEY).unwrap();
371 let token = key.generate_jwt().unwrap();
372 let sig = token.split('.').nth(2).unwrap();
373 assert!(sig
375 .chars()
376 .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_'));
377 assert!(!sig.contains('='));
378 }
379
380 #[test]
381 fn test_jwt_is_deterministic_for_same_iat() {
382 let key = AdminApiKey::new(VALID_KEY).unwrap();
383 let iat = 1_700_000_000u64;
384 assert_eq!(key.sign_jwt(iat).unwrap(), key.sign_jwt(iat).unwrap());
385 }
386
387 #[test]
388 fn test_jwt_differs_for_different_iat() {
389 let key = AdminApiKey::new(VALID_KEY).unwrap();
390 let t1 = key.sign_jwt(1_700_000_000).unwrap();
391 let t2 = key.sign_jwt(1_700_000_001).unwrap();
392 assert_ne!(t1, t2);
393 }
394
395 #[test]
396 fn test_known_jwt_signature() {
397 let key = AdminApiKey::new(VALID_KEY).unwrap();
400 let iat = 1_700_000_000u64;
401 let token = key.sign_jwt(iat).unwrap();
402
403 let parts: Vec<&str> = token.split('.').collect();
405 assert_eq!(parts.len(), 3);
406
407 let signing_input = format!("{}.{}", parts[0], parts[1]);
408 let secret_bytes = hex_decode(VALID_SECRET).unwrap();
409 let mut mac = HmacSha256::new_from_slice(&secret_bytes).unwrap();
410 mac.update(signing_input.as_bytes());
411 let expected_sig = URL_SAFE_NO_PAD.encode(mac.finalize().into_bytes());
412
413 assert_eq!(parts[2], expected_sig);
414 }
415
416 #[test]
417 fn test_different_keys_produce_different_signatures() {
418 let key1 = AdminApiKey::new(VALID_KEY).unwrap();
419 let key2 = AdminApiKey::new(
420 "aabbccdd11223344aabbccdd:0102030405060708090a0b0c0d0e0f101112131415161718191a1b1c1d1e1f20",
421 )
422 .unwrap();
423 let iat = 1_700_000_000u64;
424 assert_ne!(key1.sign_jwt(iat).unwrap(), key2.sign_jwt(iat).unwrap());
425 }
426
427 #[test]
430 fn test_display_shows_kid_masks_secret() {
431 let key = AdminApiKey::new(VALID_KEY).unwrap();
432 let s = format!("{key}");
433 assert!(s.contains(VALID_KID));
434 assert!(!s.contains(VALID_SECRET));
435 assert!(s.contains("***"));
436 }
437
438 #[test]
439 fn test_debug_contains_struct_name() {
440 let key = AdminApiKey::new(VALID_KEY).unwrap();
441 let s = format!("{key:?}");
442 assert!(s.contains("AdminApiKey"));
443 }
444
445 #[test]
446 fn test_as_ref_returns_key_id() {
447 let key = AdminApiKey::new(VALID_KEY).unwrap();
448 let r: &str = key.as_ref();
449 assert_eq!(r, VALID_KID);
450 }
451
452 #[test]
455 fn test_hex_decode_valid() {
456 assert_eq!(
457 hex_decode("deadbeef").unwrap(),
458 vec![0xde, 0xad, 0xbe, 0xef]
459 );
460 }
461
462 #[test]
463 fn test_hex_decode_uppercase() {
464 assert_eq!(
467 hex_decode("DEADBEEF").unwrap(),
468 vec![0xde, 0xad, 0xbe, 0xef]
469 );
470 }
471
472 #[test]
473 fn test_hex_decode_invalid_char_returns_auth_error() {
474 let err = hex_decode("zz").unwrap_err();
475 assert!(err.is_auth_error());
476 }
477}