Skip to main content

systemprompt_api/services/middleware/jwt/
revocation.rs

1//! JTI revocation gate for the JWT context extractor.
2//!
3//! Runs as the final stateful check after a token's claims, its backing user,
4//! and the session row have all validated. It answers the one question
5//! signature validation cannot: has this specific token been explicitly
6//! revoked (logout, admin revoke, refresh rotation)? A negative result is
7//! cached so the hot path costs one map lookup. Fails closed — a revocation
8//! store error rejects the request rather than admitting an unverifiable token.
9//!
10//! Copyright (c) systemprompt.io — Business Source License 1.1.
11//! See <https://systemprompt.io> for licensing details.
12
13use std::sync::Arc;
14use systemprompt_identifiers::AccessTokenId;
15use systemprompt_models::execution::context::ContextExtractionError;
16use systemprompt_oauth::repository::{JtiRevocationCache, OAuthRepository};
17
18#[derive(Clone)]
19pub struct JtiRevocationChecker {
20    repo: Arc<OAuthRepository>,
21    cache: Arc<JtiRevocationCache>,
22}
23
24impl std::fmt::Debug for JtiRevocationChecker {
25    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
26        f.debug_struct("JtiRevocationChecker")
27            .finish_non_exhaustive()
28    }
29}
30
31impl JtiRevocationChecker {
32    pub fn from_repository(repo: OAuthRepository) -> Self {
33        Self {
34            repo: Arc::new(repo),
35            cache: Arc::new(JtiRevocationCache::new()),
36        }
37    }
38
39    pub async fn ensure_not_revoked(
40        &self,
41        jti: &AccessTokenId,
42    ) -> Result<(), ContextExtractionError> {
43        match self.cache.peek(jti) {
44            Some(true) => return Err(ContextExtractionError::Revoked),
45            Some(false) => return Ok(()),
46            None => {},
47        }
48
49        let revoked = self.repo.is_jti_revoked(jti).await.map_err(|e| {
50            ContextExtractionError::DatabaseError {
51                context: "JTI revocation lookup failed".to_owned(),
52                source: e.into(),
53            }
54        })?;
55        self.cache.record(jti, revoked);
56        if revoked {
57            Err(ContextExtractionError::Revoked)
58        } else {
59            Ok(())
60        }
61    }
62}