Skip to content

Commit

Permalink
accept a new argument claims_namespace_path in JWT config (#4365)
Browse files Browse the repository at this point in the history
* add new optional field `claims_namespace_path` in JWT config

* return value when empty array is found in executeJSONPath

* update the docs related to claims_namespace_path

* improve encodeJSONPath, add property tests for parseJSONPath

* throw error if both claims_namespace_path and claims_namespace are set

* refactor the Data.Parser.JsonPath to Data.Parser.JSONPathSpec

* update the JWT docs

Co-Authored-By: Marion Schleifer <marion@hasura.io>

Co-authored-by: Marion Schleifer <marion@hasura.io>
Co-authored-by: rakeshkky <12475069+rakeshkky@users.noreply.github.com>
Co-authored-by: Tirumarai Selvan <tirumarai.selvan@gmail.com>
  • Loading branch information
4 people authored Apr 16, 2020
1 parent 2aa971a commit a26bc80
Show file tree
Hide file tree
Showing 20 changed files with 347 additions and 120 deletions.
14 changes: 14 additions & 0 deletions .circleci/test-server.sh
Original file line number Diff line number Diff line change
Expand Up @@ -318,6 +318,20 @@ kill_hge_servers

unset HASURA_GRAPHQL_JWT_SECRET

##########
echo -e "\n$(time_elapsed): <########## TEST GRAPHQL-ENGINE WITH ADMIN SECRET AND JWT (with claims_namespace_path) #####################################>\n"
TEST_TYPE="jwt-with-claims-namespace-path"

export HASURA_GRAPHQL_JWT_SECRET="$(jq -n --arg key "$(cat $OUTPUT_FOLDER/ssl/jwt_public.key)" '{ type: "RS512", key: $key , claims_namespace_path: "$.hasuraClaims"}')"

run_hge_with_args serve
wait_for_port 8080

pytest -n 1 -vv --hge-urls "$HGE_URL" --pg-urls "$HASURA_GRAPHQL_DATABASE_URL" --hge-key="$HASURA_GRAPHQL_ADMIN_SECRET" --hge-jwt-key-file="$OUTPUT_FOLDER/ssl/jwt_private.key" --hge-jwt-conf="$HASURA_GRAPHQL_JWT_SECRET" test_jwt.py

kill_hge_servers

unset HASURA_GRAPHQL_JWT_SECRET

# test with CORS modes

Expand Down
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ The order, collapsed state of columns and page size is now persisted across page
- server: fix an edge case where some events wouldn't be processed because of internal erorrs (#4213)
- server: fix downgrade not working to version v1.1.1 (#4354)
- server: `type` field is not required if `jwk_url` is provided in JWT config
- server: add a new field `claims_namespace_path` which accepts a JSON Path for looking up hasura claim in the JWT token (#4349)

## `v1.2.0-beta.3`

Expand Down
36 changes: 35 additions & 1 deletion docs/graphql/manual/auth/authentication/jwt.rst
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,8 @@ etc.) JWT claims, as well as Hasura specific claims inside a custom namespace
(or key) i.e. ``https://hasura.io/jwt/claims``.

The ``https://hasura.io/jwt/claims`` is the custom namespace where all Hasura
specific claims have to be present. This value can be configured in the JWT
specific claims have to be present. This value can be configured using
``claims_namespace`` or ``claims_namespace_path`` in the JWT
config while starting the server.

**Note**: ``x-hasura-default-role`` and ``x-hasura-allowed-roles`` are
Expand Down Expand Up @@ -129,6 +130,7 @@ JSON object:
"key": "<optional-key-as-string>",
"jwk_url": "<optional-url-to-refresh-jwks>",
"claims_namespace": "<optional-key-name-in-claims>",
"claims_namespace_path":"<optional-json-path-to-the-claims>",
"claims_format": "json|stringified_json",
"audience": <optional-string-or-list-of-strings-to-verify-audience>,
"issuer": "<optional-string-to-verify-issuer>"
Expand Down Expand Up @@ -220,6 +222,38 @@ inside which the Hasura specific claims will be present, e.g. ``https://mydomain

**Default value** is: ``https://hasura.io/jwt/claims``.

``claims_namespace_path``
^^^^^^^^^^^^^^^^^^^^^^^^^
An optional JSON path value to the Hasura claims in the JWT token.

Example values are ``$.hasura.claims`` or ``$`` (i.e. root of the payload)

The JWT token should be in this format if the ``claims_namespace_path`` is
set to ``$.hasura.claims``:

.. code-block:: json
{
"sub": "1234567890",
"name": "John Doe",
"admin": true,
"iat": 1516239022,
"hasura": {
"claims": {
"x-hasura-allowed-roles": ["editor","user", "mod"],
"x-hasura-default-role": "user",
"x-hasura-user-id": "1234567890",
"x-hasura-org-id": "123",
"x-hasura-custom": "custom-value"
}
}
}
.. note::

The JWT config can only have one of ``claims_namespace`` or ``claims_namespace_path``
values set. If neither keys are set, then the default value of
``claims_namespace`` i.e. https://hasura.io/jwt/claims will be used.

``claims_format``
^^^^^^^^^^^^^^^^^
Expand Down
4 changes: 3 additions & 1 deletion server/graphql-engine.cabal
Original file line number Diff line number Diff line change
Expand Up @@ -231,6 +231,7 @@ library
, Hasura.Server.PGDump
-- Exposed for testing:
, Hasura.Server.Telemetry.Counters
, Data.Parser.JSONPath

, Hasura.RQL.Types
, Hasura.RQL.Types.Run
Expand Down Expand Up @@ -374,7 +375,6 @@ library
, Data.List.Extended
, Data.HashMap.Strict.Extended
, Data.HashMap.Strict.InsOrd.Extended
, Data.Parser.JSONPath
, Data.Sequence.NonEmpty
, Data.TByteString
, Data.Text.Extended
Expand Down Expand Up @@ -426,11 +426,13 @@ test-suite graphql-engine-tests
, time
, transformers-base
, unordered-containers
, text
hs-source-dirs: src-test
main-is: Main.hs
other-modules:
Data.Parser.CacheControlSpec
Data.Parser.URLTemplate
Data.Parser.JSONPathSpec
Data.TimeSpec
Hasura.IncrementalSpec
Hasura.RQL.MetadataSpec
Expand Down
69 changes: 32 additions & 37 deletions server/src-lib/Data/Parser/JSONPath.hs
Original file line number Diff line number Diff line change
Expand Up @@ -8,50 +8,45 @@ import Control.Applicative ((<|>))
import Data.Aeson.Internal (JSONPath, JSONPathElement (..))
import Data.Attoparsec.Text
import Data.Bool (bool)
import Data.Char (isDigit)
import qualified Data.Text as T
import Prelude hiding (takeWhile)
import Text.Read (readMaybe)
import qualified Data.Text as T

parseKey :: Parser T.Text
parseKey = do
firstChar <- letter
<?> "the first character of property name must be a letter."
name <- many' (letter
<|> digit
<|> satisfy (`elem` ("-_" :: String))
)
return $ T.pack (firstChar:name)
parseSimpleKeyText :: Parser T.Text
parseSimpleKeyText = takeWhile1 (inClass "a-zA-Z0-9_-")

parseIndex :: Parser Int
parseIndex = skip (== '[') *> anyChar >>= parseDigits
where
parseDigits :: Char -> Parser Int
parseDigits firstDigit
| firstDigit == ']' = fail "empty array index"
| not $ isDigit firstDigit =
fail $ "invalid array index: " ++ [firstDigit]
| otherwise = do
remain <- many' (notChar ']')
skip (== ']')
let content = firstDigit:remain
case (readMaybe content :: Maybe Int) of
Nothing -> fail $ "invalid array index: " ++ content
Just v -> return v
parseKey :: Parser JSONPathElement
parseKey = Key <$>
( (char '.' *> parseSimpleKeyText) -- Parse `.key`
<|> T.pack <$> ((string ".['" <|> string "['") *> manyTill anyChar (string "']")) -- Parse `['key']` or `.['key']`
<|> fail "invalid key element"
)

