Skip to content

Commit 0e99087

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 60c8a98 commit 0e99087

File tree

8 files changed

+60
-30
lines changed

8 files changed

+60
-30
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ This project adheres to [Semantic Versioning](http://semver.org/).
2626
- Drop support for PostgreSQL EOL version 12 by @wolfgangwalther in #3865
2727
- Replaced `jwt-cache-max-lifetime` config with `jwt-cache-max-entries` by @mkleczek in #4084
2828
- `log-query` config now takes a boolean instead of a string value by @steve-chavez in #3934
29+
- `jwt-aud` config now takes a regular expression to match against `aud` claim #2099
2930

3031
## [13.0.7] - 2025-09-14
3132

docs/references/auth.rst

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -193,13 +193,17 @@ 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+
203207
.. _jwt_caching:
204208

205209
JWT Cache

docs/references/configuration.rst

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

598598
=============== =================================
599-
**Type** String
599+
**Type** String (must be a valid regular expression)
600600
**Default** `n/a`
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: 28 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,29 @@ 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+
80+
newtype CfgAud = CfgAud { unCfgAud :: ParsedValue (Maybe Text) R.Regex }
81+
82+
parseCfgAud :: MonadFail m => Text -> m CfgAud
83+
parseCfgAud = fmap CfgAud . (fmap . ParsedValue . Just <*> parseRegex)
84+
where
85+
parseRegex = maybe (fail "jwt-aud should be a valid regular expression") pure . R.makeRegexM . bounded
86+
-- need start and end of text bounds so that
87+
-- regex does not match parts of text
88+
bounded = ("\\`(" <>) . (<> "\\')")
89+
90+
defaultCfgAud :: CfgAud
91+
defaultCfgAud = CfgAud $ ParsedValue Nothing $ R.makeRegex (".*"::Text)
7092

7193
audMatchesCfg :: AppConfig -> Text -> Bool
72-
audMatchesCfg = maybe (const True) (==) . configJwtAudience
94+
audMatchesCfg = R.matchTest . parsedValue . unCfgAud . configJwtAudience
7395

7496
data AppConfig = AppConfig
7597
{ configAppSettings :: [(Text, Text)]
@@ -97,7 +119,7 @@ data AppConfig = AppConfig
97119
, configDbUri :: Text
98120
, configFilePath :: Maybe FilePath
99121
, configJWKS :: Maybe JwkSet
100-
, configJwtAudience :: Maybe Text
122+
, configJwtAudience :: CfgAud
101123
, configJwtRoleClaimKey :: JSPath
102124
, configJwtSecret :: Maybe BS.ByteString
103125
, configJwtSecretIsBase64 :: Bool
@@ -171,7 +193,7 @@ toText conf =
171193
,("db-pre-config", q . maybe mempty dumpQi . configDbPreConfig)
172194
,("db-tx-end", q . showTxEnd)
173195
,("db-uri", q . configDbUri)
174-
,("jwt-aud", q . fromMaybe mempty . configJwtAudience)
196+
,("jwt-aud", q . fold . sourceValue . unCfgAud . configJwtAudience)
175197
,("jwt-role-claim-key", q . T.intercalate mempty . fmap dumpJSPath . configJwtRoleClaimKey)
176198
,("jwt-secret", q . T.decodeUtf8 . showJwtSecret)
177199
,("jwt-secret-is-base64", T.toLower . show . configJwtSecretIsBase64)
@@ -279,7 +301,7 @@ parser optPath env dbSettings roleSettings roleIsolationLvl =
279301
<*> (fromMaybe "postgresql://" <$> optString "db-uri")
280302
<*> pure optPath
281303
<*> pure Nothing
282-
<*> optStringOrURI "jwt-aud"
304+
<*> (optStringEmptyable "jwt-aud" >>= maybe (pure defaultCfgAud) parseCfgAud)
283305
<*> parseRoleClaimKey "jwt-role-claim-key" "role-claim-key"
284306
<*> (fmap encodeUtf8 <$> optString "jwt-secret")
285307
<*> (fromMaybe False <$> optWithAlias
@@ -399,20 +421,6 @@ parser optPath env dbSettings roleSettings roleIsolationLvl =
399421
optStringEmptyable :: C.Key -> C.Parser C.Config (Maybe Text)
400422
optStringEmptyable k = overrideFromDbOrEnvironment C.optional k coerceText
401423

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-
416424
optInt :: (Read i, Integral i) => C.Key -> C.Parser C.Config (Maybe i)
417425
optInt k = join <$> overrideFromDbOrEnvironment C.optional k coerceInt
418426

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/Feature/Auth/AudienceJwtSecretSpec.hs

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,20 @@ spec = describe "test handling of aud claims in JWT when the jwt-aud config is s
4040
[json|{"code":"PGRST303","details":null,"hint":null,"message":"JWT not in audience"}|]
4141
{ matchStatus = 401 }
4242

43+
it "fails when the audience claim matches but is not a valid URI" $ do
44+
let jwtPayload = [json|
45+
{
46+
"exp": 9999999999,
47+
"role": "postgrest_test_author",
48+
"id": "jdoe",
49+
"aud": "urn:\\uriaudience"
50+
}|]
51+
auth = authHeaderJWT $ generateJWT jwtPayload
52+
request methodGet "/authors_only" [auth] ""
53+
`shouldRespondWith`
54+
[json|{"code":"PGRST303","details":null,"hint":null,"message":"The JWT 'aud' claim must be a string, URI or an array of mixed strings or URIs"}|]
55+
{ matchStatus = 401 }
56+
4357
it "fails when the audience claim is empty" $ do
4458
let jwtPayload = [json|
4559
{

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)