Skip to content

Commit

Permalink
feat: support string comparison for jwt-role-claim-key
Browse files Browse the repository at this point in the history
  • Loading branch information
taimoorzaeem committed Dec 8, 2024
1 parent 2df1676 commit 31933e8
Show file tree
Hide file tree
Showing 7 changed files with 162 additions and 33 deletions.
6 changes: 5 additions & 1 deletion docs/postgrest.dict
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ CSV
durations
DDL
DOM
DSL
DevOps
dockerize
enum
Expand Down Expand Up @@ -68,9 +69,11 @@ isdistinct
JS
js
JSON
JSPath
JWK
JWT
jwt
Keycloak
Kubernetes
localhost
login
Expand All @@ -94,10 +97,11 @@ npm
nxl
nxr
OAuth
ORM
Observability
Okta
OpenAPI
openapi
ORM
ov
parametrized
passphrase
Expand Down
37 changes: 37 additions & 0 deletions docs/references/auth.rst
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,43 @@ JWT Claims Validation

PostgREST honors the :code:`exp` claim for token expiration, rejecting expired tokens.

.. _jwt_role_claim_key_extract:

JWT Role Claim Key Extraction
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

A JSPath DSL that specifies the location of the :code:`role` key in the JWT claims. This can be used to consume a JWT provided by a third party service like Auth0, Okta or Keycloak.

The DSL follows the `JSONPath <https://goessner.net/articles/JsonPath/>`_ expression grammar with extended string comparison operators. Supported operators are:

- ``==`` selects the first array element that exactly matches the right operand
- ``!=`` selects the first array element that does not match the right operand
- ``^==`` selects the first array element that starts with the right operand
- ``$==`` selects the first array element that ends with the right operand
- ``*==`` selects the first array element that contains the right operand

Usage examples:

.. code:: bash
# {"postgrest":{"roles": ["other", "author"]}}
# the DSL accepts characters that are alphanumerical or one of "_$@" as keys
jwt-role-claim-key = ".postgrest.roles[1]"
# {"https://www.example.com/role": { "key": "author" }}
# non-alphanumerical characters can go inside quotes(escaped in the config value)
jwt-role-claim-key = ".\"https://www.example.com/role\".key"
# {"postgrest":{"roles": ["other", "author"]}}
# `@` represents the current element in the array
# all the these match the string "author"
jwt-role-claim-key = ".postgrest.roles[?(@ == \"author\")]"
jwt-role-claim-key = ".postgrest.roles[?(@ != \"other\")]"
jwt-role-claim-key = ".postgrest.roles[?(@ ^== \"aut\")]"
jwt-role-claim-key = ".postgrest.roles[?(@ $== \"hor\")]"
jwt-role-claim-key = ".postgrest.roles[?(@ *== \"utho\")]"
JWT Security
~~~~~~~~~~~~

Expand Down
12 changes: 1 addition & 11 deletions docs/references/configuration.rst
Original file line number Diff line number Diff line change
Expand Up @@ -620,17 +620,7 @@ jwt-role-claim-key

*For backwards compatibility, this config parameter is also available without prefix as "role-claim-key".*

A JSPath DSL that specifies the location of the :code:`role` key in the JWT claims. This can be used to consume a JWT provided by a third party service like Auth0, Okta or Keycloak. Usage examples:

.. code:: bash
# {"postgrest":{"roles": ["other", "author"]}}
# the DSL accepts characters that are alphanumerical or one of "_$@" as keys
jwt-role-claim-key = ".postgrest.roles[1]"
# {"https://www.example.com/role": { "key": "author }}
# non-alphanumerical characters can go inside quotes(escaped in the config value)
jwt-role-claim-key = ".\"https://www.example.com/role\".key"
See :ref:`jwt_role_claim_key_extract` on how to specify key paths and usage examples.

.. _jwt-secret:

Expand Down
16 changes: 15 additions & 1 deletion src/PostgREST/Auth.hs
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import qualified Data.ByteString as BS
import qualified Data.ByteString.Lazy.Char8 as LBS
import qualified Data.Cache as C
import qualified Data.Scientific as Sci
import qualified Data.Text as T
import qualified Data.Vault.Lazy as Vault
import qualified Data.Vector as V
import qualified Jose.Jwk as JWT
Expand All @@ -46,7 +47,8 @@ import System.TimeIt (timeItT)

