1use chrono::{DateTime, Duration, Utc};
4use rskit_errors::{AppError, ErrorCode};
5
6use super::{GenerateResult, Hasher, Key, Store, split_key, validate};
7
8pub const DEFAULT_GRACE_PERIOD: Duration = Duration::days(7);
10
11#[derive(Debug, Clone)]
13pub struct RotationConfig {
14 pub grace_period: Duration,
16 pub new_key_id: String,
18 pub owner_id: String,
20 pub name: String,
22 pub prefix: String,
24 pub scopes: Vec<String>,
26 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#[derive(Debug, Clone)]
46pub struct RotationResult {
47 pub issued: GenerateResult,
49 pub record: Key,
51 pub grace_ends_at: DateTime<Utc>,
53}
54
55pub struct Manager<S> {
57 store: S,
58 hasher: Hasher,
59}
60
61impl<S> Manager<S> {
62 #[must_use]
64 pub const fn new(store: S, hasher: Hasher) -> Self {
65 Self { store, hasher }
66 }
67
68 #[must_use]
70 pub const fn store(&self) -> &S {
71 &self.store
72 }
73
74 #[must_use]
76 pub const fn hasher(&self) -> &Hasher {
77 &self.hasher
78 }
79}
80
81impl<S: Store> Manager<S> {
82 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 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 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}