Skip to content

Commit c1eb8af

Browse files
mkleczeksteve-chavez
authored andcommitted
perf: JWT Cache based on Sieve algorithm
Changes: 1. Refactoring and some cleanup of JWT handling code: * Instead of caching AuthResult cache decoded claims (which signature was verified). Validating claims and determining role is done after cache lookup * Cleaned up API so that usage of it is simplified: lookupJwtCache cache key >>= parseClaims configJwtAud time * Handling of JwtCacheState initialization and updates of configuration is encapsulated in Auth.JwtCache module 2. Generic high performance (hopefully) scalable, dynamically resizeable cache implementation based on stm, stm-hamt and sieve algorithm. It also integrates with PostgREST measurements infrastructure providing usage stats (ie. hit ratio, evictions count)
1 parent 67bd352 commit c1eb8af

34 files changed

+612
-178
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ This project adheres to [Semantic Versioning](http://semver.org/).
1111
+ It now shows the invalid schema in the `message` field.
1212
+ The exposed schemas are now listed in the `hint` instead of the `message` field.
1313
- Improve error details of `PGRST301` error by @taimoorzaeem in #4051
14+
- JWT cache based on sieve algorithm
1415

1516
## [13.0.4] - 2025-06-17
1617

docs/references/auth.rst

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -98,9 +98,11 @@ The ``Bearer`` header value can be used with or without capitalization(``bearer`
9898
JWT Caching
9999
-----------
100100

101-
PostgREST validates ``JWTs`` on every request. We can cache ``JWTs`` to avoid this performance overhead.
101+
PostgREST validates ``JWTs`` on every request. Asymmetric signature validation (such as RSA) is slow and we can cache ``JWT`` validation results to avoid this performance overhead.
102102

103-
To enable JWT caching, the config :code:`jwt-cache-max-lifetime` is to be set. It is the maximum number of seconds for which the cache stores the JWT validation results. The cache uses the :code:`exp` claim to set the cache entry lifetime. If the JWT does not have an :code:`exp` claim, it uses the config value. See :ref:`jwt-cache-max-lifetime` for more details.
103+
JWT caching is automatically enabled if ref:`jwt-secret` is set to an asymmetric key. Otherwise it is disabled and can be enabled by setting the config :code:`jwt-cache-max-entries` to a value greater than 0. Setting it to 0 disables caching regardless of ref:`jwt-secret`.
104+
105+
See :ref:`jwt-cache-max-entries` for more details.
104106

105107
.. note::
106108

docs/references/configuration.rst

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -658,20 +658,20 @@ jwt-secret-is-base64
658658

659659
When this is set to :code:`true`, the value derived from :code:`jwt-secret` will be treated as a base64 encoded secret.
660660

661-
.. _jwt-cache-max-lifetime:
661+
.. _jwt-cache-max-entries:
662662

663-
jwt-cache-max-lifetime
663+
jwt-cache-max-entries
664664
----------------------
665665

666666
=============== =================================
667667
**Type** Int
668-
**Default** 0
668+
**Default** 1000
669669
**Reloadable** Y
670-
**Environment** PGRST_JWT_CACHE_MAX_LIFETIME
671-
**In-Database** pgrst.jwt_cache_max_lifetime
670+
**Environment** PGRST_JWT_CACHE_MAX_ENTRIES
671+
**In-Database** pgrst.jwt_cache_max_entries
672672
=============== =================================
673673

674-
Maximum number of seconds of lifetime for cached entries. The default :code:`0` disables caching. See :ref:`jwt_caching`.
674+
Maximum number of entries in JWT cache. The value :code:`0` disables JWT caching. See :ref:`jwt_caching`.
675675

676676
.. _log-level:
677677

nix/tools/loadtest.nix

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,6 @@ let
6161
export PGRST_DB_TX_END="rollback-allow-override"
6262
export PGRST_LOG_LEVEL="crit"
6363
export PGRST_JWT_SECRET="reallyreallyreallyreallyverysafe"
64-
export PGRST_JWT_CACHE_MAX_LIFETIME="86400"
6564
6665
mkdir -p "$(dirname "$_arg_output")"
6766
abs_output="$(realpath "$_arg_output")"
@@ -72,7 +71,7 @@ let
7271
${genTargetsHS} "$_arg_testdir"/gen_targets.http
7372
7473
if [ "$_arg_jwtcache" = "off" ]; then
75-
export PGRST_JWT_CACHE_MAX_LIFETIME="0"
74+
export PGRST_JWT_CACHE_MAX_ENTRIES="0"
7675
fi
7776
7877
# shellcheck disable=SC2145
@@ -89,7 +88,7 @@ let
8988
export PGRST_JWT_SECRET="@$_arg_testdir/gen_jwk.json"
9089
9190
if [ "$_arg_jwtcache" = "off" ]; then
92-
export PGRST_JWT_CACHE_MAX_LIFETIME="0"
91+
export PGRST_JWT_CACHE_MAX_ENTRIES="0"
9392
fi
9493
9594
# shellcheck disable=SC2145

postgrest.cabal

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,7 @@ library
5252
PostgREST.Auth.Jwt
5353
PostgREST.Auth.JwtCache
5454
PostgREST.Auth.Types
55+
PostgREST.Cache.Sieve
5556
PostgREST.CLI
5657
PostgREST.Config
5758
PostgREST.Config.Database
@@ -154,6 +155,10 @@ library
154155
-- https://github.com/kazu-yamamoto/logger/commit/3a71ca70afdbb93d4ecf0083eeba1fbbbcab3fc3
155156
, wai-logger >= 2.4.0
156157
, warp >= 3.3.19 && < 3.4
158+
, stm >= 2.5 && < 3
159+
, stm-hamt >= 1.2 && < 2
160+
, focus >= 1.0 && < 2
161+
, some >= 1.0.4.1 && < 2
157162
-- -fno-spec-constr may help keep compile time memory use in check,
158163
-- see https://gitlab.haskell.org/ghc/ghc/issues/16017#note_219304
159164
-- -optP-Wno-nonportable-include-path
@@ -208,6 +213,7 @@ test-suite spec
208213
Feature.Auth.AudienceJwtSecretSpec
209214
Feature.Auth.AuthSpec
210215
Feature.Auth.BinaryJwtSecretSpec
216+
Feature.Auth.JwtCacheSpec
211217
Feature.Auth.NoAnonSpec
212218
Feature.Auth.NoJwtSecretSpec
213219
Feature.ConcurrentSpec
@@ -265,6 +271,7 @@ test-suite spec
265271
, hasql-transaction >= 1.0.1 && < 1.1
266272
, heredoc >= 0.2 && < 0.3
267273
, hspec >= 2.3 && < 2.12
274+
, hspec-expectations >= 0.8.4 && < 0.9
268275
, hspec-wai >= 0.10 && < 0.12
269276
, hspec-wai-json >= 0.10 && < 0.12
270277
, http-types >= 0.12.3 && < 0.13
@@ -274,6 +281,7 @@ test-suite spec
274281
, monad-control >= 1.0.1 && < 1.1
275282
, postgrest
276283
, process >= 1.4.2 && < 1.7
284+
, prometheus-client >= 1.1.1 && < 1.2.0
277285
, protolude >= 0.3.1 && < 0.4
278286
, regex-tdfa >= 1.2.2 && < 1.4
279287
, scientific >= 0.3.4 && < 0.4

src/PostgREST/AppState.hs

Lines changed: 6 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@ import Data.IORef (IORef, atomicWriteIORef, newIORef,
5757
readIORef)
5858
import Data.Time.Clock (UTCTime, getCurrentTime)
5959

60-
import PostgREST.Auth.JwtCache (JwtCacheState)
60+
import PostgREST.Auth.JwtCache (JwtCacheState, update)
6161
import PostgREST.Config (AppConfig (..),
6262
addFallbackAppName,
6363
readAppConfig)
@@ -127,14 +127,13 @@ init conf@AppConfig{configLogLevel, configDbPoolSize} = do
127127

128128
observer $ AppStartObs prettyVersion
129129

130-
jwtCacheState <- JwtCache.init
131130
pool <- initPool conf observer
132131
(sock, adminSock) <- initSockets conf
133-
state' <- initWithPool (sock, adminSock) pool conf jwtCacheState loggerState metricsState observer
132+
state' <- initWithPool (sock, adminSock) pool conf loggerState metricsState observer
134133
pure state' { stateSocketREST = sock, stateSocketAdmin = adminSock}
135134

136-
initWithPool :: AppSockets -> SQL.Pool -> AppConfig -> JwtCache.JwtCacheState -> Logger.LoggerState -> Metrics.MetricsState -> ObservationHandler -> IO AppState
137-
initWithPool (sock, adminSock) pool conf jwtCacheState loggerState metricsState observer = do
135+
initWithPool :: AppSockets -> SQL.Pool -> AppConfig -> Logger.LoggerState -> Metrics.MetricsState -> ObservationHandler -> IO AppState
136+
initWithPool (sock, adminSock) pool conf loggerState metricsState observer = do
138137

139138
appState <- AppState pool
140139
<$> newIORef minimumPgVersion -- assume we're in a supported version when starting, this will be corrected on a later step
@@ -150,7 +149,7 @@ initWithPool (sock, adminSock) pool conf jwtCacheState loggerState metricsState
150149
<*> pure sock
151150
<*> pure adminSock
152151
<*> pure observer
153-
<*> pure jwtCacheState
152+
<*> JwtCache.init conf observer
154153
<*> pure loggerState
155154
<*> pure metricsState
156155

@@ -471,10 +470,7 @@ readInDbConfig startingUp appState@AppState{stateObserver=observer} = do
471470
-- After the config has reloaded, jwt-secret might have changed, so
472471
-- if it has changed, it is important to invalidate the jwt cache
473472
-- entries, because they were cached using the old secret
474-
if configJwtSecret conf == configJwtSecret newConf then
475-
pass
476-
else
477-
JwtCache.emptyCache (getJwtCacheState appState) -- atomic O(1) operation
473+
update (getJwtCacheState appState) newConf
478474

479475
if startingUp then
480476
pass

src/PostgREST/Auth.hs

Lines changed: 13 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -30,50 +30,32 @@ import System.TimeIt (timeItT)
3030

3131
import PostgREST.AppState (AppState, getConfig, getJwtCacheState,
3232
getTime)
33+
import PostgREST.Auth.Jwt (parseClaims)
3334
import PostgREST.Auth.JwtCache (lookupJwtCache)
3435
import PostgREST.Auth.Types (AuthResult (..))
3536
import PostgREST.Config (AppConfig (..))
36-
import PostgREST.Error (Error (..), JwtError (..))
37+
import PostgREST.Error (Error (..))
3738

38-
import qualified Data.Aeson.KeyMap as KM
39-
import PostgREST.Auth.Jwt (parseAndDecodeClaims,
40-
parseClaims)
41-
import Protolude
39+
import Protolude
4240

43-
-- | Validate authorization header.
41+
-- | Validate authorization header
4442
-- Parse and store JWT claims for future use in the request.
4543
middleware :: AppState -> Wai.Middleware
4644
middleware appState app req respond = do
47-
cfg@AppConfig{..} <- getConfig appState
45+
conf@AppConfig{..} <- getConfig appState
4846
time <- getTime appState
4947

5048
let token = Wai.extractBearerAuth =<< lookup HTTP.hAuthorization (Wai.requestHeaders req)
51-
parseAuthToken = maybe (const $ throwError (JwtErr JwtSecretMissing)) parseAndDecodeClaims configJWKS
52-
parseJwt = runExceptT $ maybe (pure KM.empty) parseAuthToken token >>= parseClaims cfg time
49+
parseJwt = runExceptT $ lookupJwtCache jwtCacheState token >>= parseClaims conf time
5350
jwtCacheState = getJwtCacheState appState
5451

55-
-- If ServerTimingEnabled -> calculate JWT validation time
56-
-- If JwtCacheMaxLifetime -> cache JWT validation result
57-
req' <- case (configServerTimingEnabled, configJwtCacheMaxLifetime) of
58-
(True, 0) -> do
59-
(dur, authResult) <- timeItT parseJwt
60-
return $ req { Wai.vault = Wai.vault req & Vault.insert authResultKey authResult & Vault.insert jwtDurKey dur }
61-
62-
(True, maxLifetime) -> do
63-
(dur, authResult) <- timeItT $ case token of
64-
Just tkn -> lookupJwtCache jwtCacheState tkn maxLifetime parseJwt time
65-
Nothing -> parseJwt
66-
return $ req { Wai.vault = Wai.vault req & Vault.insert authResultKey authResult & Vault.insert jwtDurKey dur }
67-
68-
(False, 0) -> do
69-
authResult <- parseJwt
70-
return $ req { Wai.vault = Wai.vault req & Vault.insert authResultKey authResult }
71-
72-
(False, maxLifetime) -> do
73-
authResult <- case token of
74-
Just tkn -> lookupJwtCache jwtCacheState tkn maxLifetime parseJwt time
75-
Nothing -> parseJwt
76-
return $ req { Wai.vault = Wai.vault req & Vault.insert authResultKey authResult }
52+
-- If ServerTimingEnabled -> calculate JWT validation time
53+
req' <- if configServerTimingEnabled then do
54+
(dur, authResult) <- timeItT parseJwt
55+
pure $ req { Wai.vault = Wai.vault req & Vault.insert authResultKey authResult & Vault.insert jwtDurKey dur }
56+
else do
57+
authResult <- parseJwt
58+
pure $ req { Wai.vault = Wai.vault req & Vault.insert authResultKey authResult }
7759

7860
app req' respond
7961

0 commit comments

Comments
 (0)