Skip to main content

rskit_auth/apikey/
rotation.rs

1//! API key manager and rotation support.
2
3use chrono::{DateTime, Duration, Utc};
4use rskit_errors::{AppError, ErrorCode};
5
6use super::{GenerateResult, Hasher, Key, Store, split_key, validate};
7
8/// Default grace period: 7 days.
9pub const DEFAULT_GRACE_PERIOD: Duration = Duration::days(7);
10
11/// Rotation settings for API keys.
12#[derive(Debug, Clone)]
13pub struct RotationConfig {
14    /// Grace period for the old key.
15    pub grace_period: Duration,
16    /// Replacement key identifier.
17    pub new_key_id: String,
18    /// Replacement key owner.
19    pub owner_id: String,
20    /// Replacement key display name.
21    pub name: String,
22    /// Replacement key prefix.
23    pub prefix: String,
24    /// Replacement scopes. Empty reuses the existing key scopes.
25    pub scopes: Vec<String>,
26    /// Optional replacement expiry.
27    pub expires_at: Option<DateTime<Utc>>,
28}
29
30impl Default for RotationConfig {
31    fn default() -> Self {
32        Self {
33            grace_period: DEFAULT_GRACE_PERIOD,
34            new_key_id: String::new(),
35            owner_id: String::new(),
36            name: String::new(),
37            prefix: String::new(),
38            scopes: Vec::new(),
39            expires_at: None,
40        }
41    }
42}
43
44/// Rotation result.
45#[derive(Debug, Clone)]
46pub struct RotationResult {
47    /// Newly issued key material.
48    pub issued: GenerateResult,
49    /// Persisted replacement record.
50    pub record: Key,
51    /// Grace period end for the old key.
52    pub grace_ends_at: DateTime<Utc>,
53}
54
55/// Manager for issuing, validating, and rotating API keys.
56pub struct Manager<S> {
57    store: S,
58    hasher: Hasher,
59}
60
61impl<S> Manager<S> {
62    /// Construct a manager.
63    #[must_use]
64    pub const fn new(store: S, hasher: Hasher) -> Self {
65        Self { store, hasher }
66    }
67
68    /// Access the configured store.
69    #[must_use]
70    pub const fn store(&self) -> &S {
71        &self.store
72    }
73
74    /// Access the configured hasher.
75    #[must_use]
76    pub const fn hasher(&self) -> &Hasher {
77        &self.hasher
78    }
79}
80
81impl<S: Store> Manager<S> {
82    /// Issue and persist a new key.
83    pub async fn issue_key(
84        &self,
85        key_id: &str,
86        owner_id: &str,
87        name: &str,
88        prefix: &str,
89        scopes: &[String],
90        expires_at: Option<DateTime<Utc>>,
91    ) -> Result<(GenerateResult, Key), AppError> {
92        let issued = self.hasher.generate_key(prefix)?;
93        let record = Key {
94            id: key_id.to_string(),
95            owner_id: owner_id.to_string(),
96            name: name.to_string(),
97            key_prefix: issued.key_prefix.clone(),
98            key_digest: issued.key_digest.clone(),
99            scopes: scopes.to_vec(),
100            is_active: true,
101            expires_at,
102            grace_ends_at: None,
103            rotated_by_id: None,
104            last_used_at: None,
105            created_at: Utc::now(),
106        };
107        self.store.create(record.clone()).await?;
108        Ok((issued, record))
109    }
110
111    /// Validate a plaintext key.
112    pub async fn validate_key_with_scopes(
113        &self,
114        plain_key: &str,
115        required_scopes: &[String],
116    ) -> Result<Key, AppError> {
117        let (key_prefix, _secret) = split_key(plain_key)?;
118        let candidates = self.store.list_by_prefix(&key_prefix).await?;
119
120        let mut matched: Option<Key> = None;
121        for candidate in candidates {
122            let digest_matches = self.hasher.compare(plain_key, &candidate.key_digest);
123            if digest_matches && matched.is_none() {
124                matched = Some(candidate);
125            }
126        }
127
128        let mut matched = matched.ok_or_else(AppError::invalid_token)?;
129        validate(&matched).map_err(|_| AppError::invalid_token())?;
130        if required_scopes
131            .iter()
132            .any(|scope| !matched.scopes.iter().any(|granted| granted == scope))
133        {
134            return Err(AppError::new(
135                ErrorCode::Forbidden,
136                String::from("insufficient API key scope"),
137            ));
138        }
139
140        let used_at = Utc::now();
141        self.store.update_last_used(&matched.id, used_at).await?;
142        matched.last_used_at = Some(used_at);
143        Ok(matched)
144    }
145
146    /// Rotate a key and issue a replacement.
147    pub async fn rotate_key(
148        &self,
149        old_key_id: &str,
150        config: RotationConfig,
151    ) -> Result<RotationResult, AppError> {
152        if config.new_key_id.is_empty() {
153            return Err(AppError::invalid_input(
154                "new_key_id",
155                "new_key_id is required for rotation",
156            ));
157        }
158
159        let old_key = self.store.get_by_id(old_key_id).await?;
160        validate(&old_key)
161            .map_err(|error| invalid_input_error(format!("cannot rotate key: {error}")))?;
162
163        let scopes = if config.scopes.is_empty() {
164            old_key.scopes.clone()
165        } else {
166            config.scopes.clone()
167        };
168        let owner_id = if config.owner_id.is_empty() {
169            old_key.owner_id.clone()
170        } else {
171            config.owner_id.clone()
172        };
173        let name = if config.name.is_empty() {
174            old_key.name.clone()
175        } else {
176            config.name.clone()
177        };
178        let prefix = if config.prefix.is_empty() {
179            old_key.key_prefix.clone()
180        } else {
181            config.prefix.clone()
182        };
183
184        let (issued, record) = self
185            .issue_key(
186                &config.new_key_id,
187                &owner_id,
188                &name,
189                &prefix,
190                &scopes,
191                config.expires_at,
192            )
193            .await?;
194
195        let grace_ends_at = Utc::now() + config.grace_period;
196        self.store
197            .set_rotation(old_key_id, grace_ends_at, Some(record.id.clone()))
198            .await?;
199
200        Ok(RotationResult {
201            issued,
202            record,
203            grace_ends_at,
204        })
205    }
206}
207
208#[async_trait::async_trait]
209impl<S: Store> super::KeyValidator for Manager<S> {
210    async fn validate_key(&self, plain_key: &str) -> Result<Key, AppError> {
211        self.validate_key_with_scopes(plain_key, &[]).await
212    }
213}
214
215fn invalid_input_error(message: impl Into<String>) -> AppError {
216    AppError::new(ErrorCode::InvalidInput, message.into())
217}
218
219#[cfg(test)]
220mod tests {
221    use std::collections::HashMap;
222
223    use async_trait::async_trait;
224    use chrono::{DateTime, Utc};
225    use parking_lot::Mutex;
226    use rskit_errors::AppError;
227
228    use super::{Manager, RotationConfig};
229    use crate::apikey::{Hasher, HashingConfig, Key, Store};
230
231    #[derive(Default)]
232    struct MemoryStore {
233        keys: Mutex<HashMap<String, Key>>,
234    }
235
236    #[async_trait]
237    impl Store for MemoryStore {
238        async fn create(&self, key: Key) -> Result<(), AppError> {
239            self.keys.lock().insert(key.id.clone(), key);
240            Ok(())
241        }
242
243        async fn list_by_prefix(&self, key_prefix: &str) -> Result<Vec<Key>, AppError> {
244            Ok(self
245                .keys
246                .lock()
247                .values()
248                .filter(|key| key.key_prefix == key_prefix)
249                .cloned()
250                .collect())
251        }
252
253        async fn get_by_id(&self, key_id: &str) -> Result<Key, AppError> {
254            self.keys
255                .lock()
256                .get(key_id)
257                .cloned()
258                .ok_or_else(|| AppError::not_found("API key", Some(key_id)))
259        }
260
261        async fn update_last_used(
262            &self,
263            key_id: &str,
264            used_at: DateTime<Utc>,
265        ) -> Result<(), AppError> {
266            if let Some(key) = self.keys.lock().get_mut(key_id) {
267                key.last_used_at = Some(used_at);
268            }
269            Ok(())
270        }
271
272        async fn set_rotation(
273            &self,
274            key_id: &str,
275            grace_ends_at: DateTime<Utc>,
276            rotated_by_id: Option<String>,
277        ) -> Result<(), AppError> {
278            if let Some(key) = self.keys.lock().get_mut(key_id) {
279                key.grace_ends_at = Some(grace_ends_at);
280                key.rotated_by_id = rotated_by_id;
281            }
282            Ok(())
283        }
284
285        async fn set_active(&self, key_id: &str, active: bool) -> Result<(), AppError> {
286            if let Some(key) = self.keys.lock().get_mut(key_id) {
287                key.is_active = active;
288            }
289            Ok(())
290        }
291
292        async fn delete(&self, key_id: &str) -> Result<(), AppError> {
293            self.keys.lock().remove(key_id);
294            Ok(())
295        }
296    }
297
298    fn manager() -> Manager<MemoryStore> {
299        Manager::new(
300            MemoryStore::default(),
301            Hasher::new(HashingConfig {
302                pepper: "p".repeat(32),
303                entropy_bytes: 32,
304            })
305            .unwrap(),
306        )
307    }
308
309    #[tokio::test]
310    async fn issue_validate_and_rotate_key() {
311        let manager = manager();
312        let (issued, record) = manager
313            .issue_key(
314                "key-1",
315                "user-1",
316                "primary",
317                "pk",
318                &[String::from("read")],
319                None,
320            )
321            .await
322            .unwrap();
323        assert_eq!(record.key_prefix, "pk");
324
325        let validated = manager
326            .validate_key_with_scopes(&issued.plain_key, &[String::from("read")])
327            .await
328            .unwrap();
329        assert_eq!(validated.owner_id, "user-1");
330        assert!(validated.last_used_at.is_some());
331
332        let rotation = manager
333            .rotate_key(
334                "key-1",
335                RotationConfig {
336                    new_key_id: String::from("key-2"),
337                    owner_id: String::from("user-1"),
338                    name: String::from("secondary"),
339                    prefix: String::from("pk"),
340                    ..RotationConfig::default()
341                },
342            )
343            .await
344            .unwrap();
345        assert_eq!(rotation.record.id, "key-2");
346        let original = manager.store().get_by_id("key-1").await.unwrap();
347        assert_eq!(original.rotated_by_id.as_deref(), Some("key-2"));
348    }
349
350    #[tokio::test]
351    async fn rotate_key_inherits_existing_metadata_when_config_fields_are_empty() {
352        let manager = manager();
353        let (_issued, original) = manager
354            .issue_key(
355                "key-1",
356                "owner-1",
357                "primary",
358                "pk",
359                &[String::from("read"), String::from("write")],
360                None,
361            )
362            .await
363            .unwrap();
364
365        let rotation = manager
366            .rotate_key(
367                &original.id,
368                RotationConfig {
369                    new_key_id: String::from("key-2"),
370                    ..RotationConfig::default()
371                },
372            )
373            .await
374            .unwrap();
375
376        assert_eq!(rotation.record.owner_id, "owner-1");
377        assert_eq!(rotation.record.name, "primary");
378        assert_eq!(rotation.record.key_prefix, "pk");
379        assert_eq!(
380            rotation.record.scopes,
381            vec![String::from("read"), String::from("write")]
382        );
383        assert!(rotation.grace_ends_at > Utc::now());
384        assert!(!rotation.issued.plain_key.is_empty());
385    }
386
387    #[tokio::test]
388    async fn rotate_key_rejects_missing_new_key_id_before_mutating_store() {
389        let manager = manager();
390        manager
391            .issue_key("key-1", "owner-1", "primary", "pk", &[], None)
392            .await
393            .unwrap();
394
395        let error = manager
396            .rotate_key("key-1", RotationConfig::default())
397            .await
398            .unwrap_err();
399
400        assert_eq!(error.code(), rskit_errors::ErrorCode::InvalidInput);
401        let original = manager.store().get_by_id("key-1").await.unwrap();
402        assert!(original.rotated_by_id.is_none());
403    }
404}