nuthatch/src/Nuthatch/Session.hs

135 lines
4.5 KiB
Haskell
Raw Normal View History

module Nuthatch.Session
( mintSession
, verifySession
, newCsrfToken
, constantTimeEq
) where
import Crypto.Hash (Digest, SHA256, hash)
import Crypto.MAC.HMAC (HMAC, hmac)
import qualified Data.Aeson as A
import qualified Data.Aeson.Key as Key
import qualified Data.Aeson.KeyMap as KM
import Data.ByteArray (convert)
import qualified Data.ByteArray as BA
import qualified Data.ByteString as BS
import qualified Data.ByteString.Base64.URL as B64
import qualified Data.ByteString.Lazy as BL
import Data.Text (Text)
import qualified Data.Text as T
import qualified Data.Text.Encoding as TE
import Data.Time.Clock.POSIX (getPOSIXTime)
import Nuthatch.Config (Config (..))
import Nuthatch.Paths (encodeUserId)
import Nuthatch.Types
import System.Entropy (getEntropy)
mintSession :: Config -> Text -> Text -> Maybe Text -> IO (Either Text SessionToken)
mintSession cfg iss sub display = do
now <- floor <$> getPOSIXTime
let expAt = now + cfgSessionHours cfg * 3600
uid = encodeUserId iss sub
header = A.object
[ "alg" A..= ("HS256" :: Text)
, "typ" A..= ("JWT" :: Text)
]
payload = A.object $
[ "iss" A..= ("nuthatch" :: Text)
, "aud" A..= ("nuthatch" :: Text)
, "iat" A..= now
, "exp" A..= expAt
, "sub" A..= unUserId uid
, "oidc_issuer" A..= iss
, "oidc_subject" A..= sub
] <> maybe [] (\d -> ["display" A..= d]) display
token = signJwt (TE.encodeUtf8 (cfgJwtSecret cfg)) header payload
pure (validateSessionToken token)
verifySession :: Config -> SessionToken -> IO (Either Text SessionUser)
verifySession cfg (SessionToken token) = do
now <- floor <$> getPOSIXTime
pure $ do
claims <- verifyJwt (TE.encodeUtf8 (cfgJwtSecret cfg)) token
expAt <- claimInt claims "exp"
if expAt < now
then Left "session expired"
else do
iss <- claimText claims "oidc_issuer"
sub <- claimText claims "oidc_subject"
let display = either (const Nothing) Just (claimText claims "display")
uid = encodeUserId iss sub
pure SessionUser
{ suIssuer = iss
, suSubject = sub
, suUserId = uid
, suDisplay = display
}
signJwt :: BS.ByteString -> A.Value -> A.Value -> Text
signJwt secret header payload =
let h = b64Json header
p = b64Json payload
signingInput = h <> "." <> p
sig = b64Raw (hmacSha256 secret signingInput)
in TE.decodeUtf8 (signingInput <> "." <> sig)
verifyJwt :: BS.ByteString -> Text -> Either Text A.Object
verifyJwt secret token =
case T.splitOn "." token of
[h, p, s] -> do
let signingInput = TE.encodeUtf8 (h <> "." <> p)
expected = b64Raw (hmacSha256 secret signingInput)
if not (BA.constEq expected (TE.encodeUtf8 s))
then Left "invalid session signature"
else do
payloadBytes <- b64Decode (TE.encodeUtf8 p)
case A.eitherDecodeStrict payloadBytes of
Left err -> Left (T.pack err)
Right (A.Object obj) -> Right obj
Right _ -> Left "jwt payload must be an object"
_ -> Left "malformed session token"
hmacSha256 :: BS.ByteString -> BS.ByteString -> BS.ByteString
hmacSha256 secret msg =
convert (hmac secret msg :: HMAC SHA256)
b64Json :: A.Value -> BS.ByteString
b64Json = B64.encodeUnpadded . BL.toStrict . A.encode
b64Raw :: BS.ByteString -> BS.ByteString
b64Raw = B64.encodeUnpadded
b64Decode :: BS.ByteString -> Either Text BS.ByteString
b64Decode bs =
case B64.decode bs of
Left err -> Left (T.pack err)
Right x -> Right x
claimText :: A.Object -> Text -> Either Text Text
claimText obj key =
case KM.lookup (Key.fromText key) obj of
Just (A.String t) -> Right t
_ -> Left ("missing claim " <> key)
claimInt :: A.Object -> Text -> Either Text Integer
claimInt obj key =
case KM.lookup (Key.fromText key) obj of
Just v ->
case A.fromJSON v of
A.Success (i :: Integer) -> Right i
_ -> Left ("invalid integer claim " <> key)
_ -> Left ("missing claim " <> key)
newCsrfToken :: IO CsrfToken
newCsrfToken = do
bytes <- getEntropy 32
pure (CsrfToken (TE.decodeUtf8 (B64.encodeUnpadded bytes)))
constantTimeEq :: CsrfToken -> CsrfToken -> Bool
constantTimeEq (CsrfToken a) (CsrfToken b) =
let ba = TE.encodeUtf8 a
bb = TE.encodeUtf8 b
in BS.length ba == BS.length bb
&& BA.constEq
(hash ba :: Digest SHA256)
(hash bb :: Digest SHA256)