mirror of
https://github.com/hasura/graphql-engine.git
synced 2024-12-13 19:33:55 +03:00
* 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:
parent
d210a0df2d
commit
4ce6002af2
@ -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"
|
||||
|
35
CHANGELOG.md
35
CHANGELOG.md
@ -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:
|
||||
|
@ -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
|
||||
^^^^^^^^
|
||||
|
@ -509,6 +509,7 @@ test-suite graphql-engine-tests
|
||||
, pg-client
|
||||
, process
|
||||
, QuickCheck
|
||||
, text
|
||||
, safe
|
||||
, split
|
||||
, time
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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)
|
||||
|
@ -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 =
|
||||
|
@ -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
|
||||
|
@ -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)
|
||||
|
@ -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
|
||||
|
289
server/tests-py/test_jwt_claims_map.py
Normal file
289
server/tests-py/test_jwt_claims_map.py
Normal 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
|
Loading…
Reference in New Issue
Block a user