support customizing JWT claims (close #3485) (#3575)

* improve jsonpath parser to accept special characters and property tests for the same

* make the JWTClaimsMapValueG parametrizable

* add documentation in the JWT file

* modify processAuthZHeader

Co-authored-by: Karthikeyan Chinnakonda <karthikeyan@hasura.io>
Co-authored-by: Marion Schleifer <marion@hasura.io>
This commit is contained in:
Rakesh Emmadi 2020-08-31 22:10:01 +05:30 committed by GitHub
parent d210a0df2d
commit 4ce6002af2
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 984 additions and 166 deletions

View File

@ -383,6 +383,51 @@ kill_hge_servers
unset HASURA_GRAPHQL_JWT_SECRET
# test JWT with Claims map
echo -e "\n$(time_elapsed): <########## TEST GRAPHQL-ENGINE WITH ADMIN SECRET AND JWT (with claims_map and values are json path) #####################################>\n"
TEST_TYPE="jwt-claims-map-with-json-path-values"
export HASURA_GRAPHQL_JWT_SECRET="$(jq -n --arg key "$(cat $OUTPUT_FOLDER/ssl/jwt_public.key)" '{ type: "RS512", key: $key , claims_map: {"x-hasura-user-id": {"path":"$.['"'"'https://myapp.com/jwt/claims'"'"'].user.id"}, "x-hasura-allowed-roles": {"path":"$.['"'"'https://myapp.com/jwt/claims'"'"'].role.allowed"}, "x-hasura-default-role": {"path":"$.['"'"'https://myapp.com/jwt/claims'"'"'].role.default"}}}')"
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_claims_map.py::TestJWTClaimsMapBasic
kill_hge_servers
unset HASURA_GRAPHQL_JWT_SECRET
echo -e "\n$(time_elapsed): <########## TEST GRAPHQL-ENGINE WITH ADMIN SECRET AND JWT (with claims_map and values are json path with default values set) #####################################>\n"
TEST_TYPE="jwt-claims-map-with-json-path-values-with-default-values"
export HASURA_GRAPHQL_JWT_SECRET="$(jq -n --arg key "$(cat $OUTPUT_FOLDER/ssl/jwt_public.key)" '{ type: "RS512", key: $key , claims_map: {"x-hasura-user-id": {"path":"$.['"'"'https://myapp.com/jwt/claims'"'"'].user.id", "default":"1"}, "x-hasura-allowed-roles": {"path":"$.['"'"'https://myapp.com/jwt/claims'"'"'].role.allowed", "default":["user","editor"]}, "x-hasura-default-role": {"path":"$.['"'"'https://myapp.com/jwt/claims'"'"'].role.default","default":"user"}}}')"
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_claims_map.py::TestJWTClaimsMapBasic
kill_hge_servers
unset HASURA_GRAPHQL_JWT_SECRET
echo -e "\n$(time_elapsed): <########## TEST GRAPHQL-ENGINE WITH ADMIN SECRET AND JWT (with claims_map and values are literal values) #####################################>\n"
TEST_TYPE="jwt-claims-map-with-literal-values"
export HASURA_GRAPHQL_JWT_SECRET="$(jq -n --arg key "$(cat $OUTPUT_FOLDER/ssl/jwt_public.key)" '{ type: "RS512", key: $key , claims_map: {"x-hasura-user-id": {"path":"$.['"'"'https://myapp.com/jwt/claims'"'"'].user.id"}, "x-hasura-allowed-roles": ["user","editor"], "x-hasura-default-role": "user","x-hasura-custom-header":"custom-value"}}')"
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_claims_map.py::TestJWTClaimsMapWithStaticHasuraClaimsMapValues
kill_hge_servers
unset HASURA_GRAPHQL_JWT_SECRET
# test with CORS modes
echo -e "\n$(time_elapsed): <########## TEST GRAPHQL-ENGINE WITH CORS DOMAINS ########>\n"

View File

@ -2,6 +2,41 @@
## Next release
### Server - Support for mapping session variables to default JWT claims
Some auth providers do not let users add custom claims in JWT. In such cases, the server can take a JWT configuration option called `claims_map` to specify a mapping of Hasura session variables to values in existing claims via JSONPath or literal values.
Example:-
Consider the following JWT claim:
```
{
"sub": "1234567890",
"name": "John Doe",
"admin": true,
"iat": 1516239022,
"user": {
"id": "ujdh739kd",
"appRoles": ["user", "editor"]
}
}
```
The corresponding JWT config can be:
```
{
"type":"RS512",
"key": "<The public Key>",
"claims_map": {
"x-hasura-allowed-roles": {"path":"$.user.appRoles"},
"x-hasura-default-role": {"path":"$.user.appRoles[0]","default":"user"},
"x-hasura-user-id": {"path":"$.user.id"}
}
}
```
### Breaking changes
This release contains the [PDV refactor (#4111)](https://github.com/hasura/graphql-engine/pull/4111), a significant rewrite of the internals of the server, which did include some breaking changes:

View File

@ -133,7 +133,8 @@ JSON object:
"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>"
"issuer": "<optional-string-to-verify-issuer>",
"claims_map": "<optional-object-of-session-variable-to-claim-jsonpath-or-literal-value>"
}
(``type``, ``key``) pair or ``jwk_url``, **one of them has to be present**.
@ -255,8 +256,6 @@ set to ``$.hasura.claims``:
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``
^^^^^^^^^^^^^^^^^
This is an optional field, with only the following possible values:
- ``json``
- ``stringified_json``
@ -371,6 +370,130 @@ Examples:
that you can set this field to the appropriate value.
``claims_map``
^^^^^^^^^^^^^^
This is an optional field. Certain providers might not allow adding custom claims.
In such a case, you can map Hasura session variables with existing JWT claims
using ``claims_map``. The ``claims_map`` is a JSON object where keys are session
variables and values can be a JSON path (with a default value option, when the key
specified by the JSON path doesn't exist) or a literal value.
The literal values should be a ``String``, except for the ``x-hasura-allowed-roles`` claim
which expects a ``String`` array.
The value of a claim referred by a JSON path must be a ``String``.
To use the JSON path value, the path needs to be given in a JSON object with ``path``
as the key and the JSON path as the value:
.. code-block:: json
{
"path" : "$.user.all_roles",
}
.. code-block:: json
{
"path" : "$.roles.default",
"default": "user"
}
**Example: JWT config with JSON path values**
.. code-block:: json
{
"sub": "1234567890",
"name": "John Doe",
"admin": true,
"iat": 1516239022,
"user": {
"id": "ujdh739kd"
},
"hasura": {
"all_roles": ["user", "editor"],
}
}
The mapping for ``x-hasura-allowed-roles``, ``x-hasura-default-role`` and ``x-hasura-user-id`` session
variables can be specified in the ``claims_map`` configuration as follows:
.. code-block:: json
{
"type":"RS512",
"key": "-----BEGIN PUBLIC KEY-----\nMIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDdlatRjRjogo3WojgGHFHYLugd\nUWAY9iR3fy4arWNA1KoS8kVw33cJibXr8bvwUAUparCwlvdbH6dvEOfou0/gCFQs\nHUfQrSDv+MuSUMAe8jzKE4qW+jK+xQU9a03GUnKHkkle+Q0pX/g6jXZ7r1/xAK5D\no2kQ+X5xK9cipRgEKwIDAQAB\n-----END PUBLIC KEY-----\n",
"claims_map": {
"x-hasura-allowed-roles": {"path":"$.hasura.all_roles"},
"x-hasura-default-role": {"path":"$.hasura.all_roles[0]"},
"x-hasura-user-id": {"path":"$.user.id"}
}
}
**Example: JWT config with JSON path values and default values**
.. code-block:: json
{
"sub": "1234567890",
"name": "John Doe",
"admin": true,
"iat": 1516239022,
"hasura": {
"all_roles": ["user", "editor"],
}
}
.. code-block:: json
{
"type":"RS512",
"key": "-----BEGIN PUBLIC KEY-----\nMIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDdlatRjRjogo3WojgGHFHYLugd\nUWAY9iR3fy4arWNA1KoS8kVw33cJibXr8bvwUAUparCwlvdbH6dvEOfou0/gCFQs\nHUfQrSDv+MuSUMAe8jzKE4qW+jK+xQU9a03GUnKHkkle+Q0pX/g6jXZ7r1/xAK5D\no2kQ+X5xK9cipRgEKwIDAQAB\n-----END PUBLIC KEY-----\n",
"claims_map": {
"x-hasura-allowed-roles": {"path":"$.hasura.all_roles"},
"x-hasura-default-role": {"path":"$.hasura.all_roles[0]"},
"x-hasura-user-id": {"path":"$.user.id","default":"ujdh739kd"}
}
}
In the above case, since the ``$.user.id`` doesn't exist in the JWT token, the default
value of the ``x-hasura-user-id`` i.e "ujdh739kd" will be used
**Example: JWT config containing literal values**
.. code-block:: json
{
"sub": "1234567890",
"name": "John Doe",
"admin": true,
"iat": 1516239022,
"user": {
"id": "ujdh739kd"
}
}
The corresponding JWT config should be:
.. code-block:: json
{
"type":"RS512",
"key": "-----BEGIN PUBLIC KEY-----\nMIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDdlatRjRjogo3WojgGHFHYLugd\nUWAY9iR3fy4arWNA1KoS8kVw33cJibXr8bvwUAUparCwlvdbH6dvEOfou0/gCFQs\nHUfQrSDv+MuSUMAe8jzKE4qW+jK+xQU9a03GUnKHkkle+Q0pX/g6jXZ7r1/xAK5D\no2kQ+X5xK9cipRgEKwIDAQAB\n-----END PUBLIC KEY-----\n",
"claims_map": {
"x-hasura-allowed-roles": ["user","editor"],
"x-hasura-default-role": "user",
"x-hasura-user-id": {"path":"$.user.id"}
}
}
In the above example, the ``x-hasura-allowed-roles`` and ``x-hasura-default-role`` values are set in the JWT
config and the value of the ``x-hasura-user-id`` is a JSON path to the value in the JWT token.
Examples
^^^^^^^^

View File

@ -509,6 +509,7 @@ test-suite graphql-engine-tests
, pg-client
, process
, QuickCheck
, text
, safe
, split
, time

View File

@ -744,10 +744,6 @@ tableConnectionArgs pkeyColumns table selectPermissions = do
throwInvalidCursor = parseError "the \"after\" or \"before\" cursor is invalid"
liftQErr = either (parseError . qeError) pure . runExcept
iResultToMaybe = \case
J.ISuccess v -> Just v
J.IError{} -> Nothing
getPathFromOrderBy = \case
RQL.AOCColumn pgColInfo ->
let pathElement = J.Key $ getPGColTxt $ pgiColumn pgColInfo

View File

@ -3,9 +3,9 @@
module Hasura.RQL.Types.Error
( Code(..)
, QErr(..)
, encodeJSONPath
, encodeQErr
, encodeGQLErr
, encodeJSONPath
, noInternalQErrEnc
, err400
, err404
@ -20,6 +20,8 @@ module Hasura.RQL.Types.Error
, throw500WithDetail
, throw401
, iResultToMaybe
-- Aeson helpers
, runAesonParser
, decodeValue
@ -335,6 +337,10 @@ liftIResult (IError path msg) =
liftIResult (ISuccess a) =
return a
iResultToMaybe :: IResult a -> Maybe a
iResultToMaybe (IError _ _) = Nothing
iResultToMaybe (ISuccess a) = Just a
formatMsg :: String -> String
formatMsg str = case T.splitOn "the key " txt of
[_, txt2] -> case T.splitOn " was not present" txt2 of

View File

@ -17,8 +17,9 @@ import qualified Hasura.GraphQL.Execute.LiveQuery.Options as LQ
data JWTInfo
= JWTInfo
{ jwtiClaimsNamespace :: !JWTConfigClaims
{ jwtiClaimsNamespace :: !JWTNamespace
, jwtiClaimsFormat :: !JWTClaimsFormat
, jwtiClaimsMap :: !(Maybe JWTCustomClaimsMap)
} deriving (Show, Eq)
$(deriveToJSON (aesonDrop 4 snakeCase) ''JWTInfo)
@ -65,8 +66,9 @@ isJWTSet = \case
getJWTInfo :: AuthMode -> Maybe JWTInfo
getJWTInfo (AMAdminSecretAndJWT _ jwtCtx _) =
Just $ JWTInfo claimsNs format
where
claimsNs = jcxClaimNs jwtCtx
format = jcxClaimsFormat jwtCtx
Just $ case jcxClaims jwtCtx of
JCNamespace namespace claimsFormat ->
JWTInfo namespace claimsFormat Nothing
JCMap claimsMap ->
JWTInfo (ClaimNs defaultClaimsNamespace) defaultClaimsFormat $ Just claimsMap
getJWTInfo _ = Nothing

View File

@ -158,8 +158,7 @@ setupAuthMode mAdminSecretHash mWebHook mJwtSecret mUnAuthRole httpManager logge
jwkRef <- case jcKeyOrUrl of
Left jwk -> liftIO $ newIORef (JWKSet [jwk])
Right url -> getJwkFromUrl url
let claimsFmt = fromMaybe JCFJson jcClaimsFormat
return $ JWTCtx jwkRef jcClaimNs jcAudience claimsFmt jcIssuer
return $ JWTCtx jwkRef jcAudience jcIssuer jcClaims
where
-- if we can't find any expiry time for the JWK (either in @Expires@ header or @Cache-Control@
-- header), do not start a background thread for refreshing the JWK
@ -244,7 +243,7 @@ getUserInfoWithExpTime_ userInfoFromAuthHook_ processJwt_ logger manager rawHead
mkUserInfo (URBFromSessionVariablesFallback adminRoleName)
adminSecretState sessionVariables
sessionVariables = mkSessionVariables rawHeaders
sessionVariables = mkSessionVariablesHeaders rawHeaders
checkingSecretIfSent
:: AdminSecretHash -> m (UserInfo, Maybe UTCTime) -> m (UserInfo, Maybe UTCTime)

View File

@ -6,16 +6,25 @@ module Hasura.Server.Auth.JWT
, JWTCtx (..)
, Jose.JWKSet (..)
, JWTClaimsFormat (..)
, JWTClaims(..)
, JwkFetchError (..)
, JWTConfigClaims (..)
, JWTNamespace (..)
, JWTCustomClaimsMapDefaultRole
, JWTCustomClaimsMapAllowedRoles
, JWTCustomClaimsMapValue
, ClaimsMap
, updateJwkRef
, jwkRefreshCtrl
, defaultClaimNs
, defaultClaimsFormat
, defaultClaimsNamespace
-- * Exposed for testing
, processJwt_
, allowedRolesClaim
, defaultRoleClaim
, parseClaimsMap
, JWTCustomClaimsMapValueG(..)
, JWTCustomClaimsMap(..)
) where
import Control.Exception.Lifted (try)
@ -23,7 +32,7 @@ import Control.Lens
import Control.Monad.Trans.Control (MonadBaseControl)
import Control.Monad.Trans.Maybe
import Data.IORef (IORef, readIORef, writeIORef)
import Data.Parser.JSONPath (parseJSONPath)
import Data.Time.Clock (NominalDiffTime, UTCTime, diffUTCTime,
getCurrentTime)
#ifndef PROFILING
@ -40,8 +49,7 @@ 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 (executeJSONPath, getRequestHeader,
isSessionVariable, userRoleHeader)
import Hasura.Server.Utils (executeJSONPath, getRequestHeader, userRoleHeader, isSessionVariable)
import Hasura.Server.Version (HasVersion)
import Hasura.Session
import qualified Hasura.Tracing as Tracing
@ -50,13 +58,11 @@ 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.Internal as J
import qualified Data.Aeson.TH as J
import qualified Data.ByteString.Lazy as BL
import qualified Data.ByteString.Lazy.Char8 as BLC
import qualified Data.CaseInsensitive as CI
import qualified Data.HashMap.Strict as Map
import qualified Data.Parser.JSONPath as JSONPath
import qualified Data.Text as T
import qualified Data.Text.Encoding as T
import qualified Network.HTTP.Client as HTTP
@ -73,23 +79,113 @@ data JWTClaimsFormat
$(J.deriveJSON J.defaultOptions { J.sumEncoding = J.ObjectWithSingleField
, J.constructorTagModifier = J.snakeCase . drop 3 } ''JWTClaimsFormat)
data JWTConfigClaims
defaultClaimsFormat :: JWTClaimsFormat
defaultClaimsFormat = JCFJson
allowedRolesClaim :: SessionVariable
allowedRolesClaim = mkSessionVariable "x-hasura-allowed-roles"
defaultRoleClaim :: SessionVariable
defaultRoleClaim = mkSessionVariable "x-hasura-default-role"
defaultClaimsNamespace :: Text
defaultClaimsNamespace = "https://hasura.io/jwt/claims"
-- | 'JWTCustomClaimsMapValueG' is used to represent a single value of
-- the 'JWTCustomClaimsMap'. A 'JWTCustomClaimsMapValueG' can either be
-- an JSON object or the literal value of the claim. If the value is an
-- JSON object, then it should contain a key `path`, which is the JSON path
-- to the claim value in the JWT token. There's also an option to specify a
-- default value in the map via the 'default' key, which will be used
-- when a peek at the JWT token using the JSON path fails (key does not exist).
data JWTCustomClaimsMapValueG v
= JWTCustomClaimsMapJSONPath !J.JSONPath !(Maybe v)
-- ^ JSONPath to the key in the claims map, in case
-- the key doesn't exist in the claims map then the default
-- value will be used (if provided)
| JWTCustomClaimsMapStatic !v
deriving (Show, Eq, Functor, Foldable, Traversable)
instance (J.FromJSON v) => J.FromJSON (JWTCustomClaimsMapValueG v) where
parseJSON (J.Object obj) = do
path <- obj J..: "path" >>= (either fail pure . parseJSONPath)
defaultVal <- obj J..:? "default" >>= traverse pure
pure $ JWTCustomClaimsMapJSONPath path defaultVal
parseJSON v = JWTCustomClaimsMapStatic <$> J.parseJSON v
instance (J.ToJSON v) => J.ToJSON (JWTCustomClaimsMapValueG v) where
toJSON (JWTCustomClaimsMapJSONPath jsonPath mDefVal) =
J.object $
[ "path" J..= encodeJSONPath jsonPath ]
<> [ "default" J..= defVal | Just defVal <- [mDefVal]]
toJSON (JWTCustomClaimsMapStatic v) = J.toJSON v
type JWTCustomClaimsMapDefaultRole = JWTCustomClaimsMapValueG RoleName
type JWTCustomClaimsMapAllowedRoles = JWTCustomClaimsMapValueG [RoleName]
-- Used to store other session variables like `x-hasura-user-id`
type JWTCustomClaimsMapValue = JWTCustomClaimsMapValueG SessionVariableValue
type CustomClaimsMap = Map.HashMap SessionVariable JWTCustomClaimsMapValue
-- | JWTClaimsMap is an option to provide a custom JWT claims map.
-- The JWTClaimsMap should be specified in the `HASURA_GRAPHQL_JWT_SECRET`
-- in the `claims_map`. The JWTClaimsMap, if specified, requires two
-- mandatory fields, namely, `x-hasura-allowed-roles` and the
-- `x-hasura-default-role`, other claims may also be provided in the claims map.
data JWTCustomClaimsMap
= JWTCustomClaimsMap
{ jcmDefaultRole :: !JWTCustomClaimsMapDefaultRole
, jcmAllowedRoles :: !JWTCustomClaimsMapAllowedRoles
, jcmCustomClaims :: !CustomClaimsMap
} deriving (Show,Eq)
instance J.ToJSON JWTCustomClaimsMap where
toJSON (JWTCustomClaimsMap defaultRole allowedRoles customClaims) =
J.Object $
Map.fromList $ [ (sessionVariableToText defaultRoleClaim, J.toJSON defaultRole)
, (sessionVariableToText allowedRolesClaim, J.toJSON allowedRoles)
]
<> map (sessionVariableToText *** J.toJSON) (Map.toList customClaims)
instance J.FromJSON JWTCustomClaimsMap where
parseJSON = J.withObject "JWTClaimsMap" $ \obj -> do
let withNotFoundError sessionVariable =
let errorMsg = T.unpack $
sessionVariableToText sessionVariable <> " is expected but not found"
in maybe (fail errorMsg) pure $ Map.lookup (sessionVariableToText sessionVariable) obj
allowedRoles <- withNotFoundError allowedRolesClaim >>= J.parseJSON
defaultRole <- withNotFoundError defaultRoleClaim >>= J.parseJSON
let filteredClaims = Map.delete allowedRolesClaim $ Map.delete defaultRoleClaim
$ Map.fromList $ map (first mkSessionVariable) $ Map.toList obj
customClaims <- flip Map.traverseWithKey filteredClaims $ const $ J.parseJSON
pure $ JWTCustomClaimsMap defaultRole allowedRoles customClaims
-- | JWTNamespace is used to locate the claims map within the JWT token.
-- The location can be either provided via a JSON path or the name of the
-- key in the JWT token.
data JWTNamespace
= ClaimNsPath JSONPath
| ClaimNs T.Text
deriving (Show, Eq)
instance J.ToJSON JWTConfigClaims where
instance J.ToJSON JWTNamespace where
toJSON (ClaimNsPath nsPath) = J.String . T.pack $ encodeJSONPath nsPath
toJSON (ClaimNs ns) = J.String ns
data JWTClaims
= JCNamespace !JWTNamespace !JWTClaimsFormat
| JCMap !JWTCustomClaimsMap
deriving (Show, Eq)
-- | The JWT configuration we got from the user.
data JWTConfig
= JWTConfig
{ jcKeyOrUrl :: !(Either Jose.JWK URI)
, jcClaimNs :: !JWTConfigClaims
, jcAudience :: !(Maybe Jose.Audience)
, jcClaimsFormat :: !(Maybe JWTClaimsFormat)
, jcIssuer :: !(Maybe Jose.StringOrURI)
{ jcKeyOrUrl :: !(Either Jose.JWK URI)
, jcAudience :: !(Maybe Jose.Audience)
, jcIssuer :: !(Maybe Jose.StringOrURI)
, jcClaims :: !JWTClaims
} deriving (Show, Eq)
-- | The validated runtime JWT configuration returned by 'mkJwtCtx' in 'setupAuthMode'.
@ -98,17 +194,16 @@ data JWTConfig
-- expiration schedule could be determined.
data JWTCtx
= JWTCtx
{ jcxKey :: !(IORef Jose.JWKSet)
{ jcxKey :: !(IORef Jose.JWKSet)
-- ^ This needs to be a mutable variable for 'updateJwkRef'.
, jcxClaimNs :: !JWTConfigClaims
, jcxAudience :: !(Maybe Jose.Audience)
, jcxClaimsFormat :: !JWTClaimsFormat
, jcxIssuer :: !(Maybe Jose.StringOrURI)
, jcxAudience :: !(Maybe Jose.Audience)
, jcxIssuer :: !(Maybe Jose.StringOrURI)
, jcxClaims :: !JWTClaims
} deriving (Eq)
instance Show JWTCtx where
show (JWTCtx _ nsM audM cf iss) =
show ["<IORef JWKSet>", show nsM,show audM, show cf, show iss]
show (JWTCtx _ audM iss claims) =
show ["<IORef JWKSet>", show audM, show iss, show claims]
data HasuraClaims
= HasuraClaims
@ -117,18 +212,6 @@ data HasuraClaims
} deriving (Show, Eq)
$(J.deriveJSON (J.aesonDrop 3 J.snakeCase) ''HasuraClaims)
-- NOTE: these must stay lowercase; TODO(from master) consider using "Data.CaseInsensitive"
allowedRolesClaim :: T.Text
allowedRolesClaim = "x-hasura-allowed-roles"
defaultRoleClaim :: T.Text
defaultRoleClaim = "x-hasura-default-role"
defaultClaimNs :: T.Text
defaultClaimNs = "https://hasura.io/jwt/claims"
-- | An action that refreshes the JWK at intervals in an infinite loop.
jwkRefreshCtrl
:: (HasVersion, MonadIO m, MonadBaseControl IO m, Tracing.HasReporter m)
@ -145,7 +228,7 @@ jwkRefreshCtrl logger manager url ref time = do
mTime <- either (const $ logNotice >> return Nothing) return res
-- if can't parse time from header, defaults to 1 min
-- let delay = maybe (minutes 1) fromUnits mTime
let delay = maybe (minutes 1) (convertDuration) mTime
let delay = maybe (minutes 1) convertDuration mTime
liftIO $ C.sleep delay
where
logNotice = do
@ -231,6 +314,7 @@ updateJwkRef (Logger logger) manager url jwkRef = do
HTTP.HttpExceptionRequest _ reason -> show reason
HTTP.InvalidUrlException _ reason -> show reason
type ClaimsMap = Map.HashMap SessionVariable J.Value
-- | Process the request headers to verify the JWT and extract UserInfo from it
--
@ -254,7 +338,7 @@ processJwt = processJwt_ processAuthZHeader
-- Broken out for testing with mocks:
processJwt_
:: (MonadError QErr m)
=> (_JWTCtx -> BLC.ByteString -> m (J.Object, Maybe UTCTime))
=> (_JWTCtx -> BLC.ByteString -> m (ClaimsMap, Maybe UTCTime))
-- ^ mock 'processAuthZHeader'
-> _JWTCtx
-> HTTP.RequestHeaders
@ -266,12 +350,7 @@ processJwt_ processAuthZHeader_ jwtCtx headers mUnAuthRole =
mAuthZHeader = find (\h -> fst h == CI.mk "Authorization") headers
withAuthZHeader (_, authzHeader) = do
(hasuraClaims, expTimeM) <- processAuthZHeader_ jwtCtx $ BL.fromStrict authzHeader
-- filter only x-hasura claims and convert to lower-case
let claimsMap = Map.filterWithKey (\k _ -> isSessionVariable k)
$ Map.fromList $ map (first T.toLower)
$ Map.toList hasuraClaims
(claimsMap, expTimeM) <- processAuthZHeader_ jwtCtx $ BL.fromStrict authzHeader
HasuraClaims allowedRoles defaultRole <- parseHasuraClaims claimsMap
-- see if there is a x-hasura-role header, or else pick the default role.
@ -285,7 +364,8 @@ processJwt_ processAuthZHeader_ jwtCtx headers mUnAuthRole =
Map.delete defaultRoleClaim . Map.delete allowedRolesClaim $ claimsMap
-- transform the map of text:aeson-value -> text:text
metadata <- parseJwtClaim (J.Object finalClaims) "x-hasura-* claims"
let finalClaimsObject = Map.fromList . map (first sessionVariableToText) . Map.toList $ finalClaims
metadata <- parseJwtClaim (J.Object $ finalClaimsObject) "x-hasura-* claims"
userInfo <- mkUserInfo (URBPreDetermined requestedRole) UAdminSecretNotSent $
mkSessionVariablesText $ Map.toList metadata
pure (userInfo, expTimeM)
@ -293,7 +373,7 @@ processJwt_ processAuthZHeader_ jwtCtx headers mUnAuthRole =
withoutAuthZHeader = do
unAuthRole <- maybe missingAuthzHeader return mUnAuthRole
userInfo <- mkUserInfo (URBPreDetermined unAuthRole) UAdminSecretNotSent $
mkSessionVariables headers
mkSessionVariablesHeaders headers
pure (userInfo, Nothing)
where
@ -307,8 +387,8 @@ processAuthZHeader
, MonadError QErr m)
=> JWTCtx
-> BLC.ByteString
-> m (J.Object, Maybe UTCTime)
processAuthZHeader jwtCtx@JWTCtx{jcxClaimNs, jcxClaimsFormat} authzHeader = do
-> m (ClaimsMap, Maybe UTCTime)
processAuthZHeader jwtCtx authzHeader = do
-- try to parse JWT token from Authorization header
jwt <- parseAuthzHeader
@ -316,51 +396,20 @@ processAuthZHeader jwtCtx@JWTCtx{jcxClaimNs, jcxClaimsFormat} authzHeader = do
claims <- liftJWTError invalidJWTError $ verifyJwt jwtCtx $ RawJWT jwt
let expTimeM = fmap (\(Jose.NumericDate t) -> t) $ claims ^. Jose.claimExp
unregisteredClaims = claims ^. Jose.unregisteredClaims
-- see if the hasura claims key exists in the claims map
let mHasuraClaims =
case jcxClaimNs of
ClaimNs k -> Map.lookup k $ claims ^. Jose.unregisteredClaims
ClaimNsPath path -> parseIValueJsonValue $ executeJSONPath path (J.toJSON $ claims ^. Jose.unregisteredClaims)
claimsObject <- parseClaimsMap unregisteredClaims claimsConfig
hasuraClaimsV <- maybe claimsNotFound return mHasuraClaims
-- return hasura claims value as an object. parse from string possibly
(, expTimeM) <$> parseObjectFromString hasuraClaimsV
pure $ (claimsObject, expTimeM)
where
claimsConfig = jcxClaims jwtCtx
parseAuthzHeader = do
let tokenParts = BLC.words authzHeader
case tokenParts of
["Bearer", jwt] -> return jwt
_ -> malformedAuthzHeader
parseObjectFromString jVal =
case (jcxClaimsFormat, jVal) of
(JCFStringifiedJson, J.String v) ->
either (const $ claimsErr $ strngfyErr v) return
$ J.eitherDecodeStrict $ T.encodeUtf8 v
(JCFStringifiedJson, _) ->
claimsErr "expecting a string when claims_format is stringified_json"
(JCFJson, J.Object o) -> return o
(JCFJson, _) ->
claimsErr "expecting a json object when claims_format is json"
strngfyErr v =
"expecting stringified json at: '"
<> claimsLocation
<> "', but found: " <> v
where
claimsLocation :: Text
claimsLocation =
case jcxClaimNs 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
liftJWTError :: (MonadError e' m) => (e -> e') -> ExceptT e m a -> m a
liftJWTError ef action = do
res <- runExceptT action
@ -371,28 +420,100 @@ processAuthZHeader jwtCtx@JWTCtx{jcxClaimNs, jcxClaimsFormat} authzHeader = do
malformedAuthzHeader =
throw400 InvalidHeaders "Malformed Authorization header"
claimsNotFound = do
let claimsNsError = case jcxClaimNs 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 the claims map from the JWT token or custom claims from the JWT config
parseClaimsMap
:: (MonadError QErr m)
=> J.Object -- ^ Unregistered JWT claims
-> JWTClaims -- ^ Claims config
-> m ClaimsMap -- ^ Hasura claims and other claims
parseClaimsMap unregisteredClaims jcxClaims =
case jcxClaims of
JCNamespace namespace claimsFormat -> do
claimsV <- maybe (claimsNotFound namespace) pure $ case namespace of
ClaimNs k -> Map.lookup k unregisteredClaims
ClaimNsPath path -> iResultToMaybe $ executeJSONPath path (J.toJSON unregisteredClaims)
-- get hasura claims value as an object. parse from string possibly
claimsObject <- parseObjectFromString namespace claimsFormat claimsV
-- parse x-hasura-allowed-roles, x-hasura-default-role from JWT claims
parseHasuraClaims :: forall m. (MonadError QErr m) => J.Object -> m HasuraClaims
parseHasuraClaims claimsMap = do
HasuraClaims <$>
parseClaim allowedRolesClaim "should be a list of roles" <*>
parseClaim defaultRoleClaim "should be a single role name"
-- filter only x-hasura claims
let claimsMap = Map.fromList
$ map (first mkSessionVariable)
$ filter (\(k, _) -> isSessionVariable k)
$ Map.toList claimsObject
pure claimsMap
JCMap claimsConfig -> do
let JWTCustomClaimsMap defaultRoleClaimsMap allowedRolesClaimsMap otherClaimsMap = claimsConfig
claimsObjValue = J.Object unregisteredClaims
allowedRoles <- case allowedRolesClaimsMap of
JWTCustomClaimsMapJSONPath allowedRolesJsonPath defaultVal ->
parseAllowedRolesClaim defaultVal $ iResultToMaybe $ executeJSONPath allowedRolesJsonPath claimsObjValue
JWTCustomClaimsMapStatic staticAllowedRoles -> pure staticAllowedRoles
defaultRole <- case defaultRoleClaimsMap of
JWTCustomClaimsMapJSONPath defaultRoleJsonPath defaultVal ->
parseDefaultRoleClaim defaultVal $ iResultToMaybe $
executeJSONPath defaultRoleJsonPath claimsObjValue
JWTCustomClaimsMapStatic staticDefaultRole -> pure staticDefaultRole
otherClaims <- flip Map.traverseWithKey otherClaimsMap $ \k claimObj -> do
let throwClaimErr = throw400 JWTInvalidClaims $ "JWT claim from claims_map, "
<> sessionVariableToText k <> " not found"
case claimObj of
JWTCustomClaimsMapJSONPath path defaultVal ->
maybe (onNothing (J.String <$> defaultVal) throwClaimErr) pure
$ iResultToMaybe $ executeJSONPath path claimsObjValue
JWTCustomClaimsMapStatic claimStaticValue -> pure $ J.String claimStaticValue
pure $ Map.fromList [
(allowedRolesClaim, J.toJSON allowedRoles),
(defaultRoleClaim, J.toJSON defaultRole)
] <> otherClaims
where
parseClaim :: J.FromJSON a => Text -> Text -> m a
parseClaim claim hint = do
claimV <- maybe missingClaim return $ Map.lookup claim claimsMap
parseJwtClaim claimV $ "invalid " <> claim <> "; " <> hint
parseAllowedRolesClaim defaultVal = \case
Nothing ->
onNothing defaultVal $
throw400 JWTRoleClaimMissing $ "JWT claim does not contain " <> sessionVariableToText allowedRolesClaim
Just v -> parseJwtClaim v $ "invalid " <> sessionVariableToText allowedRolesClaim <>
"; should be a list of roles"
parseDefaultRoleClaim defaultVal = \case
Nothing ->
onNothing defaultVal $
throw400 JWTRoleClaimMissing $ "JWT claim does not contain " <> sessionVariableToText defaultRoleClaim
Just v -> parseJwtClaim v $ "invalid " <> sessionVariableToText defaultRoleClaim <>
"; should be a role"
claimsNotFound namespace =
throw400 JWTInvalidClaims $ case namespace of
ClaimNsPath path -> T.pack $ "claims not found at claims_namespace_path: '"
<> encodeJSONPath path <> "'"
ClaimNs ns -> "claims key: '" <> ns <> "' not found"
parseObjectFromString namespace claimsFmt jVal =
case (claimsFmt, jVal) of
(JCFStringifiedJson, J.String v) ->
either (const $ claimsErr $ strngfyErr v) return
$ J.eitherDecodeStrict $ T.encodeUtf8 v
(JCFStringifiedJson, _) ->
claimsErr "expecting a string when claims_format is stringified_json"
(JCFJson, J.Object o) -> return o
(JCFJson, _) ->
claimsErr "expecting a json object when claims_format is json"
where
missingClaim = throw400 JWTRoleClaimMissing $ "JWT claim does not contain " <> claim
strngfyErr v =
let claimsLocation = case namespace of
ClaimNsPath path -> T.pack $ "claims_namespace_path " <> encodeJSONPath path
ClaimNs ns -> "claims_namespace " <> ns
in "expecting stringified json at: '"
<> claimsLocation
<> "', but found: " <> v
claimsErr = throw400 JWTInvalidClaims
-- | Verify the JWT against given JWK
verifyJwt
@ -420,23 +541,24 @@ verifyJwt ctx (RawJWT rawJWT) = do
instance J.ToJSON JWTConfig where
toJSON (JWTConfig keyOrUrl claimNs aud claimsFmt iss) =
J.object (jwkFields ++ sharedFields ++ claimsNsFields)
where
jwkFields = case keyOrUrl of
Left _ -> [ "type" J..= J.String "<TYPE REDACTED>"
, "key" J..= J.String "<JWK REDACTED>" ]
Right url -> [ "jwk_url" J..= url ]
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
]
toJSON (JWTConfig keyOrUrl aud iss claims) =
let keyOrUrlPairs = case keyOrUrl of
Left _ -> [ "type" J..= J.String "<TYPE REDACTED>"
, "key" J..= J.String "<JWK REDACTED>"
]
Right url -> ["jwk_url" J..= url]
claimsPairs = case claims of
JCNamespace namespace claimsFormat ->
let namespacePairs = case namespace of
ClaimNsPath nsPath ->
["claims_namespace_path" J..= encodeJSONPath nsPath]
ClaimNs ns -> ["claims_namespace" J..= J.String ns]
in namespacePairs <> ["claims_format" J..= claimsFormat]
JCMap claimsMap -> ["claims_map" J..= claimsMap]
in J.object $ keyOrUrlPairs <>
[ "audience" J..= aud
, "issuer" J..= iss
] <> claimsPairs
-- | Parse from a json string like:
-- | `{"type": "RS256", "key": "<PEM-encoded-public-key-or-X509-cert>"}`
@ -450,25 +572,27 @@ instance J.FromJSON JWTConfig where
aud <- o J..:? "audience"
iss <- o J..:? "issuer"
jwkUrl <- o J..:? "jwk_url"
isStrngfd <- o J..:? "claims_format"
claimsFormat <- o J..:? "claims_format" J..!= defaultClaimsFormat
claimsMap <- o J..:? "claims_map"
hasuraClaimsNs <-
case (claimsNsPath,claimsNs) of
(Nothing, Nothing) -> return $ ClaimNs defaultClaimNs
(Just nsPath, Nothing) -> either failJSONPathParsing (return . ClaimNsPath) . JSONPath.parseJSONPath $ nsPath
(Nothing, Nothing) -> pure $ ClaimNs defaultClaimsNamespace
(Just nsPath, Nothing) -> either failJSONPathParsing (return . ClaimNsPath) . 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
keyOrUrl <- 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 keyType rawKey
return $ JWTConfig (Left key) hasuraClaimsNs aud isStrngfd iss
(Nothing, Just url) ->
return $ JWTConfig (Right url) hasuraClaimsNs aud isStrngfd iss
pure $ Left key
(Nothing, Just url) -> pure $ Right url
pure $ JWTConfig keyOrUrl aud iss $
maybe (JCNamespace hasuraClaimsNs claimsFormat) JCMap claimsMap
where
parseKey keyType rawKey =
@ -488,6 +612,22 @@ instance J.FromJSON JWTConfig where
failJSONPathParsing err = fail $ "invalid JSON path claims_namespace_path error: " ++ err
-- parse x-hasura-allowed-roles, x-hasura-default-role from JWT claims
parseHasuraClaims :: forall m. (MonadError QErr m) => ClaimsMap -> m HasuraClaims
parseHasuraClaims claimsMap = do
HasuraClaims <$>
parseClaim allowedRolesClaim "should be a list of roles" <*>
parseClaim defaultRoleClaim "should be a single role name"
where
parseClaim :: J.FromJSON a => SessionVariable -> Text -> m a
parseClaim claim hint = do
claimV <- maybe missingClaim return $ Map.lookup claim claimsMap
parseJwtClaim claimV $ "invalid " <> claimText <> "; " <> hint
where
missingClaim = throw400 JWTRoleClaimMissing $ "JWT claim does not contain " <> claimText
claimText = sessionVariableToText claim
-- Utility:
parseJwtClaim :: (J.FromJSON a, MonadError QErr m) => J.Value -> Text -> m a
parseJwtClaim v errMsg =

View File

@ -5,12 +5,12 @@ module Hasura.Session
, isAdmin
, roleNameToTxt
, SessionVariable
, SessionVariableValue
, mkSessionVariable
, SessionVariables
, SessionVariableValue
, sessionVariableToText
, mkSessionVariablesText
, mkSessionVariables
, mkSessionVariablesHeaders
, sessionVariablesToHeaders
, getSessionVariableValue
, getSessionVariablesSet
@ -24,7 +24,6 @@ module Hasura.Session
, mkUserInfo
, adminUserInfo
, BackendOnlyFieldAccess(..)
, userInfoToList
) where
import Hasura.Incremental (Cacheable)
@ -36,6 +35,7 @@ import Hasura.Server.Utils
import Hasura.SQL.Types
import Data.Aeson
import Data.Aeson.Types (Parser, toJSONKeyText)
import Instances.TH.Lift ()
import Language.Haskell.TH.Syntax (Lift)
@ -47,7 +47,7 @@ import qualified Database.PG.Query as Q
import qualified Network.HTTP.Types as HTTP
newtype RoleName
= RoleName { getRoleTxt :: NonEmptyText }
= RoleName {getRoleTxt :: NonEmptyText}
deriving ( Show, Eq, Ord, Hashable, FromJSONKey, ToJSONKey, FromJSON
, ToJSON, Q.FromCol, Q.ToPrepArg, Lift, Generic, Arbitrary, NFData, Cacheable )
@ -72,6 +72,20 @@ newtype SessionVariable = SessionVariable {unSessionVariable :: CI.CI Text}
instance ToJSON SessionVariable where
toJSON = toJSON . CI.original . unSessionVariable
instance ToJSONKey SessionVariable where
toJSONKey = toJSONKeyText sessionVariableToText
parseSessionVariable :: Text -> Parser SessionVariable
parseSessionVariable t =
if isSessionVariable t then pure $ mkSessionVariable t
else fail $ show t <> " is not a Hasura session variable"
instance FromJSON SessionVariable where
parseJSON = withText "String" parseSessionVariable
instance FromJSONKey SessionVariable where
fromJSONKey = FromJSONKeyTextParser parseSessionVariable
sessionVariableToText :: SessionVariable -> Text
sessionVariableToText = T.toLower . CI.original . unSessionVariable
@ -95,8 +109,8 @@ mkSessionVariablesText :: [(Text, Text)] -> SessionVariables
mkSessionVariablesText =
SessionVariables . Map.fromList . map (first mkSessionVariable)
mkSessionVariables :: [HTTP.Header] -> SessionVariables
mkSessionVariables =
mkSessionVariablesHeaders :: [HTTP.Header] -> SessionVariables
mkSessionVariablesHeaders =
SessionVariables
. Map.fromList
. map (first SessionVariable)
@ -202,9 +216,3 @@ maybeRoleFromSessionVariables sessionVariables =
adminUserInfo :: UserInfo
adminUserInfo = UserInfo adminRoleName mempty BOFADisallowed
userInfoToList :: UserInfo -> [(Text, Text)]
userInfoToList userInfo =
let vars = map (first sessionVariableToText) $ Map.toList $ unSessionVariables . _uiSession $ userInfo
rn = roleNameToTxt . _uiRole $ userInfo
in (sessionVariableToText userRoleHeader, rn) : vars

View File

@ -10,6 +10,8 @@ import Control.Monad.Trans.Control
import qualified Crypto.JOSE.JWK as Jose
import Data.Aeson ((.=))
import qualified Data.Aeson as J
import Data.Parser.JSONPath
import qualified Data.HashMap.Strict as Map
import qualified Network.HTTP.Types as N
import Hasura.RQL.Types
@ -24,6 +26,13 @@ spec :: Spec
spec = do
getUserInfoWithExpTimeTests
setupAuthModeTests
parseClaimsMapTests
allowedRolesClaimText :: Text
allowedRolesClaimText = sessionVariableToText allowedRolesClaim
defaultRoleClaimText :: Text
defaultRoleClaimText = sessionVariableToText defaultRoleClaim
-- Unit test the core of our authentication code. This doesn't test the details
-- of resolving roles from JWT or webhook.
@ -50,10 +59,12 @@ getUserInfoWithExpTimeTests = describe "getUserInfo" $ do
_UserInfo nm =
mkUserInfo (URBFromSessionVariablesFallback $ mkRoleNameE nm)
UAdminSecretNotSent
(mkSessionVariables mempty)
(mkSessionVariablesHeaders mempty)
processJwt = processJwt_ $
-- processAuthZHeader:
\_jwtCtx _authzHeader -> return (claims , Nothing)
\_jwtCtx _authzHeader -> return (claimsObjToClaimsMap claims , Nothing)
where
claimsObjToClaimsMap = Map.fromList . map (first mkSessionVariable) . Map.toList
let setupAuthMode'E a b c d =
either (const $ error "fixme") id <$> setupAuthMode' a b c d
@ -272,18 +283,18 @@ getUserInfoWithExpTimeTests = describe "getUserInfo" $ do
\(mode, modeMsg) -> describe modeMsg $ do
it "authorizes successfully with JWT when requested role allowed" $ do
let claim = unObject [ allowedRolesClaim .= (["editor","user", "mod"] :: [Text])
, defaultRoleClaim .= ("user" :: Text)
let claim = unObject [ allowedRolesClaimText .= (["editor","user", "mod"] :: [Text])
, defaultRoleClaimText .= ("user" :: Text)
]
getUserInfoWithExpTime claim [("Authorization", "IGNORED"), (userRoleHeader, "editor")] mode
`shouldReturn` Right (mkRoleNameE "editor")
-- Uses the defaultRoleClaim:
-- Uses the defaultRoleClaimText:
getUserInfoWithExpTime claim [("Authorization", "IGNORED")] mode
`shouldReturn` Right (mkRoleNameE "user")
it "rejects when requested role is not allowed" $ do
let claim = unObject [ allowedRolesClaim .= (["editor","user", "mod"] :: [Text])
, defaultRoleClaim .= ("user" :: Text)
let claim = unObject [ allowedRolesClaimText .= (["editor","user", "mod"] :: [Text])
, defaultRoleClaimText .= ("user" :: Text)
]
getUserInfoWithExpTime claim [("Authorization", "IGNORED"), (userRoleHeader, "r00t")] mode
`shouldReturn` Left AccessDenied
@ -291,8 +302,8 @@ getUserInfoWithExpTimeTests = describe "getUserInfo" $ do
`shouldReturn` Left AccessDenied
-- A corner case, but the behavior seems desirable:
it "always rejects when token has empty allowedRolesClaim" $ do
let claim = unObject [ allowedRolesClaim .= ([] :: [Text]), defaultRoleClaim .= ("user" :: Text) ]
it "always rejects when token has empty allowedRolesClaimText" $ do
let claim = unObject [ allowedRolesClaimText .= ([] :: [Text]), defaultRoleClaimText .= ("user" :: Text) ]
getUserInfoWithExpTime claim [("Authorization", "IGNORED"), (userRoleHeader, "admin")] mode
`shouldReturn` Left AccessDenied
getUserInfoWithExpTime claim [("Authorization", "IGNORED"), (userRoleHeader, "user")] mode
@ -300,9 +311,9 @@ getUserInfoWithExpTimeTests = describe "getUserInfo" $ do
getUserInfoWithExpTime claim [("Authorization", "IGNORED")] mode
`shouldReturn` Left AccessDenied
it "rejects when token doesn't have proper allowedRolesClaim and defaultRoleClaim" $ do
let claim0 = unObject [ allowedRolesClaim .= (["editor","user", "mod"] :: [Text]) ]
claim1 = unObject [ defaultRoleClaim .= ("user" :: Text) ]
it "rejects when token doesn't have proper allowedRolesClaimText and defaultRoleClaimText" $ do
let claim0 = unObject [ allowedRolesClaimText .= (["editor","user", "mod"] :: [Text]) ]
claim1 = unObject [ defaultRoleClaimText .= ("user" :: Text) ]
claim2 = unObject []
for_ [claim0, claim1, claim2] $ \claim ->
getUserInfoWithExpTime claim [("Authorization", "IGNORED")] mode
@ -381,13 +392,172 @@ setupAuthModeTests = describe "setupAuthMode" $ do
setupAuthMode' (Just secret) (Just fakeAuthHook) (Just fakeJWTConfig) (Just unauthRole)
`shouldReturn` Left ()
parseClaimsMapTests :: Spec
parseClaimsMapTests = describe "parseClaimMapTests" $ do
let
parseClaimsMap_
:: J.Object
-> JWTClaims
-> IO (Either Code ClaimsMap)
parseClaimsMap_ obj claims =
runExceptT $ withExceptT qeCode $ parseClaimsMap obj claims
unObject l = case J.object l of
J.Object o -> o
_ -> error "Impossible!"
defaultClaimsMap =
Map.fromList
[ (allowedRolesClaim, J.toJSON (map mkRoleNameE ["user","editor"]))
, (defaultRoleClaim, J.toJSON (mkRoleNameE "user"))]
describe "JWT configured with namespace" $ do
describe "JWT configured with namespace key, the key is a text value which is expected to be at the root of the JWT token" $ do
it "parses claims map from the JWT token with correct namespace " $ do
let claimsObj = unObject $
[ "x-hasura-allowed-roles" .= (["user","editor"] :: [Text])
, "x-hasura-default-role" .= ("user" :: Text)
]
let obj = (unObject $ ["claims_map" .= claimsObj])
parseClaimsMap_ obj (JCNamespace (ClaimNs "claims_map") defaultClaimsFormat)
`shouldReturn`
Right defaultClaimsMap
it "doesn't parse claims map from the JWT token with wrong namespace " $ do
let claimsObj = unObject $
[ "x-hasura-allowed-roles" .= (["user","editor"] :: [Text])
, "x-hasura-default-role" .= ("user" :: Text)
]
let obj = (unObject $ ["claims_map" .= claimsObj])
parseClaimsMap_ obj (JCNamespace (ClaimNs "wrong_claims_map") defaultClaimsFormat)
`shouldReturn`
Left JWTInvalidClaims
describe "JWT configured with namespace JSON path, JSON path to the claims map" $ do
it "parse claims map from the JWT token using claims namespace JSON Path" $ do
let obj = unObject $
[ "x-hasura-allowed-roles" .= (["user","editor"] :: [Text])
, "x-hasura-default-role" .= ("user" :: Text)
, "sub" .= ("random" :: Text)
, "exp" .= (1626420800 :: Int) -- we ignore these non session variables, in the response
]
parseClaimsMap_ obj (JCNamespace (ClaimNsPath (mkJSONPathE "$")) defaultClaimsFormat)
-- "$" JSON path signifies the claims are to be found in the root of the JWT token
`shouldReturn`
Right defaultClaimsMap
it "throws error while attempting to parse claims map from the JWT token with a wrong namespace JSON Path" $ do
let claimsObj = unObject $
[ "x-hasura-allowed-roles" .= (["user","editor"] :: [Text])
, "x-hasura-default-role" .= ("user" :: Text)
]
obj = unObject $ [ "hasura_claims" .= claimsObj ]
parseClaimsMap_ obj (JCNamespace (ClaimNsPath (mkJSONPathE "$.claims")) defaultClaimsFormat)
`shouldReturn`
Left JWTInvalidClaims
describe "JWT configured with custom JWT claims" $ do
let rolesObj = unObject $
[ "allowed" .= (["user","editor"] :: [Text])
, "default" .= ("user" :: Text)
]
userId = unObject [ "id" .= ("1" :: Text)]
obj = unObject $ [ "roles" .= rolesObj
, "user" .= userId
]
userIdClaim = mkSessionVariable "x-hasura-user-id"
describe "custom claims with JSON paths to the claim location in the JWT token" $ do
it "parse custom claims values, with correct values" $ do
let customDefRoleClaim = mkCustomDefaultRoleClaim (Just "$.roles.default") Nothing
customAllowedRolesClaim = mkCustomAllowedRoleClaim (Just "$.roles.allowed") Nothing
otherClaims = Map.fromList
[(userIdClaim, (mkCustomOtherClaim (Just "$.user.id") Nothing))]
customClaimsMap = JWTCustomClaimsMap customDefRoleClaim customAllowedRolesClaim otherClaims
parseClaimsMap_ obj (JCMap customClaimsMap)
`shouldReturn`
Right (Map.fromList
[ (allowedRolesClaim, J.toJSON (map mkRoleNameE ["user","editor"]))
, (defaultRoleClaim, J.toJSON (mkRoleNameE "user"))
, (userIdClaim, J.String "1")
])
it "throws error when a specified custom claim value is missing" $ do
let customDefRoleClaim = mkCustomDefaultRoleClaim (Just "$.roles.wrong_default") Nothing -- wrong path provided
customAllowedRolesClaim = mkCustomAllowedRoleClaim (Just "$.roles.allowed") Nothing
customClaimsMap = JWTCustomClaimsMap customDefRoleClaim customAllowedRolesClaim mempty
parseClaimsMap_ obj (JCMap customClaimsMap)
`shouldReturn`
Left JWTRoleClaimMissing
it "doesn't throw an error when the specified custom claim is missing, but the default value is provided" $ do
let customDefRoleClaim = mkCustomDefaultRoleClaim (Just "$.roles.wrong_default") (Just "editor")
customAllowedRolesClaim = mkCustomAllowedRoleClaim (Just "$.roles.allowed") Nothing
customClaimsMap = JWTCustomClaimsMap customDefRoleClaim customAllowedRolesClaim mempty
parseClaimsMap_ obj (JCMap customClaimsMap)
`shouldReturn`
Right (Map.fromList
[ (allowedRolesClaim, J.toJSON (map mkRoleNameE ["user","editor"]))
, (defaultRoleClaim, J.toJSON (mkRoleNameE "editor"))
])
describe "custom claims with literal values" $ do
it "uses the literal custom claim value" $ do
let customDefRoleClaim = mkCustomDefaultRoleClaim Nothing (Just "editor")
customAllowedRolesClaim = mkCustomAllowedRoleClaim Nothing (Just ["user", "editor"])
customClaimsMap = JWTCustomClaimsMap customDefRoleClaim customAllowedRolesClaim mempty
parseClaimsMap_ mempty (JCMap customClaimsMap)
`shouldReturn`
Right (Map.fromList
[ (allowedRolesClaim, J.toJSON (map mkRoleNameE ["user","editor"]))
, (defaultRoleClaim, J.toJSON (mkRoleNameE "editor"))
])
mkCustomDefaultRoleClaim :: (Maybe Text) -> (Maybe Text) -> JWTCustomClaimsMapDefaultRole
mkCustomDefaultRoleClaim claimPath defVal =
-- check if claimPath is provided, if not then use the default value
-- as the literal value by removing the `Maybe` of defVal
case claimPath of
Just path -> JWTCustomClaimsMapJSONPath (mkJSONPathE path) $ defRoleName
Nothing -> JWTCustomClaimsMapStatic $ maybe (mkRoleNameE "user") id defRoleName
where
defRoleName = mkRoleNameE <$> defVal
mkCustomAllowedRoleClaim :: (Maybe Text) -> (Maybe [Text]) -> JWTCustomClaimsMapAllowedRoles
mkCustomAllowedRoleClaim claimPath defVal =
-- check if claimPath is provided, if not then use the default value
-- as the literal value by removing the `Maybe` of defVal
case claimPath of
Just path -> JWTCustomClaimsMapJSONPath (mkJSONPathE path) $ defAllowedRoles
Nothing ->
JWTCustomClaimsMapStatic $
maybe (fmap mkRoleNameE $ ["user", "editor"]) id defAllowedRoles
where
defAllowedRoles = fmap mkRoleNameE <$> defVal
-- use for claims other than `x-hasura-default-role` and `x-hasura-allowed-roles`
mkCustomOtherClaim :: (Maybe Text) -> (Maybe Text) -> JWTCustomClaimsMapValue
mkCustomOtherClaim claimPath defVal =
-- check if claimPath is provided, if not then use the default value
-- as the literal value by removing the `Maybe` of defVal
case claimPath of
Just path -> JWTCustomClaimsMapJSONPath (mkJSONPathE path) $ defVal
Nothing -> JWTCustomClaimsMapStatic $ maybe "default claim value" id defVal
fakeJWTConfig :: JWTConfig
fakeJWTConfig =
let jcKeyOrUrl = Left (Jose.fromOctets [])
jcClaimNs = ClaimNs ""
jcAudience = Nothing
jcClaimsFormat = Nothing
jcIssuer = Nothing
jcClaims = JCNamespace (ClaimNs "") JCFJson
in JWTConfig{..}
fakeAuthHook :: AuthHook
@ -396,6 +566,10 @@ fakeAuthHook = AuthHookG "http://fake" AHTGet
mkRoleNameE :: Text -> RoleName
mkRoleNameE = fromMaybe (error "fixme") . mkRoleName
mkJSONPathE :: Text -> JSONPath
mkJSONPathE = either error id . parseJSONPath
newtype NoReporter a = NoReporter { runNoReporter :: IO a }
deriving newtype (Functor, Applicative, Monad, MonadIO, MonadBase IO, MonadBaseControl IO)
@ -412,7 +586,7 @@ setupAuthMode' mAdminSecretHash mWebHook mJwtSecret mUnAuthRole =
-- just throw away the error message for ease of testing:
fmap (either (const $ Left ()) Right)
$ runNoReporter
$ runExceptT
$ runExceptT
$ setupAuthMode mAdminSecretHash mWebHook mJwtSecret mUnAuthRole
-- NOTE: this won't do any http or launch threads if we don't specify JWT URL:
(error "H.Manager") (Logger $ void . return)

View File

@ -62,7 +62,7 @@ unitSpecs :: Spec
unitSpecs = do
describe "Data.Parser.CacheControl" CacheControlParser.spec
describe "Data.Parser.URLTemplate" URLTemplate.spec
describe "Data.Parser.JsonPath" JsonPath.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

View File

@ -0,0 +1,289 @@
import pytest
import jwt
import math
import ruamel.yaml as yaml
import json
from validate import check_query
from datetime import datetime, timedelta
from context import PytestConf
if not PytestConf.config.getoption('--hge-jwt-key-file'):
pytest.skip('--hge-jwt-key-file is missing, skipping JWT tests', allow_module_level=True)
hge_jwt_conf = PytestConf.config.getoption('--hge-jwt-conf')
if not hge_jwt_conf:
pytest.skip('--hge-jwt-key-conf is missing, skipping JWT tests', allow_module_level=True)
if 'claims_map' not in hge_jwt_conf:
pytest.skip('cliams_map missing in jwt config, skipping JWT Claims Map tests', allow_module_level=True)
# The following claims_map is assumed to be set
# {
# "claims_map": {
# "x-hasura-user-id": {"path":"$.['https://myapp.com/jwt/claims'].user.id"}
# "x-hasura-allowed-roles": {"$.['https://myapp.com/jwt/claims'].role.allowed","default":["user","editor"]}
# "x-hasura-default-role": {"$.['https://myapp.com/jwt/claims'].role.default","default":"user"}
# }
# }
def clean_null_terms(d):
clean = {}
for k, v in d.items():
if isinstance(v, dict):
nested = clean_null_terms(v)
if len(nested.keys()) > 0:
clean[k] = nested
elif v is not None:
clean[k] = v
return clean
# TestJWTClaimsMapBasic will be called using two different JWT configs
# one with default values and the other without default values. The
# default values here is referred to the default value that's being
# used when a value is not found while looking up the JWT token using
# the JSON Path provided
@pytest.mark.parametrize('endpoint', ['/v1/graphql', '/v1alpha1/graphql'])
class TestJWTClaimsMapBasic():
def mk_claims(self, user_id=None, allowed_roles=None, default_role=None):
self.claims['https://myapp.com/jwt/claims'] = clean_null_terms({
'user': {
'id': user_id
},
'role': {
'allowed': allowed_roles,
'default': default_role
}
})
def test_jwt_claims_map_valid_claims_success(self, hge_ctx, endpoint):
self.mk_claims('1', ['user', 'editor'], 'user')
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)
def test_jwt_claims_map_invalid_role_in_request_header(self, hge_ctx, endpoint):
self.mk_claims('1', ['contractor', 'editor'], 'contractor')
token = jwt.encode(self.claims, hge_ctx.hge_jwt_key, algorithm='RS512').decode('utf-8')
self.conf['headers']['Authorization'] = 'Bearer ' + token
self.conf['response'] = {
'errors': [{
'extensions': {
'code': 'access-denied',
'path': '$'
},
'message': 'Your requested role is not in allowed roles'
}]
}
self.conf['url'] = endpoint
if endpoint == '/v1/graphql':
self.conf['status'] = 200
if endpoint == '/v1alpha1/graphql':
self.conf['status'] = 400
check_query(hge_ctx, self.conf, add_auth=False)
def test_jwt_claims_map_no_allowed_roles_in_claim(self, hge_ctx, endpoint):
self.mk_claims('1', None, 'user')
default_allowed_roles = hge_ctx.hge_jwt_conf_dict['claims_map']['x-hasura-allowed-roles'].get('default')
token = jwt.encode(self.claims, hge_ctx.hge_jwt_key, algorithm='RS512').decode('utf-8')
self.conf['headers']['Authorization'] = 'Bearer ' + token
if default_allowed_roles is None:
self.conf['response'] = {
'errors': [{
'extensions': {
'code': 'jwt-missing-role-claims',
'path': '$'
},
'message': 'JWT claim does not contain x-hasura-allowed-roles'
}]
}
self.conf['url'] = endpoint
if endpoint == '/v1/graphql':
self.conf['status'] = 200
if endpoint == '/v1alpha1/graphql':
self.conf['status'] = 400
else:
self.conf['status'] = 200
self.conf['url'] = endpoint
check_query(hge_ctx, self.conf, add_auth=False)
def test_jwt_claims_map_invalid_allowed_roles_in_claim(self, hge_ctx, endpoint):
self.mk_claims('1', 'user', 'user')
token = jwt.encode(self.claims, hge_ctx.hge_jwt_key, algorithm='RS512').decode('utf-8')
self.conf['headers']['Authorization'] = 'Bearer ' + token
self.conf['response'] = {
'errors': [{
'extensions': {
'code': 'jwt-invalid-claims',
'path': '$'
},
'message': 'invalid x-hasura-allowed-roles; should be a list of roles: parsing [] failed, expected Array, but encountered String'
}]
}
self.conf['url'] = endpoint
if endpoint == '/v1/graphql':
self.conf['status'] = 200
if endpoint == '/v1alpha1/graphql':
self.conf['status'] = 400
check_query(hge_ctx, self.conf, add_auth=False)
def test_jwt_claims_map_no_default_role(self, hge_ctx, endpoint):
# default_default_role is the default default role set in the JWT config
# when the lookup with the JSONPath fails, this is the value that will
# be used for the `x-hasura-default-role` claim
default_default_role = hge_ctx.hge_jwt_conf_dict['claims_map']['x-hasura-default-role'].get('default')
self.mk_claims('1', ['user'])
token = jwt.encode(self.claims, hge_ctx.hge_jwt_key, algorithm='RS512').decode('utf-8')
self.conf['headers']['Authorization'] = 'Bearer ' + token
if default_default_role is None:
self.conf['response'] = {
'errors': [{
'extensions': {
'code': 'jwt-missing-role-claims',
'path': '$'
},
'message': 'JWT claim does not contain x-hasura-default-role'
}]
}
if endpoint == '/v1/graphql':
self.conf['status'] = 200
if endpoint == '/v1alpha1/graphql':
self.conf['status'] = 400
else:
self.conf['status'] = 200
self.conf['url'] = endpoint
check_query(hge_ctx, self.conf, add_auth=False)
def test_jwt_claims_map_claim_not_found(self, hge_ctx, endpoint):
default_user_id = hge_ctx.hge_jwt_conf_dict['claims_map']['x-hasura-user-id'].get('default')
self.mk_claims(None, ['user', 'editor'], 'user')
token = jwt.encode(self.claims, hge_ctx.hge_jwt_key, algorithm='RS512').decode('utf-8')
self.conf['headers']['Authorization'] = 'Bearer ' + token
if default_user_id is None:
self.conf['response'] = {
'errors': [{
'extensions': {
'code': 'jwt-invalid-claims',
'path': '$'
},
'message': 'JWT claim from claims_map, x-hasura-user-id not found'
}]
}
if endpoint == '/v1/graphql':
self.conf['status'] = 200
if endpoint == '/v1alpha1/graphql':
self.conf['status'] = 400
else:
self.conf['status'] = 200
self.conf['url'] = endpoint
check_query(hge_ctx, self.conf, add_auth=False)
@pytest.fixture(autouse=True)
def transact(self, setup):
self.dir = 'queries/graphql_query/permissions'
with open(self.dir + '/user_select_query_unpublished_articles.yaml') as c:
self.conf = yaml.safe_load(c)
curr_time = datetime.now()
exp_time = curr_time + timedelta(hours=1)
self.claims = {
'sub': '1234567890',
'name': 'John Doe',
'iat': math.floor(curr_time.timestamp()),
'exp': math.floor(exp_time.timestamp())
}
@pytest.fixture(scope='class')
def setup(self, request, hge_ctx):
self.dir = 'queries/graphql_query/permissions'
st_code, resp = hge_ctx.v1q_f(self.dir + '/setup.yaml')
assert st_code == 200, resp
yield
st_code, resp = hge_ctx.v1q_f(self.dir + '/teardown.yaml')
assert st_code == 200, resp
# The values of 'x-hasura-allowed-roles' and 'x-hasura-default-role' has
# been set in the JWT config
@pytest.mark.parametrize('endpoint', ['/v1/graphql', '/v1alpha1/graphql'])
class TestJWTClaimsMapWithStaticHasuraClaimsMapValues():
def mk_claims(self, user_id=None):
self.claims['https://myapp.com/jwt/claims'] = clean_null_terms({
'user': {
'id': user_id
}
})
def test_jwt_claims_map_valid_claims_success(self, hge_ctx, endpoint):
self.mk_claims('1')
token = jwt.encode(self.claims, hge_ctx.hge_jwt_key, algorithm='RS512').decode('utf-8')
self.conf['headers']['Authorization'] = 'Bearer ' + token
self.conf['headers']['x-hasura-custom-header'] = 'custom-value'
self.conf['url'] = endpoint
self.conf['status'] = 200
check_query(hge_ctx, self.conf, add_auth=False)
def test_jwt_claims_map_invalid_role_in_request_header(self, hge_ctx, endpoint):
self.mk_claims('1')
token = jwt.encode(self.claims, hge_ctx.hge_jwt_key, algorithm='RS512').decode('utf-8')
self.conf['headers']['Authorization'] = 'Bearer ' + token
self.conf['headers']['X-Hasura-Role'] = 'random_string'
self.conf['response'] = {
'errors': [{
'extensions': {
'code': 'access-denied',
'path': '$'
},
'message': 'Your requested role is not in allowed roles'
}]
}
self.conf['url'] = endpoint
if endpoint == '/v1/graphql':
self.conf['status'] = 200
if endpoint == '/v1alpha1/graphql':
self.conf['status'] = 400
check_query(hge_ctx, self.conf, add_auth=False)
def test_jwt_claims_map_claim_not_found(self, hge_ctx, endpoint):
self.mk_claims(None)
token = jwt.encode(self.claims, hge_ctx.hge_jwt_key, algorithm='RS512').decode('utf-8')
self.conf['headers']['Authorization'] = 'Bearer ' + token
self.conf['response'] = {
'errors': [{
'extensions': {
'code': 'jwt-invalid-claims',
'path': '$'
},
'message': 'JWT claim from claims_map, x-hasura-user-id not found'
}]
}
self.conf['url'] = endpoint
if endpoint == '/v1/graphql':
self.conf['status'] = 200
if endpoint == '/v1alpha1/graphql':
self.conf['status'] = 400
check_query(hge_ctx, self.conf, add_auth=False)
@pytest.fixture(autouse=True)
def transact(self, setup):
self.dir = 'queries/graphql_query/permissions'
with open(self.dir + '/user_select_query_unpublished_articles.yaml') as c:
self.conf = yaml.safe_load(c)
curr_time = datetime.now()
exp_time = curr_time + timedelta(hours=1)
self.claims = {
'sub': '1234567890',
'name': 'John Doe',
'iat': math.floor(curr_time.timestamp()),
'exp': math.floor(exp_time.timestamp())
}
@pytest.fixture(scope='class')
def setup(self, request, hge_ctx):
self.dir = 'queries/graphql_query/permissions'
st_code, resp = hge_ctx.v1q_f(self.dir + '/setup.yaml')
assert st_code == 200, resp
yield
st_code, resp = hge_ctx.v1q_f(self.dir + '/teardown.yaml')
assert st_code == 200, resp