wai-cryptocookie-0.2: lib/Wai/CryptoCookie/Middleware.hs
{-# LANGUAGE StrictData #-}
{-# LANGUAGE NoFieldSelectors #-}
module Wai.CryptoCookie.Middleware
( Config (..)
, CryptoCookie
, get
, set
, delete
, keep
, middleware
) where
import Control.Concurrent.STM
import Control.Monad.IO.Class
import Data.ByteArray.Encoding qualified as BA
import Data.ByteString qualified as B
import Data.ByteString.Lazy qualified as BL
import Data.IORef
import Data.Kind (Type)
import Data.List (find)
import Data.Time.Clock.POSIX qualified as Time
import Network.Wai qualified as Wai
import Web.Cookie
( SetCookie (..)
, parseCookies
, parseSetCookie
, renderSetCookieBS
)
import Wai.CryptoCookie.Encoding (Encoding (..))
import Wai.CryptoCookie.Encryption (Encryption (..))
-- | Configuration for 'middleware'.
--
-- Consider using 'Wai.CryptoCookie.defaultConfig' and
-- updating desired fields only.
data Config (a :: Type) = forall e.
(Encryption e) =>
Config
{ key :: Key e
, encoding :: Encoding a
, setCookie :: SetCookie
}
data Env (a :: Type) = Env
{ encrypt :: BL.ByteString -> IO BL.ByteString
, decrypt :: BL.ByteString -> Maybe BL.ByteString
, encoding :: Encoding a
, setCookie :: SetCookie
}
encodeEncrypt :: Env a -> a -> IO B.ByteString
encodeEncrypt env a = do
cryl <- env.encrypt $! env.encoding.encode a
pure $ BA.convertToBase BA.Base64URLUnpadded $ BL.toStrict cryl
decryptDecode :: Env a -> B.ByteString -> Maybe a
decryptDecode env cry64 = do
cry <- either (const Nothing) Just do
BA.convertFromBase BA.Base64URLUnpadded cry64
env.encoding.decode =<< env.decrypt (B.fromStrict cry)
newEnv :: Config a -> IO (Env a)
newEnv Config{key, encoding, setCookie} = do
(!ec0, !dc) <- initial key
ecRef <- newIORef ec0
pure
Env
{ encrypt = \raw -> do
ec <- atomicModifyIORef' ecRef \ec -> (advance ec, ec)
pure $ encrypt ec raw
, decrypt = either (const Nothing) Just . decrypt dc
, encoding
, setCookie
}
-- | Read-write access to the "Wai.CryptoCookie" data.
--
-- See 'get', 'set', 'delete', 'keep'.
data CryptoCookie a = CryptoCookie ~(Maybe a) (TVar (Maybe (Maybe a)))
-- | The data that came through the 'Wai.Request' cookie, if any.
get :: CryptoCookie a -> Maybe a
get (CryptoCookie x _) = x
-- | Cause the eventual 'Wai.Response' corresponding to the current
-- 'Wai.Request' to __set the cookie to the specified value__.
--
-- Overrides previous uses of 'set', 'delete', and 'keep'.
set :: CryptoCookie a -> a -> STM ()
set (CryptoCookie _ x) = writeTVar x . Just . Just
-- | Cause the eventual 'Wai.Response' corresponding to the current
-- 'Wai.Request' to __delete the cookie__ by setting its expiration to
-- a date in the past.
--
-- Overrides previous uses of 'set', 'delete', and 'keep'.
delete :: CryptoCookie a -> STM ()
delete (CryptoCookie _ x) = writeTVar x $ Just Nothing
-- | Cause the eventual 'Wai.Response' corresponding to the current
-- 'Wai.Request' to __keep the cookie as it is in the client__.
--
-- This is different than 'set'ting the cookie value to the value that came
-- with the incoming 'Wai.Request', because doing that could potentially
-- re-write a cookie that was deleted by the client after they sent the
-- 'Wai.Request' but before we send the 'Wai.Response'.
--
-- Doing nothing with a 'CryptoCookie' in your 'Wai.Application' is analogous
-- to using 'keep' just before returning the 'Wai.Response'.
--
-- Overrides previous uses of 'set', 'delete', and 'keep'.
keep :: CryptoCookie a -> STM ()
keep (CryptoCookie _ x) = writeTVar x Nothing
-- | Obtain a new 'Wai.Application'-transforming function (more or less a
-- 'Wai.Middleware') wherein the 'Wai.Application' being transformed can interact
-- with a 'CryptoCookie'.
--
-- It is safe to reuse a same 'Key', as well as 'middleware', as well as the
-- function returned by 'middleware', even concurrently. The library takes care
-- of randomly and atomically 'initial'izing or 'advance'ing 'Encrypt'ion
-- contexts as necessary.
--
-- If you plan to use 'middleware' more than once, which you would do if you
-- want to have two independently `CryptoCookies`, just make sure each `Config`
-- uses a different `setCookieName`.
middleware
:: forall a m
. (MonadIO m)
=> Config a
-- ^ Consider using 'Wai.CryptoCookie.defaultConfig'.
-> m ((CryptoCookie a -> Wai.Application) -> Wai.Application)
-- ^ Remember that 'Wai.Middleware' is a type-synonym for
-- @'Wai.Application' -> 'Wai.Application'@. This type is not too different
-- from that.
middleware c = liftIO do
env <- newEnv c
pure \fapp -> \req respond -> do
tv <- newTVarIO (Nothing :: Maybe (Maybe a))
fapp (CryptoCookie (getRequestCookieData env req) tv) req \res -> do
yya1 <- readTVarIO tv
let f = case yya1 of
Nothing -> pure
Just Nothing -> expireResponseCookieData env
Just (Just a1) -> setResponseCookieData env a1
respond =<< f res
-- | Find, decrypt and decode the cookie value from the 'Wai.Request'.
--
-- 'Nothing' if the unique cookie couldn't be found
-- or couldn't be decrypted. 'Left' if the 'Encoding' failed.
getRequestCookieData :: Env a -> Wai.Request -> Maybe a
getRequestCookieData env r = do
let cookieName = setCookieName env.setCookie
[cry] <- pure $ lookupMany cookieName $ requestCookies r
decryptDecode env cry
-- | Adds the @Set-Cookie@ header to the 'Wai.Response'.
setResponseCookieData :: Env a -> a -> Wai.Response -> IO Wai.Response
setResponseCookieData env a = \res ->
case find predicate (responseCookies res) of
Nothing -> do
cry <- encodeEncrypt env a
let hval = renderSetCookieBS $ env.setCookie{setCookieValue = cry}
pure $ Wai.mapResponseHeaders (("Set-Cookie", hval) :) res
_ -> fail $ "Duplicate cookie name: " <> show cookieName
where
cookieName = setCookieName env.setCookie
predicate = \x -> setCookieName x == cookieName
-- | Adds the @Set-Cookie@ header to the 'Wai.Response'.
expireResponseCookieData :: Env a -> Wai.Response -> IO Wai.Response
expireResponseCookieData env = \res ->
case find predicate (responseCookies res) of
Nothing -> pure $ Wai.mapResponseHeaders (("Set-Cookie", hval) :) res
_ -> fail $ "Duplicate cookie name: " <> show cookieName
where
cookieName = setCookieName env.setCookie
predicate = \x -> setCookieName x == cookieName
hval =
renderSetCookieBS $
env.setCookie
{ setCookieValue = mempty
, setCookieExpires = Just (Time.posixSecondsToUTCTime 0)
, setCookieMaxAge = Just (negate 1)
}
--------------------------------------------------------------------------------
requestCookies :: Wai.Request -> [(B.ByteString, B.ByteString)]
requestCookies r = parseCookies =<< lookupMany "Cookie" (Wai.requestHeaders r)
responseCookies :: Wai.Response -> [SetCookie]
responseCookies =
fmap parseSetCookie . lookupMany "Set-Cookie" . Wai.responseHeaders
--------------------------------------------------------------------------------
lookupMany :: (Eq k) => k -> [(k, v)] -> [v]
lookupMany k = findMany (== k)
findMany :: (Eq k) => (k -> Bool) -> [(k, v)] -> [v]
findMany f = map snd . filter (\(a, _) -> f a)