import PostgREST.AppState (AppState, AuthResult (..), getConfig,
getJwtCache, getTime)
import PostgREST.Config (AppConfig (..), JSPath, JSPathExp (..))
import PostgREST.Config (AppConfig (..), FilterExp (..), JSPath,
JSPathExp (..))
import PostgREST.Error (Error (..))

import Protolude
Expand Down Expand Up @@ -121,8 +123,20 @@ parseClaims AppConfig{..} jclaims@(JSON.Object mclaims) = do
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 (EqualsCond txt)] = findFirstMatch (==) txt ar
walkJSPath (Just (JSON.Array ar)) [JSPFilter (NotEqualsCond txt)] = findFirstMatch (/=) txt ar
walkJSPath (Just (JSON.Array ar)) [JSPFilter (StartsWithCond txt)] = findFirstMatch T.isPrefixOf txt ar
walkJSPath (Just (JSON.Array ar)) [JSPFilter (EndsWithCond txt)] = findFirstMatch T.isSuffixOf txt ar
walkJSPath (Just (JSON.Array ar)) [JSPFilter (ContainsCond txt)] = findFirstMatch T.isInfixOf txt ar
walkJSPath _ _ = Nothing

findFirstMatch matchWith pattern = foldr checkMatch Nothing
where
checkMatch (JSON.String txt) acc
| pattern `matchWith` txt = Just $ JSON.String txt
| otherwise = acc
checkMatch _ acc = acc

Check warning on line 138 in src/PostgREST/Auth.hs

View check run for this annotation

Codecov / codecov/patch

src/PostgREST/Auth.hs#L138

Added line #L138 was not covered by tests

unquoted :: JSON.Value -> BS.ByteString
unquoted (JSON.String t) = encodeUtf8 t
unquoted v = LBS.toStrict $ JSON.encode v
Expand Down
6 changes: 4 additions & 2 deletions src/PostgREST/Config.hs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ module PostgREST.Config
, Environment
, JSPath
, JSPathExp(..)
, FilterExp(..)
, LogLevel(..)
, OpenAPIMode(..)
, Proxy(..)
Expand Down Expand Up @@ -54,8 +55,9 @@ import System.Posix.Types (FileMode)

