Skip to main content

authkestra_engine/store/
mod.rs

1use async_trait::async_trait;
2use std::time::Duration;
3
4#[derive(Debug, thiserror::Error)]
5pub enum StoreError {
6    #[error("Internal store error: {0}")]
7    Internal(String),
8    #[error("Not found")]
9    NotFound,
10    #[error("Serialization error: {0}")]
11    Serialization(String),
12}
13
14#[async_trait]
15pub trait KvStore<T>: Send + Sync + 'static {
16    async fn get(&self, key: &str) -> Result<Option<T>, StoreError>;
17    async fn set(&self, key: &str, value: T, ttl: Duration) -> Result<(), StoreError>;
18    async fn delete(&self, key: &str) -> Result<(), StoreError>;
19}
20
21/// Backends that can atomically fetch-and-remove a value implement this.
22#[async_trait]
23pub trait AtomicConsume<T>: KvStore<T> {
24    async fn consume(&self, key: &str) -> Result<Option<T>, StoreError>;
25}
26
27/// Backends that can atomically insert a value only if no value is
28/// currently stored under `key` implement this.
29///
30/// This is the complement of [`AtomicConsume`], not a duplicate of it:
31/// `AtomicConsume` is for values the *server* creates and later atomically
32/// fetches-and-removes (an authorization code, say — the key is only ever
33/// seen after this server put it there). `AtomicInsert` is for values whose
34/// key is supplied by the *caller* and was never stored by this server
35/// first — the shape a replay guard needs, e.g. a DPoP proof's `jti`, which
36/// a client generates and this server has never seen before the first time
37/// it's presented.
38#[async_trait]
39pub trait AtomicInsert<T>: KvStore<T> {
40    /// Inserts `value` under `key` only if `key` does not already hold a
41    /// value. Returns `Ok(true)` if the insert happened (the key was
42    /// fresh) and `Ok(false)` if a value was already present (the key was
43    /// already claimed — e.g. a replay). Must be a single atomic
44    /// operation: a check-then-set built from `get` followed by `set` is a
45    /// TOCTOU race that defeats the entire purpose of a replay guard.
46    async fn insert_if_absent(
47        &self,
48        key: &str,
49        value: T,
50        ttl: Duration,
51    ) -> Result<bool, StoreError>;
52}
53
54/// Rounds a [`Duration`] up to the nearest whole second, flooring at 1.
55///
56/// Every [`AtomicInsert`] backend whose storage only expresses TTL in
57/// whole seconds (Redis's `EX`, and the `expires_at` column every SQL
58/// backend uses) needs this same conversion, and needs it to round up:
59/// `insert_if_absent`'s return value is a security-critical replay
60/// signal, not a best-effort cache write. Truncating instead (a plain
61/// `.as_secs()`, or `.as_secs().max(1)`) would silently disable the
62/// replay guard for any caller using a sub-second TTL (e.g. a DPoP
63/// freshness window configured in milliseconds) — a 1ms TTL would
64/// truncate to `expires_at == now`, meaning the row is already expired by
65/// the time anyone could observe it, and every subsequent call with the
66/// same key would also see no live entry and also report "fresh". This
67/// bug was found and fixed for the Redis backend first (authkestra#277
68/// review) and, having no shared definition to fall to, was independently
69/// re-introduced in each SQL backend rather than caught by one fix.
70pub fn ttl_ceil_secs(ttl: Duration) -> u64 {
71    ttl.as_secs()
72        .saturating_add(u64::from(ttl.subsec_nanos() > 0))
73        .max(1)
74}
75
76/// Backends that can atomically write a value under a primary key while
77/// also maintaining a secondary lookup key implement this.
78#[async_trait]
79pub trait IndexedKvStore<T>: KvStore<T> {
80    async fn set_indexed(
81        &self,
82        primary_key: &str,
83        secondary_key: &str,
84        value: T,
85        ttl: Duration,
86    ) -> Result<(), StoreError>;
87    async fn get_by_index(&self, secondary_key: &str) -> Result<Option<T>, StoreError>;
88}
89
90#[cfg(feature = "memory")]
91pub mod memory;
92
93#[cfg(feature = "redis")]
94pub mod redis;
95
96#[cfg(any(
97    feature = "sql-postgres",
98    feature = "sql-sqlite",
99    feature = "sql-mysql"
100))]
101pub mod sql;