packages feed

natskell-1.0.0.1: test/Unit/NKeySpec.hs

{-# LANGUAGE OverloadedStrings #-}

module NKeySpec (spec) where

import           Auth.Config           (mergeAuth)
import qualified Auth.Jwt              as Jwt
import qualified Auth.NKey             as NKey
import           Auth.Resolver         (buildAuthPatch)
import qualified Auth.Token            as Token
import           Auth.Types
    ( AuthContext (AuthContext)
    , AuthError (AuthError)
    , AuthPatch (..)
    , emptyAuthPatch
    )
import qualified Auth.UserPass         as UserPass
import qualified Data.ByteString       as BS
import qualified Data.ByteString.Char8 as BC
import           Data.Either           (isLeft)
import           Data.IORef
    ( atomicModifyIORef'
    , newIORef
    , readIORef
    , writeIORef
    )
import           Test.Hspec

spec :: Spec
spec = do
  describe "parseJwtBundle" $ do
    it "extracts jwt and seed blocks" $ do
      let creds =
            "-----BEGIN NATS USER JWT-----\n\
            \jwt-token\n\
            \------END NATS USER JWT------\n\
            \-----BEGIN USER NKEY SEED-----\n\
            \seed-token\n\
            \------END USER NKEY SEED------"
      Jwt.parseJwtBundle creds `shouldBe` Just (Jwt.JwtBundle "jwt-token" "seed-token")

    it "returns Nothing when blocks are missing" $ do
      Jwt.parseJwtBundle "missing blocks" `shouldBe` Nothing

    it "rejects a credentials bundle with a missing end marker" $ do
      let creds =
            "-----BEGIN NATS USER JWT-----\n\
            \jwt-token\n\
            \-----BEGIN USER NKEY SEED-----\n\
            \seed-token\n\
            \------END USER NKEY SEED------"
      Jwt.parseJwtBundle creds `shouldBe` Nothing

    it "rejects empty credential blocks" $ do
      let creds =
            "-----BEGIN NATS USER JWT-----\n\
            \------END NATS USER JWT------\n\
            \-----BEGIN USER NKEY SEED-----\n\
            \seed-token\n\
            \------END USER NKEY SEED------"
      Jwt.parseJwtBundle creds `shouldBe` Nothing

  describe "signNonceWithSeed" $ do
    it "derives the expected public key from a seed" $ do
      let seed = "SUAHR6JNS2HKJQEAQFHYPOXFXWE4JXBPKUWFX3IMYU72UHOGXT3ZMVFHXI"
          expectedPub = "UAB7EFDOTOBBMPOCK4SXFA62FVZOADQDZOU2W4IUDCGFKJXYVOK3LV7X"
      case NKey.signNonceWithSeed seed "nonce-123" of
        Left err -> expectationFailure err
        Right (pub, sig) -> do
          pub `shouldBe` expectedPub
          BS.length sig `shouldBe` 86

    it "rejects a seed with non-canonical trailing base32 data" $ do
      let seed = "SUAHR6JNS2HKJQEAQFHYPOXFXWE4JXBPKUWFX3IMYU72UHOGXT3ZMVFHXI"

      NKey.signNonceWithSeed (seed <> "A") "nonce-123" `shouldSatisfy` isLeft

    it "rejects lower-case seed encodings" $ do
      let seed = "suahr6jns2hkjqeaqfhypoxfxwe4jxbpkuwfx3imyu72uhogxt3zmvfhxi"

      NKey.signNonceWithSeed seed "nonce-123" `shouldSatisfy` isLeft

    it "rejects seeds with an invalid checksum" $ do
      let seed = "SUAHR6JNS2HKJQEAQFHYPOXFXWE4JXBPKUWFX3IMYU72UHOGXT3ZMVFHXJ"

      NKey.signNonceWithSeed seed "nonce-123" `shouldSatisfy` isLeft

  describe "secret rendering" $ do
    it "redacts credentials and authentication patches" $ do
      let bundle = Jwt.JwtBundle "jwt-secret" "seed-secret"
          patch = emptyAuthPatch
            { patchPass = Just "password-secret"
            , patchSig = Just "signature-secret"
            }

      show bundle `shouldNotContain` "jwt-secret"
      show bundle `shouldNotContain` "seed-secret"
      show patch `shouldNotContain` "password-secret"
      show patch `shouldNotContain` "signature-secret"

  describe "auth handlers" $ do
    it "fetches a fresh token for every connection attempt" $ do
      calls <- newIORef (0 :: Int)
      let handler = do
            call <- atomicModifyIORef' calls $ \current ->
              let next = current + 1
              in (next, next)
            pure (Right ("token-" <> BC.pack (show call)))
          auth = Token.authHandler handler

      first <- buildAuthPatch auth (AuthContext Nothing)
      second <- buildAuthPatch auth (AuthContext Nothing)

      fmap patchAuthToken first `shouldBe` Right (Just "token-1")
      fmap patchAuthToken second `shouldBe` Right (Just "token-2")

    it "allows token and user/pass credentials to coexist" $ do
      let auth = mergeAuth (Token.auth "token") (UserPass.auth ("user", "pass"))

      result <- buildAuthPatch auth (AuthContext Nothing)

      fmap patchAuthToken result `shouldBe` Right (Just "token")
      fmap patchUser result `shouldBe` Right (Just "user")
      fmap patchPass result `shouldBe` Right (Just "pass")

    it "base64url encodes raw signatures returned by handlers" $ do
      let publicKey = "UAB7EFDOTOBBMPOCK4SXFA62FVZOADQDZOU2W4IUDCGFKJXYVOK3LV7X"
          auth = NKey.authHandler publicKey $ \_ ->
            pure (Right (BS.replicate 64 0))

      result <- buildAuthPatch auth (AuthContext (Just "nonce"))

      fmap (fmap BS.length . patchSig) result `shouldBe` Right (Just 86)

    it "validates public nkeys before invoking signature handlers" $ do
      called <- newIORef False
      let auth = NKey.authHandler "USER_PUBLIC_KEY" $ \_ -> do
            writeIORef called True
            pure (Right (BS.replicate 64 0))

      result <- buildAuthPatch auth (AuthContext (Just "nonce"))

      result `shouldSatisfy` isLeft
      readIORef called `shouldReturn` False

    it "returns handler failures as typed authentication errors" $ do
      let auth = Token.authHandler (pure (Left (AuthError "token unavailable")))

      result <- buildAuthPatch auth (AuthContext Nothing)

      result `shouldBe` Left (AuthError "token unavailable")