import PostgREST.Config.Database (RoleIsolationLvl,
RoleSettings)
import PostgREST.Config.JSPath (JSPath, JSPathExp (..),
dumpJSPath, pRoleClaimKey)
import PostgREST.Config.JSPath (FilterExp (..), JSPath,
JSPathExp (..), dumpJSPath,
pRoleClaimKey)
import PostgREST.Config.Proxy (Proxy (..),
isMalformedProxyUri, toURI)
import PostgREST.SchemaCache.Identifiers (QualifiedIdentifier, dumpQi,
Expand Down
83 changes: 65 additions & 18 deletions src/PostgREST/Config/JSPath.hs
Original file line number Diff line number Diff line change
@@ -1,51 +1,98 @@
{-# OPTIONS_GHC -Wno-unused-do-bind #-}
module PostgREST.Config.JSPath
( JSPath
, JSPathExp(..)
, FilterExp(..)
, dumpJSPath
, pRoleClaimKey
) where

import qualified Text.ParserCombinators.Parsec as P

import Data.Either.Combinators (mapLeft)
import Text.ParserCombinators.Parsec ((<?>))
import Text.Read (read)
import Data.Either.Combinators (mapLeft)
import Text.Read (read)

import Protolude


-- | full jspath, e.g. .property[0].attr.detail
-- | full jspath, e.g. .property[0].attr.detail[?(@ == "role1")]
type JSPath = [JSPathExp]

-- | jspath expression, e.g. .property, .property[0] or ."property-dash"
-- | jspath expression
data JSPathExp
= JSPKey Text
| JSPIdx Int
= JSPKey Text -- .property or ."property-dash"
| JSPIdx Int -- [0]
| JSPFilter FilterExp -- [?(@ == "match")], [?(@ ^== "match-prefix")], etc

data FilterExp
= EqualsCond Text
| NotEqualsCond Text
| StartsWithCond Text
| EndsWithCond Text
| ContainsCond Text

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 <> "]"

Check warning on line 38 in src/PostgREST/Config/JSPath.hs

View check run for this annotation

Codecov / codecov/patch

src/PostgREST/Config/JSPath.hs#L38

Added line #L38 was not covered by tests
where
expr =
case cond of
EqualsCond text -> " == " <> text
NotEqualsCond text -> " != " <> text
StartsWithCond text -> " ^== " <> text
EndsWithCond text -> " $== " <> text
ContainsCond text -> " *== " <> text

Check warning on line 46 in src/PostgREST/Config/JSPath.hs

View check run for this annotation

Codecov / codecov/patch

src/PostgREST/Config/JSPath.hs#L40-L46

Added lines #L40 - L46 were not covered by tests


-- 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)

pJSPath :: P.Parser JSPath
pJSPath = toJSPath <$> (period *> pPath `P.sepBy` period <* P.eof)
where
toJSPath :: [(Text, Maybe Int)] -> JSPath
toJSPath = concatMap (\(key, idx) -> JSPKey key : maybeToList (JSPIdx <$> idx))
period = P.char '.' <?> "period (.)"
pPath :: P.Parser (Text, Maybe Int)
pPath = (,) <$> pJSPKey <*> P.optionMaybe pJSPIdx
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

pJSPIdx :: P.Parser JSPathExp
pJSPIdx = do
P.char '['
num <- read <$> P.many1 P.digit
P.char ']'
return $ JSPIdx num

pJSPKey :: P.Parser Text
pJSPKey = toS <$> P.many1 (P.alphaNum <|> P.oneOf "_$@") <|> pQuotedValue <?> "attribute name [a..z0..9_$@])"
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

pJSPIdx :: P.Parser Int
pJSPIdx = P.char '[' *> (read <$> P.many1 P.digit) <* P.char ']' <?> "array index [0..n]"
pFilterConditionParser :: P.Parser FilterExp
pFilterConditionParser = do
P.char '@'
P.spaces
condOp <- P.choice $ map P.string ["==", "!=", "^==", "$==", "*=="]
P.spaces
value <- pQuotedValue
return $ case condOp of
"==" -> EqualsCond value
"!=" -> NotEqualsCond value
"^==" -> StartsWithCond value
"$==" -> EndsWithCond value
"*==" -> ContainsCond value
_ -> EqualsCond value -- Impossible case

Check warning on line 95 in src/PostgREST/Config/JSPath.hs

View check run for this annotation

Codecov / codecov/patch

src/PostgREST/Config/JSPath.hs#L95

Added line #L95 was not covered by tests

pQuotedValue :: P.Parser Text
pQuotedValue = toS <$> (P.char '"' *> P.many (P.noneOf "\"") <* P.char '"')
35 changes: 35 additions & 0 deletions test/io/fixtures.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,41 @@ roleclaims:
role: postgrest_test_author
other: true
expected_status: 401
- key: '.realm_access.roles[?(@ == "postgrest_test_author")]'
data:
realm_access:
roles:
- other
- postgrest_test_author
expected_status: 200
- key: '.realm_access.roles[?(@ != "other")]'
data:
realm_access:
roles:
- other
- postgrest_test_author
expected_status: 200
- key: '.realm_access.roles[?(@ ^== "postgrest_te")]'
data:
realm_access:
roles:
- other
- postgrest_test_author
expected_status: 200
- key: '.realm_access.roles[?(@ $== "st_test_author")]'
data:
realm_access:
roles:
- other
- postgrest_test_author
expected_status: 200
- key: '.realm_access.roles[?(@ *== "_test_")]'
data:
realm_access:
roles:
- other
- postgrest_test_author
expected_status: 200

invalidroleclaimkeys:
- 'role.other'
Expand Down

0 comments on commit 31933e8

Please sign in to comment.