Skip to content

Commit a871fcf

Browse files
committed
change: use RFC 9535 syntax for jwt-role-claim-key config
BREAKING CHANGE On top of it, slice operator can be used by adding the pipe symbol at the end like `$.roles[0] | [1:]`. Signed-off-by: Taimoor Zaeem <taimoorzaeem@gmail.com>
1 parent da8738b commit a871fcf

41 files changed

Lines changed: 144 additions & 416 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ All notable changes to this project will be documented in this file. From versio
3232
- Build the minimal docker image for aarch64-linux by @wolfgangwalther in #4193
3333
- The name of an embedded table can no longer be used in filters if it has an alias by @laurenceisla in #4075
3434
+ e.g. `?select=alias:table(*)&table.id=eq.1` is not possible anymore, use `?select=alias:table(*)&alias.id=eq.1` instead.
35+
- Config `jwt-role-claim-key` now uses RFC 9535 syntax for JSON Path by @taimoorzaeem in #4984
3536

3637
## [14.13] - 2026-06-04
3738

cabal.project.freeze

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
index-state: hackage.haskell.org 2026-04-18T18:42:36Z
1+
index-state: hackage.haskell.org 2026-06-03T09:50:41Z

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: 13 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -224,17 +224,11 @@ 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+
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:
229+
You can quickly try out JSON Path by visiting https://serdejsonpath.live/. If result has multiple values, first one gets selected.
230230

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
236-
237-
The selected role value can also be sliced using the slice operator ``[a:b]``. It is similar to `slice operator in python <https://docs.python.org/3/library/functions.html#slice>`_. Negative index values are also supported. The syntax is as:
231+
Optionally, the result can be sliced by using the slice operator ``[a:b]`` after putting a pipe symbol like ``$.roles[0] | [1:]``. It is similar to `slice operator in python <https://docs.python.org/3/library/functions.html#slice>`_. Negative index values are also supported. The syntax is as:
238232

239233
- ``[a:b]`` take slice from index ``a`` up to ``b``
240234
- ``[a:]`` take slice from index ``a`` to end
@@ -250,30 +244,26 @@ Usage examples:
250244
.. code:: bash
251245
252246
# {"postgrest":{"roles": ["other", "author"]}}
253-
# the DSL accepts characters that are alphanumerical or one of "_$@" as keys
254-
jwt-role-claim-key = ".postgrest.roles[1]"
247+
# Escape the dollar with another $ sign in the config file
248+
jwt-role-claim-key = "$$.postgrest.roles[1]"
255249
256250
# {"https://www.example.com/role": { "key": "author" }}
257-
# non-alphanumerical characters can go inside quotes(escaped in the config value)
258-
jwt-role-claim-key = ".\"https://www.example.com/role\".key"
251+
# non-alphanumerical characters can go inside single quotes
252+
jwt-role-claim-key = "$$.'https://www.example.com/role'.key"
259253
260254
# {"postgrest":{"roles": ["other", "author"]}}
261-
# `@` represents the current element in the array
262-
# all the these match the string "author"
263-
jwt-role-claim-key = ".postgrest.roles[?(@ == \"author\")]"
264-
jwt-role-claim-key = ".postgrest.roles[?(@ != \"other\")]"
265-
jwt-role-claim-key = ".postgrest.roles[?(@ ^== \"aut\")]"
266-
jwt-role-claim-key = ".postgrest.roles[?(@ ==^ \"hor\")]"
267-
jwt-role-claim-key = ".postgrest.roles[?(@ *== \"utho\")]"
255+
# filter based on equality or regular expression
256+
jwt-role-claim-key = "$$.postgrest.roles[?(@ == 'author')]"
257+
jwt-role-claim-key = "$$.postgrest.roles[?@.search(@, '^au')]"
268258
269259
# {"postgrest":{"wlcg": ["/groupa", "/groupb/"]}}
270260
# skip the "/" character using slice operator
271-
jwt-role-claim-key = ".postgrest.wlcg[0][1:]"
272-
jwt-role-claim-key = ".postgrest.wlcg[1][1:-1]"
261+
jwt-role-claim-key = "$$.postgrest.wlcg[0] | [1:]"
262+
jwt-role-claim-key = "$$.postgrest.wlcg[1] | [1:-1]"
273263
274264
.. note::
275265

