Skip to main content

ferro_cli/commands/
make_api_key.rs

1//! `ferro make:api-key` command — generates API keys for authentication.
2//!
3//! Replicates the key generation logic from `framework/src/api/api_key.rs`
4//! without depending on the framework crate.
5
6use console::style;
7use sha2::{Digest, Sha256};
8
9const BASE62: &[u8] = b"0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
10
11/// Result of generating a new API key.
12pub(crate) struct GeneratedApiKey {
13    /// Full key (display once, never store).
14    pub raw_key: String,
15    /// First 16 characters for database lookup.
16    pub prefix: String,
17    /// SHA-256 hex digest for verification.
18    pub hashed_key: String,
19}
20
21/// Generate a new API key for the given environment.
22///
23/// Returns `None` if the environment is not "live" or "test".
24pub(crate) fn generate_api_key(env: &str) -> Option<GeneratedApiKey> {
25    if env != "live" && env != "test" {
26        return None;
27    }
28
29    let prefix_str = format!("fe_{env}_");
30    let mut rng = rand::thread_rng();
31    let random: String = (0..43)
32        .map(|_| {
33            let idx = rand::Rng::gen_range(&mut rng, 0..62);
34            BASE62[idx] as char
35        })
36        .collect();
37
38    let raw_key = format!("{prefix_str}{random}");
39    let prefix = raw_key[..16].to_string();
40
41    let mut hasher = Sha256::new();
42    hasher.update(raw_key.as_bytes());
43    let hashed_key = format!("{:x}", hasher.finalize());
44
45    Some(GeneratedApiKey {
46        raw_key,
47        prefix,
48        hashed_key,
49    })
50}
51
52/// Run the `make:api-key` command.
53pub fn run(name: String, env: String) {
54    let key = match generate_api_key(&env) {
55        Some(k) => k,
56        None => {
57            eprintln!(
58                "{} Invalid environment '{}'. Must be 'live' or 'test'.",
59                style("Error:").red().bold(),
60                env
61            );
62            std::process::exit(1);
63        }
64    };
65
66    println!();
67    println!("{}", style("API Key Generated!").green().bold());
68    println!();
69    println!(
70        "{}",
71        style("Raw Key (save this — shown only once):")
72            .yellow()
73            .bold()
74    );
75    println!("  {}", style(&key.raw_key).green());
76    println!();
77    println!("Database values:");
78    println!("  Name:       {name}");
79    println!("  Prefix:     {}", key.prefix);
80    println!("  Hashed Key: {}", key.hashed_key);
81    println!();
82    println!("Insert SQL:");
83    println!("  INSERT INTO api_keys (name, prefix, hashed_key, created_at)");
84    println!(
85        "  VALUES ('{name}', '{}', '{}', datetime('now'));",
86        key.prefix, key.hashed_key
87    );
88    println!();
89    println!("Rust snippet:");
90    println!("  let key = api_keys::ActiveModel {{");
91    println!("      name: Set(\"{name}\".to_string()),");
92    println!("      prefix: Set(\"{}\".to_string()),", key.prefix);
93    println!("      hashed_key: Set(\"{}\".to_string()),", key.hashed_key);
94    println!("      ..Default::default()");
95    println!("  }};");
96    println!("  key.insert(db).await?;");
97    println!();
98}
99
100#[cfg(test)]
101mod tests {
102    use super::*;
103
104    #[test]
105    fn key_format_live() {
106        let key = generate_api_key("live").unwrap();
107        assert!(key.raw_key.starts_with("fe_live_"));
108        // fe_live_ = 8 chars + 43 random = 51 total
109        assert_eq!(key.raw_key.len(), 51);
110    }
111
112    #[test]
113    fn key_format_test() {
114        let key = generate_api_key("test").unwrap();
115        assert!(key.raw_key.starts_with("fe_test_"));
116        assert_eq!(key.raw_key.len(), 51);
117    }
118
119    #[test]
120    fn prefix_is_first_16_chars() {
121        let key = generate_api_key("live").unwrap();
122        assert_eq!(key.prefix.len(), 16);
123        assert_eq!(key.prefix, &key.raw_key[..16]);
124    }
125
126    #[test]
127    fn hash_is_valid_sha256_hex() {
128        let key = generate_api_key("live").unwrap();
129        assert_eq!(key.hashed_key.len(), 64);
130        assert!(key.hashed_key.chars().all(|c| c.is_ascii_hexdigit()));
131    }
132
133    #[test]
134    fn hash_matches_raw_key() {
135        let key = generate_api_key("live").unwrap();
136        let mut hasher = Sha256::new();
137        hasher.update(key.raw_key.as_bytes());
138        let expected = format!("{:x}", hasher.finalize());
139        assert_eq!(key.hashed_key, expected);
140    }
141
142    #[test]
143    fn consecutive_keys_are_unique() {
144        let key1 = generate_api_key("live").unwrap();
145        let key2 = generate_api_key("live").unwrap();
146        assert_ne!(key1.raw_key, key2.raw_key);
147        assert_ne!(key1.hashed_key, key2.hashed_key);
148    }
149
150    #[test]
151    fn random_part_is_base62() {
152        let key = generate_api_key("live").unwrap();
153        let random_part = &key.raw_key[8..]; // skip "fe_live_"
154        assert!(random_part.chars().all(|c| c.is_ascii_alphanumeric()));
155    }
156
157    #[test]
158    fn invalid_env_returns_none() {
159        assert!(generate_api_key("staging").is_none());
160        assert!(generate_api_key("production").is_none());
161        assert!(generate_api_key("").is_none());
162    }
163}