feat: JWT cache implementation based on sieve algorithm (#4084)
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)
This commit is contained in:
@@ -0,0 +1,167 @@
|
||||
{-# LANGUAGE AllowAmbiguousTypes #-}
|
||||
{-# LANGUAGE DataKinds #-}
|
||||
{-# LANGUAGE ExistentialQuantification #-}
|
||||
{-# LANGUAGE FlexibleContexts #-}
|
||||
{-# LANGUAGE ImpredicativeTypes #-}
|
||||
{-# LANGUAGE OverloadedStrings #-}
|
||||
{-# LANGUAGE ScopedTypeVariables #-}
|
||||
{-# LANGUAGE TypeApplications #-}
|
||||
module Feature.Auth.JwtCacheSpec
|
||||
|
||||
where
|
||||
|
||||
import Network.Wai (Application)
|
||||
|
||||
import Network.HTTP.Types
|
||||
import Test.Hspec (Expectation, SpecWith, describe, it,
|
||||
shouldBe)
|
||||
import Test.Hspec.Wai
|
||||
|
||||
import Data.String (String)
|
||||
import PostgREST.Metrics (MetricsState (..))
|
||||
import Prometheus (getCounter)
|
||||
import Protolude
|
||||
import SpecHelper
|
||||
import Test.Hspec.Expectations.Contrib (annotate)
|
||||
import Test.Hspec.Wai.JSON (json)
|
||||
|
||||
spec :: SpecWith (MetricsState, Application)
|
||||
spec = describe "Server started with JWT and metrics enabled" $ do
|
||||
it "Should not have JWT in cache" $ do
|
||||
let auth = genToken [json|{"exp": 9999999999, "role": "postgrest_test_author", "id": "jdoe1"}|]
|
||||
|
||||
expectCounters
|
||||
[
|
||||
requests (+ 1)
|
||||
, hits (+ 0)
|
||||
] $
|
||||
|
||||
request methodGet "/authors_only" [auth] ""
|
||||
|
||||
it "Should have JWT in cache" $ do
|
||||
let auth = genToken [json|{"exp": 9999999999, "role": "postgrest_test_author", "id": "jdoe2"}|]
|
||||
|
||||
expectCounters
|
||||
[
|
||||
requests (+ 2)
|
||||
, hits (+ 1)
|
||||
] $
|
||||
|
||||
request methodGet "/authors_only" [auth] "" `shouldRespondWith` 200
|
||||
*> request methodGet "/authors_only" [auth] "" `shouldRespondWith` 200
|
||||
|
||||
it "Should not cache invalid JWTs" $ do
|
||||
let auth = authHeaderJWT "some random bytes"
|
||||
|
||||
expectCounters
|
||||
[
|
||||
requests (+ 2)
|
||||
, hits (+ 0)
|
||||
] $
|
||||
|
||||
request methodGet "/authors_only" [auth] "" `shouldRespondWith` 401
|
||||
*> request methodGet "/authors_only" [auth] "" `shouldRespondWith` 401
|
||||
|
||||
it "Should cache expired JWTs" $ do
|
||||
let auth = genToken [json|{"exp": 1, "role": "postgrest_test_author", "id": "jdoe2"}|]
|
||||
|
||||
expectCounters
|
||||
[
|
||||
requests (+ 2)
|
||||
, hits (+ 1)
|
||||
] $
|
||||
|
||||
request methodGet "/authors_only" [auth] "" `shouldRespondWith` 401
|
||||
*> request methodGet "/authors_only" [auth] "" `shouldRespondWith` 401
|
||||
|
||||
it "Should evict entries from the JWT cache (jwt cache max is 2)" $ do
|
||||
let jwt1 = genToken [json|{"exp": 9999999999, "role": "postgrest_test_author", "id": "jdoe3"}|]
|
||||
jwt2 = genToken [json|{"exp": 9999999999, "role": "postgrest_test_author", "id": "jdoe4"}|]
|
||||
jwt3 = genToken [json|{"exp": 9999999999, "role": "postgrest_test_author", "id": "jdoe5"}|]
|
||||
|
||||
expectCounters
|
||||
[
|
||||
requests (+ 6)
|
||||
, hits (+ 0)
|
||||
, evictions (+ 4)
|
||||
] $
|
||||
|
||||
request methodGet "/authors_only" [jwt1] ""
|
||||
*> request methodGet "/authors_only" [jwt2] ""
|
||||
*> request methodGet "/authors_only" [jwt3] ""
|
||||
*> request methodGet "/authors_only" [jwt1] ""
|
||||
*> request methodGet "/authors_only" [jwt2] ""
|
||||
*> request methodGet "/authors_only" [jwt3] ""
|
||||
|
||||
it "Should not evict entries from the JWT cache in FIFO order" $ do
|
||||
let jwt1 = genToken [json|{"exp": 9999999999, "role": "postgrest_test_author", "id": "jdoe6"}|]
|
||||
jwt2 = genToken [json|{"exp": 9999999999, "role": "postgrest_test_author", "id": "jdoe7"}|]
|
||||
jwt3 = genToken [json|{"exp": 9999999999, "role": "postgrest_test_author", "id": "jdoe8"}|]
|
||||
|
||||
expectCounters
|
||||
[
|
||||
requests (+ 6)
|
||||
, hits (+ 3)
|
||||
, evictions (+ 1)
|
||||
] $
|
||||
|
||||
request methodGet "/authors_only" [jwt1] ""
|
||||
*> request methodGet "/authors_only" [jwt2] ""
|
||||
-- this one should hit the cache
|
||||
*> request methodGet "/authors_only" [jwt1] ""
|
||||
-- this one should trigger eviction of jwt2 (not FIFO)
|
||||
*> request methodGet "/authors_only" [jwt3] ""
|
||||
-- these two should hit the cache
|
||||
*> request methodGet "/authors_only" [jwt1] ""
|
||||
*> request methodGet "/authors_only" [jwt3] ""
|
||||
|
||||
-- This one makes sure we test the scenario when finger
|
||||
-- has to move through the whole list first and pass the head
|
||||
-- The test case was added based on coverage report
|
||||
-- showing this scenario was not covered by previous tests
|
||||
it "Should evict entries even though all were hit" $ do
|
||||
let jwt1 = genToken [json|{"exp": 9999999999, "role": "postgrest_test_author", "id": "jdoe9"}|]
|
||||
jwt2 = genToken [json|{"exp": 9999999999, "role": "postgrest_test_author", "id": "jdoe10"}|]
|
||||
jwt3 = genToken [json|{"exp": 9999999999, "role": "postgrest_test_author", "id": "jdoe11"}|]
|
||||
|
||||
expectCounters
|
||||
[
|
||||
requests (+ 7)
|
||||
, hits (+ 4)
|
||||
, evictions (+ 1)
|
||||
] $
|
||||
|
||||
request methodGet "/authors_only" [jwt1] ""
|
||||
*> request methodGet "/authors_only" [jwt2] ""
|
||||
-- these two should hit the cache
|
||||
*> request methodGet "/authors_only" [jwt1] ""
|
||||
*> request methodGet "/authors_only" [jwt2] ""
|
||||
-- this one should trigger eviction of jwt1
|
||||
*> request methodGet "/authors_only" [jwt3] ""
|
||||
-- these two should hit the cache
|
||||
*> request methodGet "/authors_only" [jwt2] ""
|
||||
*> request methodGet "/authors_only" [jwt3] ""
|
||||
|
||||
where
|
||||
counterToInt = second (fmap (round @Double @Int) . getCounter)
|
||||
expectCounters = stateCheck . fmap (\(g, h) -> StateCheck (counterToInt . g) (flip shouldBe . h))
|
||||
genToken = authHeaderJWT . generateJWT
|
||||
requests = (,) (getF @"jwtCacheRequests")
|
||||
hits = (,) (getF @"jwtCacheHits")
|
||||
evictions = (,) (getF @"jwtCacheEvictions")
|
||||
|
||||
|
||||
-- should be moved to helpers???
|
||||
getF :: forall s r a. (KnownSymbol s, HasField s r a) => r -> (String, a)
|
||||
getF r = (symbolVal (Proxy @s), getField @s r)
|
||||
|
||||
data StateCheck st = forall a. (Show a, Eq a) => StateCheck (st -> (String, WaiSession st a)) (a -> a -> Expectation)
|
||||
|
||||
stateCheck :: (Traversable t) => t (StateCheck st) -> WaiSession st a -> WaiSession st ()
|
||||
stateCheck checks act = do
|
||||
metrics <- getState
|
||||
expectations <- traverse (\(StateCheck g expect) -> let (msg, m) = g metrics in m >>= createExpectation msg m . expect) checks
|
||||
void act
|
||||
sequenceA_ expectations
|
||||
where
|
||||
createExpectation msg metrics expect = pure $ metrics >>= liftIO . annotate msg . expect
|
||||
Reference in New Issue
Block a user