Skip to main content

ghost_io_api/auth/
admin.rs

1//! Admin API authentication using JWT.
2//!
3//! The Ghost Admin API uses short-lived JWTs signed with HMAC-SHA256 (HS256).
4//! Admin API keys have the format `{id}:{hex_secret}` where `id` is placed in
5//! the JWT `kid` header and `hex_secret` (hex-decoded) is the HMAC signing key.
6//!
7//! Tokens expire after 5 minutes and carry only `iat` and `exp` claims.
8//!
9//! # Example
10//!
11//! ```
12//! use ghost_io_api::auth::admin::AdminApiKey;
13//!
14//! let raw = "6748592f4b9b7700010f6564:b1b5b9c1d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1";
15//! let key = AdminApiKey::new(raw).unwrap();
16//! let token = key.generate_jwt().unwrap();
17//! assert!(!token.is_empty());
18//! ```
19
20use 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
30/// Lifetime of a generated JWT in seconds (5 minutes).
31const TOKEN_EXPIRY_SECS: u64 = 5 * 60;
32
33/// Admin API key for JWT-based authentication.
34///
35/// Ghost Admin API keys have the format `{id}:{hex_secret}` where:
36/// - `id` is the key identifier placed in the JWT `kid` header
37/// - `hex_secret` is a hex-encoded byte string used as the HMAC-SHA256 signing key
38///
39/// # Security
40///
41/// Admin API keys grant **write access** to your Ghost installation. Never
42/// expose them in client-side code or public repositories.
43///
44/// # Example
45///
46/// ```
47/// use ghost_io_api::auth::admin::AdminApiKey;
48///
49/// let raw = "6748592f4b9b7700010f6564:b1b5b9c1d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1";
50/// let key = AdminApiKey::new(raw).unwrap();
51/// assert_eq!(key.key_id(), "6748592f4b9b7700010f6564");
52/// ```
53#[derive(Debug, Clone, PartialEq, Eq)]
54pub struct AdminApiKey {
55    key_id: String,
56    hex_secret: String,
57}
58
59impl AdminApiKey {
60    /// Creates a new Admin API key from the Ghost `{id}:{hex_secret}` format.
61    ///
62    /// # Validation
63    ///
64    /// - Must contain exactly one `:` separator
65    /// - `id` must be non-empty
66    /// - `hex_secret` must be non-empty, contain only hex digits, and have an even length
67    ///
68    /// # Errors
69    ///
70    /// Returns [`GhostError::Auth`] if the key is malformed.
71    ///
72    /// # Example
73    ///
74    /// ```
75    /// use ghost_io_api::auth::admin::AdminApiKey;
76    ///
77    /// let key = AdminApiKey::new(
78    ///     "6748592f4b9b7700010f6564:b1b5b9c1d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1"
79    /// ).unwrap();
80    /// assert!(key.is_valid());
81    ///
82    /// // Missing separator
83    /// assert!(AdminApiKey::new("noseparator").is_err());
84    ///
85    /// // Non-hex secret
86    /// assert!(AdminApiKey::new("kid:xyz").is_err());
87    /// ```
88    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    /// Returns the key ID used as the JWT `kid` header value.
127    pub fn key_id(&self) -> &str {
128        &self.key_id
129    }
130
131    /// Returns `true` if the key is structurally valid.
132    ///
133    /// Keys constructed via [`AdminApiKey::new`] always pass this check.
134    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    /// Generates a signed HS256 JWT for use with the Ghost Admin API.
142    ///
143    /// The token includes:
144    /// - Header: `{"alg":"HS256","kid":"<id>","typ":"JWT"}`
145    /// - Payload: `{"exp":<now+300>,"iat":<now>}`
146    /// - Signature: HMAC-SHA256 over `<header_b64>.<payload_b64>`, keyed with
147    ///   the hex-decoded secret
148    ///
149    /// # Errors
150    ///
151    /// Returns [`GhostError::Auth`] if the system clock is before the Unix
152    /// epoch or if the secret bytes are rejected by the HMAC implementation.
153    ///
154    /// # Example
155    ///
156    /// ```
157    /// use ghost_io_api::auth::admin::AdminApiKey;
158    ///
159    /// let key = AdminApiKey::new(
160    ///     "6748592f4b9b7700010f6564:b1b5b9c1d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1"
161    /// ).unwrap();
162    /// let token = key.generate_jwt().unwrap();
163    ///
164    /// // JWT has three dot-separated parts
165    /// assert_eq!(token.split('.').count(), 3);
166    /// ```
167    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    /// Generates a JWT with an explicit `iat` timestamp (used in tests).
177    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    /// Formats the key for display, masking the secret entirely.
217    ///
218    /// # Example
219    ///
220    /// ```
221    /// use ghost_io_api::auth::admin::AdminApiKey;
222    ///
223    /// let key = AdminApiKey::new(
224    ///     "6748592f4b9b7700010f6564:b1b5b9c1d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3f4a5b6c7d8e9f0a1"
225    /// ).unwrap();
226    /// let display = format!("{key}");
227    /// assert!(display.contains("6748592f4b9b7700010f6564"));
228    /// assert!(!display.contains("b1b5b9c1"));
229    /// ```
230    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    // ── construction ──────────────────────────────────────────────────────────
251
252    #[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    // ── JWT structure ─────────────────────────────────────────────────────────
327
328    #[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        // Base64url chars only; no `=` padding
374        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        // Verifies the exact HMAC-SHA256 output against a reference value so
398        // a regression in signing logic is caught immediately.
399        let key = AdminApiKey::new(VALID_KEY).unwrap();
400        let iat = 1_700_000_000u64;
401        let token = key.sign_jwt(iat).unwrap();
402
403        // Re-derive expected signature independently
404        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    // ── display / trait impls ─────────────────────────────────────────────────
428
429    #[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    // ── hex_decode helper ─────────────────────────────────────────────────────
453
454    #[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        // AdminApiKey::new normalises to lowercase before storing, but the
465        // helper itself accepts whatever it receives.
466        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}