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>
This commit is contained in:
Taimoor Zaeem
2026-06-26 18:32:05 +00:00
committed by Wolfgang Walther
parent a0bb87d693
commit bb63c3fade
37 changed files with 123 additions and 419 deletions
+2 -2
View File
@@ -31,7 +31,7 @@ import Data.Time.Clock.POSIX (utcTimeToPOSIXSeconds)
import PostgREST.Auth.Types (AuthResult (..))
import PostgREST.Config (AppConfig (..), audMatchesCfg)
import PostgREST.Config.JSPath (walkJSPath)
import PostgREST.Config.JSPath (evaluateJSPath)
import PostgREST.Error (Error (..), JwtClaimsError (..),
JwtDecodeError (..), JwtError (..))
@@ -114,7 +114,7 @@ parseClaims cfg@AppConfig{configJwtRoleClaimKey, configDbAnonRole} time mclaims
validateClaims time (audMatchesCfg cfg) mclaims
-- role defaults to anon if not specified in jwt
role <- liftEither . maybeToRight (JwtErr JwtTokenRequired) $
unquoted <$> walkJSPath (Just $ JSON.Object mclaims) configJwtRoleClaimKey <|> configDbAnonRole
unquoted <$> evaluateJSPath (Just $ JSON.Object mclaims) configJwtRoleClaimKey <|> configDbAnonRole
pure AuthResult
{ authClaims = mclaims
, authRole = role
+6 -7
View File
@@ -15,8 +15,7 @@ module PostgREST.Config
( AppConfig (..)
, Environment
, JSPath
, JSPathExp(..)
, FilterExp(..)
, defaultRoleJSPathKey
, LogLevel(..)
, OpenAPIMode(..)
, Proxy(..)
@@ -63,9 +62,9 @@ import System.Posix.Types (FileMode)
import PostgREST.Config.Database (RoleIsolationLvl,
RoleSettings)
import PostgREST.Config.JSPath (FilterExp (..), JSPath,
JSPathExp (..), dumpJSPath,
pRoleClaimKey)
import PostgREST.Config.JSPath (JSPath (..),
defaultRoleJSPathKey,
dumpJSPath, pRoleClaimKey)
import PostgREST.Config.Proxy (Proxy (..),
isMalformedProxyUri, toURI)
import PostgREST.SchemaCache.Identifiers (QualifiedIdentifier (..),
@@ -192,7 +191,7 @@ toText conf =
,("db-tx-end", q . showTxEnd)
,("db-uri", q . configDbUri)
,("jwt-aud", q . fromMaybe mempty . configJwtAudience)
,("jwt-role-claim-key", q . T.intercalate mempty . fmap dumpJSPath . configJwtRoleClaimKey)
,("jwt-role-claim-key", q . dumpJSPath . configJwtRoleClaimKey)
,("jwt-secret", q . T.decodeUtf8 . showJwtSecret)
,("jwt-secret-is-base64", T.toLower . show . configJwtSecretIsBase64)
,("jwt-cache-max-entries", show . configJwtCacheMaxEntries)
@@ -428,7 +427,7 @@ parser optPath env dbSettings roleSettings roleIsolationLvl =
parseRoleClaimKey :: C.Key -> C.Key -> C.Parser C.Config JSPath
parseRoleClaimKey k al =
optWithAlias (optString k) (optString al) >>= \case
Nothing -> pure [JSPKey "role"]
Nothing -> pure defaultRoleJSPathKey -- $.role
Just rck -> either (fail . show) pure $ pRoleClaimKey rck
parseCORSAllowedOrigins k =
+34 -97
View File
@@ -1,123 +1,60 @@
{-# OPTIONS_GHC -Wno-unused-do-bind #-}
{-# LANGUAGE LambdaCase #-}
{-|
Module : PostgREST.Config.JSPath
Description : Parsing and evaluation logic of JSPath
-}
module PostgREST.Config.JSPath
( JSPath
, JSPathExp(..)
, FilterExp(..)
( JSPath(..)
, defaultRoleJSPathKey
, dumpJSPath
, pRoleClaimKey
, walkJSPath
, evaluateJSPath
) where
import qualified Data.Aeson as JSON
import qualified Data.Aeson.Key as K
import qualified Data.Aeson.KeyMap as KM
import qualified Data.Aeson.JSONPath as JSP
import qualified Data.Aeson.JSONPath.Parser as JSP
import qualified Data.Aeson.JSONPath.Types as JSP
import qualified Data.Text as T
import qualified Data.Vector as V
import qualified Text.ParserCombinators.Parsec as P
import Data.Either.Combinators (mapLeft)
import Data.Either.Extra (fromRight')
import Text.ParserCombinators.Parsec ((<?>))
import Text.Read (read)
import Protolude
-- | full jspath, e.g. .property[0].attr.detail[?(@ == "role1")]
type JSPath = [JSPathExp]
-- | full jspath, e.g. "$.property[0].attr.detail[?(@ == "role1")]"
newtype JSPath = JSPath JSP.Query
-- NOTE: We only accept one JSPFilter expr (at the end of input)
-- | jspath expression
data JSPathExp
= JSPKey Text -- .property or ."property-dash"
| JSPIdx Int -- [0]
| JSPFilter FilterExp -- [?(@ == "match")]
-- | Default value for "jwt-role-claim-key" config
defaultRoleJSPathKey :: JSPath
defaultRoleJSPathKey = fromRight' $ P.parse pJSPath "" "$.role"
data FilterExp
= EqualsCond Text
| NotEqualsCond Text
| StartsWithCond Text
| EndsWithCond Text
| ContainsCond Text
-- | Dump JSPath
-- e.g. "$.property[0].attr.detail[?(@ == "role1")]"
dumpJSPath :: JSPath -> Text
dumpJSPath (JSPath query) = (escapeDollarChar . escapeDoubleQuotes) jsPathDump
where
jsPathDump = JSP.dumpQuery query
escapeDoubleQuotes = T.replace "\"" "\\\""
-- When dumping, $ must be escaped
escapeDollarChar = T.replace "$" "$$"
dumpJSPath :: JSPathExp -> Text
-- TODO: this needs to be quoted properly for special chars
dumpJSPath (JSPKey k) = "." <> show k
dumpJSPath (JSPIdx i) = "[" <> show i <> "]"
dumpJSPath (JSPFilter cond) = "[?(@" <> expr <> ")]"
where
expr =
case cond of
EqualsCond text -> " == " <> show text
NotEqualsCond text -> " != " <> show text
StartsWithCond text -> " ^== " <> show text
EndsWithCond text -> " ==^ " <> show text
ContainsCond text -> " *== " <> show text
-- | Evaluate JSPath on a JSON
walkJSPath :: Maybe JSON.Value -> JSPath -> Maybe JSON.Value
walkJSPath x [] = x
walkJSPath (Just (JSON.Object o)) (JSPKey key:rest) = walkJSPath (KM.lookup (K.fromText key) o) rest
walkJSPath (Just (JSON.Array ar)) (JSPIdx idx:rest) = walkJSPath (ar V.!? idx) rest
walkJSPath (Just (JSON.Array ar)) [JSPFilter jspFilter] = case jspFilter of
EqualsCond txt -> findFirstMatch (==) txt ar
NotEqualsCond txt -> findFirstMatch (/=) txt ar
StartsWithCond txt -> findFirstMatch T.isPrefixOf txt ar
EndsWithCond txt -> findFirstMatch T.isSuffixOf txt ar
ContainsCond txt -> findFirstMatch T.isInfixOf txt ar
where
findFirstMatch matchWith pattern = find (\case
JSON.String txt -> pattern `matchWith` txt
_ -> False)
walkJSPath _ _ = Nothing
-- |
-- Evaluate JSPath on a JSON
-- The result of JSON Path query is a Vector, we select the first
-- string element as the role.
evaluateJSPath :: Maybe JSON.Value -> JSPath -> Maybe JSON.Value
evaluateJSPath Nothing _ = Nothing
evaluateJSPath (Just json) (JSPath query) = JSP.queryQQ query json V.!? 0
-- Used for the config value "role-claim-key"
pRoleClaimKey :: Text -> Either Text JSPath
pRoleClaimKey selStr =
mapLeft show $ P.parse pJSPath ("failed to parse role-claim-key value (" <> toS selStr <> ")") (toS selStr)
-- | Parse RFC 9535 JSPath: $.roles[0]
pJSPath :: P.Parser JSPath
pJSPath = P.many1 pJSPathExp <* P.eof
pJSPathExp :: P.Parser JSPathExp
pJSPathExp = pJSPKey <|> pJSPFilter <|> pJSPIdx
pJSPKey :: P.Parser JSPathExp
pJSPKey = do
P.char '.'
val <- toS <$> P.many1 (P.alphaNum <|> P.oneOf "_$@") <|> pQuotedValue
return (JSPKey val) <?> "pJSPKey: JSPath attribute key"
pJSPIdx :: P.Parser JSPathExp
pJSPIdx = do
P.char '['
num <- read <$> P.many1 P.digit
P.char ']'
return (JSPIdx num) <?> "pJSPIdx: JSPath array index"
pJSPFilter :: P.Parser JSPathExp
pJSPFilter = do
P.try $ P.string "[?("
condition <- pFilterConditionParser
P.char ')'
P.char ']'
P.eof -- this should be the last jspath expression
return (JSPFilter condition) <?> "pJSPFilter: JSPath filter exp"
pFilterConditionParser :: P.Parser FilterExp
pFilterConditionParser = do
P.char '@'
P.spaces
filt <- matchOperator
P.spaces
filt <$> pQuotedValue
where
matchOperator =
P.try (P.string "==^" $> EndsWithCond)
<|> P.try (P.string "==" $> EqualsCond)
<|> P.try (P.string "!=" $> NotEqualsCond)
<|> P.try (P.string "^==" $> StartsWithCond)
<|> P.try (P.string "*==" $> ContainsCond)
pQuotedValue :: P.Parser Text
pQuotedValue = toS <$> (P.char '"' *> P.many (P.noneOf "\"") <* P.char '"')
pJSPath = JSPath <$> JSP.pQuery <?> "pJSPath: JSPath root query"