parseElement :: Parser JSONPathElement
parseElement = do
dotLen <- T.length <$> takeWhile (== '.')
if dotLen > 1
then fail "multiple dots in json path"
else peekChar >>= \case
Nothing -> fail "empty json path"
Just '[' -> Index <$> parseIndex
_ -> Key <$> parseKey
parseIndex :: Parser JSONPathElement
parseIndex = Index <$>
( ((char '[' *> manyTill anyChar (char ']')) >>= maybe (fail "invalid array index") pure . readMaybe) -- Parse `[Int]`
<|> fail "invalid index element"
)

parseElements :: Parser JSONPath
parseElements = skipWhile (== '$') *> many1 parseElement
parseElements = skipWhile (== '$') *> parseRemaining
where
parseFirstKey = Key <$> parseSimpleKeyText
parseElements' = many1 (parseIndex <|> parseKey)
parseRemaining = do
maybeFirstChar <- peekChar
case maybeFirstChar of
Nothing -> pure []
Just firstChar ->
-- If first char is not any of '.' and '[', then parse first key
-- Eg:- Parse "key1.key2[0]"
if firstChar `notElem` (".[" :: String) then do
firstKey <- parseFirstKey
remainingElements <- parseElements'
pure $ firstKey:remainingElements
else parseElements'

