nuthatch/src/Nuthatch/Session.hs

161 lines
5.4 KiB
Haskell

module Nuthatch.Session
( mintSession
, verifySession
, newCsrfToken
, constantTimeEq
) where
import Control.Lens
( (&)
, (?~)
, (^.)
, (.~)
)
import Crypto.Hash (Digest, SHA256, hash)
import Crypto.JOSE (runJOSE)
import Crypto.JOSE.Compact (decodeCompact, encodeCompact)
import Crypto.JOSE.JWA.JWS (Alg (HS256))
import Crypto.JOSE.JWK (fromOctets)
import Crypto.JOSE.JWS
( algorithms
, newJWSHeaderProtected
)
import Crypto.JWT
( Audience (..)
, ClaimsSet
, HasClaimsSet (..)
, JWTError
, NumericDate (..)
, SignedJWT
, defaultJWTValidationSettings
, emptyClaimsSet
, issuerPredicate
, signJWT
, verifyJWT
)
import qualified Data.Aeson as A
import qualified Data.Aeson.KeyMap as KM
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.Set (singleton)
import Data.String (fromString)
import Data.Text (Text)
import qualified Data.Text as T
import qualified Data.Text.Encoding as TE
import Data.Time.Clock (addUTCTime, getCurrentTime)
import Nuthatch.Config (Config (..))
import Nuthatch.Paths (encodeUserId)
import Nuthatch.Types
import System.Entropy (getEntropy)
data SessionClaims = SessionClaims
{ registeredClaims :: ClaimsSet
, oidcIssuer :: Text
, oidcSubject :: Text
, displayName :: Maybe Text
}
deriving stock (Eq, Show)
instance HasClaimsSet SessionClaims where
claimsSet update claims =
fmap
(\registered -> claims { registeredClaims = registered })
(update (registeredClaims claims))
instance A.FromJSON SessionClaims where
parseJSON value = A.withObject "SessionClaims" parse value
where
parse object =
SessionClaims
<$> A.parseJSON value
<*> object A..: "oidc_issuer"
<*> object A..: "oidc_subject"
<*> object A..:? "display"
instance A.ToJSON SessionClaims where
toJSON claims =
case A.toJSON (registeredClaims claims) of
A.Object object ->
A.Object $
KM.insert "oidc_issuer" (A.String (oidcIssuer claims)) $
KM.insert "oidc_subject" (A.String (oidcSubject claims)) $
maybe
object
(\display -> KM.insert "display" (A.String display) object)
(displayName claims)
value -> value
mintSession :: Config -> Text -> Text -> Maybe Text -> IO (Either Text SessionToken)
mintSession cfg issuer subject display = do
now <- getCurrentTime
let expires = addUTCTime (fromInteger (cfgSessionHours cfg * 3600)) now
userId = encodeUserId issuer subject
applicationIssuer = "nuthatch"
applicationAudience = "nuthatch"
userSubject = fromString (T.unpack (unUserId userId))
result <- runJOSE $ do
let claims = SessionClaims
{ registeredClaims =
emptyClaimsSet
& claimIss ?~ applicationIssuer
& claimSub ?~ userSubject
& claimAud ?~ Audience [applicationAudience]
& claimIat ?~ NumericDate now
& claimExp ?~ NumericDate expires
, oidcIssuer = issuer
, oidcSubject = subject
, displayName = display
}
key = fromOctets (TE.encodeUtf8 (cfgJwtSecret cfg))
header = newJWSHeaderProtected HS256
encodeCompact <$> signJWT key header claims
pure $ case result of
Left (_ :: JWTError) -> Left "session token creation failed"
Right compact ->
validateSessionToken (TE.decodeUtf8 (BL.toStrict compact))
verifySession :: Config -> SessionToken -> IO (Either Text SessionUser)
verifySession cfg (SessionToken token) = do
let applicationIssuer = "nuthatch"
applicationAudience = "nuthatch"
result <- runJOSE $ do
jwt <- decodeCompact (BL.fromStrict (TE.encodeUtf8 token))
let settings =
defaultJWTValidationSettings (== applicationAudience)
& issuerPredicate .~ (== applicationIssuer)
& algorithms .~ singleton HS256
key = fromOctets (TE.encodeUtf8 (cfgJwtSecret cfg))
verifyJWT settings key (jwt :: SignedJWT)
pure $ case result of
Left (_ :: JWTError) -> Left "session token validation failed"
Right claims ->
let userId = encodeUserId (oidcIssuer claims) (oidcSubject claims)
expectedSubject = fromString (T.unpack (unUserId userId))
registered = registeredClaims claims
in if registered ^. claimSub /= Just expectedSubject
|| registered ^. claimAud /= Just (Audience ["nuthatch"])
|| registered ^. claimIat == Nothing
|| registered ^. claimExp == Nothing
then Left "session token claims are invalid"
else Right SessionUser
{ suIssuer = oidcIssuer claims
, suSubject = oidcSubject claims
, suUserId = userId
, suDisplay = displayName claims
}
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 left = TE.encodeUtf8 a
right = TE.encodeUtf8 b
in BS.length left == BS.length right
&& BA.constEq
(hash left :: Digest SHA256)
(hash right :: Digest SHA256)