{-# OPTIONS_GHC -fno-warn-orphans #-}

module Web.ServerSession.Frontend.Yesod.Jwt
  ( backend
  , siteApproot
  , ServerSessionJwtConfig(..)
  , ServerSessionJwtException(..)
  , forceInvalidate
  ) where

-- Module heavily inspired by:
-- serversession-frontend-yesod-1.0@sha256:8ddb112a1ef6ee863f5ea13978dd08e1c39444c1a252f775a780013430bcc884,1230


import Import.NoModel hiding (State, state, Header, deleteCookie, authKey)
import Yesod.Core.Types
import Model.Types.Common

import Model.Tokens.Session

import Jose.Jwt (JwtEncoding(..))
import qualified Jose.Jwt as Jose
import qualified Jose.Jwk as Jose

import qualified Network.Wai as Wai

import Web.Cookie (parseCookies, SetCookie(..))

import Web.ServerSession.Core hiding (SessionMap, setCookieName)

import qualified Data.Map as Map

import qualified Data.Aeson as JSON


instance Universe ForceInvalidate
instance Finite ForceInvalidate
finitePathPiece ''ForceInvalidate
  [ "current", "all", "none" ]


data ServerSessionJwtConfig = ServerSessionJwtConfig
  { sJwtJwkSet     :: JwkSet
  , sJwtStart
  , sJwtExpiration :: Maybe NominalDiffTime
  , sJwtEncoding   :: JwtEncoding
  , sJwtIssueBy    :: InstanceId
  , sJwtIssueFor   :: ClusterId
  , sJwtClockLeniencyStart, sJwtClockLeniencyEnd :: Maybe NominalDiffTime
  }


data ServerSessionJwtException
  = SessionTokenJwtError Jose.JwtError
  | SessionTokenUnsecured
  | SessionTokenInvalidFormat String
  | SessionTokenExpired | SessionTokenNotStarted
  deriving (Eq, Show, Generic, Typeable)
instance Exception ServerSessionJwtException


backend :: ( Applicative m
           , Storage sto
           , SessionData sto ~ Map Text ByteString
           )
        => ServerSessionJwtConfig
        -> (Wai.Request -> Maybe Text)
        -> State sto
        -> m (Maybe SessionBackend)
backend jwtCfg getApprootText' state = pure $ Just SessionBackend{..}
  where
    sbLoadSession :: Wai.Request -> IO (SessionMap, SaveSession)
    sbLoadSession req = do
      session <- runMaybeT . catchMPlus (Proxy @ServerSessionJwtException) $
        decodeSession jwtCfg =<< hoistMaybe (findSession state req)
      (sessionData, saveSessionToken) <- loadSession state $ encodeUtf8 . toPathPiece . sessionId <$> session

      let save :: SessionMap -> IO [Header]
          save sessMap = pure <$> do
            saveRes <- saveSession state saveSessionToken sessMap
            case saveRes of
              Nothing   ->
                return $ deleteCookie state approot'
              Just sess ->
                fmap (createCookie state approot' sess) . encodeSession jwtCfg =<< mkSessionToken jwtCfg sess

          approot' = getApprootText' req

      return (sessionData, save)

findSession :: State sto
            -> Wai.Request
            -> Maybe Jwt
findSession state req = do
  [raw] <- return $ do
    (hdrName, header) <- Wai.requestHeaders req
    guard $ hdrName == hCookie
    (k, v) <- parseCookies header
    guard $ k == encodeUtf8 (getCookieName state)
    return v
  return $ Jwt raw


mkSessionToken :: MonadIO m
               => ServerSessionJwtConfig
               -> Session sess
               -> m (SessionToken sess)
mkSessionToken ServerSessionJwtConfig{..} Session{..} = liftIO $
    mkSessionToken' <$> getCurrentTime <*> getRandom
  where
    mkSessionToken' now sessionIdentifier
      = let sessionId = sessionKey
            sessionIssuedAt = now
            sessionIssuedBy = sJwtIssueBy
            sessionIssuedFor = sJwtIssueFor
            sessionExpiresAt = flip addUTCTime now <$> sJwtExpiration
            sessionStartsAt = flip addUTCTime now <$> sJwtStart
         in SessionToken{..}


deleteCookie :: State sto -> Maybe Text -> Header
deleteCookie state approot' = DeleteCookie (encodeUtf8 $ getCookieName state) $ cookiePath approot'

createCookie :: State sto -> Maybe Text -> Session sess -> Jwt -> Header
createCookie state approot' session (Jwt payload) = AddCookie def
  { setCookieName     = encodeUtf8 $ getCookieName state
  , setCookieValue    = payload
  , setCookiePath     = Just $ cookiePath approot'
  , setCookieExpires  = cookieExpires state session
  , setCookieDomain   = Nothing -- Setting anything here would have browsers include subdomains, which might be wrong
  , setCookieHttpOnly = getHttpOnlyCookies state
  , setCookieSecure   = getSecureCookies state
  }


decodeSession :: ( MonadThrow m
                 , MonadIO m
                 )
              => ServerSessionJwtConfig
              -> Jwt
              -> m (SessionToken sess)
decodeSession ServerSessionJwtConfig{..} (Jwt bs) = do
  content <- either (throwM . SessionTokenJwtError) return =<< liftIO (Jose.decode (Jose.keys sJwtJwkSet) Nothing bs)
  content' <- case content of
    Jose.Unsecured _ -> throwM SessionTokenUnsecured
    Jose.Jws (_header, payload) -> return payload
    Jose.Jwe (_header, payload) -> return payload
  session@SessionToken{..} <- either (throwM . SessionTokenInvalidFormat) return $ JSON.eitherDecodeStrict content'

  now <- liftIO getCurrentTime
  unless (NTop sessionExpiresAt > NTop (Just $ maybe id addUTCTime (negate <$> sJwtClockLeniencyEnd) now)) $
    throwM SessionTokenExpired
  unless (sessionStartsAt <= Just (maybe id addUTCTime sJwtClockLeniencyStart now)) $
    throwM SessionTokenNotStarted

  return session

encodeSession :: MonadIO m
              => ServerSessionJwtConfig
              -> SessionToken sess
              -> m Jwt
encodeSession ServerSessionJwtConfig{..} token = liftIO $
    either throwM return =<< Jose.encode (Jose.keys sJwtJwkSet) sJwtEncoding payload
  where payload = Jose.Claims . toStrict $ JSON.encode token


-- | Invalidate the current session ID (and possibly more, check
-- 'ForceInvalidate').  This is useful to avoid session fixation
-- attacks (cf. <http://www.acrossecurity.com/papers/session_fixation.pdf>).
--
-- Note that the invalidate /does not/ occur when the call to
-- this action is made!  The sessions will be invalidated on the
-- end of the handler processing.  This means that later calls to
-- 'forceInvalidate' on the same handler will override earlier
-- calls.
--
-- This function works by setting a session variable that is
-- checked when saving the session.  The session variable set by
-- this function is then discarded and is not persisted across
-- requests.
forceInvalidate :: MonadHandler m => ForceInvalidate -> m ()
forceInvalidate = setSessionBS forceInvalidateKey . encodeUtf8 . toPathPiece


instance IsSessionData (Map Text ByteString) where
  type Decomposed (Map Text ByteString) = Map Text ByteString
  emptySession = mempty
  decomposeSession authKey session
    = let dsAuthId = Map.lookup authKey session
          dsForceInvalidate = fromMaybe DoNotForceInvalidate
            $ fromPathPiece . decodeUtf8 =<< Map.lookup forceInvalidateKey session
          dsDecomposed = session
            & Map.delete authKey
            & Map.delete forceInvalidateKey
       in DecomposedSession{..}
  recomposeSession authKey mAuthId
    = maybe id (Map.insert authKey) mAuthId
  isDecomposedEmpty _ = Map.null
  isSameDecomposed _ = (==)
