From 688cf83a3bb4b8272b8b0e1ccdf9a80b7ce3766a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20K=C5=82eczek?= Date: Tue, 21 Oct 2025 23:17:11 +0200 Subject: [PATCH] change: Make jwt-aud config value a regular expression This change adds flexibility to aud claim validation. jwt-aud configuration property can now be specified as a regular expression. For example, it is now possible to * configure multiple acceptable aud values with '|' regex operator, eg: 'audience1|audience2|audience3' * accept any audience from a particular domain, eg: 'https://[a-z0-9]*\.example\.com' --- CHANGELOG.md | 1 + docs/references/auth.rst | 6 +++- docs/references/configuration.rst | 4 +-- src/PostgREST/Config.hs | 48 ++++++++++++++++++------------- test/io/fixtures.yaml | 2 +- test/io/test_cli.py | 8 +++--- test/spec/SpecHelper.hs | 7 +++-- 7 files changed, 46 insertions(+), 30 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index efb7d3b8da..9cf134ca10 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,7 @@ This project adheres to [Semantic Versioning](http://semver.org/). - Drop support for PostgreSQL EOL version 12 by @wolfgangwalther in #3865 - Replaced `jwt-cache-max-lifetime` config with `jwt-cache-max-entries` by @mkleczek in #4084 - `log-query` config now takes a boolean instead of a string value by @steve-chavez in #3934 +- `jwt-aud` config now takes a regular expression to match against `aud` claim #2099 ## [13.0.7] - 2025-09-14 diff --git a/docs/references/auth.rst b/docs/references/auth.rst index b92bd8c210..cac4dc5ff2 100644 --- a/docs/references/auth.rst +++ b/docs/references/auth.rst @@ -193,13 +193,17 @@ PostgREST has built-in validation of the `JWT audience claim ` 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. +Examples: +- To make PostgREST accept ``aud`` claim value from a set ``audience1``, ``audience2``, ``otheraudience``, :ref:`jwt-aud` claim should be set to ``audience1|audience2|otheraudience``. +- To make PostgREST accept ``aud`` claim value matching any ``https`` URI pointing to a host in ``example.com`` domain, :ref:`jwt-aud` claim should be set to ``https://[a-zA-Z0-9_]*\.example\.com``. + .. _jwt_caching: JWT Cache diff --git a/docs/references/configuration.rst b/docs/references/configuration.rst index c3b563f1f2..ad11091282 100644 --- a/docs/references/configuration.rst +++ b/docs/references/configuration.rst @@ -596,14 +596,14 @@ jwt-aud ------- =============== ================================= - **Type** String + **Type** String (must be a valid regular expression) **Default** `n/a` **Reloadable** Y **Environment** PGRST_JWT_AUD **In-Database** pgrst.jwt_aud =============== ================================= - Specifies an audience for the JWT ``aud`` claim. See :ref:`jwt_aud`. + Specifies a regular expression to match against the JWT ``aud`` claim. See :ref:`jwt_aud`. .. _jwt-role-claim-key: diff --git a/src/PostgREST/Config.hs b/src/PostgREST/Config.hs index 2bfcb9e93b..d1a9f3c42c 100644 --- a/src/PostgREST/Config.hs +++ b/src/PostgREST/Config.hs @@ -19,6 +19,7 @@ module PostgREST.Config , LogLevel(..) , OpenAPIMode(..) , Proxy(..) + , CfgAud , toText , isMalformedProxyUri , readAppConfig @@ -29,6 +30,8 @@ module PostgREST.Config , addTargetSessionAttrs , exampleConfigFile , audMatchesCfg + , defaultCfgAud + , parseCfgAud ) where import qualified Data.Aeson as JSON @@ -50,7 +53,7 @@ import Data.List.NonEmpty (fromList, toList) import Data.Maybe (fromJust) import Data.Scientific (floatingOrInteger) import Jose.Jwk (Jwk, JwkSet) -import Network.URI (escapeURIString, isURI, +import Network.URI (escapeURIString, isUnescapedInURIComponent) import Numeric (readOct, showOct) import System.Environment (getEnvironment) @@ -66,10 +69,29 @@ import PostgREST.Config.Proxy (Proxy (..), import PostgREST.SchemaCache.Identifiers (QualifiedIdentifier, dumpQi, toQi) -import Protolude hiding (Proxy, toList) +import Protolude hiding (Proxy, toList) +import qualified Text.Regex.TDFA as R + +data ParsedValue a b = ParsedValue { + sourceValue :: a, + parsedValue :: b +} + +newtype CfgAud = CfgAud { unCfgAud :: ParsedValue (Maybe Text) R.Regex } + +parseCfgAud :: MonadFail m => Text -> m CfgAud +parseCfgAud = fmap CfgAud . (fmap . ParsedValue . Just <*> parseRegex) + where + parseRegex = maybe (fail "jwt-aud should be a valid regular expression") pure . R.makeRegexM . bounded + -- need start and end of text bounds so that + -- regex does not match parts of text + bounded = ("\\`(" <>) . (<> "\\')") + +defaultCfgAud :: CfgAud +defaultCfgAud = CfgAud $ ParsedValue Nothing $ R.makeRegex (".*"::Text) audMatchesCfg :: AppConfig -> Text -> Bool -audMatchesCfg = maybe (const True) (==) . configJwtAudience +audMatchesCfg = R.matchTest . parsedValue . unCfgAud . configJwtAudience data AppConfig = AppConfig { configAppSettings :: [(Text, Text)] @@ -97,7 +119,7 @@ data AppConfig = AppConfig , configDbUri :: Text , configFilePath :: Maybe FilePath , configJWKS :: Maybe JwkSet - , configJwtAudience :: Maybe Text + , configJwtAudience :: CfgAud , configJwtRoleClaimKey :: JSPath , configJwtSecret :: Maybe BS.ByteString , configJwtSecretIsBase64 :: Bool @@ -171,7 +193,7 @@ toText conf = ,("db-pre-config", q . maybe mempty dumpQi . configDbPreConfig) ,("db-tx-end", q . showTxEnd) ,("db-uri", q . configDbUri) - ,("jwt-aud", q . fromMaybe mempty . configJwtAudience) + ,("jwt-aud", q . fold . sourceValue . unCfgAud . configJwtAudience) ,("jwt-role-claim-key", q . T.intercalate mempty . fmap dumpJSPath . configJwtRoleClaimKey) ,("jwt-secret", q . T.decodeUtf8 . showJwtSecret) ,("jwt-secret-is-base64", T.toLower . show . configJwtSecretIsBase64) @@ -279,7 +301,7 @@ parser optPath env dbSettings roleSettings roleIsolationLvl = <*> (fromMaybe "postgresql://" <$> optString "db-uri") <*> pure optPath <*> pure Nothing - <*> optStringOrURI "jwt-aud" + <*> (optStringEmptyable "jwt-aud" >>= maybe (pure defaultCfgAud) parseCfgAud) <*> parseRoleClaimKey "jwt-role-claim-key" "role-claim-key" <*> (fmap encodeUtf8 <$> optString "jwt-secret") <*> (fromMaybe False <$> optWithAlias @@ -399,20 +421,6 @@ parser optPath env dbSettings roleSettings roleIsolationLvl = optStringEmptyable :: C.Key -> C.Parser C.Config (Maybe Text) optStringEmptyable k = overrideFromDbOrEnvironment C.optional k coerceText - optStringOrURI :: C.Key -> C.Parser C.Config (Maybe Text) - optStringOrURI k = do - stringOrURI <- mfilter (/= "") <$> overrideFromDbOrEnvironment C.optional k coerceText - -- If the string contains ':' then it should - -- be a valid URI according to RFC 3986 - case stringOrURI of - Just s -> if T.isInfixOf ":" s then validateURI s else return (Just s) - Nothing -> return Nothing - where - validateURI :: Text -> C.Parser C.Config (Maybe Text) - validateURI s = if isURI (T.unpack s) - then return $ Just s - else fail "jwt-aud should be a string or a valid URI" - optInt :: (Read i, Integral i) => C.Key -> C.Parser C.Config (Maybe i) optInt k = join <$> overrideFromDbOrEnvironment C.optional k coerceInt diff --git a/test/io/fixtures.yaml b/test/io/fixtures.yaml index ee00182cc0..68892f5dfb 100644 --- a/test/io/fixtures.yaml +++ b/test/io/fixtures.yaml @@ -45,7 +45,7 @@ cli: expect: error use_defaultenv: true env: - PGRST_JWT_AUD: 'http://%%localhorst.invalid' + PGRST_JWT_AUD: '[' - name: invalid log-level expect: error use_defaultenv: true diff --git a/test/io/test_cli.py b/test/io/test_cli.py index 0f46e4c0b0..3b48cc0b22 100644 --- a/test/io/test_cli.py +++ b/test/io/test_cli.py @@ -266,15 +266,15 @@ def test_schema_cache_snapshot(baseenv, key, snapshot_yaml): assert formatted == snapshot_yaml -def test_jwt_aud_config_set_to_invalid_uri(defaultenv): - "PostgREST should exit with an error message in output if jwt-aud config is set to an invalid URI" +def test_jwt_aud_config_set_to_invalid_regex(defaultenv): + "PostgREST should exit with an error message in output if jwt-aud config is set to an invalid regular expression" env = { **defaultenv, - "PGRST_JWT_AUD": "foo://%%$$^^.com", + "PGRST_JWT_AUD": "[", } error = cli(["--dump-config"], env=env, expect_error=True) - assert "jwt-aud should be a string or a valid URI" in error + assert "jwt-aud should be a valid regular expression" in error def test_jwt_secret_min_length(defaultenv): diff --git a/test/spec/SpecHelper.hs b/test/spec/SpecHelper.hs index 3c48a1134d..c05df0e2db 100644 --- a/test/spec/SpecHelper.hs +++ b/test/spec/SpecHelper.hs @@ -34,10 +34,12 @@ import PostgREST.Config (AppConfig (..), JSPathExp (..), LogLevel (..), OpenAPIMode (..), + defaultCfgAud, parseCfgAud, parseSecret) import PostgREST.SchemaCache.Identifiers (QualifiedIdentifier (..)) import Protolude hiding (get, toS) import Protolude.Conv (toS) +import Protolude.Partial (fromJust) filterAndMatchCT :: BS.ByteString -> MatchHeader filterAndMatchCT val = MatchHeader $ \headers _ -> @@ -135,7 +137,7 @@ baseCfg = let secret = encodeUtf8 "reallyreallyreallyreallyverysafe" in , configDbUri = "postgresql://" , configFilePath = Nothing , configJWKS = rightToMaybe $ parseSecret secret - , configJwtAudience = Nothing + , configJwtAudience = defaultCfgAud , configJwtRoleClaimKey = [JSPKey "role"] , configJwtSecret = Just secret , configJwtSecretIsBase64 = False @@ -218,7 +220,8 @@ testCfgAudienceJWT :: AppConfig testCfgAudienceJWT = baseCfg { configJwtSecret = Just generateSecret - , configJwtAudience = Just "youraudience" + -- parseCfgAud might fail on invalid regex but it is safe here + , configJwtAudience = fromJust $ parseCfgAud "urn..uriaudience|youraudience" , configJWKS = rightToMaybe $ parseSecret generateSecret }