From f5fd2e71b692a94ae0c2f88c569edbfa90edcee1 Mon Sep 17 00:00:00 2001 From: Taimoor Zaeem Date: Mon, 22 Jun 2026 13:07:08 +0500 Subject: [PATCH] test: move jwt error tests from io tests to spec tests Towards #4946. Signed-off-by: Taimoor Zaeem --- postgrest.cabal | 1 + test/io/test_auth.py | 69 +-------------- test/spec/Feature/Query/ErrorSpec.hs | 127 ++++++++++++++++++++++++++- test/spec/SpecHelper.hs | 26 ++++-- 4 files changed, 145 insertions(+), 78 deletions(-) diff --git a/postgrest.cabal b/postgrest.cabal index c9beb8ee2..a5150ac91 100644 --- a/postgrest.cabal +++ b/postgrest.cabal @@ -298,6 +298,7 @@ test-suite spec , regex-tdfa >= 1.2.2 && < 1.4 , scientific >= 0.3.4 && < 0.4 , text >= 1.2.2 && < 2.2 + , time >= 1.6 && < 1.15 , transformers-base >= 0.4.4 && < 0.5 , wai >= 3.2.1 && < 3.3 , wai-extra >= 3.0.19 && < 3.2 diff --git a/test/io/test_auth.py b/test/io/test_auth.py index 15b4bd52b..9840b4fc0 100644 --- a/test/io/test_auth.py +++ b/test/io/test_auth.py @@ -7,7 +7,7 @@ import time import pytest from config import BASEDIR, CONFIGSDIR, FIXTURES, SECRET -from util import authheader, jwtauthheader, parse_server_timings_header, relativeSeconds +from util import authheader, jwtauthheader, parse_server_timings_header from postgrest import ( run, sleep_until_postgrest_config_reload, @@ -67,73 +67,6 @@ def test_read_secret_from_stdin_dbconfig(defaultenv): assert response.status_code == 200 -def test_jwt_errors(defaultenv): - "invalid JWT should throw error" - - env = {**defaultenv, "PGRST_JWT_SECRET": SECRET, "PGRST_JWT_AUD": "io tests"} - - with run(env=env) as postgrest: - headers = jwtauthheader({}, "other secret") - response = postgrest.session.get("/", headers=headers) - assert response.status_code == 401 - assert response.json()["message"] == "No suitable key or wrong key type" - assert ( - response.json()["details"] == "None of the keys was able to decode the JWT" - ) - - headers = jwtauthheader({"role": "not_existing"}, SECRET) - response = postgrest.session.get("/", headers=headers) - assert response.status_code == 401 - assert response.json()["message"] == 'role "not_existing" does not exist' - - # -35 seconds, because we allow clock skew of 30 seconds - headers = jwtauthheader({"exp": relativeSeconds(-35)}, SECRET) - response = postgrest.session.get("/", headers=headers) - assert response.status_code == 401 - assert response.json()["message"] == "JWT expired" - - # 35 seconds, because we allow clock skew of 30 seconds - headers = jwtauthheader({"nbf": relativeSeconds(35)}, SECRET) - response = postgrest.session.get("/", headers=headers) - assert response.status_code == 401 - assert response.json()["message"] == "JWT not yet valid" - - # 35 seconds, because we allow clock skew of 35 seconds - headers = jwtauthheader({"iat": relativeSeconds(35)}, SECRET) - response = postgrest.session.get("/", headers=headers) - assert response.status_code == 401 - assert response.json()["message"] == "JWT issued at future" - - headers = jwtauthheader({"aud": "not set"}, SECRET) - response = postgrest.session.get("/", headers=headers) - assert response.status_code == 401 - assert response.json()["message"] == "JWT not in audience" - - # partial token, no signature - headers = authheader("eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.bm90IGFuIG9iamVjdA") - response = postgrest.session.get("/", headers=headers) - assert response.status_code == 401 - assert response.json()["message"] == "Expected 3 parts in JWT; got 2" - - # complete token but random characters - headers = authheader("quifquirndsjagnrgniur.fonvoienqhhdj.iuqvnvhojah") - response = postgrest.session.get("/", headers=headers) - assert response.status_code == 401 - assert response.json()["message"] == "JWT cryptographic operation failed" - - # token with algorithm "none" - headers = authheader( - "eyJ0eXAiOiJKV1QiLCJhbGciOiJub25lIn0.e30.yOBhlOIqn56T-4NvyEXCjfi3UmyQZ-BzXtePMO2NgRI" - ) - response = postgrest.session.get("/", headers=headers) - assert response.status_code == 401 - assert response.json()["message"] == "Wrong or unsupported encoding algorithm" - assert ( - response.json()["details"] - == "JWT is unsecured but expected 'alg' was not 'none'" - ) - - def test_fail_with_invalid_password(defaultenv): "Connecting with an invalid password should fail without retries." uri = f'postgresql://?dbname={defaultenv["PGDATABASE"]}&host={defaultenv["PGHOST"]}&user=some_protected_user&password=invalid_pass' diff --git a/test/spec/Feature/Query/ErrorSpec.hs b/test/spec/Feature/Query/ErrorSpec.hs index bfd7e49ce..b0f8a8b8b 100644 --- a/test/spec/Feature/Query/ErrorSpec.hs +++ b/test/spec/Feature/Query/ErrorSpec.hs @@ -5,12 +5,14 @@ import Test.Hspec import Test.Hspec.Wai import Test.Hspec.Wai.JSON +import PostgREST.Config (AppConfig (..)) + import Protolude hiding (get) import SpecHelper spec :: SpecWithConfig -spec withConfig = withConfig baseCfg $ do - describe "Test PostgreSQL and PostgREST errors" $ do +spec withConfig = do + withConfig baseCfg $ describe "Test PostgreSQL and PostgREST errors" $ do it "should return 500 for cardinality_violation" $ get "/bad_subquery" `shouldRespondWith` 500 @@ -100,3 +102,124 @@ spec withConfig = withConfig baseCfg $ do , matchHeaders = [ "Proxy-Status" <:> "PostgREST; error=PGRST205" , "Content-Length" <:> "119" ] } + + context "JWT Errors" $ do + it "error on jwt encoded with wrong secret" $ do + let jwtPayload = [json|{}|] + auth = authHeaderJWT $ generateJWTWithSecret jwtPayload "wrong secret" + request methodGet "/authors_only" [auth] "" + `shouldRespondWith` + [json|{ + "code":"PGRST301", + "details":"None of the keys was able to decode the JWT", + "hint":null, + "message":"No suitable key or wrong key type" + }|] + { matchStatus = 401 } + + it "when role does not exist" $ do + let jwtPayload = [json|{ "role": "not existing" }|] + auth = authHeaderJWT $ generateJWT jwtPayload + request methodGet "/authors_only" [auth] "" + `shouldRespondWith` + [json|{ + "code":"22023", + "details":null, + "hint":null, + "message":"role \"not existing\" does not exist" + }|] + { matchStatus = 401 } + + context "we allow 30 seconds clock skew" $ do + it "it should return error if expired" $ do + currentTime <- liftIO $ relativeSeconds (-35) + let jwtPayload = [json|{ "exp": #{currentTime} }|] + auth = authHeaderJWT $ generateJWT jwtPayload + request methodGet "/authors_only" [auth] "" + `shouldRespondWith` + [json|{ + "code":"PGRST303", + "details":null, + "hint":null, + "message":"JWT expired" + }|] + { matchStatus = 401 } + + it "it should return error if used before it is valid" $ do + currentTime <- liftIO $ relativeSeconds 35 + let jwtPayload = [json|{ "nbf": #{currentTime} }|] + auth = authHeaderJWT $ generateJWT jwtPayload + request methodGet "/authors_only" [auth] "" + `shouldRespondWith` + [json|{ + "code":"PGRST303", + "details":null, + "hint":null, + "message":"JWT not yet valid" + }|] + { matchStatus = 401 } + + it "it should return error if issued at future" $ do + currentTime <- liftIO $ relativeSeconds 35 + let jwtPayload = [json|{ "iat": #{currentTime} }|] + auth = authHeaderJWT $ generateJWT jwtPayload + request methodGet "/authors_only" [auth] "" + `shouldRespondWith` + [json|{ + "code":"PGRST303", + "details":null, + "hint":null, + "message":"JWT issued at future" + }|] + { matchStatus = 401 } + + withConfig baseCfg { configJwtAudience = Just "spec tests" } $ describe "Test JWT Audience error" $ do + it "it should return error if JWT not in audience" $ do + let jwtPayload = [json|{ "aud": "not set" }|] + auth = authHeaderJWT $ generateJWT jwtPayload + request methodGet "/authors_only" [auth] "" + `shouldRespondWith` + [json|{ + "code":"PGRST303", + "details":null, + "hint":null, + "message":"JWT not in audience" + }|] + { matchStatus = 401 } + + withConfig baseCfg $ describe "Test JWT Token format errors" $ do + it "when partial token is provided" $ do + let auth = authHeaderJWT "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.bm90IGFuIG9iamVjdA" + request methodGet "/authors_only" [auth] "" + `shouldRespondWith` + [json|{ + "code":"PGRST301", + "details":null, + "hint":null, + "message":"Expected 3 parts in JWT; got 2" + }|] + { matchStatus = 401 } + + it "when token is complete but random characters" $ do + let auth = authHeaderJWT "quifquirndsjagnrgniur.fonvoienqhhdj.iuqvnvhojah" + request methodGet "/authors_only" [auth] "" + `shouldRespondWith` + [json|{ + "code":"PGRST301", + "details":null, + "hint":null, + "message":"JWT cryptographic operation failed" + }|] + { matchStatus = 401 } + + it "when token with algorithm 'none' is used" $ do + let auth = authHeaderJWT "eyJ0eXAiOiJKV1QiLCJhbGciOiJub25lIn0.e30.yOBhlOIqn56T-4NvyEXCjfi3UmyQZ-BzXtePMO2NgRI" + request methodGet "/authors_only" [auth] "" + `shouldRespondWith` + [json|{ + "code":"PGRST301", + "details":"JWT is unsecured but expected 'alg' was not 'none'", + "hint":null, + "message":"Wrong or unsupported encoding algorithm" + }|] + { matchStatus = 401 } diff --git a/test/spec/SpecHelper.hs b/test/spec/SpecHelper.hs index 78456b36d..0a67164b0 100644 --- a/test/spec/SpecHelper.hs +++ b/test/spec/SpecHelper.hs @@ -13,14 +13,15 @@ import qualified Jose.Jwa as JWT import qualified Jose.Jws as JWT import qualified Jose.Jwt as JWT -import Data.Aeson ((.=)) -import Data.CaseInsensitive (CI (..), original) -import Data.List (lookup) -import Data.List.NonEmpty (fromList) -import Network.Wai (Application) -import Network.Wai.Test (SResponse (simpleBody, simpleHeaders, simpleStatus)) -import System.IO.Unsafe (unsafePerformIO) -import Text.Regex.TDFA ((=~)) +import Data.Aeson ((.=)) +import Data.CaseInsensitive (CI (..), original) +import Data.List (lookup) +import Data.List.NonEmpty (fromList) +import Data.Time.Clock.POSIX (getPOSIXTime) +import Network.Wai (Application) +import Network.Wai.Test (SResponse (simpleBody, simpleHeaders, simpleStatus)) +import System.IO.Unsafe (unsafePerformIO) +import Text.Regex.TDFA ((=~)) import Network.HTTP.Types @@ -207,6 +208,10 @@ generateJWT :: BL.ByteString -> ByteString generateJWT claims = either mempty JWT.unJwt $ JWT.hmacEncode JWT.HS256 generateSecret (BL.toStrict claims) +generateJWTWithSecret :: BL.ByteString -> ByteString -> ByteString +generateJWTWithSecret claims secret = + either mempty JWT.unJwt $ JWT.hmacEncode JWT.HS256 secret (BL.toStrict claims) + -- | Tests whether the text can be parsed as a json object containing -- the key "message", and optional keys "details", "hint", "code", -- and no extraneous keys @@ -245,3 +250,8 @@ getInsertDataForTiobePlsTable rows = readFixtureFile :: FilePath -> BL.ByteString readFixtureFile file = unsafePerformIO $ BL.readFile $ "test/spec/fixtures/" <> file + +relativeSeconds :: Integer -> IO Integer +relativeSeconds s = do + currTime <- getPOSIXTime + return $ floor currTime + s