Skip to content

Commit bdb73a0

Browse files
committed
change: use RFC 9535 syntax for jwt-role-claim-key config
BREAKING CHANGE Breaks the string comparison operators implemented in #3813. Those can be replaced with regex searches using JSON Path `search()` function. Signed-off-by: Taimoor Zaeem <taimoorzaeem@gmail.com>
1 parent a6840f9 commit bdb73a0

37 files changed

Lines changed: 124 additions & 420 deletions

CHANGELOG.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,19 @@ All notable changes to this project will be documented in this file. From versio
3434
- Build the minimal docker image for aarch64-linux by @wolfgangwalther in #4193
3535
- The name of an embedded table can no longer be used in filters if it has an alias by @laurenceisla in #4075
3636
+ e.g. `?select=alias:table(*)&table.id=eq.1` is not possible anymore, use `?select=alias:table(*)&alias.id=eq.1` instead.
37+
- Config `jwt-role-claim-key` now uses RFC 9535 syntax for JSON Path by @taimoorzaeem in #4984
38+
39+
#### Changed Syntax for JWT Role Extraction
40+
41+
The `jwt-role-claim-key` config should be updated according to the following:
42+
43+
- All config values must start with `$` character.
44+
+ Example: `.roles.read` -> `$.roles.read`
45+
- Keys with special characters, with the exception of `_` char must be quoted.
46+
+ Example: `.roles.write-role` -> `$.roles["write-role"]`
47+
- String comparison operators (`^==`, `==^` and `*==`) are replaced with regular expression search.
48+
+ Example: `.roles[?(@ ^== "postgrest_test_")]` -> `$.roles[?search(@, "^postgrest_test_")]`
49+
- Detailed reference for syntax: [RFC 9535](https://www.rfc-editor.org/rfc/rfc9535.html#name-jsonpath-syntax-and-semanti).
3750

3851
## [14.13] - 2026-06-04
3952

docs/postgrest.dict

Lines changed: 0 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,6 @@ CSV
3030
durations
3131
DDL
3232
DOM
33-
DSL
3433
DevOps
3534
Dramatiq
3635
dockerize
@@ -76,7 +75,6 @@ isdistinct
7675
JS
7776
js
7877
JSON
79-
JSPath
8078
JWK
8179
JWT
8280
jwt

docs/references/auth.rst

Lines changed: 11 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -224,40 +224,31 @@ It's recommended to leave the JWT cache enabled as our load tests indicate ~20%
224224
JWT Role Extraction
225225
-------------------
226226

227-
A JSPath DSL that specifies the location of the :code:`role` key in the JWT claims. It's configured by :ref:`jwt-role-claim-key`. This can be used to consume a JWT provided by a third party service like Auth0, Okta, Microsoft Entra or Keycloak.
227+
A JSON Path (`RFC 9535 <https://www.rfc-editor.org/rfc/rfc9535.html>`_) can be specified for the location of the :code:`role` key in the JWT claims. It's configured by :ref:`jwt-role-claim-key`. This can be used to consume a JWT provided by a third party service like Auth0, Okta, Microsoft Entra or Keycloak.
228228

229-
The DSL follows the `JSONPath <https://goessner.net/articles/JsonPath/>`_ expression grammar with extended string comparison operators. Supported operators are:
230-
231-
- ``==`` selects the first array element that exactly matches the right operand
232-
- ``!=`` selects the first array element that does not match the right operand
233-
- ``^==`` selects the first array element that starts with the right operand
234-
- ``==^`` selects the first array element that ends with the right operand
235-
- ``*==`` selects the first array element that contains the right operand
229+
You can quickly try out JSON Path by visiting https://serdejsonpath.live.
236230

237231
Usage examples:
238232

239233
.. code:: bash
240234
241235
# {"postgrest":{"roles": ["other", "author"]}}
242-
# the DSL accepts characters that are alphanumerical or one of "_$@" as keys
243-
jwt-role-claim-key = ".postgrest.roles[1]"
236+
jwt-role-claim-key = "$$.postgrest.roles[1]"
244237
245238
# {"https://www.example.com/role": { "key": "author" }}
246-
# non-alphanumerical characters can go inside quotes(escaped in the config value)
247-
jwt-role-claim-key = ".\"https://www.example.com/role\".key"
239+
# non-alphanumerical characters can go inside single quotes
240+
jwt-role-claim-key = "$$['https://www.example.com/role'].key"
248241
249242
# {"postgrest":{"roles": ["other", "author"]}}
250-
# `@` represents the current element in the array
251-
# all the these match the string "author"
252-
jwt-role-claim-key = ".postgrest.roles[?(@ == \"author\")]"
253-
jwt-role-claim-key = ".postgrest.roles[?(@ != \"other\")]"
254-
jwt-role-claim-key = ".postgrest.roles[?(@ ^== \"aut\")]"
255-
jwt-role-claim-key = ".postgrest.roles[?(@ ==^ \"hor\")]"
256-
jwt-role-claim-key = ".postgrest.roles[?(@ *== \"utho\")]"
243+
# filter based on equality or regular expression
244+
jwt-role-claim-key = "$$.postgrest.roles[?(@ == 'author')]"
245+
jwt-role-claim-key = "$$.postgrest.roles[?search(@, '^au')]"
257246
258247
.. note::
259248

260-
The string comparison operators are implemented as a custom extension to the JSPath and does not strictly follow the `RFC 9535 <https://www.rfc-editor.org/rfc/rfc9535.html>`_.
249+
- If JSON Path query returns multiple values, the first one gets selected.
250+
- Only when using the :ref:`file_config`, all ``$`` characters in the value must be escaped with an additional ``$`` char. For :ref:`env_variables_config` and :ref:`in_db_config`, only use a single ``$`` char.
251+
- In our implementation, only the `search()` function from `JSON Path Functions <https://www.rfc-editor.org/rfc/rfc9535.html#name-function-extensions>`_ is available for filtering.
261252

262253
JWT Security
263254
------------

docs/references/configuration.rst

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -694,7 +694,7 @@ jwt-role-claim-key
694694

695695
=============== =================================
696696
**Type** String
697-
**Default** .role
697+
**Default** $.role
698698
**Reloadable** Y
699699
**Environment** PGRST_JWT_ROLE_CLAIM_KEY
700700
**In-Database** pgrst.jwt_role_claim_key
@@ -704,6 +704,10 @@ jwt-role-claim-key
704704

705705
See :ref:`jwt_role_extract` on how to specify key paths and usage examples.
706706

707+
.. warning::
708+
709+
Only when using :ref:`file_config`, the ``$`` char needs to be escaped, so use ``$$`` and PostgREST will interpret it as a single ``$`` character.
710+
707711
.. _jwt-secret:
708712

709713
jwt-secret

nix/overlays/haskell-packages.nix

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,16 @@ let
4949
# Before upgrading fuzzyset to 0.3, check: https://github.com/PostgREST/postgrest/issues/3329
5050
fuzzyset = prev.fuzzyset_0_2_4;
5151

52+
# TODO: Remove once available in nixpkgs
53+
aeson-jsonpath =
54+
prev.callHackageDirect
55+
{
56+
pkg = "aeson-jsonpath";
57+
ver = "0.4.2.0";
58+
sha256 = "sha256-K+3brf1zjSSjojtSCXFrip5rrP7AO/S4zndAxAnvEfc=";
59+
}
60+
{ };
61+
5262
http2 =
5363
prev.callHackageDirect
5464
{

postgrest.cabal

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,7 @@ library
103103
, HTTP >= 4000.3.7 && < 4000.5
104104
, Ranged-sets >= 0.3 && < 0.6
105105
, aeson >= 2.0.3 && < 2.3
106+
, aeson-jsonpath >= 0.4.2 && < 0.5
106107
, auto-update >= 0.1.4 && < 0.3
107108
, base64-bytestring >= 1 && < 1.3
108109
, bytestring >= 0.10.8 && < 0.13

src/PostgREST/Auth/Jwt.hs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -31,7 +31,7 @@ import Data.Time.Clock.POSIX (utcTimeToPOSIXSeconds)
3131

3232
import PostgREST.Auth.Types (AuthResult (..))
3333
import PostgREST.Config (AppConfig (..), audMatchesCfg)
34-
import PostgREST.Config.JSPath (walkJSPath)
34+
import PostgREST.Config.JSPath (evaluateJSPath)
3535
import PostgREST.Error (Error (..), JwtClaimsError (..),
3636
JwtDecodeError (..), JwtError (..))
3737

@@ -114,7 +114,7 @@ parseClaims cfg@AppConfig{configJwtRoleClaimKey, configDbAnonRole} time mclaims
114114
validateClaims time (audMatchesCfg cfg) mclaims
115115
-- role defaults to anon if not specified in jwt
116116
role <- liftEither . maybeToRight (JwtErr JwtTokenRequired) $
117-
unquoted <$> walkJSPath (Just $ JSON.Object mclaims) configJwtRoleClaimKey <|> configDbAnonRole
117+
unquoted <$> evaluateJSPath (Just $ JSON.Object mclaims) configJwtRoleClaimKey <|> configDbAnonRole
118118
pure AuthResult
119119
{ authClaims = mclaims
120120
, authRole = role

src/PostgREST/Config.hs

Lines changed: 6 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,7 @@ module PostgREST.Config
1515
( AppConfig (..)
1616
, Environment
1717
, JSPath
18-
, JSPathExp(..)
19-
, FilterExp(..)
18+
, defaultRoleJSPathKey
2019
, LogLevel(..)
2120
, OpenAPIMode(..)
2221
, Proxy(..)
@@ -63,9 +62,9 @@ import System.Posix.Types (FileMode)
6362

6463
import PostgREST.Config.Database (RoleIsolationLvl,
6564
RoleSettings)
66-
import PostgREST.Config.JSPath (FilterExp (..), JSPath,
67-
JSPathExp (..), dumpJSPath,
68-
pRoleClaimKey)
65+
import PostgREST.Config.JSPath (JSPath (..),
66+
defaultRoleJSPathKey,
67+
dumpJSPath, pRoleClaimKey)
6968
import PostgREST.Config.Proxy (Proxy (..),
7069
isMalformedProxyUri, toURI)
7170
import PostgREST.SchemaCache.Identifiers (QualifiedIdentifier (..),
@@ -192,7 +191,7 @@ toText conf =
192191
,("db-tx-end", q . showTxEnd)
193192
,("db-uri", q . configDbUri)
194193
,("jwt-aud", q . fromMaybe mempty . configJwtAudience)
195-
,("jwt-role-claim-key", q . T.intercalate mempty . fmap dumpJSPath . configJwtRoleClaimKey)
194+
,("jwt-role-claim-key", q . dumpJSPath . configJwtRoleClaimKey)
196195
,("jwt-secret", q . T.decodeUtf8 . showJwtSecret)
197196
,("jwt-secret-is-base64", T.toLower . show . configJwtSecretIsBase64)
198197
,("jwt-cache-max-entries", show . configJwtCacheMaxEntries)
@@ -428,7 +427,7 @@ parser optPath env dbSettings roleSettings roleIsolationLvl =
428427
parseRoleClaimKey :: C.Key -> C.Key -> C.Parser C.Config JSPath
429428
parseRoleClaimKey k al =
430429
optWithAlias (optString k) (optString al) >>= \case
431-
Nothing -> pure [JSPKey "role"]
430+
Nothing -> pure defaultRoleJSPathKey -- $.role
432431
Just rck -> either (fail . show) pure $ pRoleClaimKey rck
433432

434433
parseCORSAllowedOrigins k =

src/PostgREST/Config/JSPath.hs

Lines changed: 35 additions & 98 deletions
Original file line numberDiff line numberDiff line change
@@ -1,123 +1,60 @@
1-
{-# OPTIONS_GHC -Wno-unused-do-bind #-}
2-
{-# LANGUAGE LambdaCase #-}
1+
{-|
2+
Module : PostgREST.Config.JSPath
3+
Description : Parsing and evaluation logic of JSPath
4+
-}
35
module PostgREST.Config.JSPath
4-
( JSPath
5-
, JSPathExp(..)
6-
, FilterExp(..)
6+
( JSPath(..)
7+
, defaultRoleJSPathKey
78
, dumpJSPath
89
, pRoleClaimKey
9-
, walkJSPath
10+
, evaluateJSPath
1011
) where
1112

1213
import qualified Data.Aeson as JSON
13-
import qualified Data.Aeson.Key as K
14-
import qualified Data.Aeson.KeyMap as KM
14+
import qualified Data.Aeson.JSONPath as JSP
15+
import qualified Data.Aeson.JSONPath.Parser as JSP
16+
import qualified Data.Aeson.JSONPath.Types as JSP
1517
import qualified Data.Text as T
1618
import qualified Data.Vector as V
1719
import qualified Text.ParserCombinators.Parsec as P
1820

1921
import Data.Either.Combinators (mapLeft)
22+
import Data.Either.Extra (fromRight')
2023
import Text.ParserCombinators.Parsec ((<?>))
21-
import Text.Read (read)
2224

2325
import Protolude
2426

2527

26-
-- | full jspath, e.g. .property[0].attr.detail[?(@ == "role1")]
27-
type JSPath = [JSPathExp]
28+
-- | full jspath, e.g. "$.property[0].attr.detail[?(@ == "role1")]"
29+
newtype JSPath = JSPath JSP.Query
2830

29-
-- NOTE: We only accept one JSPFilter expr (at the end of input)
30-
-- | jspath expression
31-
data JSPathExp
32-
= JSPKey Text -- .property or ."property-dash"
33-
| JSPIdx Int -- [0]
34-
| JSPFilter FilterExp -- [?(@ == "match")]
31+
-- | Default value for "jwt-role-claim-key" config
32+
defaultRoleJSPathKey :: JSPath
33+
defaultRoleJSPathKey = fromRight' $ P.parse pJSPath "" "$.role"
3534

36-
data FilterExp
37-
= EqualsCond Text
38-
| NotEqualsCond Text
39-
| StartsWithCond Text
40-
| EndsWithCond Text
41-
| ContainsCond Text
42-
43-
dumpJSPath :: JSPathExp -> Text
44-
-- TODO: this needs to be quoted properly for special chars
45-
dumpJSPath (JSPKey k) = "." <> show k
46-
dumpJSPath (JSPIdx i) = "[" <> show i <> "]"
47-
dumpJSPath (JSPFilter cond) = "[?(@" <> expr <> ")]"
48-
where
49-
expr =
50-
case cond of
51-
EqualsCond text -> " == " <> show text
52-
NotEqualsCond text -> " != " <> show text
53-
StartsWithCond text -> " ^== " <> show text
54-
EndsWithCond text -> " ==^ " <> show text
55-
ContainsCond text -> " *== " <> show text
56-
57-
-- | Evaluate JSPath on a JSON
58-
walkJSPath :: Maybe JSON.Value -> JSPath -> Maybe JSON.Value
59-
walkJSPath x [] = x
60-
walkJSPath (Just (JSON.Object o)) (JSPKey key:rest) = walkJSPath (KM.lookup (K.fromText key) o) rest
61-
walkJSPath (Just (JSON.Array ar)) (JSPIdx idx:rest) = walkJSPath (ar V.!? idx) rest
62-
walkJSPath (Just (JSON.Array ar)) [JSPFilter jspFilter] = case jspFilter of
63-
EqualsCond txt -> findFirstMatch (==) txt ar
64-
NotEqualsCond txt -> findFirstMatch (/=) txt ar
65-
StartsWithCond txt -> findFirstMatch T.isPrefixOf txt ar
66-
EndsWithCond txt -> findFirstMatch T.isSuffixOf txt ar
67-
ContainsCond txt -> findFirstMatch T.isInfixOf txt ar
68-
where
69-
findFirstMatch matchWith pattern = find (\case
70-
JSON.String txt -> pattern `matchWith` txt
71-
_ -> False)
72-
walkJSPath _ _ = Nothing
35+
-- | Dump JSPath
36+
-- e.g. "$.property[0].attr.detail[?(@ == "role1")]"
37+
dumpJSPath :: JSPath -> Text
38+
dumpJSPath (JSPath query) = (escapeDollarChar . escapeDoubleQuotes) jsPathDump
39+
where
40+
jsPathDump = JSP.dumpQuery query
41+
escapeDoubleQuotes = T.replace "\"" "\\\""
42+
-- When dumping, $ must be escaped
43+
escapeDollarChar = T.replace "$" "$$"
44+
45+
-- |
46+
-- Evaluate JSPath on a JSON
47+
-- The result of JSON Path query is a Vector, we select the first
48+
-- string element as the role.
49+
evaluateJSPath :: Maybe JSON.Value -> JSPath -> Maybe JSON.Value
50+
evaluateJSPath Nothing _ = Nothing
51+
evaluateJSPath (Just json) (JSPath query) = JSP.queryQQ query json V.!? 0
7352

7453
-- Used for the config value "role-claim-key"
7554
pRoleClaimKey :: Text -> Either Text JSPath
7655
pRoleClaimKey selStr =
7756
mapLeft show $ P.parse pJSPath ("failed to parse role-claim-key value (" <> toS selStr <> ")") (toS selStr)
7857

58+
-- | Parse RFC 9535 JSPath: $.roles[0]
7959
pJSPath :: P.Parser JSPath
80-
pJSPath = P.many1 pJSPathExp <* P.eof
81-
82-
pJSPathExp :: P.Parser JSPathExp
83-
pJSPathExp = pJSPKey <|> pJSPFilter <|> pJSPIdx
84-
85-
pJSPKey :: P.Parser JSPathExp
86-
pJSPKey = do
87-
P.char '.'
88-
val <- toS <$> P.many1 (P.alphaNum <|> P.oneOf "_$@") <|> pQuotedValue
89-
return (JSPKey val) <?> "pJSPKey: JSPath attribute key"
90-
91-
pJSPIdx :: P.Parser JSPathExp
92-
pJSPIdx = do
93-
P.char '['
94-
num <- read <$> P.many1 P.digit
95-
P.char ']'
96-
return (JSPIdx num) <?> "pJSPIdx: JSPath array index"
97-
98-
pJSPFilter :: P.Parser JSPathExp
99-
pJSPFilter = do
100-
P.try $ P.string "[?("
101-
condition <- pFilterConditionParser
102-
P.char ')'
103-
P.char ']'
104-
P.eof -- this should be the last jspath expression
105-
return (JSPFilter condition) <?> "pJSPFilter: JSPath filter exp"
106-
107-
pFilterConditionParser :: P.Parser FilterExp
108-
pFilterConditionParser = do
109-
P.char '@'
110-
P.spaces
111-
filt <- matchOperator
112-
P.spaces
113-
filt <$> pQuotedValue
114-
where
115-
matchOperator =
116-
P.try (P.string "==^" $> EndsWithCond)
117-
<|> P.try (P.string "==" $> EqualsCond)
118-
<|> P.try (P.string "!=" $> NotEqualsCond)
119-
<|> P.try (P.string "^==" $> StartsWithCond)
120-
<|> P.try (P.string "*==" $> ContainsCond)
121-
122-
pQuotedValue :: P.Parser Text
123-
pQuotedValue = toS <$> (P.char '"' *> P.many (P.noneOf "\"") <* P.char '"')
60+
pJSPath = JSPath <$> JSP.pQuery <?> "pJSPath: JSPath root query"

stack.yaml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ nix:
99
- zlib
1010

1111
extra-deps:
12+
- aeson-jsonpath-0.4.2.0
1213
- configurator-pg-0.2.11
1314
- fuzzyset-0.2.4
1415
- hasql-notifications-0.2.4.0

0 commit comments

Comments
 (0)