Skip to main content

fraiseql_server/
token_revocation.rs

1//! Token revocation — reject JWTs whose `jti` claim has been revoked.
2//!
3//! After JWT signature verification succeeds, the server checks the token's
4//! `jti` (JWT ID) claim against a revocation store.  If the `jti` is present,
5//! the token is rejected with 401.
6//!
7//! Two production backends: Redis (recommended) and PostgreSQL (fallback).
8//! An in-memory backend is provided for testing and single-instance dev.
9//!
10//! Revoked JTIs expire automatically when the JWT's `exp` claim passes, keeping
11//! the store bounded.
12
13#[cfg(test)]
14mod tests;
15
16use std::sync::Arc;
17
18use async_trait::async_trait;
19use chrono::{DateTime, Utc};
20use dashmap::DashMap;
21use serde::Deserialize;
22use tracing::{debug, info, warn};
23
24// ───────────────────────────────────────────────────────────────
25// Configuration
26// ───────────────────────────────────────────────────────────────
27
28/// Token revocation configuration embedded in the compiled schema.
29#[derive(Debug, Clone, Deserialize)]
30pub struct TokenRevocationConfig {
31    /// Whether token revocation is enabled.
32    #[serde(default)]
33    pub enabled: bool,
34
35    /// Storage backend: `"redis"` or `"postgres"` or `"memory"`.
36    #[serde(default = "default_backend")]
37    pub backend: String,
38
39    /// Reject JWTs that lack a `jti` claim when revocation is enabled.
40    #[serde(default = "default_true")]
41    pub require_jti: bool,
42
43    /// If the revocation store is unreachable:
44    /// - `false` (default): reject the request (fail-closed)
45    /// - `true`: allow the request (fail-open)
46    #[serde(default)]
47    pub fail_open: bool,
48
49    /// Redis URL (inherited from `[fraiseql.redis]` if not set here).
50    pub redis_url: Option<String>,
51}
52
53fn default_backend() -> String {
54    "memory".into()
55}
56const fn default_true() -> bool {
57    true
58}
59
60// ───────────────────────────────────────────────────────────────
61// Trait
62// ───────────────────────────────────────────────────────────────
63
64/// Revocation store abstraction.
65// Reason: used as dyn Trait (Arc<dyn RevocationStore>); async_trait ensures Send bounds and
66// dyn-compatibility async_trait: dyn-dispatch required; remove when RTN + Send is stable (RFC 3425)
67#[async_trait]
68pub trait RevocationStore: Send + Sync {
69    /// Check if a JTI has been revoked.
70    async fn is_revoked(&self, jti: &str) -> Result<bool, RevocationError>;
71
72    /// Revoke a single JTI.  `ttl_secs` is the remaining JWT lifetime —
73    /// the store should auto-expire the entry after this duration.
74    async fn revoke(&self, jti: &str, ttl_secs: u64) -> Result<(), RevocationError>;
75
76    /// Revoke all tokens for a user (by `sub` claim).
77    /// Returns the number of tokens revoked.
78    async fn revoke_all_for_user(&self, sub: &str) -> Result<u64, RevocationError>;
79}
80
81/// Revocation store error.
82#[derive(Debug, thiserror::Error)]
83#[non_exhaustive]
84pub enum RevocationError {
85    /// Backend is unreachable or returned an error.
86    #[error("revocation store error: {0}")]
87    Backend(String),
88}
89
90// ───────────────────────────────────────────────────────────────
91// In-memory backend
92// ───────────────────────────────────────────────────────────────
93
94/// In-memory revocation store for testing and single-instance dev.
95pub struct InMemoryRevocationStore {
96    /// Map of JTI → (sub, `expires_at`).
97    pub(crate) entries: DashMap<String, (String, DateTime<Utc>)>,
98}
99
100impl InMemoryRevocationStore {
101    /// Create a new, empty in-memory revocation store.
102    #[must_use]
103    pub fn new() -> Self {
104        Self {
105            entries: DashMap::new(),
106        }
107    }
108
109    /// Remove expired entries.
110    pub fn cleanup_expired(&self) {
111        let now = Utc::now();
112        self.entries.retain(|_, (_, exp)| *exp > now);
113    }
114}
115
116impl Default for InMemoryRevocationStore {
117    fn default() -> Self {
118        Self::new()
119    }
120}
121
122// Reason: RevocationStore is defined with #[async_trait]; all implementations must match
123// its transformed method signatures to satisfy the trait contract
124// async_trait: dyn-dispatch required; remove when RTN + Send is stable (RFC 3425)
125#[async_trait]
126impl RevocationStore for InMemoryRevocationStore {
127    async fn is_revoked(&self, jti: &str) -> Result<bool, RevocationError> {
128        if let Some(entry) = self.entries.get(jti) {
129            let (_, expires_at) = entry.value();
130            if *expires_at > Utc::now() {
131                return Ok(true);
132            }
133            // Expired — remove lazily.
134            drop(entry);
135            self.entries.remove(jti);
136        }
137        Ok(false)
138    }
139
140    async fn revoke(&self, jti: &str, ttl_secs: u64) -> Result<(), RevocationError> {
141        let expires_at = Utc::now() + chrono::Duration::seconds(ttl_secs.cast_signed());
142        // We store an empty sub — single-JTI revocation doesn't need sub.
143        self.entries.insert(jti.to_string(), (String::new(), expires_at));
144        Ok(())
145    }
146
147    async fn revoke_all_for_user(&self, sub: &str) -> Result<u64, RevocationError> {
148        // Collect all JTIs belonging to this user and remove them from the store.
149        // Two-pass approach (collect keys, then remove) avoids holding a mutable
150        // reference to DashMap while iterating, which would deadlock.
151        let keys_to_remove: Vec<String> = self
152            .entries
153            .iter()
154            .filter(|entry| {
155                let (s, _) = entry.value();
156                s == sub
157            })
158            .map(|entry| entry.key().clone())
159            .collect();
160
161        let count = keys_to_remove.len() as u64;
162        for key in &keys_to_remove {
163            self.entries.remove(key);
164        }
165        Ok(count)
166    }
167}
168
169// ───────────────────────────────────────────────────────────────
170// Redis backend (optional)
171// ───────────────────────────────────────────────────────────────
172
173/// Redis-backed JWT revocation store.
174///
175/// Stores revoked JTI claims in Redis with automatic TTL-based expiry.
176/// Requires the `redis-rate-limiting` feature.
177#[cfg(feature = "redis-rate-limiting")]
178pub struct RedisRevocationStore {
179    client:     redis::Client,
180    key_prefix: String,
181}
182
183#[cfg(feature = "redis-rate-limiting")]
184impl RedisRevocationStore {
185    /// Create a new Redis-backed revocation store.
186    ///
187    /// # Errors
188    ///
189    /// Returns error if the Redis URL is invalid.
190    pub fn new(redis_url: &str) -> Result<Self, RevocationError> {
191        let client = redis::Client::open(redis_url)
192            .map_err(|e| RevocationError::Backend(format!("Redis connection error: {e}")))?;
193        Ok(Self {
194            client,
195            key_prefix: "fraiseql:revoked:".into(),
196        })
197    }
198}
199
200#[cfg(feature = "redis-rate-limiting")]
201// Reason: RevocationStore is defined with #[async_trait]; all implementations must match
202// its transformed method signatures to satisfy the trait contract
203// async_trait: dyn-dispatch required; remove when RTN + Send is stable (RFC 3425)
204#[async_trait]
205impl RevocationStore for RedisRevocationStore {
206    async fn is_revoked(&self, jti: &str) -> Result<bool, RevocationError> {
207        use redis::AsyncCommands;
208        let mut conn = self
209            .client
210            .get_multiplexed_async_connection()
211            .await
212            .map_err(|e| RevocationError::Backend(format!("Redis: {e}")))?;
213        let key = format!("{}{jti}", self.key_prefix);
214        let exists: bool = conn
215            .exists(&key)
216            .await
217            .map_err(|e| RevocationError::Backend(format!("Redis EXISTS: {e}")))?;
218        Ok(exists)
219    }
220
221    async fn revoke(&self, jti: &str, ttl_secs: u64) -> Result<(), RevocationError> {
222        use redis::AsyncCommands;
223        let mut conn = self
224            .client
225            .get_multiplexed_async_connection()
226            .await
227            .map_err(|e| RevocationError::Backend(format!("Redis: {e}")))?;
228        let key = format!("{}{jti}", self.key_prefix);
229        let _: () = conn
230            .set_ex(&key, "1", ttl_secs)
231            .await
232            .map_err(|e| RevocationError::Backend(format!("Redis SET EX: {e}")))?;
233        Ok(())
234    }
235
236    async fn revoke_all_for_user(&self, sub: &str) -> Result<u64, RevocationError> {
237        let mut conn = self
238            .client
239            .get_multiplexed_async_connection()
240            .await
241            .map_err(|e| RevocationError::Backend(format!("Redis: {e}")))?;
242        // SECURITY: Use SCAN cursor iteration instead of KEYS to avoid O(N) blocking.
243        // KEYS blocks Redis for the entire scan duration; SCAN is non-blocking and
244        // yields results in small batches, making it safe for production use.
245        // User-keyed entries use prefix: fraiseql:revoked:user:{sub}:*
246        let pattern = format!("{}user:{sub}:*", self.key_prefix);
247        let mut cursor: u64 = 0;
248        let mut all_keys: Vec<String> = Vec::new();
249        loop {
250            let (next_cursor, batch): (u64, Vec<String>) = redis::cmd("SCAN")
251                .arg(cursor)
252                .arg("MATCH")
253                .arg(&pattern)
254                .arg("COUNT")
255                .arg(100u32)
256                .query_async(&mut conn)
257                .await
258                .map_err(|e| RevocationError::Backend(format!("Redis SCAN: {e}")))?;
259            all_keys.extend(batch);
260            cursor = next_cursor;
261            if cursor == 0 {
262                break;
263            }
264        }
265        let count = all_keys.len() as u64;
266        if !all_keys.is_empty() {
267            let _: () = redis::cmd("DEL")
268                .arg(&all_keys)
269                .query_async(&mut conn)
270                .await
271                .map_err(|e| RevocationError::Backend(format!("Redis DEL: {e}")))?;
272        }
273        Ok(count)
274    }
275}
276
277// ───────────────────────────────────────────────────────────────
278// PostgreSQL backend
279// ───────────────────────────────────────────────────────────────
280
281/// Maximum size of the dedicated pool used for token-revocation metadata.
282/// Revocation is metadata-light (one row per revoked token), so a small pool is
283/// sufficient and keeps startup cheap.
284const REVOCATION_POOL_MAX: u32 = 5;
285
286/// Idempotent DDL for the PostgreSQL revocation store.
287const REVOKED_TOKENS_SCHEMA_SQL: &str = "\
288CREATE TABLE IF NOT EXISTS fraiseql_revoked_tokens (
289    jti TEXT PRIMARY KEY,
290    sub TEXT,
291    expires_at TIMESTAMPTZ NOT NULL
292);
293CREATE INDEX IF NOT EXISTS idx_fraiseql_revoked_tokens_sub
294    ON fraiseql_revoked_tokens (sub);
295CREATE INDEX IF NOT EXISTS idx_fraiseql_revoked_tokens_expires
296    ON fraiseql_revoked_tokens (expires_at);";
297
298/// PostgreSQL-backed JWT revocation store.
299///
300/// Persists revoked `jti` claims in `fraiseql_revoked_tokens`, so revocations
301/// survive a restart and are shared across replicas — unlike the in-memory
302/// backend, which the server silently fell back to for `backend = "postgres"`
303/// before this was implemented (#357). Each row carries an `expires_at` matching
304/// the JWT's remaining lifetime; `is_revoked` ignores expired rows and
305/// [`cleanup_expired`](Self::cleanup_expired) prunes them.
306pub struct PostgresRevocationStore {
307    pool: sqlx::PgPool,
308}
309
310impl PostgresRevocationStore {
311    /// Create a Postgres revocation store, ensuring the backing table exists
312    /// (idempotent DDL).
313    ///
314    /// # Errors
315    ///
316    /// Returns [`RevocationError::Backend`] if the schema cannot be created.
317    pub async fn new(pool: sqlx::PgPool) -> Result<Self, RevocationError> {
318        sqlx::raw_sql(REVOKED_TOKENS_SCHEMA_SQL)
319            .execute(&pool)
320            .await
321            .map_err(|e| RevocationError::Backend(format!("schema creation failed: {e}")))?;
322        Ok(Self { pool })
323    }
324
325    /// Delete expired revocation rows. Optional housekeeping; `is_revoked` already
326    /// ignores expired entries, so this only reclaims space.
327    ///
328    /// # Errors
329    ///
330    /// Returns [`RevocationError::Backend`] if the delete fails.
331    pub async fn cleanup_expired(&self) -> Result<u64, RevocationError> {
332        let result = sqlx::query("DELETE FROM fraiseql_revoked_tokens WHERE expires_at <= NOW()")
333            .execute(&self.pool)
334            .await
335            .map_err(|e| RevocationError::Backend(format!("cleanup failed: {e}")))?;
336        Ok(result.rows_affected())
337    }
338}
339
340// Reason: RevocationStore is defined with #[async_trait]; all implementations must match
341// its transformed method signatures to satisfy the trait contract
342// async_trait: dyn-dispatch required; remove when RTN + Send is stable (RFC 3425)
343#[async_trait]
344impl RevocationStore for PostgresRevocationStore {
345    async fn is_revoked(&self, jti: &str) -> Result<bool, RevocationError> {
346        let revoked: bool = sqlx::query_scalar(
347            "SELECT EXISTS (
348                 SELECT 1 FROM fraiseql_revoked_tokens WHERE jti = $1 AND expires_at > NOW()
349             )",
350        )
351        .bind(jti)
352        .fetch_one(&self.pool)
353        .await
354        .map_err(|e| RevocationError::Backend(format!("is_revoked query failed: {e}")))?;
355        Ok(revoked)
356    }
357
358    async fn revoke(&self, jti: &str, ttl_secs: u64) -> Result<(), RevocationError> {
359        let expires_at = Utc::now() + chrono::Duration::seconds(ttl_secs.cast_signed());
360        // Single-JTI revocation does not carry a `sub` (the trait signature has none);
361        // it is recorded NULL, matching the in-memory backend.
362        sqlx::query(
363            "INSERT INTO fraiseql_revoked_tokens (jti, sub, expires_at)
364             VALUES ($1, NULL, $2)
365             ON CONFLICT (jti) DO UPDATE SET expires_at = EXCLUDED.expires_at",
366        )
367        .bind(jti)
368        .bind(expires_at)
369        .execute(&self.pool)
370        .await
371        .map_err(|e| RevocationError::Backend(format!("revoke insert failed: {e}")))?;
372        Ok(())
373    }
374
375    async fn revoke_all_for_user(&self, sub: &str) -> Result<u64, RevocationError> {
376        let result = sqlx::query("DELETE FROM fraiseql_revoked_tokens WHERE sub = $1")
377            .bind(sub)
378            .execute(&self.pool)
379            .await
380            .map_err(|e| RevocationError::Backend(format!("revoke_all_for_user failed: {e}")))?;
381        Ok(result.rows_affected())
382    }
383}
384
385// ───────────────────────────────────────────────────────────────
386// Token Revocation Manager
387// ───────────────────────────────────────────────────────────────
388
389/// High-level token revocation manager wrapping a backend store.
390pub struct TokenRevocationManager {
391    store:       Arc<dyn RevocationStore>,
392    require_jti: bool,
393    fail_open:   bool,
394}
395
396impl TokenRevocationManager {
397    /// Create a new revocation manager.
398    #[must_use]
399    pub fn new(store: Arc<dyn RevocationStore>, require_jti: bool, fail_open: bool) -> Self {
400        Self {
401            store,
402            require_jti,
403            fail_open,
404        }
405    }
406
407    /// Check if a token should be rejected.
408    ///
409    /// Returns `Ok(())` if the token is allowed, or an error reason if rejected.
410    ///
411    /// # Errors
412    ///
413    /// Returns `TokenRejection::MissingJti` if JTI is required but absent.
414    /// Returns `TokenRejection::Revoked` if the token has been revoked.
415    /// Returns `TokenRejection::StoreUnavailable` if the revocation store is unreachable and
416    /// `fail_open` is false.
417    pub async fn check_token(&self, jti: Option<&str>) -> Result<(), TokenRejection> {
418        let jti = match jti {
419            Some(j) if !j.is_empty() => j,
420            _ => {
421                if self.require_jti {
422                    return Err(TokenRejection::MissingJti);
423                }
424                // No JTI and not required — allow through.
425                return Ok(());
426            },
427        };
428
429        match self.store.is_revoked(jti).await {
430            Ok(true) => Err(TokenRejection::Revoked),
431            Ok(false) => Ok(()),
432            Err(e) => {
433                warn!(error = %e, jti = %jti, "Revocation store check failed");
434                if self.fail_open {
435                    debug!("fail_open=true — allowing request despite store error");
436                    Ok(())
437                } else {
438                    Err(TokenRejection::StoreUnavailable)
439                }
440            },
441        }
442    }
443
444    /// Revoke a single token by JTI.
445    ///
446    /// # Errors
447    ///
448    /// Returns `RevocationError` if the underlying revocation store operation fails.
449    pub async fn revoke(&self, jti: &str, ttl_secs: u64) -> Result<(), RevocationError> {
450        self.store.revoke(jti, ttl_secs).await
451    }
452
453    /// Revoke all tokens for a user.
454    ///
455    /// # Errors
456    ///
457    /// Returns `RevocationError` if the underlying revocation store operation fails.
458    pub async fn revoke_all_for_user(&self, sub: &str) -> Result<u64, RevocationError> {
459        self.store.revoke_all_for_user(sub).await
460    }
461
462    /// Whether JTI is required.
463    #[must_use]
464    pub const fn require_jti(&self) -> bool {
465        self.require_jti
466    }
467}
468
469impl std::fmt::Debug for TokenRevocationManager {
470    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
471        f.debug_struct("TokenRevocationManager")
472            .field("require_jti", &self.require_jti)
473            .field("fail_open", &self.fail_open)
474            .finish_non_exhaustive()
475    }
476}
477
478/// Why a token was rejected.
479#[derive(Debug, Clone, PartialEq, Eq)]
480#[non_exhaustive]
481pub enum TokenRejection {
482    /// Token has been revoked.
483    Revoked,
484    /// Token lacks a `jti` claim and `require_jti` is enabled.
485    MissingJti,
486    /// Revocation store is unavailable and `fail_open` is false.
487    StoreUnavailable,
488}
489
490// ───────────────────────────────────────────────────────────────
491// Builder from compiled schema
492// ───────────────────────────────────────────────────────────────
493
494/// Build a `TokenRevocationManager` for the DB-agnostic backends (`memory`, `redis`)
495/// from the compiled schema's `security.token_revocation` JSON.
496///
497/// The `postgres` backend is **deferred** here (returns `Ok(None)`) because it needs
498/// a database connection; it is provisioned by [`build_postgres_revocation_manager`]
499/// on the PostgreSQL runtime path and installed via `Server::with_revocation_manager`.
500///
501/// # Errors
502///
503/// Returns `ServerError::ConfigError` when the `token_revocation` JSON cannot be
504/// parsed, or when `backend` is an unrecognised value — previously an unknown
505/// backend silently fell back to in-memory, defeating the operator's intent (#357).
506pub fn revocation_manager_from_schema(
507    schema: &fraiseql_core::schema::CompiledSchema,
508) -> crate::Result<Option<Arc<TokenRevocationManager>>> {
509    let Some(security) = schema.security.as_ref() else {
510        return Ok(None);
511    };
512    let Some(revocation_val) = security.additional.get("token_revocation") else {
513        return Ok(None);
514    };
515    // The CLI compiler serialises an absent `[security.token_revocation]` as JSON `null`,
516    // so a null value means "not configured" — treat it like an absent key rather than a
517    // malformed config. A non-null value that fails to parse IS a genuine misconfig.
518    if revocation_val.is_null() {
519        return Ok(None);
520    }
521    let config: TokenRevocationConfig =
522        serde_json::from_value(revocation_val.clone()).map_err(|e| {
523            crate::ServerError::ConfigError(format!(
524                "invalid security.token_revocation config: {e}"
525            ))
526        })?;
527
528    if !config.enabled {
529        return Ok(None);
530    }
531
532    let store: Arc<dyn RevocationStore> = match config.backend.as_str() {
533        #[cfg(feature = "redis-rate-limiting")]
534        "redis" => {
535            let url = config.redis_url.as_deref().unwrap_or("redis://localhost:6379");
536            match RedisRevocationStore::new(url) {
537                Ok(s) => {
538                    info!(backend = "redis", "Token revocation store initialized");
539                    Arc::new(s)
540                },
541                Err(e) => {
542                    warn!(error = %e, "Failed to init Redis revocation store — falling back to in-memory");
543                    Arc::new(InMemoryRevocationStore::new())
544                },
545            }
546        },
547        #[cfg(not(feature = "redis-rate-limiting"))]
548        "redis" => {
549            warn!(
550                "token_revocation.backend = \"redis\" but the `redis-rate-limiting` feature is \
551                 not compiled in. Falling back to in-memory."
552            );
553            Arc::new(InMemoryRevocationStore::new())
554        },
555        "memory" | "env" => {
556            info!(backend = "memory", "Token revocation store initialized (in-memory)");
557            Arc::new(InMemoryRevocationStore::new())
558        },
559        "postgres" => {
560            // Needs a database connection — provisioned by the PostgreSQL runtime path
561            // (build_postgres_revocation_manager) and installed via with_revocation_manager.
562            info!(
563                backend = "postgres",
564                "Token revocation backend = postgres; provisioned by the PostgreSQL runtime"
565            );
566            return Ok(None);
567        },
568        other => {
569            return Err(crate::ServerError::ConfigError(format!(
570                "unknown token_revocation backend {other:?}; \
571                 expected \"memory\", \"redis\", or \"postgres\""
572            )));
573        },
574    };
575
576    Ok(Some(Arc::new(TokenRevocationManager::new(
577        store,
578        config.require_jti,
579        config.fail_open,
580    ))))
581}
582
583/// Build a PostgreSQL-backed `TokenRevocationManager` from the compiled schema's
584/// `security.token_revocation` config, connecting a dedicated metadata pool from
585/// `database_url`.
586///
587/// Returns `Ok(None)` when token revocation is disabled or the backend is not
588/// `"postgres"` (the `memory`/`redis` backends are built on the generic construction
589/// path by [`revocation_manager_from_schema`]). Call this on the PostgreSQL runtime
590/// path and install the result with `Server::with_revocation_manager`.
591///
592/// # Errors
593///
594/// Returns an error message when the `token_revocation` config is invalid, the
595/// database cannot be reached, or the backing table cannot be created.
596pub async fn build_postgres_revocation_manager(
597    database_url: &str,
598    schema: &fraiseql_core::schema::CompiledSchema,
599) -> std::result::Result<Option<Arc<TokenRevocationManager>>, String> {
600    let Some(security) = schema.security.as_ref() else {
601        return Ok(None);
602    };
603    let Some(revocation_val) = security.additional.get("token_revocation") else {
604        return Ok(None);
605    };
606    // A null value means the section is absent (see revocation_manager_from_schema).
607    if revocation_val.is_null() {
608        return Ok(None);
609    }
610    let config: TokenRevocationConfig = serde_json::from_value(revocation_val.clone())
611        .map_err(|e| format!("invalid security.token_revocation config: {e}"))?;
612
613    if !config.enabled || config.backend != "postgres" {
614        return Ok(None);
615    }
616
617    let pool = sqlx::postgres::PgPoolOptions::new()
618        .max_connections(REVOCATION_POOL_MAX)
619        .connect(database_url)
620        .await
621        .map_err(|e| format!("token revocation: failed to connect to PostgreSQL: {e}"))?;
622
623    let store = PostgresRevocationStore::new(pool)
624        .await
625        .map_err(|e| format!("token revocation: {e}"))?;
626
627    info!(backend = "postgres", "Token revocation store initialized (PostgreSQL)");
628    Ok(Some(Arc::new(TokenRevocationManager::new(
629        Arc::new(store),
630        config.require_jti,
631        config.fail_open,
632    ))))
633}
634
635/// Returns `true` when token revocation is enabled with the `postgres` backend.
636///
637/// Non-PostgreSQL runtime paths use this to warn that the backend is unavailable:
638/// the binary cannot connect a PostgreSQL pool from, e.g., a MySQL `database_url`,
639/// so `revocation_manager_from_schema` defers the backend and nothing builds it.
640#[must_use]
641pub fn revocation_backend_is_postgres(schema: &fraiseql_core::schema::CompiledSchema) -> bool {
642    schema
643        .security
644        .as_ref()
645        .and_then(|s| s.additional.get("token_revocation"))
646        .and_then(|v| serde_json::from_value::<TokenRevocationConfig>(v.clone()).ok())
647        .is_some_and(|c| c.enabled && c.backend == "postgres")
648}