From a26bc80496954477c912364e9e11aebe4e121669 Mon Sep 17 00:00:00 2001 From: Karthikeyan Chinnakonda Date: Thu, 16 Apr 2020 12:15:21 +0530 Subject: [PATCH] accept a new argument `claims_namespace_path` in JWT config (#4365) * 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 Co-authored-by: Marion Schleifer Co-authored-by: rakeshkky <12475069+rakeshkky@users.noreply.github.com> Co-authored-by: Tirumarai Selvan --- .circleci/test-server.sh | 14 ++ CHANGELOG.md | 1 + .../manual/auth/authentication/jwt.rst | 36 ++++- server/graphql-engine.cabal | 4 +- server/src-lib/Data/Parser/JSONPath.hs | 69 +++++---- server/src-lib/Data/URL/Template.hs | 4 +- server/src-lib/Hasura/Prelude.hs | 2 +- server/src-lib/Hasura/RQL/Types/Error.hs | 7 +- server/src-lib/Hasura/Server/Auth/JWT.hs | 89 +++++++++--- server/src-lib/Hasura/Server/Config.hs | 9 +- server/src-lib/Hasura/Server/Utils.hs | 15 ++ server/src-test/Data/Parser/JSONPathSpec.hs | 31 +++++ server/src-test/Main.hs | 2 + server/tests-py/context.py | 4 +- .../query_illegal_cast_is_not_allowed.yaml | 2 +- .../user_cannot_update_id_col_article.yaml | 2 +- .../user_update_resident_preset_error.yaml | 8 +- server/tests-py/test_config_api.py | 21 ++- server/tests-py/test_jwt.py | 131 +++++++++++++----- server/tests-py/validate.py | 16 ++- 20 files changed, 347 insertions(+), 120 deletions(-) create mode 100644 server/src-test/Data/Parser/JSONPathSpec.hs diff --git a/.circleci/test-server.sh b/.circleci/test-server.sh index b7b06f6891c..2c31a49c122 100755 --- a/.circleci/test-server.sh +++ b/.circleci/test-server.sh @@ -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 diff --git a/CHANGELOG.md b/CHANGELOG.md index 6f67aac3dfe..76627c4d995 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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` diff --git a/docs/graphql/manual/auth/authentication/jwt.rst b/docs/graphql/manual/auth/authentication/jwt.rst index fbc09c7752a..a30d4dd0db1 100644 --- a/docs/graphql/manual/auth/authentication/jwt.rst +++ b/docs/graphql/manual/auth/authentication/jwt.rst @@ -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 @@ -129,6 +130,7 @@ JSON object: "key": "", "jwk_url": "", "claims_namespace": "", + "claims_namespace_path":"", "claims_format": "json|stringified_json", "audience": , "issuer": "" @@ -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`` ^^^^^^^^^^^^^^^^^ diff --git a/server/graphql-engine.cabal b/server/graphql-engine.cabal index 94a735796ca..01a79e1ca21 100644 --- a/server/graphql-engine.cabal +++ b/server/graphql-engine.cabal @@ -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 @@ -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 @@ -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 diff --git a/server/src-lib/Data/Parser/JSONPath.hs b/server/src-lib/Data/Parser/JSONPath.hs index fbca6ac4763..694815055ee 100644 --- a/server/src-lib/Data/Parser/JSONPath.hs +++ b/server/src-lib/Data/Parser/JSONPath.hs @@ -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 @@ -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" diff --git a/server/src-lib/Data/URL/Template.hs b/server/src-lib/Data/URL/Template.hs index e9fba28113b..a364cbd0d4b 100644 --- a/server/src-lib/Data/URL/Template.hs +++ b/server/src-lib/Data/URL/Template.hs @@ -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 diff --git a/server/src-lib/Hasura/Prelude.hs b/server/src-lib/Hasura/Prelude.hs index 87e71b97883..c4ff0380b48 100644 --- a/server/src-lib/Hasura/Prelude.hs +++ b/server/src-lib/Hasura/Prelude.hs @@ -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) diff --git a/server/src-lib/Hasura/RQL/Types/Error.hs b/server/src-lib/Hasura/RQL/Types/Error.hs index 863352d8945..980f1f09f28 100644 --- a/server/src-lib/Hasura/RQL/Types/Error.hs +++ b/server/src-lib/Hasura/RQL/Types/Error.hs @@ -5,6 +5,7 @@ module Hasura.RQL.Types.Error , QErr(..) , encodeQErr , encodeGQLErr + , encodeJSONPath , noInternalQErrEnc , err400 , err404 @@ -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 = diff --git a/server/src-lib/Hasura/Server/Auth/JWT.hs b/server/src-lib/Hasura/Server/Auth/JWT.hs index b0fbfc892ce..b4568005aac 100644 --- a/server/src-lib/Hasura/Server/Auth/JWT.hs +++ b/server/src-lib/Hasura/Server/Auth/JWT.hs @@ -6,6 +6,7 @@ module Hasura.Server.Auth.JWT , Jose.JWKSet (..) , JWTClaimsFormat (..) , JwkFetchError (..) + , JWTConfigClaims (..) , updateJwkRef , jwkRefreshCtrl , defaultClaimNs @@ -22,6 +23,7 @@ import Data.Time.Clock (NominalDiffTime, UTCTime, diff import GHC.AssertNF import Network.URI (URI) +import Data.Aeson.Internal (JSONPath) import Data.Parser.CacheControl import Data.Parser.Expires import Hasura.HTTP @@ -30,14 +32,16 @@ import Hasura.Prelude import Hasura.RQL.Types import Hasura.Server.Auth.JWT.Internal (parseHmacKey, parseRsaKey) import Hasura.Server.Auth.JWT.Logging -import Hasura.Server.Utils (getRequestHeader, userRoleHeader) +import Hasura.Server.Utils (getRequestHeader, userRoleHeader, executeJSONPath) import Hasura.Server.Version (HasVersion) +import Hasura.RQL.Types.Error (encodeJSONPath) import qualified Control.Concurrent.Extended as C import qualified Crypto.JWT as Jose import qualified Data.Aeson as J import qualified Data.Aeson.Casing as J import qualified Data.Aeson.TH as J +import qualified Data.Aeson.Internal as J import qualified Data.ByteString.Lazy as BL import qualified Data.ByteString.Lazy.Char8 as BLC import qualified Data.CaseInsensitive as CI @@ -47,7 +51,7 @@ import qualified Data.Text.Encoding as T import qualified Network.HTTP.Client as HTTP import qualified Network.HTTP.Types as HTTP import qualified Network.Wreq as Wreq - +import qualified Data.Parser.JSONPath as JSONPath newtype RawJWT = RawJWT BL.ByteString @@ -59,10 +63,19 @@ data JWTClaimsFormat $(J.deriveJSON J.defaultOptions { J.sumEncoding = J.ObjectWithSingleField , J.constructorTagModifier = J.snakeCase . drop 3 } ''JWTClaimsFormat) +data JWTConfigClaims + = ClaimNsPath JSONPath + | ClaimNs T.Text + deriving (Show, Eq) + +instance J.ToJSON JWTConfigClaims where + toJSON (ClaimNsPath nsPath) = J.String . T.pack $ encodeJSONPath nsPath + toJSON (ClaimNs ns) = J.String ns + data JWTConfig = JWTConfig { jcKeyOrUrl :: !(Either Jose.JWK URI) - , jcClaimNs :: !(Maybe T.Text) + , jcClaimNs :: !JWTConfigClaims , jcAudience :: !(Maybe Jose.Audience) , jcClaimsFormat :: !(Maybe JWTClaimsFormat) , jcIssuer :: !(Maybe Jose.StringOrURI) @@ -71,7 +84,7 @@ data JWTConfig data JWTCtx = JWTCtx { jcxKey :: !(IORef Jose.JWKSet) - , jcxClaimNs :: !(Maybe T.Text) + , jcxClaimNs :: !JWTConfigClaims , jcxAudience :: !(Maybe Jose.Audience) , jcxClaimsFormat :: !JWTClaimsFormat , jcxIssuer :: !(Maybe Jose.StringOrURI) @@ -79,7 +92,7 @@ data JWTCtx instance Show JWTCtx where show (JWTCtx _ nsM audM cf iss) = - show ["", show nsM, show audM, show cf, show iss] + show ["", show nsM,show audM, show cf, show iss] data HasuraClaims = HasuraClaims @@ -231,12 +244,15 @@ processAuthZHeader jwtCtx headers authzHeader = do -- verify the JWT claims <- liftJWTError invalidJWTError $ verifyJwt jwtCtx $ RawJWT jwt - let claimsNs = fromMaybe defaultClaimNs $ jcxClaimNs jwtCtx - claimsFmt = jcxClaimsFormat jwtCtx + let claimsFmt = jcxClaimsFormat jwtCtx expTimeM = fmap (\(Jose.NumericDate t) -> t) $ claims ^. Jose.claimExp - -- see if the hasura claims key exist in the claims map - let mHasuraClaims = Map.lookup claimsNs $ claims ^. Jose.unregisteredClaims + -- see if the hasura claims key exists in the claims map + let mHasuraClaims = + case jcxClaimNs jwtCtx of + ClaimNs k -> Map.lookup k $ claims ^. Jose.unregisteredClaims + ClaimNsPath path -> parseIValueJsonValue $ executeJSONPath path (J.toJSON $ claims ^. Jose.unregisteredClaims) + hasuraClaimsV <- maybe claimsNotFound return mHasuraClaims -- get hasura claims value as an object. parse from string possibly @@ -277,12 +293,22 @@ processAuthZHeader jwtCtx headers authzHeader = do (JCFJson, _) -> claimsErr "expecting a json object when claims_format is json" - strngfyErr v = "expecting stringified json at: '" - <> fromMaybe defaultClaimNs (jcxClaimNs jwtCtx) - <> "', but found: " <> v + strngfyErr v = + "expecting stringified json at: '" + <> claimsLocation + <> "', but found: " <> v + where + claimsLocation :: Text + claimsLocation = + case jcxClaimNs jwtCtx of + ClaimNsPath path -> T.pack $ "claims_namespace_path " <> encodeJSONPath path + ClaimNs ns -> "claims_namespace " <> ns claimsErr = throw400 JWTInvalidClaims + parseIValueJsonValue (J.IError _ _) = Nothing + parseIValueJsonValue (J.ISuccess v) = Just v + -- see if there is a x-hasura-role header, or else pick the default role getCurrentRole defaultRole = let mUserRole = getRequestHeader userRoleHeader headers @@ -305,8 +331,10 @@ processAuthZHeader jwtCtx headers authzHeader = do currRoleNotAllowed = throw400 AccessDenied "Your current role is not in allowed roles" claimsNotFound = do - let claimsNs = fromMaybe defaultClaimNs $ jcxClaimNs jwtCtx - throw400 JWTInvalidClaims $ "claims key: '" <> claimsNs <> "' not found" + let claimsNsError = case jcxClaimNs jwtCtx of + ClaimNsPath path -> T.pack $ "claims not found at claims_namespace_path: '" <> (encodeJSONPath path) <> "'" + ClaimNs ns -> "claims key: '" <> ns <> "' not found" + throw400 JWTInvalidClaims $ claimsNsError -- parse x-hasura-allowed-roles, x-hasura-default-role from JWT claims @@ -369,14 +397,19 @@ verifyJwt ctx (RawJWT rawJWT) = do instance J.ToJSON JWTConfig where toJSON (JWTConfig keyOrUrl claimNs aud claimsFmt iss) = - J.object (jwkFields ++ sharedFields) + J.object (jwkFields ++ sharedFields ++ claimsNsFields) where jwkFields = case keyOrUrl of Left _ -> [ "type" J..= J.String "" , "key" J..= J.String "" ] Right url -> [ "jwk_url" J..= url ] - sharedFields = [ "claims_namespace" J..= claimNs - , "claims_format" J..= claimsFmt + + claimsNsFields = case claimNs of + ClaimNsPath nsPath -> + ["claims_namespace_path" J..= (encodeJSONPath nsPath)] + ClaimNs ns -> ["claims_namespace" J..= J.String ns] + + sharedFields = [ "claims_format" J..= claimsFmt , "audience" J..= aud , "issuer" J..= iss ] @@ -388,24 +421,33 @@ instance J.FromJSON JWTConfig where parseJSON = J.withObject "JWTConfig" $ \o -> do mRawKey <- o J..:? "key" - claimNs <- o J..:? "claims_namespace" + claimsNs <- o J..:? "claims_namespace" + claimsNsPath <- o J..:? "claims_namespace_path" aud <- o J..:? "audience" iss <- o J..:? "issuer" jwkUrl <- o J..:? "jwk_url" isStrngfd <- o J..:? "claims_format" + + hasuraClaimsNs <- + case (claimsNsPath,claimsNs) of + (Nothing, Nothing) -> return $ ClaimNs defaultClaimNs + (Just nsPath, Nothing) -> either failJSONPathParsing (return . ClaimNsPath) . JSONPath.parseJSONPath $ nsPath + (Nothing, Just ns) -> return $ ClaimNs ns + (Just _, Just _) -> fail "claims_namespace and claims_namespace_path both cannot be set" + case (mRawKey, jwkUrl) of (Nothing, Nothing) -> fail "key and jwk_url both cannot be empty" (Just _, Just _) -> fail "key, jwk_url both cannot be present" (Just rawKey, Nothing) -> do keyType <- o J..: "type" - key <- parseKey rawKey keyType - return $ JWTConfig (Left key) claimNs aud isStrngfd iss + key <- parseKey keyType rawKey + return $ JWTConfig (Left key) hasuraClaimsNs aud isStrngfd iss (Nothing, Just url) -> - return $ JWTConfig (Right url) claimNs aud isStrngfd iss + return $ JWTConfig (Right url) hasuraClaimsNs aud isStrngfd iss where - parseKey rawKey keyType = + parseKey keyType rawKey = case keyType of "HS256" -> runEither $ parseHmacKey rawKey 256 "HS384" -> runEither $ parseHmacKey rawKey 384 @@ -417,4 +459,7 @@ instance J.FromJSON JWTConfig where _ -> invalidJwk ("Key type: " <> T.unpack keyType <> " is not supported") runEither = either (invalidJwk . T.unpack) return + invalidJwk msg = fail ("Invalid JWK: " <> msg) + + failJSONPathParsing err = fail $ "invalid JSON path claims_namespace_path error: " ++ err diff --git a/server/src-lib/Hasura/Server/Config.hs b/server/src-lib/Hasura/Server/Config.hs index 04e772d0663..c72fe8b1f8d 100644 --- a/server/src-lib/Hasura/Server/Config.hs +++ b/server/src-lib/Hasura/Server/Config.hs @@ -15,8 +15,8 @@ import qualified Hasura.GraphQL.Execute.LiveQuery.Options as LQ data JWTInfo = JWTInfo - { jwtiClaimsNamespace :: !Text - , jwtiClaimsFormat :: !JWTClaimsFormat + { jwtiClaimsNamespace :: !JWTConfigClaims + , jwtiClaimsFormat :: !JWTClaimsFormat } deriving (Show, Eq) $(deriveToJSON (aesonDrop 4 snakeCase) ''JWTInfo) @@ -44,7 +44,6 @@ runGetConfig am isAllowListEnabled liveQueryOpts = ServerConfig isAllowListEnabled liveQueryOpts - isAdminSecretSet :: AuthMode -> Bool isAdminSecretSet = \case AMNoAuth -> False @@ -62,8 +61,8 @@ isJWTSet = \case getJWTInfo :: AuthMode -> Maybe JWTInfo getJWTInfo (AMAdminSecretAndJWT _ jwtCtx _) = - Just $ JWTInfo ns format + Just $ JWTInfo claimsNs format where - ns = fromMaybe defaultClaimNs $ jcxClaimNs jwtCtx + claimsNs = jcxClaimNs jwtCtx format = jcxClaimsFormat jwtCtx getJWTInfo _ = Nothing diff --git a/server/src-lib/Hasura/Server/Utils.hs b/server/src-lib/Hasura/Server/Utils.hs index 72d55e55c1a..c18c3deb9d2 100644 --- a/server/src-lib/Hasura/Server/Utils.hs +++ b/server/src-lib/Hasura/Server/Utils.hs @@ -11,6 +11,7 @@ import Language.Haskell.TH.Syntax (Lift, Q, TExp) import System.Environment import System.Exit import System.Process +import Data.Aeson.Internal import qualified Data.ByteString as B import qualified Data.CaseInsensitive as CI @@ -27,6 +28,7 @@ import qualified Network.Wreq as Wreq import qualified Text.Regex.TDFA as TDFA import qualified Text.Regex.TDFA.ReadRegex as TDFA import qualified Text.Regex.TDFA.TDFA as TDFA +import qualified Data.Vector as V import Hasura.RQL.Instances () @@ -227,3 +229,16 @@ makeReasonMessage errors showError = [singleError] -> "because " <> showError singleError _ -> "for the following reasons:\n" <> T.unlines (map ((" • " <>) . showError) errors) + +executeJSONPath :: JSONPath -> Value -> IResult Value +executeJSONPath jsonPath = iparse (valueParser jsonPath) + where + valueParser path value = case path of + [] -> pure value + (pathElement:remaining) -> parseWithPathElement pathElement value >>= + (( pathElement) . valueParser remaining) + where + parseWithPathElement = \case + Key k -> withObject "Object" (.: k) + Index i -> withArray "Array" $ + maybe (fail "Array index out of range") pure . (V.!? i) diff --git a/server/src-test/Data/Parser/JSONPathSpec.hs b/server/src-test/Data/Parser/JSONPathSpec.hs new file mode 100644 index 00000000000..4bfa91a0122 --- /dev/null +++ b/server/src-test/Data/Parser/JSONPathSpec.hs @@ -0,0 +1,31 @@ +module Data.Parser.JSONPathSpec (spec) where + +import Hasura.Prelude +import Hasura.RQL.Types (encodeJSONPath) + +import Data.Parser.JSONPath +import Test.Hspec +import Test.QuickCheck + +import qualified Data.Text as T + +spec :: Spec +spec = describe "parseJSONPath" $ + it "JSONPath parser" $ + withMaxSuccess 1000 $ + forAll(resize 20 generateJSONPath) $ \jsonPath -> + let encPath = encodeJSONPath jsonPath + parsedJSONPathE = parseJSONPath $ T.pack encPath + in case parsedJSONPathE of + Left err -> counterexample (err <> ": " <> encPath) False + Right parsedJSONPath -> property $ parsedJSONPath == jsonPath + +generateJSONPath :: Gen JSONPath +generateJSONPath = map (either id id) <$> listOf1 genPathElementEither + where + genPathElementEither = do + indexLeft <- Left <$> genIndex + keyRight <- Right <$> genKey + elements [indexLeft, keyRight] + genIndex = Index <$> choose (0, 100) + genKey = (Key . T.pack) <$> listOf1 (elements $ alphaNumerics ++ ".,!@#$%^&*_-?:;|/\"") diff --git a/server/src-test/Main.hs b/server/src-test/Main.hs index e66b73e6e9f..c1f3cfc9bda 100644 --- a/server/src-test/Main.hs +++ b/server/src-test/Main.hs @@ -26,6 +26,7 @@ import Hasura.Server.Migrate import Hasura.Server.Version import qualified Data.Parser.CacheControlSpec as CacheControlParser +import qualified Data.Parser.JSONPathSpec as JsonPath import qualified Data.Parser.URLTemplate as URLTemplate import qualified Data.TimeSpec as TimeSpec import qualified Hasura.IncrementalSpec as IncrementalSpec @@ -57,6 +58,7 @@ unitSpecs :: Spec unitSpecs = do describe "Data.Parser.CacheControl" CacheControlParser.spec describe "Data.Parser.URLTemplate" URLTemplate.spec + describe "Data.Parser.JsonPath" JsonPath.spec describe "Hasura.Incremental" IncrementalSpec.spec -- describe "Hasura.RQL.Metadata" MetadataSpec.spec -- Commenting until optimizing the test in CI describe "Data.Time" TimeSpec.spec diff --git a/server/tests-py/context.py b/server/tests-py/context.py index c46be3918f5..31ff7db5232 100644 --- a/server/tests-py/context.py +++ b/server/tests-py/context.py @@ -406,7 +406,7 @@ class HGECtx: def __init__(self, hge_url, pg_url, config): self.http = requests.Session() - self. hge_key = config.getoption('--hge-key') + self.hge_key = config.getoption('--hge-key') self.hge_url = hge_url self.pg_url = pg_url self.hge_webhook = config.getoption('--hge-webhook') @@ -417,6 +417,8 @@ class HGECtx: with open(hge_jwt_key_file) as f: self.hge_jwt_key = f.read() self.hge_jwt_conf = config.getoption('--hge-jwt-conf') + if self.hge_jwt_conf is not None: + self.hge_jwt_conf_dict = json.loads(self.hge_jwt_conf) self.webhook_insecure = config.getoption('--test-webhook-insecure') self.metadata_disabled = config.getoption('--test-metadata-disabled') self.may_skip_test_teardown = False diff --git a/server/tests-py/queries/v1/select/boolexp/postgis/query_illegal_cast_is_not_allowed.yaml b/server/tests-py/queries/v1/select/boolexp/postgis/query_illegal_cast_is_not_allowed.yaml index 8e7d5b8d7ff..4e285f03fbc 100644 --- a/server/tests-py/queries/v1/select/boolexp/postgis/query_illegal_cast_is_not_allowed.yaml +++ b/server/tests-py/queries/v1/select/boolexp/postgis/query_illegal_cast_is_not_allowed.yaml @@ -4,7 +4,7 @@ status: 400 response: code: unexpected-payload error: cannot cast column of type "geography" to type "integer" - path: $.args.where.geog_col.$cast + path: $.args.where.geog_col.['$cast'] query: type: select args: diff --git a/server/tests-py/queries/v1/update/permissions/user_cannot_update_id_col_article.yaml b/server/tests-py/queries/v1/update/permissions/user_cannot_update_id_col_article.yaml index b15903e7cff..13a2acb1352 100644 --- a/server/tests-py/queries/v1/update/permissions/user_cannot_update_id_col_article.yaml +++ b/server/tests-py/queries/v1/update/permissions/user_cannot_update_id_col_article.yaml @@ -5,7 +5,7 @@ headers: X-Hasura-User-Id: '1' status: 400 response: - path: $.args.$set + path: $.args.['$set'] error: role "user" does not have permission to update column "id" code: permission-denied query: diff --git a/server/tests-py/queries/v1/update/permissions/user_update_resident_preset_error.yaml b/server/tests-py/queries/v1/update/permissions/user_update_resident_preset_error.yaml index a2b0403950d..3de948c831c 100644 --- a/server/tests-py/queries/v1/update/permissions/user_update_resident_preset_error.yaml +++ b/server/tests-py/queries/v1/update/permissions/user_update_resident_preset_error.yaml @@ -4,15 +4,15 @@ status: 400 headers: X-Hasura-Role: user1 response: - path: "$.args.$set" - error: column "city" is not updatable for role "user1"; its value is predefined in - permission + path: $.args.['$set'] + error: column "city" is not updatable for role "user1"; its value is predefined + in permission code: not-supported query: type: update args: table: resident - "$set": + $set: city: hobart where: name: clarke diff --git a/server/tests-py/test_config_api.py b/server/tests-py/test_config_api.py index 8d2c1723738..1a0e0406c89 100644 --- a/server/tests-py/test_config_api.py +++ b/server/tests-py/test_config_api.py @@ -1,5 +1,6 @@ import ruamel.yaml as yaml import re +import json class TestConfigAPI(): @@ -7,6 +8,8 @@ class TestConfigAPI(): admin_secret = hge_ctx.hge_key auth_hook = hge_ctx.hge_webhook jwt_conf = hge_ctx.hge_jwt_conf + if jwt_conf is not None: + jwt_conf_dict = json.loads(hge_ctx.hge_jwt_conf) headers = {} if admin_secret is not None: @@ -25,14 +28,18 @@ class TestConfigAPI(): assert body['is_jwt_set'] == (jwt_conf is not None) if jwt_conf is not None: - claims_namespace = "https://hasura.io/jwt/claims" - if 'claims_namespace' in jwt_conf: - claims_namespace = jwt_conf['claims_namespace'] claims_format = "json" - if 'claims_format' in jwt_conf: - claims_format = jwt_conf['claims_format'] - assert body['jwt']['claims_namespace'] == claims_namespace - assert body['jwt']['claims_format'] == claims_format + if 'claims_namespace_path' in jwt_conf_dict: + assert body['jwt']['claims_namespace_path'] == jwt_conf_dict['claims_namespace_path'] + assert body['jwt']['claims_format'] == claims_format + else: + claims_namespace = "https://hasura.io/jwt/claims" + if 'claims_namespace' in jwt_conf_dict: + claims_namespace = jwt_conf_dict['claims_namespace'] + if 'claims_format' in jwt_conf_dict: + claims_format = jwt_conf_dict['claims_format'] + assert body['jwt']['claims_namespace'] == claims_namespace + assert body['jwt']['claims_format'] == claims_format else: assert body['jwt'] == None diff --git a/server/tests-py/test_jwt.py b/server/tests-py/test_jwt.py index 131f77f4f8b..74aa08ea26f 100644 --- a/server/tests-py/test_jwt.py +++ b/server/tests-py/test_jwt.py @@ -11,7 +11,7 @@ from cryptography.hazmat.backends import default_backend from cryptography.hazmat.primitives.asymmetric import rsa from cryptography.hazmat.primitives import serialization -from validate import check_query +from validate import check_query, mk_claims_with_namespace_path from context import PytestConf @@ -42,23 +42,32 @@ def mk_claims(conf, claims): class TestJWTBasic(): def test_jwt_valid_claims_success(self, hge_ctx, endpoint): - self.claims['https://hasura.io/jwt/claims'] = mk_claims(hge_ctx.hge_jwt_conf, { - 'x-hasura-user-id': '1', - 'x-hasura-allowed-roles': ['user', 'editor'], - 'x-hasura-default-role': 'user' + hasura_claims = mk_claims(hge_ctx.hge_jwt_conf, { + 'x-hasura-user-id': '1', + 'x-hasura-allowed-roles': ['user', 'editor'], + 'x-hasura-default-role': 'user' }) + claims_namespace_path = None + if 'claims_namespace_path' in hge_ctx.hge_jwt_conf_dict: + claims_namespace_path = hge_ctx.hge_jwt_conf_dict['claims_namespace_path'] + self.claims = mk_claims_with_namespace_path(self.claims,hasura_claims,claims_namespace_path) token = jwt.encode(self.claims, hge_ctx.hge_jwt_key, algorithm='RS512').decode('utf-8') self.conf['headers']['Authorization'] = 'Bearer ' + token self.conf['url'] = endpoint self.conf['status'] = 200 - check_query(hge_ctx, self.conf, add_auth=False) + check_query(hge_ctx, self.conf, add_auth=False,claims_namespace_path=claims_namespace_path) def test_jwt_invalid_role_in_request_header(self, hge_ctx, endpoint): - self.claims['https://hasura.io/jwt/claims'] = mk_claims(hge_ctx.hge_jwt_conf, { + hasura_claims = mk_claims(hge_ctx.hge_jwt_conf, { 'x-hasura-user-id': '1', 'x-hasura-allowed-roles': ['contractor', 'editor'], 'x-hasura-default-role': 'contractor' }) + claims_namespace_path = None + if 'claims_namespace_path' in hge_ctx.hge_jwt_conf_dict: + claims_namespace_path = hge_ctx.hge_jwt_conf_dict['claims_namespace_path'] + + self.claims = mk_claims_with_namespace_path(self.claims,hasura_claims,claims_namespace_path) token = jwt.encode(self.claims, hge_ctx.hge_jwt_key, algorithm='RS512').decode('utf-8') self.conf['headers']['Authorization'] = 'Bearer ' + token self.conf['response'] = { @@ -75,13 +84,18 @@ class TestJWTBasic(): self.conf['status'] = 200 if endpoint == '/v1alpha1/graphql': self.conf['status'] = 400 - check_query(hge_ctx, self.conf, add_auth=False) + check_query(hge_ctx, self.conf, add_auth=False,claims_namespace_path=claims_namespace_path) def test_jwt_no_allowed_roles_in_claim(self, hge_ctx, endpoint): - self.claims['https://hasura.io/jwt/claims'] = mk_claims(hge_ctx.hge_jwt_conf, { + hasura_claims = mk_claims(hge_ctx.hge_jwt_conf, { 'x-hasura-user-id': '1', 'x-hasura-default-role': 'user' }) + claims_namespace_path = None + if 'claims_namespace_path' in hge_ctx.hge_jwt_conf_dict: + claims_namespace_path = hge_ctx.hge_jwt_conf_dict['claims_namespace_path'] + + self.claims = mk_claims_with_namespace_path(self.claims,hasura_claims,claims_namespace_path) token = jwt.encode(self.claims, hge_ctx.hge_jwt_key, algorithm='RS512').decode('utf-8') self.conf['headers']['Authorization'] = 'Bearer ' + token self.conf['response'] = { @@ -98,14 +112,19 @@ class TestJWTBasic(): self.conf['status'] = 200 if endpoint == '/v1alpha1/graphql': self.conf['status'] = 400 - check_query(hge_ctx, self.conf, add_auth=False) + check_query(hge_ctx, self.conf, add_auth=False,claims_namespace_path=claims_namespace_path) def test_jwt_invalid_allowed_roles_in_claim(self, hge_ctx, endpoint): - self.claims['https://hasura.io/jwt/claims'] = mk_claims(hge_ctx.hge_jwt_conf, { + hasura_claims = mk_claims(hge_ctx.hge_jwt_conf, { 'x-hasura-user-id': '1', 'x-hasura-allowed-roles': 'user', 'x-hasura-default-role': 'user' }) + claims_namespace_path = None + if 'claims_namespace_path' in hge_ctx.hge_jwt_conf_dict: + claims_namespace_path = hge_ctx.hge_jwt_conf_dict['claims_namespace_path'] + + self.claims = mk_claims_with_namespace_path(self.claims,hasura_claims,claims_namespace_path) token = jwt.encode(self.claims, hge_ctx.hge_jwt_key, algorithm='RS512').decode('utf-8') self.conf['headers']['Authorization'] = 'Bearer ' + token self.conf['response'] = { @@ -122,13 +141,18 @@ class TestJWTBasic(): self.conf['status'] = 200 if endpoint == '/v1alpha1/graphql': self.conf['status'] = 400 - check_query(hge_ctx, self.conf, add_auth=False) + check_query(hge_ctx, self.conf, add_auth=False,claims_namespace_path=claims_namespace_path) def test_jwt_no_default_role(self, hge_ctx, endpoint): - self.claims['https://hasura.io/jwt/claims'] = mk_claims(hge_ctx.hge_jwt_conf, { + hasura_claims = mk_claims(hge_ctx.hge_jwt_conf, { 'x-hasura-user-id': '1', 'x-hasura-allowed-roles': ['user'], }) + claims_namespace_path = None + if 'claims_namespace_path' in hge_ctx.hge_jwt_conf_dict: + claims_namespace_path = hge_ctx.hge_jwt_conf_dict['claims_namespace_path'] + + self.claims = mk_claims_with_namespace_path(self.claims,hasura_claims,claims_namespace_path) token = jwt.encode(self.claims, hge_ctx.hge_jwt_key, algorithm='RS512').decode('utf-8') self.conf['headers']['Authorization'] = 'Bearer ' + token self.conf['response'] = { @@ -145,14 +169,19 @@ class TestJWTBasic(): self.conf['status'] = 200 if endpoint == '/v1alpha1/graphql': self.conf['status'] = 400 - check_query(hge_ctx, self.conf, add_auth=False) + check_query(hge_ctx, self.conf, add_auth=False,claims_namespace_path=claims_namespace_path) def test_jwt_expired(self, hge_ctx, endpoint): - self.claims['https://hasura.io/jwt/claims'] = mk_claims(hge_ctx.hge_jwt_conf, { + hasura_claims = mk_claims(hge_ctx.hge_jwt_conf, { 'x-hasura-user-id': '1', 'x-hasura-default-role': 'user', 'x-hasura-allowed-roles': ['user'], }) + claims_namespace_path = None + if 'claims_namespace_path' in hge_ctx.hge_jwt_conf_dict: + claims_namespace_path = hge_ctx.hge_jwt_conf_dict['claims_namespace_path'] + + self.claims = mk_claims_with_namespace_path(self.claims,hasura_claims,claims_namespace_path) exp = datetime.utcnow() - timedelta(minutes=1) self.claims['exp'] = round(exp.timestamp()) @@ -172,15 +201,19 @@ class TestJWTBasic(): self.conf['status'] = 200 if endpoint == '/v1alpha1/graphql': self.conf['status'] = 400 - check_query(hge_ctx, self.conf, add_auth=False) + check_query(hge_ctx, self.conf, add_auth=False,claims_namespace_path=claims_namespace_path) def test_jwt_invalid_signature(self, hge_ctx, endpoint): - self.claims['https://hasura.io/jwt/claims'] = mk_claims(hge_ctx.hge_jwt_conf, { + hasura_claims = mk_claims(hge_ctx.hge_jwt_conf, { 'x-hasura-user-id': '1', 'x-hasura-default-role': 'user', 'x-hasura-allowed-roles': ['user'], }) + claims_namespace_path = None + if 'claims_namespace_path' in hge_ctx.hge_jwt_conf_dict: + claims_namespace_path = hge_ctx.hge_jwt_conf_dict['claims_namespace_path'] + self.claims = mk_claims_with_namespace_path(self.claims,hasura_claims,claims_namespace_path) wrong_key = gen_rsa_key() token = jwt.encode(self.claims, wrong_key, algorithm='HS256').decode('utf-8') self.conf['headers']['Authorization'] = 'Bearer ' + token @@ -198,41 +231,49 @@ class TestJWTBasic(): self.conf['status'] = 200 if endpoint == '/v1alpha1/graphql': self.conf['status'] = 400 - check_query(hge_ctx, self.conf, add_auth=False) + check_query(hge_ctx, self.conf, add_auth=False,claims_namespace_path=claims_namespace_path) def test_jwt_no_audience_in_conf(self, hge_ctx, endpoint): jwt_conf = json.loads(hge_ctx.hge_jwt_conf) if 'audience' in jwt_conf: pytest.skip('audience present in conf, skipping testing no audience') - self.claims['https://hasura.io/jwt/claims'] = mk_claims(hge_ctx.hge_jwt_conf, { + hasura_claims = mk_claims(hge_ctx.hge_jwt_conf, { 'x-hasura-user-id': '1', 'x-hasura-default-role': 'user', 'x-hasura-allowed-roles': ['user'], }) + claims_namespace_path = None + if 'claims_namespace_path' in hge_ctx.hge_jwt_conf_dict: + claims_namespace_path = hge_ctx.hge_jwt_conf_dict['claims_namespace_path'] + self.claims = mk_claims_with_namespace_path(self.claims,hasura_claims,claims_namespace_path) self.claims['aud'] = 'hasura-test-suite' token = jwt.encode(self.claims, hge_ctx.hge_jwt_key, algorithm='RS512').decode('utf-8') self.conf['headers']['Authorization'] = 'Bearer ' + token self.conf['url'] = endpoint - check_query(hge_ctx, self.conf, add_auth=False) + check_query(hge_ctx, self.conf, add_auth=False,claims_namespace_path=claims_namespace_path) def test_jwt_no_issuer_in_conf(self, hge_ctx, endpoint): jwt_conf = json.loads(hge_ctx.hge_jwt_conf) if 'issuer' in jwt_conf: pytest.skip('issuer present in conf, skipping testing no issuer') - self.claims['https://hasura.io/jwt/claims'] = mk_claims(hge_ctx.hge_jwt_conf, { + hasura_claims = mk_claims(hge_ctx.hge_jwt_conf, { 'x-hasura-user-id': '1', 'x-hasura-default-role': 'user', 'x-hasura-allowed-roles': ['user'], }) + claims_namespace_path = None + if 'claims_namespace_path' in hge_ctx.hge_jwt_conf_dict: + claims_namespace_path = hge_ctx.hge_jwt_conf_dict['claims_namespace_path'] + self.claims = mk_claims_with_namespace_path(self.claims,hasura_claims,claims_namespace_path) self.claims['iss'] = 'rubbish-issuer' token = jwt.encode(self.claims, hge_ctx.hge_jwt_key, algorithm='RS512').decode('utf-8') self.conf['headers']['Authorization'] = 'Bearer ' + token self.conf['url'] = endpoint - check_query(hge_ctx, self.conf, add_auth=False) + check_query(hge_ctx, self.conf, add_auth=False,claims_namespace_path=claims_namespace_path) @pytest.fixture(autouse=True) def transact(self, setup): @@ -240,7 +281,7 @@ class TestJWTBasic(): with open(self.dir + '/user_select_query_unpublished_articles.yaml') as c: self.conf = yaml.safe_load(c) curr_time = datetime.utcnow() - exp_time = curr_time + timedelta(hours=1) + exp_time = curr_time + timedelta(hours=10) self.claims = { 'sub': '1234567890', 'name': 'John Doe', @@ -280,11 +321,16 @@ class TestSubscriptionJwtExpiry(object): 'name': 'John Doe', 'iat': math.floor(curr_time.timestamp()) } - self.claims['https://hasura.io/jwt/claims'] = mk_claims(hge_ctx.hge_jwt_conf, { + hasura_claims = mk_claims(hge_ctx.hge_jwt_conf, { 'x-hasura-user-id': '1', 'x-hasura-default-role': 'user', 'x-hasura-allowed-roles': ['user'], }) + claims_namespace_path = None + if 'claims_namespace_path' in hge_ctx.hge_jwt_conf_dict: + claims_namespace_path = hge_ctx.hge_jwt_conf_dict['claims_namespace_path'] + + self.claims = mk_claims_with_namespace_path(self.claims,hasura_claims,claims_namespace_path) exp = curr_time + timedelta(seconds=4) self.claims['exp'] = round(exp.timestamp()) token = jwt.encode(self.claims, hge_ctx.hge_jwt_key, algorithm='RS512').decode('utf-8') @@ -307,27 +353,36 @@ class TestJwtAudienceCheck(): audience = jwt_conf['audience'] audience = audience if isinstance(audience, str) else audience[0] - self.claims['https://hasura.io/jwt/claims'] = mk_claims(hge_ctx.hge_jwt_conf, { + hasura_claims = mk_claims(hge_ctx.hge_jwt_conf, { 'x-hasura-user-id': '1', 'x-hasura-default-role': 'user', 'x-hasura-allowed-roles': ['user'], }) - self.claims['aud'] = audience + claims_namespace_path = None + if 'claims_namespace_path' in hge_ctx.hge_jwt_conf_dict: + claims_namespace_path = hge_ctx.hge_jwt_conf_dict['claims_namespace_path'] + self.claims = mk_claims_with_namespace_path(self.claims,hasura_claims,claims_namespace_path) + self.claims['aud'] = audience token = jwt.encode(self.claims, hge_ctx.hge_jwt_key, algorithm='RS512').decode('utf-8') self.conf['headers']['Authorization'] = 'Bearer ' + token - check_query(hge_ctx, self.conf, add_auth=False) + check_query(hge_ctx, self.conf, add_auth=False,claims_namespace_path=claims_namespace_path) def test_jwt_invalid_audience(self, hge_ctx, endpoint): jwt_conf = json.loads(hge_ctx.hge_jwt_conf) if 'audience' not in jwt_conf: pytest.skip('audience not present in conf, skipping testing audience') - self.claims['https://hasura.io/jwt/claims'] = mk_claims(hge_ctx.hge_jwt_conf, { + hasura_claims = mk_claims(hge_ctx.hge_jwt_conf, { 'x-hasura-user-id': '1', 'x-hasura-default-role': 'user', 'x-hasura-allowed-roles': ['user'], }) + claims_namespace_path = None + if 'claims_namespace_path' in hge_ctx.hge_jwt_conf_dict: + claims_namespace_path = hge_ctx.hge_jwt_conf_dict['claims_namespace_path'] + + self.claims = mk_claims_with_namespace_path(self.claims,hasura_claims,claims_namespace_path) self.claims['aud'] = 'rubbish_audience' token = jwt.encode(self.claims, hge_ctx.hge_jwt_key, algorithm='RS512').decode('utf-8') @@ -346,7 +401,7 @@ class TestJwtAudienceCheck(): self.conf['status'] = 200 if endpoint == '/v1alpha1/graphql': self.conf['status'] = 400 - check_query(hge_ctx, self.conf, add_auth=False) + check_query(hge_ctx, self.conf, add_auth=False,claims_namespace_path=claims_namespace_path) @pytest.fixture(autouse=True) def transact(self, setup): @@ -379,27 +434,37 @@ class TestJwtIssuerCheck(): pytest.skip('issuer not present in conf, skipping testing issuer') issuer = jwt_conf['issuer'] - self.claims['https://hasura.io/jwt/claims'] = mk_claims(hge_ctx.hge_jwt_conf, { + hasura_claims = mk_claims(hge_ctx.hge_jwt_conf, { 'x-hasura-user-id': '1', 'x-hasura-default-role': 'user', 'x-hasura-allowed-roles': ['user'], }) + claims_namespace_path = None + if 'claims_namespace_path' in hge_ctx.hge_jwt_conf_dict: + claims_namespace_path = hge_ctx.hge_jwt_conf_dict['claims_namespace_path'] + + self.claims = mk_claims_with_namespace_path(self.claims,hasura_claims,claims_namespace_path) self.claims['iss'] = issuer token = jwt.encode(self.claims, hge_ctx.hge_jwt_key, algorithm='RS512').decode('utf-8') self.conf['headers']['Authorization'] = 'Bearer ' + token - check_query(hge_ctx, self.conf, add_auth=False) + check_query(hge_ctx, self.conf, add_auth=False,claims_namespace_path=claims_namespace_path) def test_jwt_invalid_issuer(self, hge_ctx, endpoint): jwt_conf = json.loads(hge_ctx.hge_jwt_conf) if 'issuer' not in jwt_conf: pytest.skip('issuer not present in conf, skipping testing issuer') - self.claims['https://hasura.io/jwt/claims'] = mk_claims(hge_ctx.hge_jwt_conf, { + hasura_claims = mk_claims(hge_ctx.hge_jwt_conf, { 'x-hasura-user-id': '1', 'x-hasura-default-role': 'user', 'x-hasura-allowed-roles': ['user'], }) + claims_namespace_path = None + if 'claims_namespace_path' in hge_ctx.hge_jwt_conf_dict: + claims_namespace_path = hge_ctx.hge_jwt_conf_dict['claims_namespace_path'] + + self.claims = mk_claims_with_namespace_path(self.claims,hasura_claims,claims_namespace_path) self.claims['iss'] = 'rubbish_issuer' token = jwt.encode(self.claims, hge_ctx.hge_jwt_key, algorithm='RS512').decode('utf-8') @@ -418,7 +483,7 @@ class TestJwtIssuerCheck(): self.conf['status'] = 200 if endpoint == '/v1alpha1/graphql': self.conf['status'] = 400 - check_query(hge_ctx, self.conf, add_auth=False) + check_query(hge_ctx, self.conf, add_auth=False,claims_namespace_path=claims_namespace_path) @pytest.fixture(autouse=True) def transact(self, setup): diff --git a/server/tests-py/validate.py b/server/tests-py/validate.py index 51bfc327841..080103a60a4 100644 --- a/server/tests-py/validate.py +++ b/server/tests-py/validate.py @@ -123,9 +123,21 @@ def test_forbidden_webhook(hge_ctx, conf): }) +def mk_claims_with_namespace_path(claims,hasura_claims,namespace_path): + if namespace_path is None: + claims['https://hasura.io/jwt/claims'] = hasura_claims + elif namespace_path == "$.hasuraClaims": + claims['hasuraClaims'] = hasura_claims + else: + raise Exception( + '''claims_namespace_path should not be anything + other than $.hasuraClaims for testing. The + value of claims_namespace_path was {}'''.format(namespace_path)) + return claims + # Returns the response received and a bool indicating whether the test passed # or not (this will always be True unless we are `--accepting`) -def check_query(hge_ctx, conf, transport='http', add_auth=True): +def check_query(hge_ctx, conf, transport='http', add_auth=True, claims_namespace_path=None): hge_ctx.tests_passed = True headers = {} if 'headers' in conf: @@ -150,8 +162,8 @@ def check_query(hge_ctx, conf, transport='http', add_auth=True): claim = { "sub": "foo", "name": "bar", - "https://hasura.io/jwt/claims": hClaims } + claim = mk_claims_with_namespace_path(claim,hClaims,claims_namespace_path) headers['Authorization'] = 'Bearer ' + jwt.encode(claim, hge_ctx.hge_jwt_key, algorithm='RS512').decode( 'UTF-8')