correct: handle array values in JWT aud claim correctly

This commit is contained in:
Laurence Isla
2025-05-07 21:54:32 +00:00
parent b3bff90d68
commit e0c5b3a314
5 changed files with 253 additions and 38 deletions
+19 -2
View File
@@ -166,10 +166,27 @@ PostgREST honors the following `JWT claims <https://datatracker.ietf.org/doc/htm
- ``exp`` Expiration Time - ``exp`` Expiration Time
- ``iat`` Issued At - ``iat`` Issued At
- ``nbf`` Not Before - ``nbf`` Not Before
- ``aud`` Audience, see :ref:`jwt-aud` - ``aud`` :ref:`Audience <jwt_aud_validation>`
.. note:: .. 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: .. _jwt_role_claim_key_extract:
+1 -5
View File
@@ -599,11 +599,7 @@ jwt-aud
**In-Database** pgrst.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. Specifies an audience for the JWT ``aud`` claim. See :ref:`jwt_aud_validation`.
.. warning::
Using this setting will only reject tokens with a different audience claim. Tokens **without** audience claim will still be accepted.
.. _jwt-role-claim-key: .. _jwt-role-claim-key:
+6
View File
@@ -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 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. very simple authentication system inside the PostgreSQL database.
-} -}
{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE RecordWildCards #-} {-# LANGUAGE RecordWildCards #-}
module PostgREST.Auth module PostgREST.Auth
( getResult ( getResult
@@ -99,6 +100,7 @@ parseToken AppConfig{..} (Just tkn) time = do
allowedSkewSeconds = 30 :: Int64 allowedSkewSeconds = 30 :: Int64
now = floor . nominalDiffTimeToSeconds $ utcTimeToPOSIXSeconds time now = floor . nominalDiffTimeToSeconds $ utcTimeToPOSIXSeconds time
sciToInt = fromMaybe 0 . Sci.toBoundedInteger sciToInt = fromMaybe 0 . Sci.toBoundedInteger
allStrings = all (\case (JSON.String _) -> True; _ -> False)
isValidExpClaim :: JSON.Value -> Either JwtError Bool isValidExpClaim :: JSON.Value -> Either JwtError Bool
isValidExpClaim (JSON.Number secs) = Right $ now <= (sciToInt secs + allowedSkewSeconds) 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" isValidIatClaim _ = Left $ JwtClaimsError "The JWT 'iat' claim must be a number"
isValidAudClaim :: JSON.Value -> Either JwtError Bool 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.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" isValidAudClaim _ = Left $ JwtClaimsError "The JWT 'aud' claim must be a string or an array of strings"
parseClaims :: Monad m => parseClaims :: Monad m =>
+213 -18
View File
@@ -3,43 +3,238 @@ module Feature.Auth.AudienceJwtSecretSpec where
import Network.Wai (Application) import Network.Wai (Application)
import Network.HTTP.Types import Network.HTTP.Types
import Test.Hspec
import Test.Hspec.Wai
import Protolude hiding (get) import Protolude hiding (get)
import SpecHelper import SpecHelper
import Test.Hspec
import Test.Hspec.Wai
import Test.Hspec.Wai.JSON
spec :: SpecWith ((), Application) 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
context "when the audience claim is a string" $ do
-- this test will stop working 9999999999s after the UNIX EPOCH -- this test will stop working 9999999999s after the UNIX EPOCH
it "succeeds with jwt token containing with an audience claim" $ do it "succeeds when the audience claim matches" $ do
{- This is the decoded contents of authHeaderJWT let jwtPayload =
[json|{
{
"exp": 9999999999, "exp": 9999999999,
"role": "postgrest_test_author", "role": "postgrest_test_author",
"id": "jdoe", "id": "jdoe",
"aud": "youraudience" "aud": "youraudience"
} }|]
auth = authHeaderJWT $ generateJWT jwtPayload
-}
let auth = authHeaderJWT "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjk5OTk5OTk5OTksInJvbGUiOiJwb3N0Z3Jlc3RfdGVzdF9hdXRob3IiLCJpZCI6Impkb2UiLCJhdWQiOiJ5b3VyYXVkaWVuY2UifQ.fJ4tLKSmolWGWehWN20qiU9dMO-WY0RI2VvacL7-ZGo"
request methodGet "/authors_only" [auth] "" request methodGet "/authors_only" [auth] ""
`shouldRespondWith` 200 `shouldRespondWith` 200
it "succeeds with jwt token that does not contain an audience claim" $ do it "fails when the audience claim does not match" $ do
{- This is the decoded contents of authHeaderJWT 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 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 }
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
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 "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, "exp": 9999999999,
"role": "postgrest_test_author", "role": "postgrest_test_author",
"id": "jdoe" "id": "jdoe"
} }|]
-} auth = authHeaderJWT $ generateJWT jwtPayload
let auth = authHeaderJWT "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjk5OTk5OTk5OTksInJvbGUiOiJwb3N0Z3Jlc3RfdGVzdF9hdXRob3IiLCJpZCI6Impkb2UifQ.Dpss-QoLYjec5OTsOaAc3FNVsSjA89wACoV-0ra3ClA"
request methodGet "/authors_only" [auth] "" request methodGet "/authors_only" [auth] ""
`shouldRespondWith` 200 `shouldRespondWith` 200
it "requests without JWT token should work" $ 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 get "/has_count_column" `shouldRespondWith` 200
+2 -1
View File
@@ -137,7 +137,8 @@ main = do
analyzeTable "child_entities" analyzeTable "child_entities"
specs = uncurry describe <$> [ 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.ConcurrentSpec" , Feature.ConcurrentSpec.spec)
, ("Feature.CorsSpec" , Feature.CorsSpec.spec) , ("Feature.CorsSpec" , Feature.CorsSpec.spec)
, ("Feature.CustomMediaSpec" , Feature.Query.CustomMediaSpec.spec) , ("Feature.CustomMediaSpec" , Feature.Query.CustomMediaSpec.spec)