packages feed

shomei-servant-0.2.0.0: src/Shomei/Mfa/Dto.hs

-- | MFA, TOTP, and recovery-code wire types.
module Shomei.Mfa.Dto
  ( MfaProof (..),
    MfaCompleteRequest (..),
    mfaCompletionOf,
    TotpEnrollResponse (..),
    TotpVerifyRequest (..),
    TotpRemoveRequest (..),
    totpRemovalProofOf,
    RecoveryCodesResponse (..),
    RecoveryCodesCountResponse (..),
  )
where

import Data.Aeson (Value, object, withObject, (.:), (.:?))
import Data.Aeson qualified as Aeson
import Data.Aeson.Key qualified as Key
import Data.Aeson.KeyMap qualified as KeyMap
import Data.Aeson.Types (Parser)
import Data.List (sort)
import Data.Maybe (catMaybes, isJust)
import Data.Text qualified as Text
import Shomei.Mfa.Totp.Workflow (TotpRemovalProof (..))
import Shomei.Mfa.Workflow (MfaCompletion (..))
import Shomei.Prelude

data MfaProof
  = PasskeyProof {assertion :: !Value}
  | TotpProof {code :: !Text}
  | RecoveryCodeProof {code :: !Text}
  deriving stock (Generic)

instance FromJSON MfaProof where
  parseJSON = withObject "MfaProof" \objectValue -> do
    proofType <- objectValue .: "type" :: Parser Text
    case proofType of
      "passkey" -> requireKeys ["assertion", "type"] objectValue >> PasskeyProof <$> objectValue .: "assertion"
      "totp" -> requireKeys ["code", "type"] objectValue >> TotpProof <$> objectValue .: "code"
      "recovery_code" -> requireKeys ["code", "type"] objectValue >> RecoveryCodeProof <$> objectValue .: "code"
      other -> fail ("unknown MFA proof type: " <> Text.unpack other)
    where
      requireKeys expected objectValue =
        unless (sort (map Key.toText (KeyMap.keys objectValue)) == expected) $
          fail "MFA proof contains missing or unexpected fields"

instance ToJSON MfaProof where
  toJSON = \case
    PasskeyProof assertion -> object ["type" Aeson..= ("passkey" :: Text), "assertion" Aeson..= assertion]
    TotpProof code -> object ["type" Aeson..= ("totp" :: Text), "code" Aeson..= code]
    RecoveryCodeProof code -> object ["type" Aeson..= ("recovery_code" :: Text), "code" Aeson..= code]

data MfaCompleteRequest = MfaCompleteRequest
  { ceremonyId :: !Text,
    proof :: !MfaProof
  }
  deriving stock (Generic)
  deriving anyclass (FromJSON, ToJSON)

mfaCompletionOf :: MfaCompleteRequest -> MfaCompletion
mfaCompletionOf MfaCompleteRequest {proof} = case proof of
  PasskeyProof assertion -> MfaPasskey assertion
  TotpProof code -> MfaTotp code
  RecoveryCodeProof code -> MfaRecoveryCode code

data TotpEnrollResponse = TotpEnrollResponse
  { secret :: !Text,
    otpauthUri :: !Text
  }
  deriving stock (Generic)
  deriving anyclass (FromJSON, ToJSON)

newtype TotpVerifyRequest = TotpVerifyRequest {code :: Text}
  deriving stock (Generic)
  deriving anyclass (FromJSON, ToJSON)

data TotpRemoveRequest = TotpRemoveRequest
  { code :: !(Maybe Text),
    recoveryCode :: !(Maybe Text)
  }
  deriving stock (Generic)

instance FromJSON TotpRemoveRequest where
  parseJSON = withObject "TotpRemoveRequest" \objectValue -> do
    code <- objectValue .:? "code"
    recoveryCode <- objectValue .:? "recoveryCode"
    case (isJust code, isJust recoveryCode) of
      (True, False) -> pure (TotpRemoveRequest code recoveryCode)
      (False, True) -> pure (TotpRemoveRequest code recoveryCode)
      _ -> fail "exactly one of code, recoveryCode must be present"

instance ToJSON TotpRemoveRequest where
  toJSON (TotpRemoveRequest code recoveryCode) =
    object (catMaybes [("code" Aeson..=) <$> code, ("recoveryCode" Aeson..=) <$> recoveryCode])

totpRemovalProofOf :: TotpRemoveRequest -> TotpRemovalProof
totpRemovalProofOf (TotpRemoveRequest code recoveryCode) = case (code, recoveryCode) of
  (Just value, _) -> RemoveWithCode value
  (_, Just value) -> RemoveWithRecoveryCode value
  _ -> RemoveWithCode ""

newtype RecoveryCodesResponse = RecoveryCodesResponse {codes :: [Text]}
  deriving stock (Generic)
  deriving anyclass (FromJSON, ToJSON)

newtype RecoveryCodesCountResponse = RecoveryCodesCountResponse {remaining :: Int}
  deriving stock (Generic)
  deriving anyclass (FromJSON, ToJSON)