packages feed

libjwt-typed-0.1: test/Interop/JWTDecoding.hs

{-# LANGUAGE TypeOperators #-}
{-# LANGUAGE DataKinds #-}
{-# LANGUAGE DeriveGeneric #-}
{-# LANGUAGE DerivingStrategies #-}
{-# LANGUAGE FlexibleContexts #-}
{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE OverloadedLabels #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE TypeApplications #-}

module Interop.JWTDecoding
  ( spec
  )
where

import           Web.Libjwt
import qualified Env                           as E
import           Interop.JWTHelpers

import           Control.Monad.Catch

import qualified Data.Aeson                    as JSON

import qualified Data.Either.Validation        as V

import qualified Data.List.NonEmpty            as NEL

import qualified Data.Map.Strict               as Map

import qualified Data.Text                     as T
import qualified Data.Text.Encoding            as TE

import           Data.Time.Clock                ( UTCTime )

import qualified Data.UUID                     as UUID


import           GHC.Generics

import           Prelude                 hiding ( exp )

import           Test.Hspec

import qualified Web.JWT                       as JWT


spec :: Spec
spec = do
  describe "HS256"
    $ runTests
    $ HS256
        "MWNmYzExODA5OWFjOGM3NDNmMmM5Zjg5ZDc0YTM3M2VhMGNkMzA2MDY3ZjFhZDk5N2I3OTc5Yjdm\
        \NDg3NDBkMiAgLQo"
  describe "none" $ runTests None
  describe "RS256" $ runTests $ RS256 E.testRsa2048KeyPair

runTests :: Alg -> Spec
runTests a = sequence_ $ tests <*> [a]

tests :: [Alg -> Spec]
tests =
  [ basicTest
  , typTest
  , richHeaderTest
  , audSingleTest
  , audListTest
  , unregisteredClaimsTest
  , unregisteredClaimsNsTest
  , unregisteredClaimsOptionsTest
  , utfTest
  , invalidClaimTest
  , invalidOptClaimTest
  , validTokenTest
  , invalidTokenTest
  ]

basicTest :: Alg -> Spec
basicTest a =
  let joseHeader =
          JWT.JOSEHeader (Just "JWT") Nothing (mkJWTAlgorithm a) Nothing
      cs = mempty { JWT.iss = JWT.stringOrURI "libjwt-typed-test"
                  , JWT.sub = JWT.stringOrURI "test"
                  , JWT.iat = JWT.numericDate 1595386660
                  , JWT.nbf = JWT.numericDate 1595386660
                  }
  in  E.specify "basic" $ mkTest joseHeader cs a nullClaims

typTest :: Alg -> Spec
typTest a =
  let joseHeader =
          JWT.JOSEHeader (Just "jose") Nothing (mkJWTAlgorithm a) Nothing
      cs = mempty { JWT.iss = JWT.stringOrURI "libjwt-typed-test"
                  , JWT.sub = JWT.stringOrURI "test-typ"
                  }
  in  E.xspecify "typ" $ mkTest joseHeader cs a nullClaims -- Web.JWT always uses JWT

richHeaderTest :: Alg -> Spec
richHeaderTest a =
  let joseHeader = JWT.JOSEHeader (Just "JWT")
                                  (Just "test")
                                  (mkJWTAlgorithm a)
                                  (Just "test")
      cs = mempty { JWT.iss = JWT.stringOrURI "libjwt-typed-test"
                  , JWT.sub = JWT.stringOrURI "test-rich-header"
                  }
  in  E.specify "rich-header" $ mkTest joseHeader cs a nullClaims

audSingleTest :: Alg -> Spec
audSingleTest a =
  let joseHeader =
          JWT.JOSEHeader (Just "JWT") Nothing (mkJWTAlgorithm a) Nothing
      cs = mempty { JWT.iss = JWT.stringOrURI "libjwt-typed-test"
                  , JWT.sub = JWT.stringOrURI "test-aud-single"
                  , JWT.aud = Left <$> JWT.stringOrURI "nobody"
                  }
  in  E.specify "aud-single" $ mkTest joseHeader cs a nullClaims

audListTest :: Alg -> Spec
audListTest a =
  let joseHeader =
          JWT.JOSEHeader (Just "JWT") Nothing (mkJWTAlgorithm a) Nothing
      cs = mempty
        { JWT.iss = JWT.stringOrURI "libjwt-typed-test"
        , JWT.sub = JWT.stringOrURI "test-aud-list"
        , JWT.aud = fmap Right
                    .   sequence
                    $   [JWT.stringOrURI]
                    <*> ["nobody-1", "nobody-2", "nobody-3"]
        }
  in  E.specify "aud-list" $ mkTest joseHeader cs a nullClaims

data PreferredContact = Email | Phone
  deriving stock (Show, Eq, Generic)

instance AFlag PreferredContact

unregisteredClaimsTest :: Alg -> Spec
unregisteredClaimsTest a =
  let
    joseHeader = JWT.JOSEHeader (Just "JWT") Nothing (mkJWTAlgorithm a) Nothing
    cs         = mempty
      { JWT.iss                = JWT.stringOrURI "libjwt-typed-test"
      , JWT.sub                = JWT.stringOrURI "test-unregistered"
      , JWT.unregisteredClaims = JWT.ClaimsMap $ Map.fromList
        [ ("name"            , JSON.String "John Doe")
        , ("userId"          , JSON.Number 12345)
        , ("isAdmin"         , JSON.Bool False)
        , ("preferredContact", JSON.String "email")
        , ("systemId"        , JSON.String $ UUID.toText E.testJTI)
        , ("createdAt"       , JSON.String "2020-07-31T11:45:00Z")
        ]
      }
  in
    E.specify "unregistered-claims" $ mkTest joseHeader cs a $ toPrivateClaims
      ( #name ->> ("John Doe" :: String)
      , #userId ->> (12345 :: Int)
      , #isAdmin ->> False
      , #preferredContact ->> Flag Email
      , #systemId ->> E.testJTI
      , #createdAt ->> Just (read "2020-07-31 11:45:00 UTC" :: UTCTime)
      )

unregisteredClaimsOptionsTest :: Alg -> Spec
unregisteredClaimsOptionsTest a =
  let
    joseHeader = JWT.JOSEHeader (Just "JWT") Nothing (mkJWTAlgorithm a) Nothing
    cs         = mempty
      { JWT.iss                = JWT.stringOrURI "libjwt-typed-test"
      , JWT.sub                = JWT.stringOrURI "test-unregistered-opt"
      , JWT.unregisteredClaims = JWT.ClaimsMap $ Map.fromList
                                   [ ("name", JSON.String "John Doe")
                                   , ("userId"          , JSON.Number 12345)
                                   , ("isAdmin"         , JSON.Bool False)
                                   , ("preferredContact", JSON.String "email")
                                   ]
      }
  in
    E.specify "unregistered-claims-options"
    $ mkTest joseHeader cs a
    $ toPrivateClaims
        ( #name ->> ("John Doe" :: String)
        , #userId ->> (12345 :: Int)
        , #isAdmin ->> False
        , #preferredContact ->> Just (Flag Email)
        , #systemId ->> (Nothing :: Maybe UUID.UUID)
        , #prefs ->> ([] :: [String])
        )

unregisteredClaimsNsTest :: Alg -> Spec
unregisteredClaimsNsTest a =
  let
    joseHeader = JWT.JOSEHeader (Just "JWT") Nothing (mkJWTAlgorithm a) Nothing
    cs         = mempty
      { JWT.iss                = JWT.stringOrURI "libjwt-typed-test"
      , JWT.sub                = JWT.stringOrURI "test-unregistered"
      , JWT.iat                = JWT.numericDate 1595386660
      , JWT.nbf                = JWT.numericDate 1595386660
      , JWT.unregisteredClaims = JWT.ClaimsMap $ Map.fromList
        [ ("http://example.com/name"    , JSON.String "John Doe")
        , ("http://example.com/userId"  , JSON.Number 12345)
        , ("http://example.com/isAdmin" , JSON.Bool False)
        , ("http://example.com/systemId", JSON.String $ UUID.toText E.testJTI)
        ]
      }
  in
    E.specify "unregistered-claims-ns"
    $ mkTest joseHeader cs a
    $ toPrivateClaims
    $ withNs
        (Ns @"http://example.com")
        ( #name ->> ("John Doe" :: String)
        , #userId ->> (12345 :: Int)
        , #isAdmin ->> False
        , #systemId ->> E.testJTI
        )

utfTest :: Alg -> Spec
utfTest a =
  let joseHeader =
          JWT.JOSEHeader (Just "JWT") Nothing (mkJWTAlgorithm a) Nothing
      cs = mempty
        { JWT.iss                = JWT.stringOrURI "libjwt-typed-test"
        , JWT.sub                = JWT.stringOrURI "test-utf"
        , JWT.unregisteredClaims = JWT.ClaimsMap $ Map.fromList
                                     [ ("name"  , JSON.String "孔子")
                                     , ("status", JSON.String "不患人之不己知,患不知人也")
                                     ]
        }
  in  E.specify "utf" $ mkTest joseHeader cs a $ toPrivateClaims
        (#name ->> ("孔子" :: String), #status ->> ("不患人之不己知,患不知人也" :: T.Text))

invalidClaimTest :: Alg -> Spec
invalidClaimTest a =
  let joseHeader =
          JWT.JOSEHeader (Just "JWT") Nothing (mkJWTAlgorithm a) Nothing
      cs = mempty
        { JWT.iss                = JWT.stringOrURI "libjwt-typed-test"
        , JWT.sub                = JWT.stringOrURI "invalid-claim-test"
        , JWT.unregisteredClaims = JWT.ClaimsMap
                                     $ Map.singleton "not-a-number" JSON.Null
        }
      token = jwtEncode cs joseHeader a
  in  E.specify "invalid-claim"
        $       decodeTest @'["not-a-number" ->> Int] @ 'NoNs
                  a
                  token
                  (\decoded ->
                    expectationFailure
                      $  "Web.JWT: unexpectedly decoded token\n"
                      ++ show token
                      ++ "\n to: "
                      ++ show decoded
                  )
        `catch` (\(Missing missing) -> pure $ missing `shouldBe` "not-a-number")

invalidOptClaimTest :: Alg -> Spec
invalidOptClaimTest a =
  let
    joseHeader = JWT.JOSEHeader (Just "JWT") Nothing (mkJWTAlgorithm a) Nothing
    cs         = mempty
      { JWT.iss                = JWT.stringOrURI "libjwt-typed-test"
      , JWT.sub                = JWT.stringOrURI "invalid-opt-claim-test"
      , JWT.unregisteredClaims = JWT.ClaimsMap
                                   $ Map.singleton "maybeNumber" JSON.Null
      }
  in
    E.specify "invalid-opt-claim"
    $   mkTest joseHeader cs a
    $   toPrivateClaims
    $   #maybeNumber
    ->> (Nothing :: Maybe Int)

validTokenTest :: Alg -> Spec
validTokenTest a =
  let
    t0         = 1597945008
    ttl        = 60
    t1         = t0 + 30
    joseHeader = JWT.JOSEHeader (Just "JWT") Nothing (mkJWTAlgorithm a) Nothing
    cs         = mempty
      { JWT.iss                = JWT.stringOrURI "libjwt-typed-test"
      , JWT.sub                = JWT.stringOrURI "valid-token-test"
      , JWT.iat                = JWT.numericDate t0
      , JWT.exp                = JWT.numericDate $ t0 + ttl
      , JWT.nbf                = JWT.numericDate t0
      , JWT.unregisteredClaims =
        JWT.ClaimsMap $ Map.singleton "number" $ JSON.Number 30
      }
  in
    E.specifyWithTime t1 "valid-token"
    $ validationTest @'["number" ->> Int] @ 'NoNs
        a
        (jwtEncode cs joseHeader a)
        (  checkAge 3600
        <> checkIssuer "libjwt-typed-test"
        <> checkSubject "valid-token-test"
        <> checkClaim (> 0) #number
        )
    $ \result -> result `shouldSatisfy` \case
        (V.Success _) -> True
        (V.Failure _) -> False

invalidTokenTest :: Alg -> Spec
invalidTokenTest a =
  let
    t0         = 1597945008
    ttl        = 60
    t1         = t0 + 120
    joseHeader = JWT.JOSEHeader (Just "JWT") Nothing (mkJWTAlgorithm a) Nothing
    cs         = mempty
      { JWT.iss                = JWT.stringOrURI "libjwt-typed-test"
      , JWT.sub                = JWT.stringOrURI "invalid-token-test"
      , JWT.iat                = JWT.numericDate t0
      , JWT.exp                = JWT.numericDate $ t0 + ttl
      , JWT.nbf                = JWT.numericDate t0
      , JWT.unregisteredClaims =
        JWT.ClaimsMap $ Map.singleton "number" $ JSON.Number (-30)
      }
  in
    E.specifyWithTime t1 "invalid-token"
    $ validationTest @'["number" ->> Int] @ 'NoNs
        a
        (jwtEncode cs joseHeader a)
        (  checkAge 3600
        <> checkIssuer "libjwt-typed-test"
        <> checkSubject "valid-token-test"
        <> checkClaim (> 0) #number
        )
    $ \result -> result `shouldSatisfy` \case
        (V.Success _) -> False
        (V.Failure errors) ->
          let es = NEL.toList errors
          in  all (`elem` es)
                  [TokenExpired 60, InvalidClaim "number", InvalidClaim "sub"]

mkTest
  :: ( Show (PrivateClaims cs ns)
     , Eq (PrivateClaims cs ns)
     , Decode (PrivateClaims cs ns)
     )
  => JWT.JOSEHeader
  -> JWT.JWTClaimsSet
  -> Alg
  -> PrivateClaims cs ns
  -> E.TestEnv Expectation
mkTest inHeader inClaims a expected =
  let token = jwtEncode inClaims inHeader a
  in  handle (handleDecodeException token) $ decodeTest a token $ \jwt -> do
        expectHeader inHeader $ header jwt
        expectPayload inClaims (payload jwt) expected

decodeTest
  :: Decode (PrivateClaims pc ns)
  => Alg
  -> T.Text
  -> (Jwt pc ns -> Expectation)
  -> E.TestEnv Expectation
decodeTest a token test =
  fmap (test . getDecoded) $ E.MkTest $ decodeByteString a $ TE.encodeUtf8 token

validationTest
  :: Decode (PrivateClaims pc ns)
  => Alg
  -> T.Text
  -> JwtValidation pc ns
  -> (  ValidationNEL ValidationFailure (Validated (Jwt pc ns))
     -> Expectation
     )
  -> E.TestEnv Expectation
validationTest a token validation test =
  fmap test
    $ E.MkTest
    $ jwtFromByteString defaultValidationSettings validation a
    $ TE.encodeUtf8 token

jwtEncode :: JWT.JWTClaimsSet -> JWT.JOSEHeader -> Alg -> T.Text
jwtEncode inClaims inHeader =
  maybe (JWT.encodeUnsigned inClaims inHeader)
        (\signer -> JWT.encodeSigned signer inHeader inClaims)
    . mkSigner

handleDecodeException :: T.Text -> SomeDecodeException -> E.TestEnv Expectation
handleDecodeException token e =
  pure
    $  expectationFailure
    $  "Web.JWT: Decoding token\n"
    ++ show token
    ++ "\n\tthrew exception:\n"
    ++ show e

expectHeader :: JWT.JOSEHeader -> Header -> Expectation
expectHeader expected got = do
  typ got `shouldBe` typ' (JWT.typ expected)
  mkJWTAlgorithm (alg got) `shouldBe` JWT.alg expected
 where
  typ' Nothing      = Typ Nothing
  typ' (Just "JWT") = JWT
  typ' (Just "jwt") = JWT
  typ' (Just text ) = Typ $ Just $ TE.encodeUtf8 text

expectPayload
  :: (Show (PrivateClaims cs ns), Eq (PrivateClaims cs ns))
  => JWT.JWTClaimsSet
  -> Payload cs ns
  -> PrivateClaims cs ns
  -> Expectation
expectPayload expected got expectedPc = got `shouldBe` ClaimsSet
  { iss           = iss' (JWT.iss expected)
  , sub           = sub' (JWT.sub expected)
  , aud           = aud' (JWT.aud expected)
  , exp           = exp' (JWT.exp expected)
  , iat           = iat' (JWT.iat expected)
  , nbf           = nbf' (JWT.nbf expected)
  , jti           = jti' (JWT.jti expected)
  , privateClaims = expectedPc
  }
 where
  stringOrURIToString  = T.unpack . JWT.stringOrURIToText

  intDateToNumericDate = fromPOSIX . JWT.secondsSinceEpoch

  iss' mt = Iss $ stringOrURIToString <$> mt

  sub' mt = Sub $ stringOrURIToString <$> mt

  aud' Nothing           = mempty
  aud' (Just (Left  s )) = Aud [stringOrURIToString s]
  aud' (Just (Right ss)) = Aud $ map stringOrURIToString ss

  exp' md = Exp $ intDateToNumericDate <$> md

  iat' md = Iat $ intDateToNumericDate <$> md

  nbf' md = Nbf $ intDateToNumericDate <$> md

  jti' mt = Jti $ UUID.fromText . JWT.stringOrURIToText =<< mt