correct: handle array values in JWT aud claim correctly
This commit is contained in:
@@ -166,10 +166,27 @@ PostgREST honors the following `JWT claims <https://datatracker.ietf.org/doc/htm
|
||||
- ``exp`` Expiration Time
|
||||
- ``iat`` Issued At
|
||||
- ``nbf`` Not Before
|
||||
- ``aud`` Audience, see :ref:`jwt-aud`
|
||||
- ``aud`` :ref:`Audience <jwt_aud_validation>`
|
||||
|
||||
.. 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 <https://datatracker.ietf.org/doc/html/rfc7519#section-4.1.3>`_.
|
||||
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 <pgrst303>` 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:
|
||||
|
||||
|
||||
@@ -599,11 +599,7 @@ jwt-aud
|
||||
**In-Database** pgrst.jwt_aud
|
||||
=============== =================================
|
||||
|
||||
Specifies the `JWT audience claim <https://datatracker.ietf.org/doc/html/rfc7519#section-4.1.3>`_. 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:
|
||||
|
||||
|
||||
@@ -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 =>
|
||||
|
||||
@@ -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
|
||||
|
||||
+2
-1
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user