1use std::collections::{BTreeMap, HashMap, HashSet};
2use std::sync::atomic::{AtomicBool, Ordering};
3use std::sync::{Arc, OnceLock, Weak};
4
5use better_auth_core::store::SecondaryStorage;
6use better_auth_core::wire::ApiKeyView;
7use better_auth_core::{
8 ApiKey, AuthContext, AuthError, AuthResult, CreateApiKey, FieldValue, UpdateApiKey,
9};
10use chrono::Utc;
11use futures_util::{StreamExt, TryFutureExt, future, stream};
12use serde_json::Value;
13use tokio::sync::Mutex;
14
15use super::ApiKeyConfig;
16
17const STORAGE_CONCURRENCY: usize = 10;
18
19mod usage;
20pub(super) use usage::consume;
21#[cfg(test)]
22mod batch_tests;
23
24#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
26pub enum ApiKeyStorage {
27 #[default]
28 Database,
29 SecondaryStorage,
30}
31
32pub(super) fn backend<'a>(
33 config: &'a ApiKeyConfig,
34 ctx: &'a AuthContext<impl better_auth_core::AuthSchema>,
35) -> Option<&'a Arc<dyn SecondaryStorage>> {
36 config
37 .custom_storage
38 .as_ref()
39 .or(ctx.secondary_storage.as_ref())
40}
41
42pub(super) fn group(config: &ApiKeyConfig) -> String {
43 if config.storage == ApiKeyStorage::Database {
44 "database".into()
45 } else if config.custom_storage.is_some() {
46 format!("custom:{}", config.config_id)
47 } else if config.fallback_to_database {
48 "secondary-storage-with-fallback".into()
49 } else {
50 "secondary-storage".into()
51 }
52}
53
54fn required_backend<'a>(
55 config: &'a ApiKeyConfig,
56 ctx: &'a AuthContext<impl better_auth_core::AuthSchema>,
57) -> AuthResult<&'a Arc<dyn SecondaryStorage>> {
58 backend(config, ctx).ok_or_else(|| {
59 AuthError::internal(
60 "Secondary storage is required when storage mode is 'secondary-storage'",
61 )
62 })
63}
64
65pub(super) fn now() -> better_auth_core::FieldDate {
66 Utc::now().into()
67}
68
69fn ttl(key: &ApiKey) -> Option<u64> {
70 key.expires_at.as_ref().and_then(|expires| {
71 let seconds =
72 ((expires.milliseconds() - Utc::now().timestamp_millis() as f64) / 1000.0).floor();
73 (seconds > 0.0).then_some(seconds as u64)
74 })
75}
76
77fn serialize(key: &ApiKey) -> AuthResult<String> {
78 let mut value: BTreeMap<String, Box<serde_json::value::RawValue>> =
79 serde_json::from_str(&serde_json::to_string(key)?)?;
80 let metadata = key
82 .metadata
83 .as_deref()
84 .map(serde_json::from_str)
85 .transpose()?
86 .unwrap_or(Value::Null);
87 let _ = value.insert(
88 "metadata".into(),
89 serde_json::value::to_raw_value(&metadata)?,
90 );
91 Ok(serde_json::to_string(&value)?)
92}
93
94fn deserialize(value: Option<Value>) -> Option<ApiKey> {
95 let Value::String(value) = value? else {
96 return None;
97 };
98 let mut object: BTreeMap<String, Box<serde_json::value::RawValue>> =
100 serde_json::from_str(&value).ok()?;
101 if let Some(metadata) = object.get_mut("metadata") {
102 let decoded: Value = serde_json::from_str(metadata.get()).ok()?;
103 if !decoded.is_null() {
104 *metadata = serde_json::value::to_raw_value(&decoded.to_string()).ok()?;
105 }
106 }
107 if object
108 .get("configId")
109 .is_none_or(|value| value.get() == "null")
110 {
111 let _ = object.insert("configId".into(), serde_json::value::to_raw_value("").ok()?);
112 }
113 serde_json::from_str(&serde_json::to_string(&object).ok()?).ok()
114}
115
116async fn cached(storage: &dyn SecondaryStorage, key: &str) -> AuthResult<Option<ApiKey>> {
117 Ok(deserialize(storage.get(key).await?))
118}
119
120fn reference_ids(value: Option<Value>) -> Vec<better_auth_core::SchemaValue<String>> {
121 let value = match value {
122 Some(Value::String(value)) => serde_json::from_str(&value).ok(),
123 value => value,
124 };
125 value
126 .and_then(|value| serde_json::from_value(value).ok())
127 .unwrap_or_default()
128}
129
130async fn modify_reference(
131 storage: &dyn SecondaryStorage,
132 key: &ApiKey,
133 insert: bool,
134) -> AuthResult<()> {
135 let index = format!("api-key:by-ref:{}", key.reference_id);
138 static LOCKS: OnceLock<std::sync::Mutex<HashMap<String, Weak<Mutex<()>>>>> = OnceLock::new();
139 let lock = {
140 let mut locks = LOCKS
141 .get_or_init(Default::default)
142 .lock()
143 .map_err(|_| AuthError::internal("API key reference-list lock poisoned"))?;
144 locks.retain(|_, lock| lock.strong_count() > 0);
145 match locks.get(&index).and_then(Weak::upgrade) {
146 Some(lock) => lock,
147 None => {
148 let lock = Arc::new(Mutex::new(()));
149 let _ = locks.insert(index.clone(), Arc::downgrade(&lock));
150 lock
151 }
152 }
153 };
154 let _guard = lock.lock().await;
155 let mut ids = reference_ids(storage.get(&index).await?);
156 if insert {
157 if !ids.contains(&key.id) {
158 ids.push(key.id.clone());
159 }
160 } else {
161 ids.retain(|id| id != &key.id);
162 }
163 if ids.is_empty() {
164 storage.delete(&index).await
165 } else {
166 storage
167 .set(&index, &serde_json::to_string(&ids)?, None)
168 .await
169 }
170}
171
172pub(super) async fn put(
173 storage: &dyn SecondaryStorage,
174 key: &ApiKey,
175 fallback: bool,
176) -> AuthResult<()> {
177 put_with_failure_flag(storage, key, fallback, None).await
178}
179
180async fn put_with_failure_flag(
181 storage: &dyn SecondaryStorage,
182 key: &ApiKey,
183 fallback: bool,
184 failed: Option<&AtomicBool>,
185) -> AuthResult<()> {
186 let value = serialize(key)?;
187 let ttl = ttl(key);
188 let hashed = format!("api-key:{}", key.key_hash);
189 let id = format!("api-key:by-id:{}", key.id.display_string()?);
190 let reference = format!("api-key:by-ref:{}", key.reference_id);
191 if fallback {
192 let stop_batch = |_: &AuthError| {
194 if let Some(failed) = failed {
195 failed.store(true, Ordering::Relaxed);
196 }
197 };
198 let (hashed, id, reference) = tokio::join!(
199 storage.set(&hashed, &value, ttl).inspect_err(stop_batch),
200 storage.set(&id, &value, ttl).inspect_err(stop_batch),
201 storage.delete(&reference).inspect_err(stop_batch)
202 );
203 hashed?;
204 id?;
205 reference
206 } else {
207 let (hashed, id) = tokio::join!(
208 storage.set(&hashed, &value, ttl),
209 storage.set(&id, &value, ttl)
210 );
211 hashed?;
212 id?;
213 modify_reference(storage, key, true).await
214 }
215}
216
217async fn remove_cached(
218 storage: &dyn SecondaryStorage,
219 key: &ApiKey,
220 fallback: bool,
221) -> AuthResult<()> {
222 let hashed = format!("api-key:{}", key.key_hash);
223 let id = format!("api-key:by-id:{}", key.id.display_string()?);
224 let reference = format!("api-key:by-ref:{}", key.reference_id);
225 if fallback {
226 let (hashed, id, reference) = tokio::join!(
227 storage.delete(&hashed),
228 storage.delete(&id),
229 storage.delete(&reference)
230 );
231 hashed?;
232 id?;
233 reference
234 } else {
235 let (hashed, id, reference) = tokio::join!(
236 storage.delete(&hashed),
237 storage.delete(&id),
238 modify_reference(storage, key, false)
239 );
240 hashed?;
241 id?;
242 reference
243 }
244}
245
246pub(crate) async fn get_by_id(
247 config: &ApiKeyConfig,
248 ctx: &AuthContext<impl better_auth_core::AuthSchema>,
249 id: &str,
250) -> AuthResult<Option<ApiKey>> {
251 get(config, ctx, id, false).await
252}
253
254pub(super) async fn get_by_hash(
255 config: &ApiKeyConfig,
256 ctx: &AuthContext<impl better_auth_core::AuthSchema>,
257 hash: &str,
258) -> AuthResult<Option<ApiKey>> {
259 get(config, ctx, hash, true).await
260}
261
262async fn get(
263 config: &ApiKeyConfig,
264 ctx: &AuthContext<impl better_auth_core::AuthSchema>,
265 value: &str,
266 hash: bool,
267) -> AuthResult<Option<ApiKey>> {
268 let storage = backend(config, ctx);
269 if config.storage == ApiKeyStorage::SecondaryStorage {
270 if let Some(storage) = storage {
271 let key = if hash {
272 format!("api-key:{value}")
273 } else {
274 format!("api-key:by-id:{value}")
275 };
276 if let Some(key) = cached(storage.as_ref(), &key).await? {
277 return Ok(Some(key));
278 }
279 }
280 if !config.fallback_to_database {
281 return Ok(None);
282 }
283 }
284 let key = if hash {
285 ctx.database.get_api_key_by_hash(value).await?
286 } else {
287 ctx.database.get_api_key_by_id(value).await?
288 };
289 if config.storage == ApiKeyStorage::SecondaryStorage
290 && let (Some(storage), Some(key)) = (storage, key.as_ref())
291 {
292 put(storage.as_ref(), key, config.fallback_to_database).await?;
293 }
294 Ok(key)
295}
296
297pub(super) async fn create(
298 config: &ApiKeyConfig,
299 ctx: &AuthContext<impl better_auth_core::AuthSchema>,
300 input: CreateApiKey,
301) -> AuthResult<ApiKey> {
302 let key = if config.storage == ApiKeyStorage::Database || config.fallback_to_database {
303 ctx.database.create_api_key(input).await?
304 } else {
305 let created_at = now();
306 ApiKey {
307 additional_fields: Default::default(),
308 id: ctx
309 .config
310 .advanced
311 .generate_id("apikey", None)?
312 .filter(|id| !id.is_empty())
313 .unwrap_or_else(|| better_auth_core::id::random_id(None))
314 .into(),
315 name: input.name,
316 start: input.start,
317 prefix: input.prefix,
318 key_hash: input.key_hash,
319 reference_id: input.reference_id,
320 config_id: input.config_id,
321 refill_interval: input.refill_interval,
322 refill_amount: input.refill_amount,
323 last_refill_at: None,
324 enabled: input.enabled.into(),
325 rate_limit_enabled: input.rate_limit_enabled.into(),
326 rate_limit_time_window: input.rate_limit_time_window,
327 rate_limit_max: input.rate_limit_max,
328 request_count: Some(0.0),
329 remaining: input.remaining,
330 last_request: None,
331 expires_at: input.expires_at,
332 created_at: created_at.clone(),
333 updated_at: created_at,
334 permissions: input.permissions,
335 metadata: input.metadata,
336 }
337 };
338 if config.storage == ApiKeyStorage::SecondaryStorage {
339 put(
340 required_backend(config, ctx)?.as_ref(),
341 &key,
342 config.fallback_to_database,
343 )
344 .await?;
345 }
346 Ok(key)
347}
348
349pub(super) fn apply_update(key: &mut ApiKey, update: UpdateApiKey) {
350 if let Some(name) = update.name {
351 key.name = name;
352 }
353 macro_rules! optional { ($($field:ident),* $(,)?) => { $(if let Some(value) = update.$field { key.$field = Some(value); })* }; }
354 optional!(
355 remaining,
356 rate_limit_time_window,
357 rate_limit_max,
358 refill_interval,
359 refill_amount,
360 permissions,
361 metadata,
362 request_count
363 );
364 if let Some(value) = update.enabled {
365 key.enabled = value.into();
366 }
367 if let Some(value) = update.rate_limit_enabled {
368 key.rate_limit_enabled = value.into();
369 }
370 if let Some(value) = update.expires_at {
371 key.expires_at = value;
372 }
373 if let Some(value) = update.last_request {
374 key.last_request = value;
375 }
376 if let Some(value) = update.last_refill_at {
377 key.last_refill_at = value;
378 }
379 key.updated_at = now();
380}
381
382pub(super) async fn update(
383 config: &ApiKeyConfig,
384 ctx: &AuthContext<impl better_auth_core::AuthSchema>,
385 mut key: ApiKey,
386 update: UpdateApiKey,
387) -> AuthResult<ApiKey> {
388 if config.storage == ApiKeyStorage::Database || config.fallback_to_database {
389 key = match ctx.database.update_api_key(&key.id, update).await {
390 Ok(updated) => updated,
391 Err(AuthError::NotFound(_)) => return Ok(key),
393 Err(error) => return Err(error),
394 };
395 } else {
396 apply_update(&mut key, update);
397 }
398 if config.storage == ApiKeyStorage::SecondaryStorage {
399 put(
400 required_backend(config, ctx)?.as_ref(),
401 &key,
402 config.fallback_to_database,
403 )
404 .await?;
405 }
406 Ok(key)
407}
408
409pub(super) async fn delete(
410 config: &ApiKeyConfig,
411 ctx: &AuthContext<impl better_auth_core::AuthSchema>,
412 key: &ApiKey,
413) -> AuthResult<()> {
414 if config.storage == ApiKeyStorage::SecondaryStorage {
415 remove_cached(
416 required_backend(config, ctx)?.as_ref(),
417 key,
418 config.fallback_to_database,
419 )
420 .await?;
421 }
422 if config.storage == ApiKeyStorage::Database || config.fallback_to_database {
423 ctx.database.delete_api_key(&key.id).await?;
424 }
425 Ok(())
426}
427
428pub(super) async fn delete_for_verification(
429 config: &ApiKeyConfig,
430 ctx: &AuthContext<impl better_auth_core::AuthSchema>,
431 key: &ApiKey,
432) -> AuthResult<()> {
433 if !config.defer_updates {
434 return delete(config, ctx, key).await;
435 }
436 let storage = backend(config, ctx).cloned();
437 let database = ctx.database.clone();
438 let config = config.clone();
439 let key = key.clone();
440 let _task = tokio::spawn(async move {
441 let result: AuthResult<()> = async {
442 if config.storage == ApiKeyStorage::SecondaryStorage {
443 let storage = storage.ok_or_else(|| {
444 AuthError::internal(
445 "Secondary storage is required when storage mode is 'secondary-storage'",
446 )
447 })?;
448 remove_cached(storage.as_ref(), &key, config.fallback_to_database).await?;
449 }
450 if config.storage == ApiKeyStorage::Database || config.fallback_to_database {
451 database.delete_api_key(&key.id).await?;
452 }
453 Ok(())
454 }
455 .await;
456 if let Err(error) = result {
457 better_auth_core::observability::logger::current().error(
458 "Deferred API key deletion failed",
459 &[better_auth_core::observability::LogArgument::Error(&error)],
460 );
461 }
462 });
463 Ok(())
464}
465
466pub(super) async fn list(
467 config: &ApiKeyConfig,
468 ctx: &AuthContext<impl better_auth_core::AuthSchema>,
469 reference: &str,
470 sort: Option<(&str, &str)>,
471) -> AuthResult<Vec<ApiKey>> {
472 let storage = backend(config, ctx);
473 if config.storage == ApiKeyStorage::SecondaryStorage {
474 if let Some(storage) = storage {
475 let ids = reference_ids(storage.get(&format!("api-key:by-ref:{reference}")).await?);
476 if !ids.is_empty() || !config.fallback_to_database {
477 let failed = AtomicBool::new(false);
478 let mut results = stream::iter(ids.into_iter().enumerate())
479 .take_while(|_| future::ready(!failed.load(Ordering::Relaxed)))
480 .map(|(index, id)| {
481 let failed = &failed;
482 async move {
483 let result = async {
484 cached(
485 storage.as_ref(),
486 &format!("api-key:by-id:{}", id.display_string()?),
487 )
488 .await
489 }
490 .await;
491 if result.is_err() {
492 failed.store(true, Ordering::Relaxed);
493 }
494 result.map(|key| (index, key))
495 }
496 })
497 .buffer_unordered(STORAGE_CONCURRENCY)
498 .collect::<Vec<_>>()
501 .await
502 .into_iter()
503 .collect::<AuthResult<Vec<_>>>()?;
504 results.sort_unstable_by_key(|(index, _)| *index);
505 let mut keys: Vec<_> = results.into_iter().filter_map(|(_, key)| key).collect();
506 if let Some((field, direction)) = sort {
507 let mut views: Vec<_> = keys
508 .iter()
509 .map(better_auth_core::wire::ApiKeyView::from)
510 .collect();
511 sort_views(&mut views, field, Some(direction))?;
512 let mut by_id: std::collections::HashMap<_, _> = keys
513 .into_iter()
514 .map(|key| (key.id.as_str().map(str::to_owned), key))
515 .collect();
516 keys = views
517 .into_iter()
518 .filter_map(|view| by_id.remove(&view.id.as_str().map(str::to_owned)))
519 .collect();
520 }
521 return Ok(keys);
522 }
523 }
524 if !config.fallback_to_database {
525 return Ok(Vec::new());
526 }
527 }
528 let (keys, total) = tokio::join!(
529 ctx.database.find_api_keys_by_reference(reference, sort),
530 ctx.database.count_api_keys_by_reference(reference),
531 );
532 let mut keys = keys?;
533 let _ = total?;
536 if config.storage == ApiKeyStorage::SecondaryStorage
537 && !keys.is_empty()
538 && let Some(storage) = storage
539 {
540 let failed = AtomicBool::new(false);
541 let mut results = stream::iter(keys.into_iter().enumerate())
542 .take_while(|_| future::ready(!failed.load(Ordering::Relaxed)))
543 .map(|(index, key)| {
544 let failed = &failed;
545 async move {
546 let result =
547 put_with_failure_flag(storage.as_ref(), &key, true, Some(failed)).await;
548 if result.is_err() {
549 failed.store(true, Ordering::Relaxed);
550 }
551 result.map(|()| (index, key))
552 }
553 })
554 .buffer_unordered(STORAGE_CONCURRENCY)
555 .collect::<Vec<_>>()
556 .await
557 .into_iter()
558 .collect::<AuthResult<Vec<_>>>()?;
559 results.sort_unstable_by_key(|(index, _)| *index);
560 keys = results.into_iter().map(|(_, key)| key).collect();
561 let ids: Vec<_> = keys.iter().map(|key| &key.id).collect();
562 storage
563 .set(
564 &format!("api-key:by-ref:{reference}"),
565 &serde_json::to_string(&ids)?,
566 None,
567 )
568 .await?;
569 }
570 Ok(keys)
571}
572
573fn compare_numbers(left: Option<f64>, right: Option<f64>) -> std::cmp::Ordering {
574 match (left, right) {
575 (Some(left), Some(right)) => left
576 .partial_cmp(&right)
577 .unwrap_or(std::cmp::Ordering::Equal),
578 (None, None) => std::cmp::Ordering::Equal,
579 (None, Some(_)) => std::cmp::Ordering::Less,
580 (Some(_), None) => std::cmp::Ordering::Greater,
581 }
582}
583
584fn compare_strings(left: Option<&str>, right: Option<&str>) -> std::cmp::Ordering {
585 match (left, right) {
586 (Some(left), Some(right)) => left.encode_utf16().cmp(right.encode_utf16()),
587 _ => left.cmp(&right),
588 }
589}
590
591fn sort_views(views: &mut [ApiKeyView], sort_by: &str, direction: Option<&str>) -> AuthResult<()> {
592 use std::cmp::Ordering::{Equal, Greater, Less};
593
594 let compare = |a: &ApiKeyView, b: &ApiKeyView| -> AuthResult<std::cmp::Ordering> {
595 let ordering = match sort_by {
596 "name" | "enabled" | "rateLimitEnabled" => {
597 let (left, right) = match sort_by {
598 "enabled" => (a.enabled.field_value(), b.enabled.field_value()),
599 "rateLimitEnabled" => (
600 a.rate_limit_enabled.field_value(),
601 b.rate_limit_enabled.field_value(),
602 ),
603 _ => (a.name.field_value(), b.name.field_value()),
604 };
605 match (&left, &right) {
606 (
607 FieldValue::Null | FieldValue::Undefined,
608 FieldValue::Null | FieldValue::Undefined,
609 ) => Equal,
610 (FieldValue::Null | FieldValue::Undefined, _) => Less,
611 (_, FieldValue::Null | FieldValue::Undefined) => Greater,
612 _ => better_auth_core::query::field_compare(&left, &right)?.unwrap_or(Equal),
613 }
614 }
615 "id" => compare_strings(a.id.as_str(), b.id.as_str()),
616 "start" => a.start.cmp(&b.start),
617 "prefix" => compare_strings(a.prefix.as_deref(), b.prefix.as_deref()),
618 "referenceId" => compare_strings(Some(&a.reference_id), Some(&b.reference_id)),
619 "configId" => compare_strings(Some(&a.config_id), Some(&b.config_id)),
620 "createdAt" => compare_numbers(
621 Some(a.created_at.milliseconds()),
622 Some(b.created_at.milliseconds()),
623 ),
624 "updatedAt" => compare_numbers(
625 Some(a.updated_at.milliseconds()),
626 Some(b.updated_at.milliseconds()),
627 ),
628 "expiresAt" => compare_numbers(
629 a.expires_at
630 .as_ref()
631 .map(better_auth_core::FieldDate::milliseconds),
632 b.expires_at
633 .as_ref()
634 .map(better_auth_core::FieldDate::milliseconds),
635 ),
636 "lastRequest" => compare_numbers(
637 a.last_request
638 .as_ref()
639 .map(better_auth_core::FieldDate::milliseconds),
640 b.last_request
641 .as_ref()
642 .map(better_auth_core::FieldDate::milliseconds),
643 ),
644 "lastRefillAt" => compare_numbers(
645 a.last_refill_at
646 .as_ref()
647 .map(better_auth_core::FieldDate::milliseconds),
648 b.last_refill_at
649 .as_ref()
650 .map(better_auth_core::FieldDate::milliseconds),
651 ),
652 "remaining" => compare_numbers(a.remaining, b.remaining),
653 "requestCount" => compare_numbers(a.request_count, b.request_count),
654 "rateLimitMax" => compare_numbers(a.rate_limit_max, b.rate_limit_max),
655 "rateLimitTimeWindow" => {
656 compare_numbers(a.rate_limit_time_window, b.rate_limit_time_window)
657 }
658 "refillAmount" => compare_numbers(a.refill_amount, b.refill_amount),
659 "refillInterval" => compare_numbers(a.refill_interval, b.refill_interval),
660 _ => Equal,
661 };
662 Ok(if direction == Some("desc") {
663 ordering.reverse()
664 } else {
665 ordering
666 })
667 };
668 #[expect(
671 clippy::indexing_slicing,
672 reason = "The outer range bounds current; current only decreases while positive"
673 )]
674 for index in 1..views.len() {
675 let mut current = index;
676 while current > 0 && compare(&views[current], &views[current - 1])? == Less {
677 views.swap(current - 1, current);
678 current -= 1;
679 }
680 }
681 Ok(())
682}
683
684pub(super) fn deduplicate(keys: &mut Vec<ApiKey>) {
685 let mut ids = HashSet::new();
686 keys.retain(|key| ids.insert(key.id.as_str().map(str::to_owned)));
687}
688
689#[cfg(test)]
690mod tests {
691 use super::*;
692
693 #[test]
694 fn cache_codec_preserves_nullable_flags_in_public_views() -> AuthResult<()> {
695 for (flag, expected, truthy) in [
696 (None, FieldValue::Undefined, false),
697 (Some(Value::Null), FieldValue::Null, false),
698 (Some(Value::Bool(false)), FieldValue::Bool(false), false),
699 (Some(Value::Bool(true)), FieldValue::Bool(true), true),
700 ] {
701 let mut cached = serde_json::json!({
702 "id": "nullable-flags", "key": "hash", "referenceId": "owner", "configId": "default",
703 "createdAt": "2030-01-02T03:04:05.000Z", "updatedAt": "2030-01-02T03:04:05.000Z",
704 "metadata": { "purpose": "device" },
705 });
706 if let Some(flag) = &flag {
707 cached["enabled"] = flag.clone();
708 cached["rateLimitEnabled"] = flag.clone();
709 }
710 let key = deserialize(Some(Value::String(cached.to_string())))
711 .ok_or_else(|| AuthError::internal("The nullable cache entry must decode"))?;
712 assert_eq!(key.enabled.field_value(), expected);
713 assert_eq!(key.rate_limit_enabled.field_value(), expected);
714 assert_eq!(key.enabled.is_truthy()?, truthy);
715 assert_eq!(key.rate_limit_enabled.is_truthy()?, truthy);
716 let serialized = serialize(&key)?;
717 let stored: Value = serde_json::from_str(&serialized)?;
718 let restored = deserialize(Some(Value::String(serialized)))
719 .ok_or_else(|| AuthError::internal("The serialized cache entry must decode"))?;
720 assert_eq!(restored.enabled.field_value(), expected);
721 assert_eq!(restored.rate_limit_enabled.field_value(), expected);
722 let view = serde_json::to_value(ApiKeyView::from(&restored))?;
723 for value in [stored, view] {
724 assert_eq!(value.get("enabled"), flag.as_ref());
725 assert_eq!(value.get("rateLimitEnabled"), flag.as_ref());
726 assert_eq!(value["metadata"], cached["metadata"]);
727 }
728 }
729 Ok(())
730 }
731
732 #[test]
733 fn cache_codec_preserves_unpaired_start_and_structured_metadata() {
734 let cached = r#"{"id":"key-id","key":"hash","referenceId":"owner","configId":null,"start":"\ud83d","enabled":true,"rateLimitEnabled":false,"createdAt":"2026-10-01T00:00:00.000Z","updatedAt":"2026-10-01T00:00:00.000Z","metadata":{"purpose":"device"}}"#;
735 let key = deserialize(Some(Value::String(cached.into()))).unwrap();
736 assert_eq!(key.start.as_ref().unwrap().as_utf16(), &[0xd83d]);
737 assert_eq!(key.config_id, "");
738 assert_eq!(key.metadata.as_deref(), Some(r#"{"purpose":"device"}"#));
739 let serialized = serialize(&key).unwrap();
740 let fields: BTreeMap<String, Box<serde_json::value::RawValue>> =
741 serde_json::from_str(&serialized).unwrap();
742 assert_eq!(fields.get("start").unwrap().get(), r#""\ud83d""#);
743 assert_eq!(
744 fields.get("metadata").unwrap().get(),
745 r#"{"purpose":"device"}"#
746 );
747 let restored = deserialize(Some(Value::String(serialized))).unwrap();
748 assert_eq!(restored.start, key.start);
749 assert_eq!(restored.metadata, key.metadata);
750 assert!(deserialize(Some(Value::String("malformed".into()))).is_none());
751 }
752}