packages feed

hstratus-auth-0.1.0.0: src-internal/Network/HStratus/Internal/Trust.hs

{-# LANGUAGE DeriveGeneric #-}
{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE OverloadedStrings #-}
{-# OPTIONS_HADDOCK prune #-}

{- |
Module      : Network.HStratus.Internal.Trust
Copyright   : (c) 2026 Tim Emiola
Maintainer  : Tim Emiola <adetokunbo@emio.la>
SPDX-License-Identifier: BSD-3-Clause

Internal types and functions for two-factor authentication trust management.
-}
module Network.HStratus.Internal.Trust
  ( -- * data types
    CodeStatus (..)
  , TrustedPhone (..)
  , TrustedDevice (..)
  , TrustedList (..)
  , TrustData (..)
  , Setup2SADevice (..)

    -- * functions
  , withSelectedPhoneOrDevice
  , pleaseReadCode
  , pleaseChooseN
  , selectPhone
  , selectDevice
  , selectTwoFaPhone
  , setup2SADeviceLabel
  , selectSetupDevice
  )
where

import Control.Applicative ((<|>))
import Control.Exception (IOException, catch, throwIO)
import Data.Aeson
  ( FromJSON (..)
  , KeyValue (..)
  , Object
  , Options (..)
  , SumEncoding (ObjectWithSingleField)
  , ToJSON (..)
  , Value (..)
  , genericParseJSON
  , genericToEncoding
  , genericToJSON
  , object
  , withObject
  , (.:)
  , (.:?)
  )
import Data.Aeson.Casing (aesonPrefix, camelCase)
import Data.Aeson.KeyMap (filterWithKey)
import qualified Data.Aeson.KeyMap as KeyMap
import Data.Aeson.Types (Parser)
import Data.List.NonEmpty (NonEmpty (..), toList)
import Data.Maybe (fromMaybe, listToMaybe)
import Data.Text (Text)
import qualified Data.Text as Text
import qualified Data.Text.IO as Text
import Data.Word (Word8)
import GHC.Generics (Generic)
import SimplePrompt (promptNonEmpty)
import System.IO.Error (isEOFError)
import Text.Read (readMaybe)


putDeviceChoice :: (Int, TrustedDevice) -> IO ()
putDeviceChoice (i, td)
  | tdModelName td == "" =
      Text.putStrLn $ Text.pack (show i) <> ") " <> tdName td <> "\tSMS\t" <> tdId td
  | otherwise =
      Text.putStrLn $ Text.pack (show i) <> ") " <> tdName td <> "\t" <> tdModelName td <> "\t" <> tdId td


-- idx is 1-based and in [1, length xs], as enforced by pleaseChooseN
nthOf :: NonEmpty a -> Int -> a
nthOf xs idx = toList xs !! (idx - 1)


-- | Prompt the user to choose one device from a non-empty list of trusted devices.
selectDevice :: NonEmpty TrustedDevice -> IO TrustedDevice
selectDevice xs = do
  Text.putStrLn "Please select a trusted device to send a code to"
  mapM_ putDeviceChoice $ zip ([1 ..] :: [Int]) (toList xs)
  idx <- pleaseChooseN 1 (length xs)
  pure (nthOf xs idx)


-- | Prompt the user to choose one phone number from a non-empty list of trusted phones.
selectPhone :: NonEmpty TrustedPhone -> IO TrustedPhone
selectPhone xs = do
  let putPhoneChoice (i, x) = Text.putStrLn $ Text.pack (show i) <> ") " <> tpnNumberWithDialCode x
  Text.putStrLn "Please select a trusted phone number to send a code to"
  mapM_ putPhoneChoice $ zip ([1 ..] :: [Int]) (toList xs)
  idx <- pleaseChooseN 1 (length xs)
  pure (nthOf xs idx)


-- | Prompt the user to enter an integer in the inclusive range @[low, high]@, retrying on invalid input.
pleaseChooseN :: Int -> Int -> IO Int
pleaseChooseN low high = do
  let prefix = "Please choose an option between " <> show low <> " and " <> show high
  result <- (readMaybe <$> promptNonEmpty prefix) `catch` onEof
  case result of
    Nothing -> pleaseChooseN low high
    Just x | x < low || x > high -> pleaseChooseN low high
    Just x -> pure x
 where
  onEof :: IOException -> IO (Maybe Int)
  onEof e
    | isEOFError e = throwIO (userError "unexpected end of input")
    | otherwise = throwIO e


-- | Prompt the user to enter a security code of the given length.
pleaseReadCode :: Word8 -> IO Text
pleaseReadCode len = do
  let prefix = "Please enter the " <> show len <> "-digit code you just received"
  Text.pack <$> promptNonEmpty prefix


-- | Information describing the status of the security code verification
data CodeStatus = CodeStatus
  { scLength :: !Word8
  -- ^ expected number of digits in the security code
  , scTooManyCodesSent :: !Bool
  -- ^ @True@ when Apple has refused to send further codes
  , scTooManyCodesValidated :: !Bool
  -- ^ @True@ when the verification attempt limit has been reached
  , scSecurityCodeLocked :: !Bool
  -- ^ @True@ when the security code gate is locked
  , scSecurityCodeCooldown :: !Bool
  -- ^ @True@ when a cooldown period is active before a new code can be sent
  }
  deriving (Eq, Show, Generic)


instance FromJSON CodeStatus where
  parseJSON = withObject "CodeStatus" $ \o ->
    CodeStatus
      <$> o .: "length"
      <*> (fromMaybe False <$> o .:? "tooManyCodesSent")
      <*> (fromMaybe False <$> o .:? "tooManyCodesValidated")
      <*> (fromMaybe False <$> o .:? "securityCodeLocked")
      <*> (fromMaybe False <$> o .:? "securityCodeCooldown")


instance ToJSON CodeStatus where
  toJSON = genericToJSON simpleOptions
  toEncoding = genericToEncoding simpleOptions


-- | A trusted phone number registered for two-factor verification
data TrustedPhone = TrustedPhone
  { tpnId :: !Word8
  -- ^ Apple's internal identifier for this phone number
  , tpnNumberWithDialCode :: !Text
  -- ^ display string including the country dial code, e.g. @"+1 (•••) •••-1234"@
  , tpnPushMode :: !(Maybe Text)
  -- ^ push delivery mode (e.g. @"sms"@); @Nothing@ when absent
  }
  deriving (Eq, Show, Generic)


instance FromJSON TrustedPhone where
  parseJSON = genericParseJSON simpleOptions


instance ToJSON TrustedPhone where
  toJSON = genericToJSON simpleOptions
  toEncoding = genericToEncoding simpleOptions


-- | Information about a trusted device
data TrustedDevice = TrustedDevice
  { tdId :: !Text
  -- ^ Apple's internal identifier for this device
  , tdName :: !Text
  -- ^ human-readable device name, e.g. @"Tim's iPhone"@
  , tdModelName :: !Text
  -- ^ model string, e.g. @"iPhone 15 Pro"@; empty string when absent
  }
  deriving (Eq, Show, Generic)


instance FromJSON TrustedDevice where
  parseJSON = withObject "TrustedDevice" $ \o ->
    TrustedDevice
      <$> o .: "id"
      <*> o .: "name"
      <*> (fromMaybe "" <$> o .:? "modelName")


instance ToJSON TrustedDevice where
  toJSON = genericToJSON simpleOptions
  toEncoding = genericToEncoding simpleOptions


-- | A non-empty list of @TrustedPhone@ or @TrustedDevice@
data TrustedList
  = -- | the account has trusted phone numbers but no trusted devices
    TrustedPhoneNumbers !(NonEmpty TrustedPhone)
  | -- | the account has trusted devices (and may also have trusted phone numbers)
    TrustedDevices !(NonEmpty TrustedDevice)
  deriving (Eq, Show, Generic)


instance FromJSON TrustedList where
  parseJSON = genericParseJSON trustedListOptions


instance ToJSON TrustedList where
  toJSON = genericToJSON trustedListOptions
  toEncoding = genericToEncoding trustedListOptions


trustedListOptions :: Options
trustedListOptions =
  ( simpleOptions
      { sumEncoding = ObjectWithSingleField
      , constructorTagModifier = camelCase
      }
  )


data TrustData = TrustData
  { tdList :: !TrustedList
  -- ^ trusted phones or devices that can receive a verification code
  , tdSecurityCode :: !CodeStatus
  -- ^ current status of the security-code gate (length, lockout flags)
  , tdNoTrustedDevices :: !Bool
  -- ^ @True@ when no trusted devices are registered; only phone numbers available
  }
  deriving (Eq, Show)


-- | Selects a phone/device and applies the appropriate handler
withSelectedPhoneOrDevice
  :: (TrustedPhone -> IO a) -> (TrustedDevice -> IO a) -> TrustData -> IO a
withSelectedPhoneOrDevice handlePhone handleDevice = do
  let ikou (TrustedDevices ys) = selectDevice ys >>= handleDevice
      ikou (TrustedPhoneNumbers (y :| [])) = handlePhone y
      ikou (TrustedPhoneNumbers ys) = selectPhone ys >>= handlePhone
  ikou . tdList


toJSONTrustData :: TrustData -> Value
toJSONTrustData td =
  let asPairs (Object o) = KeyMap.toList o
      asPairs _other = []
      fromOthers =
        [ "securityCode" .= tdSecurityCode td
        , "noTrustedDevices" .= tdNoTrustedDevices td
        ]
      fromTrustedList = asPairs $ toJSON $ tdList td
   in object $ fromOthers <> fromTrustedList


parseJSONTrustData :: Value -> Parser TrustData
parseJSONTrustData = withObject "TrustData" $ \o ->
  let securityCode = o .: "securityCode"
      noTrustedDevices = fromMaybe False <$> o .:? "noTrustedDevices"
      isListKey key _ignored = key == "trustedPhoneNumbers" || key == "trustedDevices"
      theList = parseJSON (Object $ filterWithKey isListKey o)
   in TrustData <$> theList <*> securityCode <*> noTrustedDevices


instance ToJSON TrustData where
  toJSON = toJSONTrustData


instance FromJSON TrustData where
  parseJSON = parseJSONTrustData


-- | An opaque device record used in the legacy 2SA flow; fields are Apple-defined JSON.
newtype Setup2SADevice = Setup2SADevice {setup2SAFields :: Object}
  deriving (Eq, Show)


instance FromJSON Setup2SADevice where
  parseJSON = withObject "Setup2SADevice" (pure . Setup2SADevice)


instance ToJSON Setup2SADevice where
  toJSON (Setup2SADevice o) = Object o


-- | Extract a human-readable label from a 2SA setup device, falling back to @"(unknown)"@.
setup2SADeviceLabel :: Setup2SADevice -> Text
setup2SADeviceLabel (Setup2SADevice o) = fromMaybe "(unknown)" $ do
  v <- lookup "phoneNumber" pairs <|> lookup "name" pairs
  case v of
    String t -> Just t
    _ -> Nothing
 where
  pairs = KeyMap.toList o


-- | Select a trusted phone from 'TrustData' for 2FA, prompting the user when multiple phones are available.  Returns 'Nothing' when the user opts for a trusted device instead.
selectTwoFaPhone :: TrustData -> IO (Maybe TrustedPhone)
selectTwoFaPhone td =
  let phones = case tdList td of
        TrustedPhoneNumbers ps -> toList ps
        TrustedDevices _ -> []
   in if tdNoTrustedDevices td
        then pure (listToMaybe phones)
        else pickPhoneOrDevice phones
 where
  pickPhoneOrDevice [] = pure Nothing
  pickPhoneOrDevice phones = do
    mapM_
      (\(i, p) -> Text.putStrLn $ Text.pack (show (i :: Int)) <> ") " <> tpnNumberWithDialCode p)
      (zip [1 ..] phones)
    Text.putStrLn "Press Enter to use a trusted device, or select a phone number by its index to receive an SMS:"
    response <- Text.getLine
    if Text.null response
      then pure Nothing
      else case readMaybe (Text.unpack response) of
        Just n | n >= (1 :: Int) && n <= length phones -> pure $ listToMaybe $ drop (n - 1) phones
        _ -> pickPhoneOrDevice phones


-- | Prompt the user to choose a trusted device to receive a legacy 2SA verification code.
selectSetupDevice :: NonEmpty Setup2SADevice -> IO Setup2SADevice
selectSetupDevice xs = do
  Text.putStrLn "Please select a trusted device to receive a verification code"
  mapM_ (\(i, d) -> Text.putStrLn $ Text.pack (show (i :: Int)) <> ") " <> setup2SADeviceLabel d) (zip [1 ..] (toList xs))
  idx <- pleaseChooseN 1 (length xs)
  pure (nthOf xs idx)


simpleOptions :: Options
simpleOptions = aesonPrefix camelCase