packages feed

libjwt-typed-0.1: src/Libjwt/JwtValidation.hs

--   This Source Code Form is subject to the terms of the Mozilla Public
--   License, v. 2.0. If a copy of the MPL was not distributed with this
--   file, You can obtain one at http://mozilla.org/MPL/2.0/.

{-# OPTIONS_HADDOCK show-extensions #-}

{-# LANGUAGE DerivingStrategies #-}
{-# LANGUAGE GeneralizedNewtypeDeriving #-}
{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE NamedFieldPuns #-}
{-# LANGUAGE TypeFamilies #-}

-- | Validation of JWT claims

module Libjwt.JwtValidation
  ( ValidationSettings(..)
  , defaultValidationSettings
  , runValidation
  , ValidationNEL
  , Valid
  , Check
  , JwtValidation
  , validation
  , invalid
  , valid
  , checkIssuer
  , checkSubject
  , checkAge
  , checkIssuedAfter
  , checkJwtId
  , checkClaim
  , check
  , ValidationFailure(..)
  )
where

import           Libjwt.NumericDate
import           Libjwt.Payload
import           Libjwt.PrivateClaims
import           Libjwt.RegisteredClaims

import           Control.Monad.Time

import           Control.Monad.Trans.Reader

import           Data.Coerce                    ( coerce )

import           Data.Either.Validation         ( Validation(..) )

import           Data.List.NonEmpty             ( NonEmpty(..) )
import           Data.Monoid                    ( Ap(..) )

import           Data.Time.Clock

import           Data.UUID                      ( UUID )

import           Prelude                 hiding ( exp )

type ValidationNEL a b = Validation (NonEmpty a) b

data ValidationEnv = Env { timestamp :: NumericDate
                         , settings :: ValidationSettings
                         }

-- | User-defined parameters of an validation
data ValidationSettings = Settings { leeway :: NominalDiffTime -- ^ extends the token validity period to /['nbf' - leeway, 'exp' + leeway)/ (also works for 'iat' checks such as 'checkAge')
                                   , appName :: Maybe String -- ^ used for 'aud' checks: if 'aud' claim is present, it must contain the value of this param
                                   }
  deriving stock Show

-- | 'ValidationSettings' with 'leeway' set to @0@ and 'appName' set to @Nothing@
defaultValidationSettings :: ValidationSettings
defaultValidationSettings = Settings { leeway = 0, appName = Nothing }

-- | Reasons for rejecting a JWT token
data ValidationFailure -- | User check failed 
                       = InvalidClaim String
                       -- | /exp/ check failed: the current time was after or equal to the expiration time (plus possible 'leeway')
                       | TokenExpired NominalDiffTime
                       -- | /nbf/ check failed: the current time was before the not-before time (minus possible 'leeway')
                       | TokenNotReady NominalDiffTime
                       -- | /aud/ check failed: the application processing this claim did not identify itself ('appName') with a value in the /aud/ claim
                       | WrongRecipient
                       -- | /iat/ check failed: the current time minus the time the JWT was issued (plus possible 'leeway') was greater than expected
                       | TokenTooOld NominalDiffTime
  deriving stock (Show, Eq)

data Valid = Valid
  deriving stock Show

instance Semigroup Valid where
  Valid <> Valid = Valid

type Check pc ns = Payload pc ns -> ValidationNEL ValidationFailure Valid

type CheckAp pc ns
  = Payload pc ns -> Ap (Validation (NonEmpty ValidationFailure)) Valid

-- | Construct validation from function
validation :: Check pc any -> JwtValidation pc any
validation = MkValidation . Ap . pure . coerce

-- | Validation that is always valid
valid :: ValidationNEL ValidationFailure Valid
valid = Success Valid

-- | Validation that always fails and signals @reason@
invalid
  :: ValidationFailure -- ^ reason
  -> ValidationNEL ValidationFailure Valid
invalid reason = Failure $ reason :| []

newtype JwtValidation pc any = MkValidation { rules :: Ap (Reader ValidationEnv) (CheckAp pc any) }
  deriving newtype (Semigroup)

instance Monoid (JwtValidation any1 any2) where
  mempty = validation $ const valid

-- | Run checks against the @payload@.
--
--   The exact set of checks is: @ defaultValidationRules <> v @, where @v@ is passed to this function and @defaultValidationRules@ is:
--
--    * check /exp/ claim against the current time (minus possible 'leeway'),
--    * check /nbf/ claim against the current time (plus possible 'leeway'),
--    * check /aud/ claim against 'appName'
--
--   See the docs of 'ValidationFailure' for a list of possible errors.
runValidation
  :: (MonadTime m)
  => ValidationSettings -- ^ /leeway/ and /appName/
  -> JwtValidation pc any -- ^ v
  -> Payload pc any -- ^ payload
  -> m (ValidationNEL ValidationFailure Valid)
runValidation settings v payload =
  let MkValidation { rules } = defaultValidationRules <> v
      applyRules             = runReader (getAp rules)
  in  do
        timestamp <- now
        let env = Env { timestamp, settings }
        return $ getAp $ applyRules env payload

defaultValidationRules :: JwtValidation any1 any2
defaultValidationRules = _checkExp <> _checkNbf <> _checkAud

using
  :: (ValidationEnv -> a) -> (a -> JwtValidation pc any) -> JwtValidation pc any
using get v = coerce (getAp . rules . v =<< asks get)

-- | Check the property @prop@ of a payload with the predicate @p@
--
--   If @p@ is @False@, then signal @'InvalidClaim' claim@
check
  :: String -- ^ claim
  -> (a -> Bool) -- ^ p
  -> (Payload pc any -> a) -- ^ prop
  -> JwtValidation pc any
check claim p prop =
  validation
    $ (\a -> if p a then valid else invalid $ InvalidClaim claim)
    . prop

-- | Check that /iss/ is present and equal to @issuer@. If not, then signal @'InvalidClaim' "iss"@
checkIssuer
  :: String -- ^ issuer
  -> JwtValidation any1 any2
checkIssuer issuer = check "iss" (== Iss (Just issuer)) iss

-- | Check that /sub/ is present and equal to @subject@. If not, then signal @'InvalidClaim' "sub"@
checkSubject
  :: String -- ^ subject 
  -> JwtValidation any1 any2
checkSubject subject = check "sub" (== Sub (Just subject)) sub

_checkAud :: JwtValidation any1 any2
_checkAud = using (appName . settings)
  $ \ident -> validation $ rfc7519_413 ident . aud
 where
  rfc7519_413 _       (Aud []) = valid
  rfc7519_413 Nothing _        = invalid WrongRecipient
  rfc7519_413 (Just ident) (Aud rs) | ident `elem` rs = valid
                                    | otherwise       = invalid WrongRecipient

_checkExp :: JwtValidation any1 any2
_checkExp = using (leeway . settings)
  $ \skew -> using timestamp $ \t0 -> validation $ rfc7519_414 t0 skew . exp
 where
  rfc7519_414 _ _ (Exp Nothing) = valid
  rfc7519_414 t0 skew (Exp (Just t1))
    | t0 `minusSeconds` skew < t1 = valid
    | otherwise                   = invalid $ TokenExpired $ diffSeconds t0 t1

_checkNbf :: JwtValidation any1 any2
_checkNbf = using (leeway . settings)
  $ \skew -> using timestamp $ \t0 -> validation $ rfc7519_415 t0 skew . nbf
 where
  rfc7519_415 _ _ (Nbf Nothing) = valid
  rfc7519_415 t0 skew (Nbf (Just t1))
    | t0 `plusSeconds` skew >= t1 = valid
    | otherwise                   = invalid $ TokenNotReady $ diffSeconds t1 t0

-- | Check that /iat/ (if present) is not further than @maxAge@ from 'currentTime' (minus possible 'leeway'). Otherwise signal 'TokenTooOld'.
checkAge
  :: NominalDiffTime -- ^ maxAge 
  -> JwtValidation any1 any2
checkAge maxAge = using (leeway . settings)
  $ \skew -> using timestamp $ \t0 -> validation $ ageCheck t0 skew . iat
 where
  ageCheck _ _ (Iat Nothing) = valid
  ageCheck t0 skew (Iat (Just t1))
    | age <= maxAge = valid
    | otherwise     = invalid $ TokenTooOld $ age - maxAge
    where age = diffSeconds t0 $ t1 `plusSeconds` skew

-- | Check that /iat/ (if present) is after @time@. If false, signal @'InvalidClaim' "iat"@.
checkIssuedAfter
  :: UTCTime -- ^ time
  -> JwtValidation any1 any2
checkIssuedAfter time = check
  "iat"
  (\case
    Iat Nothing   -> True
    Iat (Just t1) -> t1 > fromUTC time
  )
  iat

-- | Check that /jti/ is present and equal to @jwtId@. If not, then signal @'InvalidClaim' "jti"@
checkJwtId
  :: UUID -- ^ jwtId
  -> JwtValidation any1 any2
checkJwtId jwtId = check "jti" (== Jti (Just jwtId)) jti

-- | Check that @p a == True@, where @a@ is a value of private claim @n@. If not, signal @'InvalidClaim' n@
--   
--   Example:
--   
-- @
-- 'checkClaim' not #is_root
-- @
checkClaim
  :: (CanGet n pc, a ~ LookupClaimType n pc)
  => (a -> Bool) -- ^ p
  -> ClaimName n -- ^ n
  -> JwtValidation pc any
checkClaim p n = check (claimNameVal n) p (getClaim n . privateClaims)