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    /// How long (seconds) a `revoke-all` epoch is retained.
53    ///
54    /// `revoke-all` records a per-user epoch (see [`RevocationStore::revoke_all_for_user`])
55    /// rather than deleting individual tokens, so the entry must outlive every token that
56    /// could have been issued before the revocation. Set this **above your maximum
57    /// access-token lifetime**; once it expires a pre-revocation token would resume
58    /// working (until its own `exp`). Default: 86400 (24h).
59    #[serde(default = "default_revoke_all_ttl")]
60    pub revoke_all_ttl_secs: u64,
61}
62
63fn default_backend() -> String {
64    "memory".into()
65}
66const fn default_true() -> bool {
67    true
68}
69const fn default_revoke_all_ttl() -> u64 {
70    86_400
71}
72
73// ───────────────────────────────────────────────────────────────
74// Trait
75// ───────────────────────────────────────────────────────────────
76
77/// Revocation store abstraction.
78// Reason: used as dyn Trait (Arc<dyn RevocationStore>); async_trait ensures Send bounds and
79// dyn-compatibility async_trait: dyn-dispatch required; remove when RTN + Send is stable (RFC 3425)
80#[async_trait]
81pub trait RevocationStore: Send + Sync {
82    /// Check if a JTI has been revoked.
83    async fn is_revoked(&self, jti: &str) -> Result<bool, RevocationError>;
84
85    /// Revoke a single JTI.  `ttl_secs` is the remaining JWT lifetime —
86    /// the store should auto-expire the entry after this duration.
87    async fn revoke(&self, jti: &str, ttl_secs: u64) -> Result<(), RevocationError>;
88
89    /// Revoke **all** of a user's tokens by recording a per-user *epoch*: every token
90    /// for `sub` whose `iat` (issued-at) is at or before now is henceforth rejected by
91    /// [`user_revoked_after`](Self::user_revoked_after).
92    ///
93    /// This is an epoch, not a row delete — it catches tokens that were never
94    /// individually revoked (and tokens with no `jti`), which the previous sub-keyed
95    /// delete could not. `ttl_secs` bounds how long the epoch is retained; it must
96    /// exceed the maximum access-token lifetime so no pre-revocation token outlives it.
97    async fn revoke_all_for_user(&self, sub: &str, ttl_secs: u64) -> Result<(), RevocationError>;
98
99    /// Return the `revoke-all` epoch (unix seconds) currently in effect for `sub`, or
100    /// `None` when the user has no active epoch. Tokens with `iat <= epoch` are revoked.
101    async fn user_revoked_after(&self, sub: &str) -> Result<Option<i64>, RevocationError>;
102}
103
104/// Revocation store error.
105#[derive(Debug, thiserror::Error)]
106#[non_exhaustive]
107pub enum RevocationError {
108    /// Backend is unreachable or returned an error.
109    #[error("revocation store error: {0}")]
110    Backend(String),
111}
112
113// ───────────────────────────────────────────────────────────────
114// In-memory backend
115// ───────────────────────────────────────────────────────────────
116
117/// In-memory revocation store for testing and single-instance dev.
118pub struct InMemoryRevocationStore {
119    /// Map of JTI → (sub, `expires_at`).
120    pub(crate) entries:     DashMap<String, (String, DateTime<Utc>)>,
121    /// Per-user `revoke-all` epochs: `sub` → (`revoked_after` unix seconds, entry expiry).
122    pub(crate) user_epochs: DashMap<String, (i64, DateTime<Utc>)>,
123}
124
125impl InMemoryRevocationStore {
126    /// Create a new, empty in-memory revocation store.
127    #[must_use]
128    pub fn new() -> Self {
129        Self {
130            entries:     DashMap::new(),
131            user_epochs: DashMap::new(),
132        }
133    }
134
135    /// Remove expired entries (single-JTI revocations and per-user epochs).
136    pub fn cleanup_expired(&self) {
137        let now = Utc::now();
138        self.entries.retain(|_, (_, exp)| *exp > now);
139        self.user_epochs.retain(|_, (_, exp)| *exp > now);
140    }
141}
142
143impl Default for InMemoryRevocationStore {
144    fn default() -> Self {
145        Self::new()
146    }
147}
148
149// Reason: RevocationStore is defined with #[async_trait]; all implementations must match
150// its transformed method signatures to satisfy the trait contract
151// async_trait: dyn-dispatch required; remove when RTN + Send is stable (RFC 3425)
152#[async_trait]
153impl RevocationStore for InMemoryRevocationStore {
154    async fn is_revoked(&self, jti: &str) -> Result<bool, RevocationError> {
155        if let Some(entry) = self.entries.get(jti) {
156            let (_, expires_at) = entry.value();
157            if *expires_at > Utc::now() {
158                return Ok(true);
159            }
160            // Expired — remove lazily.
161            drop(entry);
162            self.entries.remove(jti);
163        }
164        Ok(false)
165    }
166
167    async fn revoke(&self, jti: &str, ttl_secs: u64) -> Result<(), RevocationError> {
168        let expires_at = Utc::now() + chrono::Duration::seconds(ttl_secs.cast_signed());
169        // We store an empty sub — single-JTI revocation doesn't need sub.
170        self.entries.insert(jti.to_string(), (String::new(), expires_at));
171        Ok(())
172    }
173
174    async fn revoke_all_for_user(&self, sub: &str, ttl_secs: u64) -> Result<(), RevocationError> {
175        // Record a per-user epoch: every token for `sub` with iat <= now is revoked.
176        // This catches tokens that were never individually revoked (and tokens with no
177        // jti) — unlike the previous sub-keyed delete, which only ever matched the empty
178        // sub written by `revoke`, so it removed nothing.
179        let now = Utc::now();
180        let expires_at = now + chrono::Duration::seconds(ttl_secs.cast_signed());
181        self.user_epochs.insert(sub.to_string(), (now.timestamp(), expires_at));
182        Ok(())
183    }
184
185    async fn user_revoked_after(&self, sub: &str) -> Result<Option<i64>, RevocationError> {
186        if let Some(entry) = self.user_epochs.get(sub) {
187            let (revoked_after, expires_at) = *entry.value();
188            if expires_at > Utc::now() {
189                return Ok(Some(revoked_after));
190            }
191            // Expired — remove lazily.
192            drop(entry);
193            self.user_epochs.remove(sub);
194        }
195        Ok(None)
196    }
197}
198
199// ───────────────────────────────────────────────────────────────
200// Redis backend (optional)
201// ───────────────────────────────────────────────────────────────
202
203/// Redis-backed JWT revocation store.
204///
205/// Stores revoked JTI claims in Redis with automatic TTL-based expiry.
206/// Requires the `redis-rate-limiting` feature.
207#[cfg(feature = "redis-rate-limiting")]
208pub struct RedisRevocationStore {
209    client:     redis::Client,
210    key_prefix: String,
211}
212
213#[cfg(feature = "redis-rate-limiting")]
214impl RedisRevocationStore {
215    /// Create a new Redis-backed revocation store.
216    ///
217    /// # Errors
218    ///
219    /// Returns error if the Redis URL is invalid.
220    pub fn new(redis_url: &str) -> Result<Self, RevocationError> {
221        let client = redis::Client::open(redis_url)
222            .map_err(|e| RevocationError::Backend(format!("Redis connection error: {e}")))?;
223        Ok(Self {
224            client,
225            key_prefix: "fraiseql:revoked:".into(),
226        })
227    }
228}
229
230#[cfg(feature = "redis-rate-limiting")]
231// Reason: RevocationStore is defined with #[async_trait]; all implementations must match
232// its transformed method signatures to satisfy the trait contract
233// async_trait: dyn-dispatch required; remove when RTN + Send is stable (RFC 3425)
234#[async_trait]
235impl RevocationStore for RedisRevocationStore {
236    async fn is_revoked(&self, jti: &str) -> Result<bool, RevocationError> {
237        use redis::AsyncCommands;
238        let mut conn = self
239            .client
240            .get_multiplexed_async_connection()
241            .await
242            .map_err(|e| RevocationError::Backend(format!("Redis: {e}")))?;
243        let key = format!("{}{jti}", self.key_prefix);
244        let exists: bool = conn
245            .exists(&key)
246            .await
247            .map_err(|e| RevocationError::Backend(format!("Redis EXISTS: {e}")))?;
248        Ok(exists)
249    }
250
251    async fn revoke(&self, jti: &str, ttl_secs: u64) -> Result<(), RevocationError> {
252        use redis::AsyncCommands;
253        let mut conn = self
254            .client
255            .get_multiplexed_async_connection()
256            .await
257            .map_err(|e| RevocationError::Backend(format!("Redis: {e}")))?;
258        let key = format!("{}{jti}", self.key_prefix);
259        let _: () = conn
260            .set_ex(&key, "1", ttl_secs)
261            .await
262            .map_err(|e| RevocationError::Backend(format!("Redis SET EX: {e}")))?;
263        Ok(())
264    }
265
266    async fn revoke_all_for_user(&self, sub: &str, ttl_secs: u64) -> Result<(), RevocationError> {
267        use redis::AsyncCommands;
268        let mut conn = self
269            .client
270            .get_multiplexed_async_connection()
271            .await
272            .map_err(|e| RevocationError::Backend(format!("Redis: {e}")))?;
273        // Record a per-user epoch under a single key (`…:user:{sub}`) with TTL. The old
274        // implementation SCANned `…:user:{sub}:*`, a namespace `revoke` never wrote, so it
275        // always matched nothing. The epoch is checked against each token's `iat`.
276        let key = format!("{}user:{sub}", self.key_prefix);
277        let now = Utc::now().timestamp();
278        let _: () = conn
279            .set_ex(&key, now, ttl_secs)
280            .await
281            .map_err(|e| RevocationError::Backend(format!("Redis SET EX: {e}")))?;
282        Ok(())
283    }
284
285    async fn user_revoked_after(&self, sub: &str) -> Result<Option<i64>, RevocationError> {
286        use redis::AsyncCommands;
287        let mut conn = self
288            .client
289            .get_multiplexed_async_connection()
290            .await
291            .map_err(|e| RevocationError::Backend(format!("Redis: {e}")))?;
292        let key = format!("{}user:{sub}", self.key_prefix);
293        // Redis auto-expires the key after its TTL, so a present value is always live.
294        let epoch: Option<i64> = conn
295            .get(&key)
296            .await
297            .map_err(|e| RevocationError::Backend(format!("Redis GET: {e}")))?;
298        Ok(epoch)
299    }
300}
301
302// ───────────────────────────────────────────────────────────────
303// PostgreSQL backend
304// ───────────────────────────────────────────────────────────────
305
306/// Maximum size of the dedicated pool used for token-revocation metadata.
307/// Revocation is metadata-light (one row per revoked token), so a small pool is
308/// sufficient and keeps startup cheap.
309const REVOCATION_POOL_MAX: u32 = 5;
310
311/// Idempotent DDL for the PostgreSQL revocation store.
312///
313/// `fraiseql_revoked_tokens` holds single-JTI revocations; `fraiseql_revoked_users`
314/// holds per-user `revoke-all` epochs (`revoked_after` unix seconds, retained until
315/// `expires_at`).
316const REVOKED_TOKENS_SCHEMA_SQL: &str = "\
317CREATE TABLE IF NOT EXISTS fraiseql_revoked_tokens (
318    jti TEXT PRIMARY KEY,
319    sub TEXT,
320    expires_at TIMESTAMPTZ NOT NULL
321);
322CREATE INDEX IF NOT EXISTS idx_fraiseql_revoked_tokens_sub
323    ON fraiseql_revoked_tokens (sub);
324CREATE INDEX IF NOT EXISTS idx_fraiseql_revoked_tokens_expires
325    ON fraiseql_revoked_tokens (expires_at);
326CREATE TABLE IF NOT EXISTS fraiseql_revoked_users (
327    sub TEXT PRIMARY KEY,
328    revoked_after BIGINT NOT NULL,
329    expires_at TIMESTAMPTZ NOT NULL
330);
331CREATE INDEX IF NOT EXISTS idx_fraiseql_revoked_users_expires
332    ON fraiseql_revoked_users (expires_at);";
333
334/// PostgreSQL-backed JWT revocation store.
335///
336/// Persists revoked `jti` claims in `fraiseql_revoked_tokens`, so revocations
337/// survive a restart and are shared across replicas — unlike the in-memory
338/// backend, which the server silently fell back to for `backend = "postgres"`
339/// before this was implemented (#357). Each row carries an `expires_at` matching
340/// the JWT's remaining lifetime; `is_revoked` ignores expired rows and
341/// [`cleanup_expired`](Self::cleanup_expired) prunes them.
342pub struct PostgresRevocationStore {
343    pool: sqlx::PgPool,
344}
345
346impl PostgresRevocationStore {
347    /// Create a Postgres revocation store, ensuring the backing table exists
348    /// (idempotent DDL).
349    ///
350    /// # Errors
351    ///
352    /// Returns [`RevocationError::Backend`] if the schema cannot be created.
353    pub async fn new(pool: sqlx::PgPool) -> Result<Self, RevocationError> {
354        sqlx::raw_sql(REVOKED_TOKENS_SCHEMA_SQL)
355            .execute(&pool)
356            .await
357            .map_err(|e| RevocationError::Backend(format!("schema creation failed: {e}")))?;
358        Ok(Self { pool })
359    }
360
361    /// Delete expired revocation rows. Optional housekeeping; `is_revoked` already
362    /// ignores expired entries, so this only reclaims space.
363    ///
364    /// # Errors
365    ///
366    /// Returns [`RevocationError::Backend`] if the delete fails.
367    pub async fn cleanup_expired(&self) -> Result<u64, RevocationError> {
368        let tokens = sqlx::query("DELETE FROM fraiseql_revoked_tokens WHERE expires_at <= NOW()")
369            .execute(&self.pool)
370            .await
371            .map_err(|e| RevocationError::Backend(format!("cleanup failed: {e}")))?;
372        let users = sqlx::query("DELETE FROM fraiseql_revoked_users WHERE expires_at <= NOW()")
373            .execute(&self.pool)
374            .await
375            .map_err(|e| RevocationError::Backend(format!("cleanup failed: {e}")))?;
376        Ok(tokens.rows_affected() + users.rows_affected())
377    }
378}
379
380// Reason: RevocationStore is defined with #[async_trait]; all implementations must match
381// its transformed method signatures to satisfy the trait contract
382// async_trait: dyn-dispatch required; remove when RTN + Send is stable (RFC 3425)
383#[async_trait]
384impl RevocationStore for PostgresRevocationStore {
385    async fn is_revoked(&self, jti: &str) -> Result<bool, RevocationError> {
386        let revoked: bool = sqlx::query_scalar(
387            "SELECT EXISTS (
388                 SELECT 1 FROM fraiseql_revoked_tokens WHERE jti = $1 AND expires_at > NOW()
389             )",
390        )
391        .bind(jti)
392        .fetch_one(&self.pool)
393        .await
394        .map_err(|e| RevocationError::Backend(format!("is_revoked query failed: {e}")))?;
395        Ok(revoked)
396    }
397
398    async fn revoke(&self, jti: &str, ttl_secs: u64) -> Result<(), RevocationError> {
399        let expires_at = Utc::now() + chrono::Duration::seconds(ttl_secs.cast_signed());
400        // Single-JTI revocation does not carry a `sub` (the trait signature has none);
401        // it is recorded NULL, matching the in-memory backend.
402        sqlx::query(
403            "INSERT INTO fraiseql_revoked_tokens (jti, sub, expires_at)
404             VALUES ($1, NULL, $2)
405             ON CONFLICT (jti) DO UPDATE SET expires_at = EXCLUDED.expires_at",
406        )
407        .bind(jti)
408        .bind(expires_at)
409        .execute(&self.pool)
410        .await
411        .map_err(|e| RevocationError::Backend(format!("revoke insert failed: {e}")))?;
412        Ok(())
413    }
414
415    async fn revoke_all_for_user(&self, sub: &str, ttl_secs: u64) -> Result<(), RevocationError> {
416        // Upsert a per-user epoch. The previous `DELETE … WHERE sub = $1` removed nothing
417        // because `revoke` records sub = NULL; this records "all of sub's tokens issued at
418        // or before now are revoked", checked against each token's `iat`.
419        let expires_at = Utc::now() + chrono::Duration::seconds(ttl_secs.cast_signed());
420        sqlx::query(
421            "INSERT INTO fraiseql_revoked_users (sub, revoked_after, expires_at)
422             VALUES ($1, EXTRACT(EPOCH FROM NOW())::BIGINT, $2)
423             ON CONFLICT (sub) DO UPDATE
424                 SET revoked_after = EXTRACT(EPOCH FROM NOW())::BIGINT,
425                     expires_at = EXCLUDED.expires_at",
426        )
427        .bind(sub)
428        .bind(expires_at)
429        .execute(&self.pool)
430        .await
431        .map_err(|e| RevocationError::Backend(format!("revoke_all_for_user failed: {e}")))?;
432        Ok(())
433    }
434
435    async fn user_revoked_after(&self, sub: &str) -> Result<Option<i64>, RevocationError> {
436        let epoch: Option<i64> = sqlx::query_scalar(
437            "SELECT revoked_after FROM fraiseql_revoked_users
438             WHERE sub = $1 AND expires_at > NOW()",
439        )
440        .bind(sub)
441        .fetch_optional(&self.pool)
442        .await
443        .map_err(|e| RevocationError::Backend(format!("user_revoked_after query failed: {e}")))?;
444        Ok(epoch)
445    }
446}
447
448// ───────────────────────────────────────────────────────────────
449// Token Revocation Manager
450// ───────────────────────────────────────────────────────────────
451
452/// High-level token revocation manager wrapping a backend store.
453pub struct TokenRevocationManager {
454    store:               Arc<dyn RevocationStore>,
455    require_jti:         bool,
456    fail_open:           bool,
457    revoke_all_ttl_secs: u64,
458}
459
460impl TokenRevocationManager {
461    /// Create a new revocation manager.
462    ///
463    /// `revoke_all_ttl_secs` is how long a `revoke-all` epoch is retained (see
464    /// [`TokenRevocationConfig::revoke_all_ttl_secs`]).
465    #[must_use]
466    pub fn new(
467        store: Arc<dyn RevocationStore>,
468        require_jti: bool,
469        fail_open: bool,
470        revoke_all_ttl_secs: u64,
471    ) -> Self {
472        Self {
473            store,
474            require_jti,
475            fail_open,
476            revoke_all_ttl_secs,
477        }
478    }
479
480    /// Check if a token should be rejected, by single-JTI revocation **and** by the
481    /// caller's `revoke-all` epoch.
482    ///
483    /// `jti`/`iat` are the token's claims; `sub` is the subject. The single-JTI check
484    /// uses `jti`; the epoch check rejects when the user has an active `revoke-all` epoch
485    /// and the token's `iat` is at or before it. A token with no `iat` cannot be
486    /// epoch-checked, so the epoch is skipped for it (it can still be revoked by `jti`).
487    ///
488    /// Returns `Ok(())` if the token is allowed, or an error reason if rejected.
489    ///
490    /// # Errors
491    ///
492    /// Returns `TokenRejection::MissingJti` if JTI is required but absent.
493    /// Returns `TokenRejection::Revoked` if the token's `jti` is revoked or its `iat`
494    /// predates the user's `revoke-all` epoch.
495    /// Returns `TokenRejection::StoreUnavailable` if the revocation store is unreachable and
496    /// `fail_open` is false.
497    pub async fn check_token(
498        &self,
499        jti: Option<&str>,
500        sub: &str,
501        iat: Option<i64>,
502    ) -> Result<(), TokenRejection> {
503        // 1. Single-JTI revocation.
504        match jti {
505            Some(j) if !j.is_empty() => match self.store.is_revoked(j).await {
506                Ok(true) => return Err(TokenRejection::Revoked),
507                Ok(false) => {},
508                Err(e) => {
509                    warn!(error = %e, jti = %j, "Revocation store check failed");
510                    if self.fail_open {
511                        debug!("fail_open=true — allowing request despite store error");
512                        return Ok(());
513                    }
514                    return Err(TokenRejection::StoreUnavailable);
515                },
516            },
517            _ => {
518                if self.require_jti {
519                    return Err(TokenRejection::MissingJti);
520                }
521                // No JTI and not required — fall through to the epoch check.
522            },
523        }
524
525        // 2. Per-user `revoke-all` epoch (catches tokens never individually revoked).
526        match self.store.user_revoked_after(sub).await {
527            Ok(Some(epoch)) => {
528                if iat.is_some_and(|issued| issued <= epoch) {
529                    return Err(TokenRejection::Revoked);
530                }
531                Ok(())
532            },
533            Ok(None) => Ok(()),
534            Err(e) => {
535                warn!(error = %e, sub = %sub, "Revoke-all epoch check failed");
536                if self.fail_open {
537                    debug!("fail_open=true — allowing request despite store error");
538                    Ok(())
539                } else {
540                    Err(TokenRejection::StoreUnavailable)
541                }
542            },
543        }
544    }
545
546    /// Revoke a single token by JTI.
547    ///
548    /// # Errors
549    ///
550    /// Returns `RevocationError` if the underlying revocation store operation fails.
551    pub async fn revoke(&self, jti: &str, ttl_secs: u64) -> Result<(), RevocationError> {
552        self.store.revoke(jti, ttl_secs).await
553    }
554
555    /// Revoke all of a user's tokens by recording a `revoke-all` epoch retained for
556    /// the manager's configured `revoke_all_ttl_secs` (see
557    /// [`RevocationStore::revoke_all_for_user`]).
558    ///
559    /// # Errors
560    ///
561    /// Returns `RevocationError` if the underlying revocation store operation fails.
562    pub async fn revoke_all_for_user(&self, sub: &str) -> Result<(), RevocationError> {
563        self.store.revoke_all_for_user(sub, self.revoke_all_ttl_secs).await
564    }
565
566    /// Return the `revoke-all` epoch currently in effect for `sub`, if any.
567    ///
568    /// # Errors
569    ///
570    /// Returns `RevocationError` if the underlying revocation store operation fails.
571    pub async fn user_revoked_after(&self, sub: &str) -> Result<Option<i64>, RevocationError> {
572        self.store.user_revoked_after(sub).await
573    }
574
575    /// Whether JTI is required.
576    #[must_use]
577    pub const fn require_jti(&self) -> bool {
578        self.require_jti
579    }
580}
581
582impl std::fmt::Debug for TokenRevocationManager {
583    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
584        f.debug_struct("TokenRevocationManager")
585            .field("require_jti", &self.require_jti)
586            .field("fail_open", &self.fail_open)
587            .finish_non_exhaustive()
588    }
589}
590
591/// Why a token was rejected.
592#[derive(Debug, Clone, PartialEq, Eq)]
593#[non_exhaustive]
594pub enum TokenRejection {
595    /// Token has been revoked.
596    Revoked,
597    /// Token lacks a `jti` claim and `require_jti` is enabled.
598    MissingJti,
599    /// Revocation store is unavailable and `fail_open` is false.
600    StoreUnavailable,
601}
602
603// ───────────────────────────────────────────────────────────────
604// Builder from compiled schema
605// ───────────────────────────────────────────────────────────────
606
607/// Build a `TokenRevocationManager` for the DB-agnostic backends (`memory`, `redis`)
608/// from the compiled schema's `security.token_revocation` JSON.
609///
610/// The `postgres` backend is **deferred** here (returns `Ok(None)`) because it needs
611/// a database connection; it is provisioned by [`build_postgres_revocation_manager`]
612/// on the PostgreSQL runtime path and installed via `Server::with_revocation_manager`.
613///
614/// # Errors
615///
616/// Returns `ServerError::ConfigError` when the `token_revocation` JSON cannot be
617/// parsed, or when `backend` is an unrecognised value — previously an unknown
618/// backend silently fell back to in-memory, defeating the operator's intent (#357).
619pub fn revocation_manager_from_schema(
620    schema: &fraiseql_core::schema::CompiledSchema,
621) -> crate::Result<Option<Arc<TokenRevocationManager>>> {
622    let Some(security) = schema.security.as_ref() else {
623        return Ok(None);
624    };
625    let Some(revocation_val) = security.additional.get("token_revocation") else {
626        return Ok(None);
627    };
628    // The CLI compiler serialises an absent `[security.token_revocation]` as JSON `null`,
629    // so a null value means "not configured" — treat it like an absent key rather than a
630    // malformed config. A non-null value that fails to parse IS a genuine misconfig.
631    if revocation_val.is_null() {
632        return Ok(None);
633    }
634    let config: TokenRevocationConfig =
635        serde_json::from_value(revocation_val.clone()).map_err(|e| {
636            crate::ServerError::ConfigError(format!(
637                "invalid security.token_revocation config: {e}"
638            ))
639        })?;
640
641    if !config.enabled {
642        return Ok(None);
643    }
644
645    let store: Arc<dyn RevocationStore> = match config.backend.as_str() {
646        #[cfg(feature = "redis-rate-limiting")]
647        "redis" => {
648            let url = config.redis_url.as_deref().unwrap_or("redis://localhost:6379");
649            match RedisRevocationStore::new(url) {
650                Ok(s) => {
651                    info!(backend = "redis", "Token revocation store initialized");
652                    Arc::new(s)
653                },
654                Err(e) => {
655                    warn!(error = %e, "Failed to init Redis revocation store — falling back to in-memory");
656                    Arc::new(InMemoryRevocationStore::new())
657                },
658            }
659        },
660        #[cfg(not(feature = "redis-rate-limiting"))]
661        "redis" => {
662            warn!(
663                "token_revocation.backend = \"redis\" but the `redis-rate-limiting` feature is \
664                 not compiled in. Falling back to in-memory."
665            );
666            Arc::new(InMemoryRevocationStore::new())
667        },
668        "memory" | "env" => {
669            info!(backend = "memory", "Token revocation store initialized (in-memory)");
670            Arc::new(InMemoryRevocationStore::new())
671        },
672        "postgres" => {
673            // Needs a database connection — provisioned by the PostgreSQL runtime path
674            // (build_postgres_revocation_manager) and installed via with_revocation_manager.
675            info!(
676                backend = "postgres",
677                "Token revocation backend = postgres; provisioned by the PostgreSQL runtime"
678            );
679            return Ok(None);
680        },
681        other => {
682            return Err(crate::ServerError::ConfigError(format!(
683                "unknown token_revocation backend {other:?}; \
684                 expected \"memory\", \"redis\", or \"postgres\""
685            )));
686        },
687    };
688
689    Ok(Some(Arc::new(TokenRevocationManager::new(
690        store,
691        config.require_jti,
692        config.fail_open,
693        config.revoke_all_ttl_secs,
694    ))))
695}
696
697/// Build a PostgreSQL-backed `TokenRevocationManager` from the compiled schema's
698/// `security.token_revocation` config, connecting a dedicated metadata pool from
699/// `database_url`.
700///
701/// Returns `Ok(None)` when token revocation is disabled or the backend is not
702/// `"postgres"` (the `memory`/`redis` backends are built on the generic construction
703/// path by [`revocation_manager_from_schema`]). Call this on the PostgreSQL runtime
704/// path and install the result with `Server::with_revocation_manager`.
705///
706/// # Errors
707///
708/// Returns an error message when the `token_revocation` config is invalid, the
709/// database cannot be reached, or the backing table cannot be created.
710pub async fn build_postgres_revocation_manager(
711    database_url: &str,
712    schema: &fraiseql_core::schema::CompiledSchema,
713) -> std::result::Result<Option<Arc<TokenRevocationManager>>, String> {
714    let Some(security) = schema.security.as_ref() else {
715        return Ok(None);
716    };
717    let Some(revocation_val) = security.additional.get("token_revocation") else {
718        return Ok(None);
719    };
720    // A null value means the section is absent (see revocation_manager_from_schema).
721    if revocation_val.is_null() {
722        return Ok(None);
723    }
724    let config: TokenRevocationConfig = serde_json::from_value(revocation_val.clone())
725        .map_err(|e| format!("invalid security.token_revocation config: {e}"))?;
726
727    if !config.enabled || config.backend != "postgres" {
728        return Ok(None);
729    }
730
731    let pool = sqlx::postgres::PgPoolOptions::new()
732        .max_connections(REVOCATION_POOL_MAX)
733        .connect(database_url)
734        .await
735        .map_err(|e| format!("token revocation: failed to connect to PostgreSQL: {e}"))?;
736
737    let store = PostgresRevocationStore::new(pool)
738        .await
739        .map_err(|e| format!("token revocation: {e}"))?;
740
741    info!(backend = "postgres", "Token revocation store initialized (PostgreSQL)");
742    Ok(Some(Arc::new(TokenRevocationManager::new(
743        Arc::new(store),
744        config.require_jti,
745        config.fail_open,
746        config.revoke_all_ttl_secs,
747    ))))
748}
749
750/// Returns `true` when token revocation is enabled with the `postgres` backend.
751///
752/// Non-PostgreSQL runtime paths use this to warn that the backend is unavailable:
753/// the binary cannot connect a PostgreSQL pool from, e.g., a MySQL `database_url`,
754/// so `revocation_manager_from_schema` defers the backend and nothing builds it.
755#[must_use]
756pub fn revocation_backend_is_postgres(schema: &fraiseql_core::schema::CompiledSchema) -> bool {
757    schema
758        .security
759        .as_ref()
760        .and_then(|s| s.additional.get("token_revocation"))
761        .and_then(|v| serde_json::from_value::<TokenRevocationConfig>(v.clone()).ok())
762        .is_some_and(|c| c.enabled && c.backend == "postgres")
763}