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}