diff --git a/docs/references/auth.rst b/docs/references/auth.rst index eab4f7b3c..5de9abdce 100644 --- a/docs/references/auth.rst +++ b/docs/references/auth.rst @@ -166,10 +166,27 @@ PostgREST honors the following `JWT claims ` .. note:: - PostgREST allows for a 30-second clock skew when validating the ``exp`` and ``iat`` claims. In other words, it gives an extra 30 seconds before the token is rejected if there is a slight discrepancy in the timestamps. + PostgREST allows for a 30-second clock skew when validating the ``exp``, ``iat`` and ``nbf`` claims. + In other words, it gives an extra 30 seconds before the token is rejected if there is a slight discrepancy in the timestamps. + +.. _jwt_aud_validation: + +JWT ``aud`` Claim Validation +^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + +PostgREST has built-in validation of the `JWT audience claim `_. +It works this way: + +- If :ref:`jwt-aud` is not set (the default), PostgREST identifies with all audiences and allows the JWT for any ``aud`` claim. +- If :ref:`jwt-aud` is set to a specific audience, PostgREST will check if this audience is present in the ``aud`` claim: + + + If the ``aud`` value is a JSON string, it will match it to the :ref:`jwt-aud`. + + If the ``aud`` value is a JSON array of strings, it will search every element for a match. + + If the match fails or if the ``aud`` value is not a string or array of strings, then the token will be rejected with a :ref:`401 Unauthorized ` error. + + If the ``aud`` key **is not present** or if its value is ``null`` or ``[]``, PostgREST will interpret this token as allowed for all audiences and will complete the request. .. _jwt_role_claim_key_extract: diff --git a/docs/references/configuration.rst b/docs/references/configuration.rst index 5c179e82f..560d4f22b 100644 --- a/docs/references/configuration.rst +++ b/docs/references/configuration.rst @@ -599,11 +599,7 @@ jwt-aud **In-Database** pgrst.jwt_aud =============== ================================= - Specifies the `JWT audience claim `_. If this claim is present in the client provided JWT then you must set this to the same value as in the JWT, otherwise verifying the JWT will fail. - - .. warning:: - - Using this setting will only reject tokens with a different audience claim. Tokens **without** audience claim will still be accepted. + Specifies an audience for the JWT ``aud`` claim. See :ref:`jwt_aud_validation`. .. _jwt-role-claim-key: diff --git a/src/PostgREST/Auth.hs b/src/PostgREST/Auth.hs index dc2e5e492..8febebf4c 100644 --- a/src/PostgREST/Auth.hs +++ b/src/PostgREST/Auth.hs @@ -10,6 +10,7 @@ Authentication should always be implemented in an external service. In the test suite there is an example of simple login function that can be used for a very simple authentication system inside the PostgreSQL database. -} +{-# LANGUAGE LambdaCase #-} {-# LANGUAGE RecordWildCards #-} module PostgREST.Auth ( getResult @@ -99,6 +100,7 @@ parseToken AppConfig{..} (Just tkn) time = do allowedSkewSeconds = 30 :: Int64 now = floor . nominalDiffTimeToSeconds $ utcTimeToPOSIXSeconds time sciToInt = fromMaybe 0 . Sci.toBoundedInteger + allStrings = all (\case (JSON.String _) -> True; _ -> False) isValidExpClaim :: JSON.Value -> Either JwtError Bool isValidExpClaim (JSON.Number secs) = Right $ now <= (sciToInt secs + allowedSkewSeconds) @@ -113,7 +115,11 @@ parseToken AppConfig{..} (Just tkn) time = do isValidIatClaim _ = Left $ JwtClaimsError "The JWT 'iat' claim must be a number" isValidAudClaim :: JSON.Value -> Either JwtError Bool + isValidAudClaim JSON.Null = Right True -- {"aud": null} is valid for all audiences isValidAudClaim (JSON.String str) = Right $ maybe (const True) (==) configJwtAudience str + isValidAudClaim (JSON.Array arr) + | null arr = Right True -- {"aud": []} is valid for all audiences + | allStrings arr = Right $ maybe True (\a -> JSON.String a `elem` arr) configJwtAudience isValidAudClaim _ = Left $ JwtClaimsError "The JWT 'aud' claim must be a string or an array of strings" parseClaims :: Monad m => diff --git a/test/spec/Feature/Auth/AudienceJwtSecretSpec.hs b/test/spec/Feature/Auth/AudienceJwtSecretSpec.hs index 2fda8b731..d4ad2968a 100644 --- a/test/spec/Feature/Auth/AudienceJwtSecretSpec.hs +++ b/test/spec/Feature/Auth/AudienceJwtSecretSpec.hs @@ -3,43 +3,238 @@ module Feature.Auth.AudienceJwtSecretSpec where import Network.Wai (Application) import Network.HTTP.Types +import Protolude hiding (get) +import SpecHelper import Test.Hspec import Test.Hspec.Wai - -import Protolude hiding (get) -import SpecHelper +import Test.Hspec.Wai.JSON spec :: SpecWith ((), Application) -spec = describe "test handling of aud claims in JWT" $ do +spec = describe "test handling of aud claims in JWT when the jwt-aud config is set" $ do - -- this test will stop working 9999999999s after the UNIX EPOCH - it "succeeds with jwt token containing with an audience claim" $ do - {- This is the decoded contents of authHeaderJWT + context "when the audience claim is a string" $ do + -- this test will stop working 9999999999s after the UNIX EPOCH + it "succeeds when the audience claim matches" $ do + let jwtPayload = + [json|{ + "exp": 9999999999, + "role": "postgrest_test_author", + "id": "jdoe", + "aud": "youraudience" + }|] + auth = authHeaderJWT $ generateJWT jwtPayload + request methodGet "/authors_only" [auth] "" + `shouldRespondWith` 200 - { - "exp": 9999999999, - "role": "postgrest_test_author", - "id": "jdoe", - "aud": "youraudience" - } + it "fails when the audience claim does not match" $ do + let jwtPayload = [json| + { + "exp": 9999999999, + "role": "postgrest_test_author", + "id": "jdoe", + "aud": "notyouraudience" + }|] + auth = authHeaderJWT $ generateJWT jwtPayload + request methodGet "/authors_only" [auth] "" + `shouldRespondWith` + [json|{"code":"PGRST303","details":null,"hint":null,"message":"JWT not in audience"}|] + { matchStatus = 401 } - -} - let auth = authHeaderJWT "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjk5OTk5OTk5OTksInJvbGUiOiJwb3N0Z3Jlc3RfdGVzdF9hdXRob3IiLCJpZCI6Impkb2UiLCJhdWQiOiJ5b3VyYXVkaWVuY2UifQ.fJ4tLKSmolWGWehWN20qiU9dMO-WY0RI2VvacL7-ZGo" - request methodGet "/authors_only" [auth] "" - `shouldRespondWith` 200 + it "fails when the audience claim is empty" $ do + let jwtPayload = [json| + { + "exp": 9999999999, + "role": "postgrest_test_author", + "id": "jdoe", + "aud": "" + }|] + auth = authHeaderJWT $ generateJWT jwtPayload + request methodGet "/authors_only" [auth] "" + `shouldRespondWith` + [json|{"code":"PGRST303","details":null,"hint":null,"message":"JWT not in audience"}|] + { matchStatus = 401 } - it "succeeds with jwt token that does not contain an audience claim" $ do - {- This is the decoded contents of authHeaderJWT + context "when the audience claim is an array of strings" $ do + it "succeeds when the audience claim has 1 element and it matches" $ do + let jwtPayload = [json| + { + "exp": 9999999999, + "role": "postgrest_test_author", + "id": "jdoe", + "aud": ["youraudience"] + }|] + auth = authHeaderJWT $ generateJWT jwtPayload + request methodGet "/authors_only" [auth] "" + `shouldRespondWith` 200 - { - "exp": 9999999999, - "role": "postgrest_test_author", - "id": "jdoe" - } - -} - let auth = authHeaderJWT "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjk5OTk5OTk5OTksInJvbGUiOiJwb3N0Z3Jlc3RfdGVzdF9hdXRob3IiLCJpZCI6Impkb2UifQ.Dpss-QoLYjec5OTsOaAc3FNVsSjA89wACoV-0ra3ClA" - request methodGet "/authors_only" [auth] "" - `shouldRespondWith` 200 + it "succeeds when the audience claim has more than 1 element and one matches" $ do + let jwtPayload = [json| + { + "exp": 9999999999, + "role": "postgrest_test_author", + "id": "jdoe", + "aud": ["notyouraudience", "youraudience", "anotheraudience"] + }|] + auth = authHeaderJWT $ generateJWT jwtPayload + request methodGet "/authors_only" [auth] "" + `shouldRespondWith` 200 - it "requests without JWT token should work" $ - get "/has_count_column" `shouldRespondWith` 200 + it "fails when the audience claim has 1 element and it doesn't match" $ do + let jwtPayload = [json| + { + "exp": 9999999999, + "role": "postgrest_test_author", + "id": "jdoe", + "aud": ["notyouraudience"] + }|] + auth = authHeaderJWT $ generateJWT jwtPayload + request methodGet "/authors_only" [auth] "" + `shouldRespondWith` + [json|{"code":"PGRST303","details":null,"hint":null,"message":"JWT not in audience"}|] + { matchStatus = 401 } + + + it "fails when the audience claim has more than 1 element and none matches" $ do + let jwtPayload = [json| + { + "exp": 9999999999, + "role": "postgrest_test_author", + "id": "jdoe", + "aud": ["notyouraudience", "stillnotyouraudience", "anotheraudience"] + }|] + auth = authHeaderJWT $ generateJWT jwtPayload + request methodGet "/authors_only" [auth] "" + `shouldRespondWith` + [json|{"code":"PGRST303","details":null,"hint":null,"message":"JWT not in audience"}|] + { matchStatus = 401 } + + it "ignores the audience claim and succeeds when it's empty" $ do + let jwtPayload = [json| + { + "exp": 9999999999, + "role": "postgrest_test_author", + "id": "jdoe", + "aud": [] + }|] + auth = authHeaderJWT $ generateJWT jwtPayload + request methodGet "/authors_only" [auth] "" + `shouldRespondWith` 200 + + it "ignores the audience claim and succeeds when it's null" $ do + let jwtPayload = [json| + { + "exp": 9999999999, + "role": "postgrest_test_author", + "id": "jdoe", + "aud": null + }|] + auth = authHeaderJWT $ generateJWT jwtPayload + request methodGet "/authors_only" [auth] "" + `shouldRespondWith` 200 + + context "when the audience claim is not present" $ do + it "succeeds with a JWT with no audience claim" $ do + let jwtPayload = [json| + { + "exp": 9999999999, + "role": "postgrest_test_author", + "id": "jdoe" + }|] + auth = authHeaderJWT $ generateJWT jwtPayload + request methodGet "/authors_only" [auth] "" + `shouldRespondWith` 200 + + it "succeeds without a JWT" $ + get "/has_count_column" `shouldRespondWith` 200 + +disabledSpec :: SpecWith ((), Application) +disabledSpec = describe "test handling of aud claims in JWT when the jwt-aud config is not set" $ do + + context "when the audience claim is a string" $ do + it "ignores the audience claim and suceeds" $ do + let jwtPayload = + [json|{ + "exp": 9999999999, + "role": "postgrest_test_author", + "id": "jdoe", + "aud": "youraudience" + }|] + auth = authHeaderJWT $ generateJWT jwtPayload + request methodGet "/authors_only" [auth] "" + `shouldRespondWith` 200 + + it "ignores the audience claim and suceeds when it's empty" $ do + let jwtPayload = + [json|{ + "exp": 9999999999, + "role": "postgrest_test_author", + "id": "jdoe", + "aud": "" + }|] + auth = authHeaderJWT $ generateJWT jwtPayload + request methodGet "/authors_only" [auth] "" + `shouldRespondWith` 200 + + context "when the audience is an array of strings" $ do + it "ignores the audience claim and suceeds when it has 1 element" $ do + let jwtPayload = [json| + { + "exp": 9999999999, + "role": "postgrest_test_author", + "id": "jdoe", + "aud": ["youraudience"] + }|] + auth = authHeaderJWT $ generateJWT jwtPayload + request methodGet "/authors_only" [auth] "" + `shouldRespondWith` 200 + + it "ignores the audience claim and suceeds when it has more than 1 element" $ do + let jwtPayload = [json| + { + "exp": 9999999999, + "role": "postgrest_test_author", + "id": "jdoe", + "aud": ["notyouraudience", "youraudience", "anotheraudience"] + }|] + auth = authHeaderJWT $ generateJWT jwtPayload + request methodGet "/authors_only" [auth] "" + `shouldRespondWith` 200 + + it "ignores the audience claim and suceeds when it's empty" $ do + let jwtPayload = [json| + { + "exp": 9999999999, + "role": "postgrest_test_author", + "id": "jdoe", + "aud": [] + }|] + auth = authHeaderJWT $ generateJWT jwtPayload + request methodGet "/authors_only" [auth] "" + `shouldRespondWith` 200 + + it "ignores the audience claim and succeeds when it's null" $ do + let jwtPayload = [json| + { + "exp": 9999999999, + "role": "postgrest_test_author", + "id": "jdoe", + "aud": null + }|] + auth = authHeaderJWT $ generateJWT jwtPayload + request methodGet "/authors_only" [auth] "" + `shouldRespondWith` 200 + + context "when the audience claim is not present" $ do + it "succeeds with a JWT with no audience claim" $ do + let jwtPayload = [json| + { + "exp": 9999999999, + "role": "postgrest_test_author", + "id": "jdoe" + }|] + auth = authHeaderJWT $ generateJWT jwtPayload + request methodGet "/authors_only" [auth] "" + `shouldRespondWith` 200 + + it "succeeds without a JWT" $ + get "/has_count_column" `shouldRespondWith` 200 diff --git a/test/spec/Main.hs b/test/spec/Main.hs index 16c5f3930..2fc0690b0 100644 --- a/test/spec/Main.hs +++ b/test/spec/Main.hs @@ -137,7 +137,8 @@ main = do analyzeTable "child_entities" specs = uncurry describe <$> [ - ("Feature.Auth.AuthSpec" , Feature.Auth.AuthSpec.spec) + ("Feature.Auth.AudienceJwtSecretSpec" , Feature.Auth.AudienceJwtSecretSpec.disabledSpec) + , ("Feature.Auth.AuthSpec" , Feature.Auth.AuthSpec.spec) , ("Feature.ConcurrentSpec" , Feature.ConcurrentSpec.spec) , ("Feature.CorsSpec" , Feature.CorsSpec.spec) , ("Feature.CustomMediaSpec" , Feature.Query.CustomMediaSpec.spec)