Skip to content

Commit fdd8843

Browse files
committed
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'
1 parent 0138fdf commit fdd8843

File tree

7 files changed

+50
-31
lines changed

7 files changed

+50
-31
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ All notable changes to this project will be documented in this file. From versio
2727
- From now on PostgREST will follow a `MAJOR.PATCH` two-part versioning. Only even-numbered MAJOR versions will be released, reserving odd-numbered MAJOR versions for development.
2828
- Replaced `jwt-cache-max-lifetime` config with `jwt-cache-max-entries` by @mkleczek in #4084
2929
- `log-query` config now takes a boolean instead of a string value by @steve-chavez in #3934
30+
- `jwt-aud` config now takes a regular expression to match against `aud` claim #2099
3031

3132
## [13.0.8] - 2025-10-24
3233

docs/references/auth.rst

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -193,13 +193,18 @@ PostgREST has built-in validation of the `JWT audience claim <https://datatracke
193193
It works this way:
194194

195195
- If :ref:`jwt-aud` is not set (the default), PostgREST identifies with all audiences and allows the JWT for any ``aud`` claim.
196-
- If :ref:`jwt-aud` is set to a specific audience, PostgREST will check if this audience is present in the ``aud`` claim:
196+
- If :ref:`jwt-aud` is set, PostgREST will treat it as a regular expression and check if it matches the ``aud`` claim:
197197

198198
+ If the ``aud`` value is a JSON string, it will match it to the :ref:`jwt-aud`.
199199
+ If the ``aud`` value is a JSON array of strings, it will search every element for a match.
200200
+ 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.
201201
+ 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.
202202

203+
Examples:
204+
- To make PostgREST accept ``aud`` claim value from a set ``audience1``, ``audience2``, ``otheraudience``, :ref:`jwt-aud` claim should be set to ``audience1|audience2|otheraudience``.
205+
- 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``.
206+
- To make PostgREST accept any ``aud`` claim value , :ref:`jwt-aud` claim should be set to ``.*`` (which is the default).
207+
203208
.. _jwt_caching:
204209

205210
JWT Cache

docs/references/configuration.rst

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -596,14 +596,14 @@ jwt-aud
596596
-------
597597

598598
=============== =================================
599-
**Type** String
600-
**Default** `n/a`
599+
**Type** String (must be a valid regular expression)
600+
**Default** `.*`
601601
**Reloadable** Y
602602
**Environment** PGRST_JWT_AUD
603603
**In-Database** pgrst.jwt_aud
604604
=============== =================================
605605

606-
Specifies an audience for the JWT ``aud`` claim. See :ref:`jwt_aud`.
606+
Specifies a regular expression to match against the JWT ``aud`` claim. See :ref:`jwt_aud`.
607607

608608
.. _jwt-role-claim-key:
609609

src/PostgREST/Config.hs

Lines changed: 30 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ module PostgREST.Config
1919
, LogLevel(..)
2020
, OpenAPIMode(..)
2121
, Proxy(..)
22+
, CfgAud
2223
, toText
2324
, isMalformedProxyUri
2425
, readAppConfig
@@ -29,6 +30,8 @@ module PostgREST.Config
2930
, addTargetSessionAttrs
3031
, exampleConfigFile
3132
, audMatchesCfg
33+
, defaultCfgAud
34+
, parseCfgAud
3235
) where
3336

3437
import qualified Data.Aeson as JSON
@@ -50,7 +53,7 @@ import Data.List.NonEmpty (fromList, toList)
5053
import Data.Maybe (fromJust)
5154
import Data.Scientific (floatingOrInteger)
5255
import Jose.Jwk (Jwk, JwkSet)
53-
import Network.URI (escapeURIString, isURI,
56+
import Network.URI (escapeURIString,
5457
isUnescapedInURIComponent)
5558
import Numeric (readOct, showOct)
5659
import System.Environment (getEnvironment)
@@ -66,10 +69,31 @@ import PostgREST.Config.Proxy (Proxy (..),
6669
import PostgREST.SchemaCache.Identifiers (QualifiedIdentifier, dumpQi,
6770
toQi)
6871

69-
import Protolude hiding (Proxy, toList)
72+
import Protolude hiding (Proxy, toList)
73+
import qualified Text.Regex.TDFA as R
74+
75+
data ParsedValue a b = ParsedValue {
76+
sourceValue :: a,
77+
parsedValue :: b
78+
}
79+
instance Eq a => Eq (ParsedValue a b) where
80+
x == y = sourceValue x == sourceValue y
81+
82+
newtype CfgAud = CfgAud { unCfgAud :: ParsedValue (Maybe Text) R.Regex } deriving Eq
83+
84+
parseCfgAud :: MonadFail m => Text -> m CfgAud
85+
parseCfgAud = fmap CfgAud . (fmap . ParsedValue . Just <*> parseRegex)
86+
where
87+
parseRegex = maybe (fail "jwt-aud should be a valid regular expression") pure . R.makeRegexM . bounded
88+
-- need start and end of text bounds so that
89+
-- regex does not match parts of text
90+
bounded = ("\\`(" <>) . (<> "\\')")
91+
92+
defaultCfgAud :: CfgAud
93+
defaultCfgAud = CfgAud $ ParsedValue Nothing $ R.makeRegex (".*"::Text)
7094

7195
audMatchesCfg :: AppConfig -> Text -> Bool
72-
audMatchesCfg = maybe (const True) (==) . configJwtAudience
96+
audMatchesCfg = R.matchTest . parsedValue . unCfgAud . configJwtAudience
7397

7498
data AppConfig = AppConfig
7599
{ configAppSettings :: [(Text, Text)]
@@ -97,7 +121,7 @@ data AppConfig = AppConfig
97121
, configDbUri :: Text
98122
, configFilePath :: Maybe FilePath
99123
, configJWKS :: Maybe JwkSet
100-
, configJwtAudience :: Maybe Text
124+
, configJwtAudience :: CfgAud
101125
, configJwtRoleClaimKey :: JSPath
102126
, configJwtSecret :: Maybe BS.ByteString
103127
, configJwtSecretIsBase64 :: Bool
@@ -171,7 +195,7 @@ toText conf =
171195
,("db-pre-config", q . maybe mempty dumpQi . configDbPreConfig)
172196
,("db-tx-end", q . showTxEnd)
173197
,("db-uri", q . configDbUri)
174-
,("jwt-aud", q . fromMaybe mempty . configJwtAudience)
198+
,("jwt-aud", q . fold . sourceValue . unCfgAud . configJwtAudience)
175199
,("jwt-role-claim-key", q . T.intercalate mempty . fmap dumpJSPath . configJwtRoleClaimKey)
176200
,("jwt-secret", q . T.decodeUtf8 . showJwtSecret)
177201
,("jwt-secret-is-base64", T.toLower . show . configJwtSecretIsBase64)
@@ -279,7 +303,7 @@ parser optPath env dbSettings roleSettings roleIsolationLvl =
279303
<*> (fromMaybe "postgresql://" <$> optString "db-uri")
280304
<*> pure optPath
281305
<*> pure Nothing
282-
<*> optStringOrURI "jwt-aud"
306+
<*> (optStringEmptyable "jwt-aud" >>= maybe (pure defaultCfgAud) parseCfgAud)
283307
<*> parseRoleClaimKey "jwt-role-claim-key" "role-claim-key"
284308
<*> (fmap encodeUtf8 <$> optString "jwt-secret")
285309
<*> (fromMaybe False <$> optWithAlias
@@ -399,20 +423,6 @@ parser optPath env dbSettings roleSettings roleIsolationLvl =
399423
optStringEmptyable :: C.Key -> C.Parser C.Config (Maybe Text)
400424
optStringEmptyable k = overrideFromDbOrEnvironment C.optional k coerceText
401425

402-
optStringOrURI :: C.Key -> C.Parser C.Config (Maybe Text)
403-
optStringOrURI k = do
404-
stringOrURI <- mfilter (/= "") <$> overrideFromDbOrEnvironment C.optional k coerceText
405-
-- If the string contains ':' then it should
406-
-- be a valid URI according to RFC 3986
407-
case stringOrURI of
408-
Just s -> if T.isInfixOf ":" s then validateURI s else return (Just s)
409-
Nothing -> return Nothing
410-
where
411-
validateURI :: Text -> C.Parser C.Config (Maybe Text)
412-
validateURI s = if isURI (T.unpack s)
413-
then return $ Just s
414-
else fail "jwt-aud should be a string or a valid URI"
415-
416426
optInt :: (Read i, Integral i) => C.Key -> C.Parser C.Config (Maybe i)
417427
optInt k = join <$> overrideFromDbOrEnvironment C.optional k coerceInt
418428

test/io/fixtures.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -45,7 +45,7 @@ cli:
4545
expect: error
4646
use_defaultenv: true
4747
env:
48-
PGRST_JWT_AUD: 'http://%%localhorst.invalid'
48+
PGRST_JWT_AUD: '['
4949
- name: invalid log-level
5050
expect: error
5151
use_defaultenv: true

test/io/test_cli.py

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -266,15 +266,15 @@ def test_schema_cache_snapshot(baseenv, key, snapshot_yaml):
266266
assert formatted == snapshot_yaml
267267

268268

269-
def test_jwt_aud_config_set_to_invalid_uri(defaultenv):
270-
"PostgREST should exit with an error message in output if jwt-aud config is set to an invalid URI"
269+
def test_jwt_aud_config_set_to_invalid_regex(defaultenv):
270+
"PostgREST should exit with an error message in output if jwt-aud config is set to an invalid regular expression"
271271
env = {
272272
**defaultenv,
273-
"PGRST_JWT_AUD": "foo://%%$$^^.com",
273+
"PGRST_JWT_AUD": "[",
274274
}
275275

276276
error = cli(["--dump-config"], env=env, expect_error=True)
277-
assert "jwt-aud should be a string or a valid URI" in error
277+
assert "jwt-aud should be a valid regular expression" in error
278278

279279

280280
def test_jwt_secret_min_length(defaultenv):

test/spec/SpecHelper.hs

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,10 +34,12 @@ import PostgREST.Config (AppConfig (..),
3434
JSPathExp (..),
3535
LogLevel (..),
3636
OpenAPIMode (..),
37+
defaultCfgAud, parseCfgAud,
3738
parseSecret)
3839
import PostgREST.SchemaCache.Identifiers (QualifiedIdentifier (..))
3940
import Protolude hiding (get, toS)
4041
import Protolude.Conv (toS)
42+
import Protolude.Partial (fromJust)
4143

4244
filterAndMatchCT :: BS.ByteString -> MatchHeader
4345
filterAndMatchCT val = MatchHeader $ \headers _ ->
@@ -135,7 +137,7 @@ baseCfg = let secret = encodeUtf8 "reallyreallyreallyreallyverysafe" in
135137
, configDbUri = "postgresql://"
136138
, configFilePath = Nothing
137139
, configJWKS = rightToMaybe $ parseSecret secret
138-
, configJwtAudience = Nothing
140+
, configJwtAudience = defaultCfgAud
139141
, configJwtRoleClaimKey = [JSPKey "role"]
140142
, configJwtSecret = Just secret
141143
, configJwtSecretIsBase64 = False
@@ -218,7 +220,8 @@ testCfgAudienceJWT :: AppConfig
218220
testCfgAudienceJWT =
219221
baseCfg {
220222
configJwtSecret = Just generateSecret
221-
, configJwtAudience = Just "youraudience"
223+
-- parseCfgAud might fail on invalid regex but it is safe here
224+
, configJwtAudience = fromJust $ parseCfgAud "urn..uriaudience|youraudience"
222225
, configJWKS = rightToMaybe $ parseSecret generateSecret
223226
}
224227

0 commit comments

Comments
 (0)