276-
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>`_.
266+
Note that in our implementation, only `search()` function from JSON Path is available for filtering.
277267

278268
JWT Security
279269
------------

docs/references/configuration.rst

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -654,7 +654,7 @@ jwt-role-claim-key
654654

655655
=============== =================================
656656
**Type** String
657-
**Default** .role
657+
**Default** $.role
658658
**Reloadable** Y
659659
**Environment** PGRST_JWT_ROLE_CLAIM_KEY
660660
**In-Database** pgrst.jwt_role_claim_key

nix/overlays/haskell-packages.nix

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,15 @@ 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+
aeson-jsonpath =
53+
prev.callHackageDirect
54+
{
55+
pkg = "aeson-jsonpath";
56+
ver = "0.4.2.0";
57+
sha256 = "sha256-K+3brf1zjSSjojtSCXFrip5rrP7AO/S4zndAxAnvEfc=";
58+
}
59+
{ };
60+
5261
http2 =
5362
prev.callHackageDirect
5463
{

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/App.hs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,7 @@ import qualified Data.List as L
6868
import Data.Streaming.Network (bindPortTCP)
6969
import qualified Data.Text as T
7070
import qualified Network.HTTP.Types as HTTP
71-
import qualified Network.HTTP.Types.Header as HTTP (hVary)
71+
import Network.HTTP.Types.Header (hVary)
7272
import qualified Network.Socket as NS
7373
import PostgREST.Unix (createAndBindDomainSocket)
7474
import Protolude hiding (Handler)
@@ -223,10 +223,10 @@ postgrestResponse appState conf@AppConfig{..} maybeSchemaCache jwtTime authResul
223223
serverTimingHeaders timing = [serverTimingHeader timing | configServerTimingEnabled]
224224

225225
varyHeader :: HTTP.Header
226-
varyHeader = (HTTP.hVary, "Accept, Prefer, Range")
226+
varyHeader = (hVary, "Accept, Prefer, Range")
227227

228228
varyHeaderPresent :: [HTTP.Header] -> Bool
229-
varyHeaderPresent = any (\(h, _v) -> h == HTTP.hVary)
229+
varyHeaderPresent = any (\(h, _v) -> h == hVary)
230230

231231
withTiming :: (MonadError e m, MonadIO m) => AppConfig -> m a -> m (Maybe Double, a)
232232
withTiming AppConfig{configServerTimingEnabled} f = if configServerTimingEnabled

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 (..),
@@ -189,7 +188,7 @@ toText conf =
189188
,("db-tx-end", q . showTxEnd)
190189
,("db-uri", q . configDbUri)
191190
,("jwt-aud", q . fromMaybe mempty . configJwtAudience)
192-
,("jwt-role-claim-key", q . T.intercalate mempty . fmap dumpJSPath . configJwtRoleClaimKey)
191+
,("jwt-role-claim-key", q . dumpJSPath . configJwtRoleClaimKey)
193192
,("jwt-secret", q . T.decodeUtf8 . showJwtSecret)
194193
,("jwt-secret-is-base64", T.toLower . show . configJwtSecretIsBase64)
195194
,("jwt-cache-max-entries", show . configJwtCacheMaxEntries)
@@ -419,7 +418,7 @@ parser optPath env dbSettings roleSettings roleIsolationLvl =
419418
parseRoleClaimKey :: C.Key -> C.Key -> C.Parser C.Config JSPath
420419
parseRoleClaimKey k al =
421420
optWithAlias (optString k) (optString al) >>= \case
422-
Nothing -> pure [JSPKey "role"]
421+
Nothing -> pure defaultRoleJSPathKey -- $.role
423422
Just rck -> either (fail . show) pure $ pRoleClaimKey rck
424423

425424
parseCORSAllowedOrigins k =

0 commit comments

Comments
 (0)