-- | Parse jsonpath String value
parseJSONPath :: T.Text -> Either String JSONPath
parseJSONPath = parseResult . parse parseElements
where
Expand All @@ -64,6 +59,6 @@ parseJSONPath = parseResult . parse parseElements
Left $ invalidMessage remain
else
Right r

invalidMessage s = "invalid property name: " ++ T.unpack s
++ ". Accept letters, digits, underscore (_) or hyphen (-) only"
++ ". Use single quotes enclosed in bracket if there are any special characters"
4 changes: 2 additions & 2 deletions server/src-lib/Data/URL/Template.hs
Original file line number Diff line number Diff line change
Expand Up @@ -82,12 +82,12 @@ renderURLTemplate template = do

-- QuickCheck generators
instance Arbitrary Variable where
arbitrary = Variable . T.pack <$> listOf1 (elements $ alphaNumerics <> "-_")
arbitrary = Variable . T.pack <$> listOf1 (elements $ alphaNumerics <> " -_")

instance Arbitrary URLTemplate where
arbitrary = URLTemplate <$> listOf (oneof [genText, genVariable])
where
genText = (TIText . T.pack) <$> listOf1 (elements $ alphaNumerics <> "://")
genText = (TIText . T.pack) <$> listOf1 (elements $ alphaNumerics <> " ://")
genVariable = TIVariable <$> arbitrary

genURLTemplate :: Gen URLTemplate
Expand Down
2 changes: 1 addition & 1 deletion server/src-lib/Hasura/Prelude.hs
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,7 @@ import qualified GHC.Clock as Clock
import qualified Test.QuickCheck as QC

alphaNumerics :: String
alphaNumerics = ['a'..'z'] ++ ['A'..'Z'] ++ "0123456789 "
alphaNumerics = ['a'..'z'] ++ ['A'..'Z'] ++ "0123456789"

instance Arbitrary Text where
arbitrary = T.pack <$> QC.listOf (QC.elements alphaNumerics)
Expand Down
7 changes: 5 additions & 2 deletions server/src-lib/Hasura/RQL/Types/Error.hs
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ module Hasura.RQL.Types.Error
, QErr(..)
, encodeQErr
, encodeGQLErr
, encodeJSONPath
, noInternalQErrEnc
, err400
, err404
Expand Down Expand Up @@ -197,8 +198,10 @@ encodeJSONPath = format "$"
format pfx (Key key:parts) = format (pfx ++ "." ++ formatKey key) parts

formatKey key
| T.any (=='.') key = "['" ++ T.unpack key ++ "']"
| otherwise = T.unpack key
| T.any specialChar key = "['" ++ T.unpack key ++ "']"
| otherwise = T.unpack key
where
specialChar = flip notElem (alphaNumerics ++ "_-")

instance Q.FromPGConnErr QErr where
fromPGConnErr c =
Expand Down
Loading

0 comments on commit a26bc80

Please sign in to comment.