Adding multiple jwt secrets (incorporating provenance requirements)

PR-URL: https://github.com/hasura/graphql-engine-mono/pull/3173
Co-authored-by: Solomon <24038+solomon-b@users.noreply.github.com>
Co-authored-by: Daniel Chambers <1214352+daniel-chambers@users.noreply.github.com>
GitOrigin-RevId: 395a5d5854896f866b612895d6f41e29376c2caa
This commit is contained in:
Lyndon Maydwell 2022-02-14 10:33:49 +11:00 committed by hasura-bot
parent 47315b78d6
commit ff6aac31b8
15 changed files with 294 additions and 150 deletions

View File

@ -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

View File

@ -31,3 +31,4 @@ Features
api-limits
disable-graphql-introspection
rotating-admin-secrets
multiple-jwt-secrets

View File

@ -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.

View File

@ -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``
^^^^^^^^^^^^^^

View File

@ -157,6 +157,7 @@ library
, mtl
, openapi3
, optparse-applicative
, optparse-generic
, parsec
, pg-client
, postgresql-binary

View File

@ -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.
--

View File

@ -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

View File

@ -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 =

View File

@ -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 ::

View File

@ -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 <-

View File

@ -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

View File

@ -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")

View File

@ -199,7 +199,7 @@ serveOptions =
soTxIso = Q.Serializable,
soAdminSecret = mempty,
soAuthHook = Nothing,
soJwtSecret = Nothing,
soJwtSecret = mempty,
soUnAuthRole = Nothing,
soCorsConfig = CCAllowAll,
soEnableConsole = True,

View File

@ -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 {

View File

@ -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: