diff --git a/CHANGELOG.md b/CHANGELOG.md index d3b99cda98b..f151e0172d2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,7 +23,6 @@ The optimization can be enabled using the - console: enable support for update permissions for mssql #3591 - server: add support for customization of table GraphQL schema descriptions (#7496) - cli: skip tls verfication for all API requests when `insecure-skip-tls-verify` flag is set (#4926) - - server: classify MSSQL exceptions and improve API error responses ## v2.2.0 diff --git a/docs/graphql/cloud/security/index.rst b/docs/graphql/cloud/security/index.rst index 37220504d50..277b1e550db 100644 --- a/docs/graphql/cloud/security/index.rst +++ b/docs/graphql/cloud/security/index.rst @@ -31,3 +31,4 @@ Features api-limits disable-graphql-introspection rotating-admin-secrets + multiple-jwt-secrets diff --git a/docs/graphql/cloud/security/mutiple-jwt-secrets.rst b/docs/graphql/cloud/security/mutiple-jwt-secrets.rst new file mode 100644 index 00000000000..392df7e7238 --- /dev/null +++ b/docs/graphql/cloud/security/mutiple-jwt-secrets.rst @@ -0,0 +1,40 @@ +.. meta:: + :description: Hasura Cloud multiple JWT Secrets + :keywords: hasura, docs, cloud, security, allow, , multiple, JWT, secrets + +.. _multiple_jwt_secrets: + +Multiple JWT Secrets +=========== + +.. contents:: Table of contents + :backlinks: none + :depth: 1 + :local: + +Introduction +------------ + +You can configure Hasura with a list of JWT Secrets so that you can integrate with different JWT issuers. This is useful when you have different authentication providers using the same Hasura infrastructure. + +How to use multiple jwt secrets +------------------------------- + +Multiple JWT secrets can be provided in the env var ``HASURA_GRAPHQL_JWT_SECRETS`` which takes a JSON array of JWT secret objects. + +Bearer Tokens are authenticated against the secret with a matching or absent `issuer`. + +The authentication is resolved as follows: + +1. Raw token values matched with configurations by looking in locations configured in HASURA_GRAPHQL_JWT_SECRETS under the "header" field (Authorization Header if missing). +2. Tokens are filtered to ensure that the `issuer` field matches, or that the issuer is absent either from the configuration, or the token. +3. If no candidate tokens are found then the unauthorized flow is performed (depends on ``HASURA_GRAPHQL_UNAUTHORIZED_ROLE``). +3. If multiple candidate tokens are found then an error is raised as the desired token is ambiguous. +3. If one candidate token is found then it is verified against the corresponding configured secret. + +.. note:: + Authentication resolution is identical when using ``HASURA_GRAPHQL_JWT_SECRET`` or a single ``HASURA_GRAPHQL_JWT_SECRETS`` configuration. + +.. note:: + + If both ``HASURA_GRAPHQL_JWT_SECRET`` and ``HASURA_GRAPHQL_JWT_SECRETS`` are set, then ``HASURA_GRAPHQL_JWT_SECRETS`` will be used. diff --git a/docs/graphql/core/auth/authentication/jwt.rst b/docs/graphql/core/auth/authentication/jwt.rst index 53268710b0a..45be44d5555 100644 --- a/docs/graphql/core/auth/authentication/jwt.rst +++ b/docs/graphql/core/auth/authentication/jwt.rst @@ -378,6 +378,16 @@ Examples: Certain providers require you to verify the ``iss`` claim on the JWT. To do that you can set this field to the appropriate value. +.. note:: + + A JWT configuration without an issuer will match any issuer field present in + an incoming JWT. + +.. note:: + + An incoming JWT without an issuer specified will match a configuration + even if it specifies an issuer. + ``claims_map`` ^^^^^^^^^^^^^^ diff --git a/server/graphql-engine.cabal b/server/graphql-engine.cabal index 0eda4f6d757..934634f609e 100644 --- a/server/graphql-engine.cabal +++ b/server/graphql-engine.cabal @@ -157,6 +157,7 @@ library , mtl , openapi3 , optparse-applicative + , optparse-generic , parsec , pg-client , postgresql-binary diff --git a/server/src-lib/Hasura/Prelude.hs b/server/src-lib/Hasura/Prelude.hs index d212a54130d..9cfec347651 100644 --- a/server/src-lib/Hasura/Prelude.hs +++ b/server/src-lib/Hasura/Prelude.hs @@ -21,6 +21,7 @@ module Hasura.Prelude liftEitherM, hoistMaybe, hoistEither, + readJson, tshow, -- * Trace debugging @@ -267,6 +268,9 @@ hoistEither = ExceptT . pure tshow :: Show a => a -> Text tshow = T.pack . show +readJson :: (J.FromJSON a) => String -> Either String a +readJson = J.eitherDecodeStrict . txtToBs . T.pack + -- | Customized 'J.Options' which apply "snake case" to Generic or Template -- Haskell JSON derivations. -- diff --git a/server/src-lib/Hasura/Server/API/Config.hs b/server/src-lib/Hasura/Server/API/Config.hs index 1cd1aa7f328..e6e3dca96f2 100644 --- a/server/src-lib/Hasura/Server/API/Config.hs +++ b/server/src-lib/Hasura/Server/API/Config.hs @@ -35,7 +35,7 @@ data ServerConfig = ServerConfig scfgIsAdminSecretSet :: !Bool, scfgIsAuthHookSet :: !Bool, scfgIsJwtSet :: !Bool, - scfgJwt :: !(Maybe JWTInfo), + scfgJwt :: ![JWTInfo], scfgIsAllowListEnabled :: !Bool, scfgLiveQueries :: !LQ.LiveQueriesOptions, scfgConsoleAssetsDir :: !(Maybe Text), @@ -90,11 +90,12 @@ isJWTSet = \case AMAdminSecretAndJWT {} -> True _ -> False -getJWTInfo :: AuthMode -> Maybe JWTInfo -getJWTInfo (AMAdminSecretAndJWT _ jwtCtx _) = - Just $ case jcxClaims jwtCtx of - JCNamespace namespace claimsFormat -> - JWTInfo namespace claimsFormat Nothing - JCMap claimsMap -> - JWTInfo (ClaimNs defaultClaimsNamespace) defaultClaimsFormat $ Just claimsMap -getJWTInfo _ = Nothing +getJWTInfo :: AuthMode -> [JWTInfo] +getJWTInfo (AMAdminSecretAndJWT _ jwtCtxs _) = + let f jwtCtx = case jcxClaims jwtCtx of + JCNamespace namespace claimsFormat -> + JWTInfo namespace claimsFormat Nothing + JCMap claimsMap -> + JWTInfo (ClaimNs defaultClaimsNamespace) defaultClaimsFormat $ Just claimsMap + in fmap f jwtCtxs +getJWTInfo _ = mempty diff --git a/server/src-lib/Hasura/Server/Auth.hs b/server/src-lib/Hasura/Server/Auth.hs index 222eeb7ca8e..4ebb9100f3f 100644 --- a/server/src-lib/Hasura/Server/Auth.hs +++ b/server/src-lib/Hasura/Server/Auth.hs @@ -36,6 +36,7 @@ import Data.ByteString (ByteString) import Data.HashSet qualified as Set import Data.Hashable qualified as Hash import Data.IORef (newIORef) +import Data.List qualified as L import Data.Text.Encoding qualified as T import Data.Time.Clock (UTCTime) import Hasura.Base.Error @@ -96,7 +97,7 @@ data AuthMode = AMNoAuth | AMAdminSecret !(Set.HashSet AdminSecretHash) !(Maybe RoleName) | AMAdminSecretAndHook !(Set.HashSet AdminSecretHash) !AuthHook - | AMAdminSecretAndJWT !(Set.HashSet AdminSecretHash) !JWTCtx !(Maybe RoleName) + | AMAdminSecretAndJWT !(Set.HashSet AdminSecretHash) ![JWTCtx] !(Maybe RoleName) deriving (Show, Eq) -- | Validate the user's requested authentication configuration, launching any @@ -109,17 +110,17 @@ setupAuthMode :: ) => Set.HashSet AdminSecretHash -> Maybe AuthHook -> - Maybe JWTConfig -> + [JWTConfig] -> Maybe RoleName -> H.Manager -> Logger Hasura -> ExceptT Text (ManagedT m) AuthMode -setupAuthMode adminSecretHashSet mWebHook mJwtSecret mUnAuthRole httpManager logger = - case (not (Set.null adminSecretHashSet), mWebHook, mJwtSecret) of - (True, Nothing, Nothing) -> return $ AMAdminSecret adminSecretHashSet mUnAuthRole - (True, Nothing, Just jwtConf) -> do - jwtCtx <- mkJwtCtx jwtConf - return $ AMAdminSecretAndJWT adminSecretHashSet jwtCtx mUnAuthRole +setupAuthMode adminSecretHashSet mWebHook mJwtSecrets mUnAuthRole httpManager logger = + case (not (Set.null adminSecretHashSet), mWebHook, not (null mJwtSecrets)) of + (True, Nothing, False) -> return $ AMAdminSecret adminSecretHashSet mUnAuthRole + (True, Nothing, True) -> do + jwtCtxs <- traverse mkJwtCtx (L.nub mJwtSecrets) + pure $ AMAdminSecretAndJWT adminSecretHashSet jwtCtxs mUnAuthRole -- Nothing below this case uses unauth role. Throw a fatal error if we would otherwise ignore -- that parameter, lest users misunderstand their auth configuration: _ @@ -128,15 +129,15 @@ setupAuthMode adminSecretHashSet mWebHook mJwtSecret mUnAuthRole httpManager log "Fatal Error: --unauthorized-role (HASURA_GRAPHQL_UNAUTHORIZED_ROLE)" <> requiresAdminScrtMsg <> " and is not allowed when --auth-hook (HASURA_GRAPHQL_AUTH_HOOK) is set" - (False, Nothing, Nothing) -> return AMNoAuth - (True, Just hook, Nothing) -> return $ AMAdminSecretAndHook adminSecretHashSet hook - (False, Just _, Nothing) -> + (False, Nothing, False) -> return AMNoAuth + (True, Just hook, False) -> return $ AMAdminSecretAndHook adminSecretHashSet hook + (False, Just _, False) -> throwError $ "Fatal Error : --auth-hook (HASURA_GRAPHQL_AUTH_HOOK)" <> requiresAdminScrtMsg - (False, Nothing, Just _) -> + (False, Nothing, True) -> throwError $ "Fatal Error : --jwt-secret (HASURA_GRAPHQL_JWT_SECRET)" <> requiresAdminScrtMsg - (_, Just _, Just _) -> + (_, Just _, True) -> throwError "Fatal Error: Both webhook and JWT mode cannot be enabled at the same time" where @@ -204,7 +205,7 @@ getUserInfoWithExpTime_ :: m (UserInfo, Maybe UTCTime, [N.Header]) ) -> -- | mock 'processJwt' - (JWTCtx -> [N.Header] -> Maybe RoleName -> m (UserInfo, Maybe UTCTime, [N.Header])) -> + ([JWTCtx] -> [N.Header] -> Maybe RoleName -> m (UserInfo, Maybe UTCTime, [N.Header])) -> _Logger_Hasura -> _Manager -> [N.Header] -> @@ -232,8 +233,8 @@ getUserInfoWithExpTime_ userInfoFromAuthHook_ processJwt_ logger manager rawHead -- this is the case that actually ends up consuming the request AST AMAdminSecretAndHook adminSecretHashSet hook -> checkingSecretIfSent adminSecretHashSet $ userInfoFromAuthHook_ logger manager hook rawHeaders reqs - AMAdminSecretAndJWT adminSecretHashSet jwtSecret unAuthRole -> - checkingSecretIfSent adminSecretHashSet $ processJwt_ jwtSecret rawHeaders unAuthRole + AMAdminSecretAndJWT adminSecretHashSet jwtSecrets unAuthRole -> + checkingSecretIfSent adminSecretHashSet $ processJwt_ jwtSecrets rawHeaders unAuthRole where -- CAREFUL!: mkUserInfoFallbackAdminRole adminSecretState = diff --git a/server/src-lib/Hasura/Server/Auth/JWT.hs b/server/src-lib/Hasura/Server/Auth/JWT.hs index 655f8ab2b32..bf3f9083e88 100644 --- a/server/src-lib/Hasura/Server/Auth/JWT.hs +++ b/server/src-lib/Hasura/Server/Auth/JWT.hs @@ -1,6 +1,28 @@ +-- | +-- Module : Hasura.Server.Auth.JWT +-- Description : Implements JWT Configuration and Validation Logic. +-- Copyright : Hasura +-- +-- This module implements the bulk of Hasura's JWT capabilities and interactions. +-- Its main point of non-testing invocation is `Hasura.Server.Auth`. +-- +-- It exports both `processJwt` and `processJwt_` with `processJwt_` being the +-- majority of the implementation with the JWT Token processing function +-- passed in as an argument in order to enable mocking in test-code. +-- +-- In `processJwt_`, prior to validation of the token, first the token locations +-- and issuers are reconciled. Locations are either specified as auth or +-- cookie (with cookie name) or assumed to be auth. Issuers can be omitted or +-- specified, where an omitted configured issuer can match any issuer specified by +-- a request. +-- +-- If none match, then this is considered an no-auth request, if one matches, +-- then normal token auth is performed, and if multiple match, then this is +-- considered an ambiguity error. module Hasura.Server.Auth.JWT ( processJwt, RawJWT, + StringOrURI (..), JWTConfig (..), JWTCtx (..), Jose.JWKSet (..), @@ -20,6 +42,7 @@ module Hasura.Server.Auth.JWT -- * Exposed for testing processJwt_, + tokenIssuer, allowedRolesClaim, defaultRoleClaim, parseClaimsMap, @@ -38,10 +61,14 @@ import Data.Aeson qualified as J import Data.Aeson.Casing qualified as J import Data.Aeson.Internal (JSONPath) import Data.Aeson.TH qualified as J +import Data.ByteArray.Encoding qualified as BAE +import Data.ByteString.Char8 qualified as BC +import Data.ByteString.Internal qualified as B import Data.ByteString.Lazy qualified as BL import Data.ByteString.Lazy.Char8 qualified as BLC import Data.CaseInsensitive qualified as CI import Data.HashMap.Strict qualified as Map +import Data.Hashable import Data.IORef (IORef, readIORef, writeIORef) import Data.Parser.CacheControl import Data.Parser.Expires @@ -93,7 +120,9 @@ $( J.deriveJSON data JWTHeader = JHAuthorization | JHCookie Text -- cookie name - deriving (Show, Eq) + deriving (Show, Eq, Generic) + +instance Hashable JWTHeader instance J.FromJSON JWTHeader where parseJSON = J.withObject "JWTHeader" $ \o -> do @@ -217,6 +246,21 @@ data JWTClaims | JCMap !JWTCustomClaimsMap deriving (Show, Eq) +-- | Hashable Wrapper for constructing a HashMap of JWTConfigs +newtype StringOrURI = StringOrURI {unStringOrURI :: Jose.StringOrURI} + deriving newtype (Show, Eq, J.ToJSON, J.FromJSON) + +instance J.ToJSONKey StringOrURI + +instance J.FromJSONKey StringOrURI + +instance J.ToJSONKey (Maybe StringOrURI) + +instance J.FromJSONKey (Maybe StringOrURI) + +instance Hashable StringOrURI where + hashWithSalt i = hashWithSalt i . J.encode + -- | The JWT configuration we got from the user. data JWTConfig = JWTConfig { jcKeyOrUrl :: !(Either Jose.JWK URI), @@ -377,6 +421,21 @@ determineJwkExpiryLifetime getCurrentTime' (Logger logger) responseHeaders = type ClaimsMap = Map.HashMap SessionVariable J.Value +-- | Decode a Jose ClaimsSet without verifying the signature +decodeClaimsSet :: RawJWT -> Maybe Jose.ClaimsSet +decodeClaimsSet (RawJWT jwt) = do + (_, c, _) <- extractElems $ BL.splitWith (== B.c2w '.') jwt + case BAE.convertFromBase BAE.Base64URLUnpadded $ BL.toStrict c of + Left _ -> Nothing + Right s -> J.decode $ BL.fromStrict s + where + extractElems (h : c : s : _) = Just (h, c, s) + extractElems _ = Nothing + +-- | Extract the issuer from a bearer tokena _without_ verifying it. +tokenIssuer :: RawJWT -> Maybe StringOrURI +tokenIssuer = coerce <$> (decodeClaimsSet >=> view Jose.claimIss) + -- | Process the request headers to verify the JWT and extract UserInfo from it -- From the JWT config, we check which header to expect, it can be the "Authorization" -- or "Cookie" header @@ -393,69 +452,115 @@ processJwt :: ( MonadIO m, MonadError QErr m ) => - JWTCtx -> + [JWTCtx] -> HTTP.RequestHeaders -> Maybe RoleName -> m (UserInfo, Maybe UTCTime, [N.Header]) -processJwt = processJwt_ processAuthZOrCookieHeader jcxHeader +processJwt = processJwt_ processHeaderSimple tokenIssuer jcxHeader + +type AuthTokenLocation = JWTHeader -- Broken out for testing with mocks: processJwt_ :: (MonadError QErr m) => -- | mock 'processAuthZOrCookieHeader' - (_JWTCtx -> BLC.ByteString -> m (Maybe (ClaimsMap, Maybe UTCTime))) -> - (_JWTCtx -> JWTHeader) -> - _JWTCtx -> + (JWTCtx -> BLC.ByteString -> m (ClaimsMap, Maybe UTCTime)) -> + (RawJWT -> Maybe StringOrURI) -> + (JWTCtx -> JWTHeader) -> + [JWTCtx] -> HTTP.RequestHeaders -> Maybe RoleName -> m (UserInfo, Maybe UTCTime, [N.Header]) -processJwt_ processAuthZOrCookieHeader_ fGetHeaderType jwtCtx headers mUnAuthRole = - maybe withoutAuthZHeader withAuthZHeader mAuthZHeader +processJwt_ processJwtBytes decodeIssuer fGetHeaderType jwtCtxs headers mUnAuthRole = do + -- Here we use `intersectKeys` to match up the correct locations of JWTs to those specified in JWTCtxs + -- Then we match up issuers, where no-issuer specified in a JWTCtx can match any issuer in a JWT + -- Then there should either be zero matches - Perform no auth + -- Or one match - Perform normal auth + -- Otherwise there is an ambiguous situation which we currently treat as an error. + issuerMatches <- traverse issuerMatch $ intersectKeys (keyCtxOnAuthTypes jwtCtxs) (keyTokensOnAuthTypes headers) + + -- ltraceM "issuerMatches" issuerMatches + + case (lefts issuerMatches, rights issuerMatches) of + ([], []) -> withoutAuthZ + (_ : _, []) -> jwtNotIssuerError + (_, [(ctx, val)]) -> withAuthZ val ctx + _ -> throw400 InvalidHeaders "Could not verify JWT: Multiple JWTs found" where - expectedHeader = + intersectKeys :: (Hashable a, Eq a) => Map.HashMap a [b] -> Map.HashMap a [c] -> [(b, c)] + intersectKeys m n = concatMap (uncurry cartesianProduct) $ Map.elems $ Map.intersectionWith (,) m n + + issuerMatch (j, b) = do + b'' <- case b of + (JHCookie _, b') -> pure b' + (JHAuthorization, b') -> + case BC.words b' of + ["Bearer", jwt] -> pure jwt + _ -> throw400 InvalidHeaders "Malformed Authorization header" + + case (StringOrURI <$> jcxIssuer j, decodeIssuer $ RawJWT $ BLC.fromStrict b'') of + (Nothing, _) -> pure $ Right (j, b'') + (_, Nothing) -> pure $ Right (j, b'') + (ci, ji) + | ci == ji -> pure $ Right (j, b'') + | otherwise -> pure $ Left (ci, ji, j, b'') + + cartesianProduct :: [a] -> [b] -> [(a, b)] + cartesianProduct as bs = [(a, b) | a <- as, b <- bs] + + keyCtxOnAuthTypes :: [JWTCtx] -> Map.HashMap AuthTokenLocation [JWTCtx] + keyCtxOnAuthTypes = Map.fromListWith (++) . fmap (expectedHeader &&& pure) + + keyTokensOnAuthTypes :: [HTTP.Header] -> Map.HashMap AuthTokenLocation [(AuthTokenLocation, B.ByteString)] + keyTokensOnAuthTypes = Map.fromListWith (++) . map (fst &&& pure) . concatMap findTokensInHeader + + findTokensInHeader :: Header -> [(AuthTokenLocation, B.ByteString)] + findTokensInHeader (key, val) + | key == CI.mk "Authorization" = [(JHAuthorization, val)] + | key == CI.mk "Cookie" = bimap JHCookie T.encodeUtf8 <$> Spock.parseCookies val + | otherwise = [] + + expectedHeader :: JWTCtx -> AuthTokenLocation + expectedHeader jwtCtx = case fGetHeaderType jwtCtx of - JHAuthorization -> "Authorization" - JHCookie _ -> "Cookie" + JHAuthorization -> JHAuthorization + JHCookie name -> JHCookie name - mAuthZHeader = - find (\(headerName, _) -> headerName == CI.mk expectedHeader) headers + withAuthZ authzHeader jwtCtx = do + authMode <- processJwtBytes jwtCtx $ BL.fromStrict authzHeader - withAuthZHeader (_, authzHeader) = do - claimsMapTuple <- processAuthZOrCookieHeader_ jwtCtx (BL.fromStrict authzHeader) + let (claimsMap, expTimeM) = authMode + in do + HasuraClaims allowedRoles defaultRole <- parseHasuraClaims claimsMap + -- see if there is a x-hasura-role header, or else pick the default role. + -- The role returned is unauthenticated at this point: + let requestedRole = + fromMaybe defaultRole $ + getRequestHeader userRoleHeader headers >>= mkRoleName . bsToTxt - case claimsMapTuple of - Nothing -> withoutAuthZHeader - Just (claimsMap, expTimeM) -> do - HasuraClaims allowedRoles defaultRole <- parseHasuraClaims claimsMap - let requestedRole = fromMaybe defaultRole $ getRequestHeader userRoleHeader headers >>= mkRoleName . bsToTxt + when (requestedRole `notElem` allowedRoles) $ + throw400 AccessDenied "Your requested role is not in allowed roles" + let finalClaims = + Map.delete defaultRoleClaim . Map.delete allowedRolesClaim $ claimsMap - when (requestedRole `notElem` allowedRoles) $ - throw400 AccessDenied "Your requested role is not in allowed roles" - let finalClaims = - Map.delete defaultRoleClaim . Map.delete allowedRolesClaim $ claimsMap + let finalClaimsObject = mapKeys sessionVariableToText finalClaims + metadata <- parseJwtClaim (J.Object finalClaimsObject) "x-hasura-* claims" + userInfo <- + mkUserInfo (URBPreDetermined requestedRole) UAdminSecretNotSent $ + mkSessionVariablesText metadata + pure (userInfo, expTimeM, []) - let finalClaimsObject = mapKeys sessionVariableToText finalClaims - metadata <- parseJwtClaim (J.Object $ finalClaimsObject) "x-hasura-* claims" - userInfo <- - mkUserInfo (URBPreDetermined requestedRole) UAdminSecretNotSent $ - mkSessionVariablesText metadata - pure (userInfo, expTimeM, []) - - withoutAuthZHeader = do - unAuthRole <- onNothing mUnAuthRole missingAuthzHeader + withoutAuthZ = do + unAuthRole <- onNothing mUnAuthRole (throw400 InvalidHeaders "Missing 'Authorization' or 'Cookie' header in JWT authentication mode") userInfo <- mkUserInfo (URBPreDetermined unAuthRole) UAdminSecretNotSent $ mkSessionVariablesHeaders headers pure (userInfo, Nothing, []) - where - missingAuthzHeader = - throw400 InvalidHeaders $ - "Missing " <> bsToTxt expectedHeader <> " header in JWT authentication mode" --- | Parse and verify the 'Authorization' or 'Cookie' header (depending upon --- the `jcHeader` value of the `JWTConfig`), returning either Nothing or the raw claims --- object, and the expiration, if any. -processAuthZOrCookieHeader :: + jwtNotIssuerError = throw400 JWTInvalid "Could not verify JWT: JWTNotInIssuer" + +-- | Processes a token payload (excluding the `Bearer ` prefix in the context of a JWTCtx) +processHeaderSimple :: ( MonadIO m, MonadError QErr m ) => @@ -463,48 +568,29 @@ processAuthZOrCookieHeader :: BLC.ByteString -> -- The "Maybe" in "m (Maybe (...))" covers the case where the -- requested Cookie name is not present (returns "m Nothing") - m (Maybe (ClaimsMap, Maybe UTCTime)) -processAuthZOrCookieHeader jwtCtx authzHeader = do + m (ClaimsMap, Maybe UTCTime) +processHeaderSimple jwtCtx jwt = do + --iss <- _ <$> Jose.decodeCompact (BL.fromStrict token) + --let ctx = M.lookup iss jwtCtx + -- try to parse JWT token from Authorization or Cookie header - jwt <- - case jcxHeader jwtCtx of - JHAuthorization -> Just <$> parseAuthzHeader - JHCookie cName -> parseCookieHeader cName - -- verify the JWT - case jwt of - Nothing -> pure Nothing - Just jwt' -> do - claims <- liftJWTError invalidJWTError $ verifyJwt jwtCtx $ RawJWT jwt' + claims <- liftJWTError invalidJWTError $ verifyJwt jwtCtx $ RawJWT jwt - let expTimeM = fmap (\(Jose.NumericDate t) -> t) $ claims ^. Jose.claimExp + let expTimeM = fmap (\(Jose.NumericDate t) -> t) $ claims ^. Jose.claimExp - claimsObject <- parseClaimsMap claims claimsConfig + claimsObject <- parseClaimsMap claims claimsConfig - pure $ Just (claimsObject, expTimeM) + pure (claimsObject, expTimeM) where claimsConfig = jcxClaims jwtCtx - parseAuthzHeader = do - let tokenParts = BLC.words authzHeader - case tokenParts of - ["Bearer", jwt] -> return jwt - _ -> malformedAuthzHeader - - parseCookieHeader cName = do - let cookies = Spock.parseCookies $ BL.toStrict authzHeader - jwtCookie = snd <$> find (\(hn, _) -> hn == cName) cookies - pure $ BL.fromStrict . txtToBs <$> jwtCookie liftJWTError :: (MonadError e' m) => (e -> e') -> ExceptT e m a -> m a liftJWTError ef action = do res <- runExceptT action onLeft res (throwError . ef) - invalidJWTError e = - err400 JWTInvalid $ "Could not verify JWT: " <> tshow e - - malformedAuthzHeader = - throw400 InvalidHeaders "Malformed Authorization header" + invalidJWTError e = err400 JWTInvalid $ "Could not verify JWT: " <> tshow e -- | parse the claims map from the JWT token or custom claims from the JWT config parseClaimsMap :: diff --git a/server/src-lib/Hasura/Server/Init.hs b/server/src-lib/Hasura/Server/Init.hs index 84af19be2eb..282d7d5a373 100644 --- a/server/src-lib/Hasura/Server/Init.hs +++ b/server/src-lib/Hasura/Server/Init.hs @@ -187,7 +187,8 @@ mkServeOptions rso = do txIso <- fromMaybe Q.ReadCommitted <$> withEnv (rsoTxIso rso) (fst txIsoEnv) adminScrt <- fmap (maybe mempty Set.singleton) $ withEnvs (rsoAdminSecret rso) $ map fst [adminSecretEnv, accessKeyEnv] authHook <- mkAuthHook $ rsoAuthHook rso - jwtSecret <- withEnvJwtConf (rsoJwtSecret rso) $ fst jwtSecretEnv + jwtSecret <- (`onNothing` mempty) <$> withEnvJwtConf (rsoJwtSecret rso) (fst jwtSecretEnv) + unAuthRole <- withEnv (rsoUnAuthRole rso) $ fst unAuthRoleEnv corsCfg <- mkCorsConfig $ rsoCorsConfig rso enableConsole <- diff --git a/server/src-lib/Hasura/Server/Init/Config.hs b/server/src-lib/Hasura/Server/Init/Config.hs index 0b5baac82e2..b5b74e4abe7 100644 --- a/server/src-lib/Hasura/Server/Init/Config.hs +++ b/server/src-lib/Hasura/Server/Init/Config.hs @@ -220,7 +220,7 @@ data ServeOptions impl = ServeOptions soTxIso :: Q.TxIsolation, soAdminSecret :: Set.HashSet AdminSecretHash, soAuthHook :: Maybe AuthHook, - soJwtSecret :: Maybe JWTConfig, + soJwtSecret :: [JWTConfig], soUnAuthRole :: Maybe RoleName, soCorsConfig :: CorsConfig, soEnableConsole :: Bool, @@ -378,9 +378,6 @@ readLogLevel s = case T.toLower $ T.strip $ T.pack s of "error" -> Right L.LevelError _ -> Left "Valid log levels: debug, info, warn or error" -readJson :: (J.FromJSON a) => String -> Either String a -readJson = J.eitherDecodeStrict . txtToBs . T.pack - class FromEnv a where fromEnv :: String -> Either String a @@ -447,6 +444,9 @@ instance FromEnv Seconds where instance FromEnv JWTConfig where fromEnv = readJson +instance FromEnv [JWTConfig] where + fromEnv = readJson + instance L.EnabledLogTypes impl => FromEnv [L.EngineLogType impl] where fromEnv = L.parseEnabledLogTypes diff --git a/server/src-test/Hasura/Server/AuthSpec.hs b/server/src-test/Hasura/Server/AuthSpec.hs index 702919b56be..268eb50b663 100644 --- a/server/src-test/Hasura/Server/AuthSpec.hs +++ b/server/src-test/Hasura/Server/AuthSpec.hs @@ -67,9 +67,9 @@ getUserInfoWithExpTimeTests = describe "getUserInfo" $ do (mkSessionVariablesHeaders mempty) processAuthZHeader _jwtCtx _authzHeader = - return $ Just (mapKeys mkSessionVariable claims, Nothing) + pure (mapKeys mkSessionVariable claims, Nothing) - processJwt = processJwt_ processAuthZHeader (const JHAuthorization) + processJwt = processJwt_ processAuthZHeader tokenIssuer (const JHAuthorization) let getUserInfoWithExpTime :: J.Object -> @@ -85,17 +85,17 @@ getUserInfoWithExpTimeTests = describe "getUserInfo" $ do describe "started without admin secret" $ do it "gives admin by default" $ do - mode <- setupAuthMode'E Nothing Nothing Nothing Nothing - getUserInfoWithExpTime mempty [] mode + mode <- setupAuthMode'E Nothing Nothing mempty Nothing + getUserInfoWithExpTime mempty mempty mode `shouldReturn` Right adminRoleName it "allows any requested role" $ do - mode <- setupAuthMode'E Nothing Nothing Nothing Nothing + mode <- setupAuthMode'E Nothing Nothing mempty Nothing getUserInfoWithExpTime mempty [(userRoleHeader, "r00t")] mode `shouldReturn` Right (mkRoleNameE "r00t") describe "admin secret only" $ do describe "unauth role NOT set" $ do - mode <- runIO $ setupAuthMode'E (Just $ Set.singleton $ hashAdminSecret "secret") Nothing Nothing Nothing + mode <- runIO $ setupAuthMode'E (Just $ Set.singleton $ hashAdminSecret "secret") Nothing mempty Nothing it "accepts when admin secret matches" $ do getUserInfoWithExpTime mempty [(adminSecretHeader, "secret")] mode @@ -120,7 +120,7 @@ getUserInfoWithExpTimeTests = describe "getUserInfo" $ do `shouldReturn` Left AccessDenied it "rejects when no secret sent, since no fallback unauth role" $ do - getUserInfoWithExpTime mempty [] mode + getUserInfoWithExpTime mempty mempty mode `shouldReturn` Left AccessDenied getUserInfoWithExpTime mempty [(userRoleHeader, "r00t"), (userRoleHeader, "admin")] mode `shouldReturn` Left AccessDenied @@ -132,7 +132,7 @@ getUserInfoWithExpTimeTests = describe "getUserInfo" $ do describe "unauth role set" $ do mode <- runIO $ - setupAuthMode'E (Just $ Set.singleton $ hashAdminSecret "secret") Nothing Nothing (Just ourUnauthRole) + setupAuthMode'E (Just $ Set.singleton $ hashAdminSecret "secret") Nothing mempty (Just ourUnauthRole) it "accepts when admin secret matches" $ do getUserInfoWithExpTime mempty [(adminSecretHeader, "secret")] mode `shouldReturn` Right adminRoleName @@ -158,7 +158,7 @@ getUserInfoWithExpTimeTests = describe "getUserInfo" $ do `shouldReturn` Left AccessDenied it "accepts when no secret sent and unauth role defined" $ do - getUserInfoWithExpTime mempty [] mode + getUserInfoWithExpTime mempty mempty mode `shouldReturn` Right ourUnauthRole getUserInfoWithExpTime mempty [("heh", "heh")] mode `shouldReturn` Right ourUnauthRole @@ -170,7 +170,7 @@ getUserInfoWithExpTimeTests = describe "getUserInfo" $ do describe "webhook" $ do mode <- runIO $ - setupAuthMode'E (Just $ Set.singleton $ hashAdminSecret "secret") (Just fakeAuthHook) Nothing Nothing + setupAuthMode'E (Just $ Set.singleton $ hashAdminSecret "secret") (Just fakeAuthHook) mempty Nothing it "accepts when admin secret matches" $ do getUserInfoWithExpTime mempty [(adminSecretHeader, "secret")] mode @@ -195,7 +195,7 @@ getUserInfoWithExpTimeTests = describe "getUserInfo" $ do `shouldReturn` Left AccessDenied it "authenticates with webhook when no admin secret sent" $ do - getUserInfoWithExpTime mempty [] mode + getUserInfoWithExpTime mempty mempty mode `shouldReturn` Right (mkRoleNameE "hook") getUserInfoWithExpTime mempty [("blah", "blah")] mode `shouldReturn` Right (mkRoleNameE "hook") @@ -218,7 +218,7 @@ getUserInfoWithExpTimeTests = describe "getUserInfo" $ do describe "unauth role NOT set" $ do mode <- runIO $ - setupAuthMode'E (Just $ Set.singleton $ hashAdminSecret "secret") Nothing (Just fakeJWTConfig) Nothing + setupAuthMode'E (Just $ Set.singleton $ hashAdminSecret "secret") Nothing [fakeJWTConfig] Nothing it "accepts when admin secret matches" $ do getUserInfoWithExpTime mempty [(adminSecretHeader, "secret")] mode @@ -245,7 +245,7 @@ getUserInfoWithExpTimeTests = describe "getUserInfo" $ do it "rejects when admin secret not sent and no 'Authorization' header" $ do getUserInfoWithExpTime mempty [("blah", "blah")] mode `shouldReturn` Left InvalidHeaders - getUserInfoWithExpTime mempty [] mode + getUserInfoWithExpTime mempty mempty mode `shouldReturn` Left InvalidHeaders describe "unauth role set" $ do @@ -254,7 +254,7 @@ getUserInfoWithExpTimeTests = describe "getUserInfo" $ do setupAuthMode'E (Just $ Set.singleton $ hashAdminSecret "secret") Nothing - (Just fakeJWTConfig) + [fakeJWTConfig] (Just ourUnauthRole) it "accepts when admin secret matches" $ do @@ -284,7 +284,7 @@ getUserInfoWithExpTimeTests = describe "getUserInfo" $ do it "authorizes as unauth role when no 'Authorization' header" $ do getUserInfoWithExpTime mempty [("blah", "blah")] mode `shouldReturn` Right ourUnauthRole - getUserInfoWithExpTime mempty [] mode + getUserInfoWithExpTime mempty mempty mode `shouldReturn` Right ourUnauthRole describe "when Authorization header sent, and no admin secret" $ do @@ -293,14 +293,14 @@ getUserInfoWithExpTimeTests = describe "getUserInfo" $ do setupAuthMode'E (Just $ Set.singleton $ hashAdminSecret "secret") Nothing - (Just fakeJWTConfig) + [fakeJWTConfig] (Just ourUnauthRole) modeB <- runIO $ setupAuthMode'E (Just $ Set.singleton $ hashAdminSecret "secret") Nothing - (Just fakeJWTConfig) + [fakeJWTConfig] Nothing -- Here the unauth role does not come into play at all, so map same tests over both modes: @@ -312,10 +312,10 @@ getUserInfoWithExpTimeTests = describe "getUserInfo" $ do [ allowedRolesClaimText .= (["editor", "user", "mod"] :: [Text]), defaultRoleClaimText .= ("user" :: Text) ] - getUserInfoWithExpTime claim [("Authorization", "IGNORED"), (userRoleHeader, "editor")] mode + getUserInfoWithExpTime claim [("Authorization", "Bearer IGNORED"), (userRoleHeader, "editor")] mode `shouldReturn` Right (mkRoleNameE "editor") -- Uses the defaultRoleClaimText: - getUserInfoWithExpTime claim [("Authorization", "IGNORED")] mode + getUserInfoWithExpTime claim [("Authorization", "Bearer IGNORED")] mode `shouldReturn` Right (mkRoleNameE "user") it "rejects when requested role is not allowed" $ do @@ -324,27 +324,27 @@ getUserInfoWithExpTimeTests = describe "getUserInfo" $ do [ allowedRolesClaimText .= (["editor", "user", "mod"] :: [Text]), defaultRoleClaimText .= ("user" :: Text) ] - getUserInfoWithExpTime claim [("Authorization", "IGNORED"), (userRoleHeader, "r00t")] mode + getUserInfoWithExpTime claim [("Authorization", "Bearer IGNORED"), (userRoleHeader, "r00t")] mode `shouldReturn` Left AccessDenied - getUserInfoWithExpTime claim [("Authorization", "IGNORED"), (userRoleHeader, "admin")] mode + getUserInfoWithExpTime claim [("Authorization", "Bearer IGNORED"), (userRoleHeader, "admin")] mode `shouldReturn` Left AccessDenied -- A corner case, but the behavior seems desirable: it "always rejects when token has empty allowedRolesClaimText" $ do - let claim = unObject [allowedRolesClaimText .= ([] :: [Text]), defaultRoleClaimText .= ("user" :: Text)] - getUserInfoWithExpTime claim [("Authorization", "IGNORED"), (userRoleHeader, "admin")] mode + let claim = unObject [allowedRolesClaimText .= (mempty :: [Text]), defaultRoleClaimText .= ("user" :: Text)] + getUserInfoWithExpTime claim [("Authorization", "Bearer IGNORED"), (userRoleHeader, "admin")] mode `shouldReturn` Left AccessDenied - getUserInfoWithExpTime claim [("Authorization", "IGNORED"), (userRoleHeader, "user")] mode + getUserInfoWithExpTime claim [("Authorization", "Bearer IGNORED"), (userRoleHeader, "user")] mode `shouldReturn` Left AccessDenied - getUserInfoWithExpTime claim [("Authorization", "IGNORED")] mode + getUserInfoWithExpTime claim [("Authorization", "Bearer IGNORED")] mode `shouldReturn` Left AccessDenied 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 [] + claim2 = unObject mempty for_ [claim0, claim1, claim2] $ \claim -> - getUserInfoWithExpTime claim [("Authorization", "IGNORED")] mode + getUserInfoWithExpTime claim [("Authorization", "Bearer IGNORED")] mode `shouldReturn` Left JWTRoleClaimMissing -- (*) FIXME NOTE (re above): @@ -370,53 +370,53 @@ setupAuthModeTests = describe "setupAuthMode" $ do -- These are all various error cases, except for the AMNoAuth mode: it "with no admin secret provided" $ do - setupAuthMode' Nothing Nothing Nothing Nothing + setupAuthMode' Nothing Nothing mempty Nothing `shouldReturn` Right AMNoAuth -- We insist on an admin secret in order to use webhook or JWT auth: - setupAuthMode' Nothing Nothing (Just fakeJWTConfig) Nothing + setupAuthMode' Nothing Nothing [fakeJWTConfig] Nothing `shouldReturn` Left () - setupAuthMode' Nothing (Just fakeAuthHook) Nothing Nothing + setupAuthMode' Nothing (Just fakeAuthHook) mempty Nothing `shouldReturn` Left () -- ...and we can't have both: - setupAuthMode' Nothing (Just fakeAuthHook) (Just fakeJWTConfig) Nothing + setupAuthMode' Nothing (Just fakeAuthHook) [fakeJWTConfig] Nothing `shouldReturn` Left () -- If the unauthenticated role was set but would otherwise be ignored this -- should be an error (for now), since users might expect all access to use -- the specified role. This first case would be the real worrying one: - setupAuthMode' Nothing Nothing Nothing (Just unauthRole) + setupAuthMode' Nothing Nothing mempty (Just unauthRole) `shouldReturn` Left () - setupAuthMode' Nothing Nothing (Just fakeJWTConfig) (Just unauthRole) + setupAuthMode' Nothing Nothing [fakeJWTConfig] (Just unauthRole) `shouldReturn` Left () - setupAuthMode' Nothing (Just fakeAuthHook) Nothing (Just unauthRole) + setupAuthMode' Nothing (Just fakeAuthHook) mempty (Just unauthRole) `shouldReturn` Left () - setupAuthMode' Nothing (Just fakeAuthHook) (Just fakeJWTConfig) (Just unauthRole) + setupAuthMode' Nothing (Just fakeAuthHook) [fakeJWTConfig] (Just unauthRole) `shouldReturn` Left () it "with admin secret provided" $ do - setupAuthMode' (Just secret) Nothing Nothing Nothing + setupAuthMode' (Just secret) Nothing mempty Nothing `shouldReturn` Right (AMAdminSecret secret Nothing) - setupAuthMode' (Just secret) Nothing Nothing (Just unauthRole) + setupAuthMode' (Just secret) Nothing mempty (Just unauthRole) `shouldReturn` Right (AMAdminSecret secret $ Just unauthRole) - setupAuthMode' (Just secret) Nothing (Just fakeJWTConfig) Nothing >>= \case + setupAuthMode' (Just secret) Nothing [fakeJWTConfig] Nothing >>= \case Right (AMAdminSecretAndJWT s _ Nothing) -> do s `shouldBe` secret _ -> expectationFailure "AMAdminSecretAndJWT" - setupAuthMode' (Just secret) Nothing (Just fakeJWTConfig) (Just unauthRole) >>= \case + setupAuthMode' (Just secret) Nothing [fakeJWTConfig] (Just unauthRole) >>= \case Right (AMAdminSecretAndJWT s _ ur) -> do s `shouldBe` secret ur `shouldBe` Just unauthRole _ -> expectationFailure "AMAdminSecretAndJWT" - setupAuthMode' (Just secret) (Just fakeAuthHook) Nothing Nothing + setupAuthMode' (Just secret) (Just fakeAuthHook) mempty Nothing `shouldReturn` Right (AMAdminSecretAndHook secret fakeAuthHook) -- auth hook can't make use of unauthenticated role for now (no good UX): - setupAuthMode' (Just secret) (Just fakeAuthHook) Nothing (Just unauthRole) + setupAuthMode' (Just secret) (Just fakeAuthHook) mempty (Just unauthRole) `shouldReturn` Left () -- we can't have both: - setupAuthMode' (Just secret) (Just fakeAuthHook) (Just fakeJWTConfig) Nothing + setupAuthMode' (Just secret) (Just fakeAuthHook) [fakeJWTConfig] Nothing `shouldReturn` Left () - setupAuthMode' (Just secret) (Just fakeAuthHook) (Just fakeJWTConfig) (Just unauthRole) + setupAuthMode' (Just secret) (Just fakeAuthHook) [fakeJWTConfig] (Just unauthRole) `shouldReturn` Left () parseClaimsMapTests :: Spec @@ -628,10 +628,10 @@ instance Tracing.HasReporter NoReporter setupAuthMode' :: Maybe (HashSet AdminSecretHash) -> Maybe AuthHook -> - Maybe JWTConfig -> + [JWTConfig] -> Maybe RoleName -> IO (Either () AuthMode) -setupAuthMode' mAdminSecretHash mWebHook mJwtSecret mUnAuthRole = +setupAuthMode' mAdminSecretHash mWebHook jwtSecrets mUnAuthRole = -- just throw away the error message for ease of testing: fmap (either (const $ Left ()) Right) $ runNoReporter $ @@ -640,7 +640,7 @@ setupAuthMode' mAdminSecretHash mWebHook mJwtSecret mUnAuthRole = setupAuthMode (fromMaybe Set.empty mAdminSecretHash) mWebHook - mJwtSecret + jwtSecrets mUnAuthRole -- NOTE: this won't do any http or launch threads if we don't specify JWT URL: (error "H.Manager") diff --git a/server/tests-hspec/Harness/Constants.hs b/server/tests-hspec/Harness/Constants.hs index 8252cabf5e7..10ffa7483a3 100644 --- a/server/tests-hspec/Harness/Constants.hs +++ b/server/tests-hspec/Harness/Constants.hs @@ -199,7 +199,7 @@ serveOptions = soTxIso = Q.Serializable, soAdminSecret = mempty, soAuthHook = Nothing, - soJwtSecret = Nothing, + soJwtSecret = mempty, soUnAuthRole = Nothing, soCorsConfig = CCAllowAll, soEnableConsole = True, diff --git a/server/tests-py/queries/unauthorized_role/cookie_header_absent_unauth_role_not_set.yaml b/server/tests-py/queries/unauthorized_role/cookie_header_absent_unauth_role_not_set.yaml index cfd8ac401cd..187c1b0ea05 100644 --- a/server/tests-py/queries/unauthorized_role/cookie_header_absent_unauth_role_not_set.yaml +++ b/server/tests-py/queries/unauthorized_role/cookie_header_absent_unauth_role_not_set.yaml @@ -9,7 +9,7 @@ response: - extensions: path: $ code: invalid-headers - message: Missing Cookie header in JWT authentication mode + message: Missing 'Authorization' or 'Cookie' header in JWT authentication mode query: query: | query { diff --git a/server/tests-py/test_config_api.py b/server/tests-py/test_config_api.py index 23b6186480b..860dd99ce1e 100644 --- a/server/tests-py/test_config_api.py +++ b/server/tests-py/test_config_api.py @@ -57,7 +57,7 @@ class TestConfigAPI(): assert body['jwt']['claims_namespace'] == claims_namespace assert body['jwt']['claims_format'] == claims_format else: - assert body['jwt'] == None + assert body['jwt'] == [] # test if the request fails without auth headers if admin secret is set if admin_secret is not None: