packages feed

hstratus-auth-0.1.0.0: src/Network/HStratus/Internal/Http/Api.hs

{-# LANGUAGE NamedFieldPuns #-}
{-# LANGUAGE OverloadedStrings #-}
{-# OPTIONS_HADDOCK prune #-}

module Network.HStratus.Internal.Http.Api
  ( -- * API handle
    ApiLogger (..)
  , Api (..)
  , mkApi
  , mkApiWith
  , withLogger
  , fileLogger
  , verboseLogger
  , redactingLogger

    -- * Authenticated HTTP
  , rawRequest
  , rawRequest'
  , callApi
  , asJson
  , extractOr'
  , showStatusOf

    -- * Header helpers
  , authHeaders
  , requiredHeaders
  , callRequiredHeaders

    -- * Request builders
  , maybeValue
  , asObject
  , mkJsonRequest
  , withJsonRequestHeaders
  , callHandlingResponse

    -- * Constants
  , hClientId

    -- * Types
  , AuthCode
  )
where

import Control.Exception (throwIO)
import Control.Monad (unless, when)
import qualified Crypto.Hash.SHA256 as SHA256
import Crypto.SRP
  ( KnownAlgorithm (SHA256)
  , PrimeGroup (G2048)
  , digestSize
  )
import Data.Aeson
  ( FromJSON (..)
  , Key
  , eitherDecode
  , encode
  )
import Data.Aeson.KeyMap (fromList)
import Data.Aeson.Types (Value (..))
import Data.ByteString (ByteString, isPrefixOf)
import qualified Data.ByteString as BS
import qualified Data.ByteString.Lazy as LBS
import Data.CaseInsensitive (mk, original)
import Data.Maybe (catMaybes)
import qualified Data.Set as Set
import Data.String.Conv (toS)
import Data.Text (Text)
import qualified Data.Text as Text
import Data.Time (getCurrentTime)
import Network.HStratus.Http.Endpoints (Endpoints (..), Realm, realmEndpoints)
import Network.HStratus.Internal.Endpoints
  ( homeHeaders
  , withAcceptJson
  , withAppleOauthHeaders
  , withBody
  , withHeaders
  , withWidgetKey
  )
import Network.HStratus.Internal.Http
  ( hCounter
  , hSessionId
  , needsRetry
  )
import Network.HStratus.Internal.HttpErrors
  ( ApiResponse
  , AuthError (..)
  , extractOr
  )
import Network.HStratus.Internal.PBKDF2 (FancyPseudoRandomF, wrapIO)
import Network.HStratus.Internal.Session
  ( SavedHeaders (..)
  , cookiePath
  , loadSavedHeaders
  , updateSavedHeaders
  , updateSessionSavedHeaders
  )
import Network.HStratus.Session (Session (..))
import qualified Network.HStratus.Session as Session
import Network.HTTP.Client
  ( Manager
  , Request (..)
  , RequestBody (..)
  , Response (..)
  , httpLbs
  )
import Network.HTTP.Client.TLS (newTlsManager)
import Network.HTTP.Types
  ( HeaderName
  , RequestHeaders
  , Status (..)
  , hAccept
  , hContentType
  )
import System.Directory (doesFileExist)
import System.IO (Handle, hPutStrLn)
import System.Posix.Files (setFileMode)
import Web.Cookie.Jar (usingCookiesFromFile)


-- | A hook called after every HTTP response; receives the outgoing 'Request' and the 'Response'.
newtype ApiLogger = ApiLogger (Request -> Response LBS.ByteString -> IO ())


{- | Bundles the HTTP manager, session state, and endpoint configuration
needed to call the iCloud API.  Created by 'mkApi' or 'mkApiWith'.
-}
data Api = Api
  { apiManager :: !Manager
  -- ^ shared TLS manager used for all requests
  , apiSession :: !Session
  -- ^ credentials and on-disk session paths
  , apiEndpoints :: !Endpoints
  -- ^ iCloud service URLs and the widget key
  , apiHashAlgorithm :: !KnownAlgorithm
  -- ^ hash algorithm used for SRP (always SHA-256 in practice)
  , apiWrappedPseudoRF :: !FancyPseudoRandomF
  -- ^ PBKDF2 pseudo-random function, pre-wrapped with the hash algorithm
  , apiGroup :: !PrimeGroup
  -- ^ SRP prime group (always G2048 in practice)
  , apiLogger :: !(Maybe ApiLogger)
  -- ^ optional logger invoked after every HTTP response
  }


{- | Create an 'Api' using the endpoint set for the given 'Realm'.

Loads credentials and session state from disk via 'Network.HStratus.Session.loadSession'
and creates a new TLS manager.  Use 'mkApiWith' to supply a pre-built manager
and endpoint set — for example in tests.
-}
mkApi :: Realm -> IO Api
mkApi realm = do
  let apiHashAlgorithm = SHA256
      apiGroup = G2048
      apiEndpoints = realmEndpoints realm
      apiLogger = Nothing
  apiManager <- newTlsManager
  apiSession <- Session.loadSession
  apiWrappedPseudoRF <- wrapIO SHA256.hmac $ digestSize apiHashAlgorithm
  pure
    Api
      { apiGroup
      , apiEndpoints
      , apiManager
      , apiHashAlgorithm
      , apiSession
      , apiWrappedPseudoRF
      , apiLogger
      }


-- | Create an 'Api' from a pre-built 'Session', 'Endpoints', and 'Manager'. Use this when you need to supply your own HTTP manager or a custom endpoint set — for example in tests.
mkApiWith :: Session -> Endpoints -> Manager -> IO Api
mkApiWith apiSession apiEndpoints apiManager = do
  let apiHashAlgorithm = SHA256
      apiGroup = G2048
      apiLogger = Nothing
  apiWrappedPseudoRF <- wrapIO SHA256.hmac $ digestSize apiHashAlgorithm
  pure
    Api
      { apiGroup
      , apiEndpoints
      , apiManager
      , apiHashAlgorithm
      , apiSession
      , apiWrappedPseudoRF
      , apiLogger
      }


-- | Attach a logger to an 'Api'; it is called after every HTTP response.
withLogger :: ApiLogger -> Api -> Api
withLogger logger api = api{apiLogger = Just logger}


{- | Build an 'ApiLogger' that appends one entry per response to a 'Handle'.

Each entry contains:

* a summary line: @TIMESTAMP METHOD URL STATUS@
* one response header per line: @Name: value@
* the raw response body
* a @---@ separator

__Security warning:__ all request and response headers are written verbatim,
including @Set-Cookie@, @X-Apple-Session-Token@, @X-Apple-TwoSV-Trust-Token@,
and @scnt@. Log files produced by this logger may contain live session tokens.
Use 'redactingLogger' when the log destination is not fully trusted.

Not safe for concurrent use from multiple threads against the same handle.
-}
fileLogger :: Handle -> ApiLogger
fileLogger h = ApiLogger $ \req resp -> do
  now <- getCurrentTime
  let scheme = if secure req then "https" else "http" :: String
      uri = scheme <> "://" <> toS (host req) <> toS (path req)
      status = statusCode (responseStatus resp)
      summary = show now <> " " <> toS (method req) <> " " <> uri <> " " <> show status
      fmtHdr (name, val) = toS (original name) <> ": " <> toS val
  hPutStrLn h summary
  mapM_ (hPutStrLn h . fmtHdr) (requestHeaders req)
  hPutStrLn h ""
  mapM_ (hPutStrLn h . fmtHdr) (responseHeaders resp)
  hPutStrLn h ""
  LBS.hPutStr h (responseBody resp)
  hPutStrLn h "\n---"


{- | Like 'fileLogger' but also logs the query string in the URL and the
request body when present.

__Security warning:__ carries the same token-exposure risk as 'fileLogger' and
additionally logs request bodies, which may contain passwords or SRP parameters.
Use 'redactingLogger' when the log destination is not fully trusted.
-}
verboseLogger :: Handle -> ApiLogger
verboseLogger h = ApiLogger $ \req resp -> do
  now <- getCurrentTime
  let scheme = if secure req then "https" else "http" :: String
      qs = if BS.null (queryString req) then "" else "?" <> toS (queryString req)
      uri = scheme <> "://" <> toS (host req) <> toS (path req) <> qs
      status = statusCode (responseStatus resp)
      summary = show now <> " " <> toS (method req) <> " " <> uri <> " " <> show status
      fmtHdr (name, val) = toS (original name) <> ": " <> toS val
  hPutStrLn h summary
  mapM_ (hPutStrLn h . fmtHdr) (requestHeaders req)
  logReqBody (requestBody req)
  hPutStrLn h ""
  mapM_ (hPutStrLn h . fmtHdr) (responseHeaders resp)
  hPutStrLn h ""
  LBS.hPutStr h (responseBody resp)
  hPutStrLn h "\n---"
 where
  logReqBody (RequestBodyLBS lbs)
    | not (LBS.null lbs) = hPutStrLn h "" >> LBS.hPutStr h lbs
  logReqBody (RequestBodyBS bs)
    | not (BS.null bs) = hPutStrLn h "" >> LBS.hPutStr h (LBS.fromStrict bs)
  logReqBody _ = pure ()


{- | Like 'fileLogger' but replaces the values of sensitive headers with
@\<redacted\>@ before writing.

The following headers are redacted in both request and response:
@Set-Cookie@, @Cookie@, @X-Apple-Session-Token@, @X-Apple-TwoSV-Trust-Token@,
@scnt@, @Authorization@.

Safe to write to shared or untrusted log destinations.
-}
redactingLogger :: Handle -> ApiLogger
redactingLogger h = ApiLogger $ \req resp -> do
  now <- getCurrentTime
  let scheme = if secure req then "https" else "http" :: String
      uri = scheme <> "://" <> toS (host req) <> toS (path req)
      status = statusCode (responseStatus resp)
      summary = show now <> " " <> toS (method req) <> " " <> uri <> " " <> show status
      fmtHdr (name, val) = toS (original name) <> ": " <> toS val
  hPutStrLn h summary
  mapM_ (hPutStrLn h . fmtHdr . redactHeader) (requestHeaders req)
  hPutStrLn h ""
  mapM_ (hPutStrLn h . fmtHdr . redactHeader) (responseHeaders resp)
  hPutStrLn h ""
  LBS.hPutStr h (responseBody resp)
  hPutStrLn h "\n---"


sensitiveHeaderNames :: Set.Set HeaderName
sensitiveHeaderNames =
  Set.fromList
    [ mk "Set-Cookie"
    , mk "Cookie"
    , mk "X-Apple-Session-Token"
    , mk "X-Apple-TwoSV-Trust-Token"
    , mk "scnt"
    , mk "Authorization"
    ]


redactHeader :: (HeaderName, BS.ByteString) -> (HeaderName, BS.ByteString)
redactHeader (name, val)
  | name `Set.member` sensitiveHeaderNames = (name, "<redacted>")
  | otherwise = (name, val)


-- | Make a session request and obtain the raw byte results
rawRequest :: Api -> Request -> IO (Response LBS.ByteString)
rawRequest = rawRequest' True


-- | Make a session request and obtain the raw byte results
rawRequest' :: Bool -> Api -> Request -> IO (Response LBS.ByteString)
rawRequest' mayRetry api req = do
  let Api{apiManager = mgr, apiSession = s, apiLogger = mbLogger} = api
      jarPath = cookiePath (sessionTopDir s) (sessionCreds s)
  resp <- usingCookiesFromFile jarPath req $ flip httpLbs mgr
  jarExists <- doesFileExist jarPath
  when jarExists $ setFileMode jarPath 0o600
  updateSessionSavedHeaders s $ updateSavedHeaders $ responseHeaders resp
  mapM_ (\(ApiLogger logFn) -> logFn req resp) mbLogger
  if mayRetry && needsRetry (statusCode (responseStatus resp))
    then rawRequest' False api req
    else pure resp


{- | Make a session request to obtain a JSON payload

call api with request, obtain response
save the sessionData from the response headers
save any cookies from the response headers
if the response is JSON, parse it, and see if it parses as an ApiError
if JSON parsing fails, log to stderr
if it parses as an ApiError, indicate that

if the response is not JSON, use 'rawRequest' instead
-}
callApi
  :: (FromJSON a) => Api -> Request -> IO (Response (ApiResponse a))
callApi api req = do
  let isJsonType ct = "application/json" `isPrefixOf` ct || "text/json" `isPrefixOf` ct
  raw <- rawRequest api req
  let code = statusCode (responseStatus raw)
      theType = lookup hContentType $ responseHeaders raw
      isJson = maybe False isJsonType theType
  when (code >= 400 && LBS.null (responseBody raw)) $
    throwIO $
      UnexpectedResponse $
        showStatusOf raw
  unless (code >= 400 || isJson) $
    throwIO $
      UnexpectedResponse $
        "response was not JSON: " <> toS (show theType)
  mapM asJson raw


-- confirm the content-type of the response before attempting to parse
-- if it's wrong, throw  InvalidContentType
-- try to parse, if that fails, throw WrongDataType
asJson :: (FromJSON a) => LBS.ByteString -> IO a
asJson resp = case eitherDecode resp of
  Left _err -> throwIO $ UnexpectedResponse "did not decode JSON response correctly"
  Right x -> pure x


-- | Extract the successful value from a response, throwing 'UnexpectedResponse' for 4xx\/5xx status codes.
extractOr' :: Response (ApiResponse a) -> IO a
extractOr' r | statusCode (responseStatus r) >= 400 = throwIO $ UnexpectedResponse $ showStatusOf r
extractOr' r = extractOr $ responseBody r


-- | Format the HTTP status of a response as a human-readable 'Text' string.
showStatusOf :: Response a -> Text
showStatusOf resp =
  let showResponse' x s | x >= 500 = "server error:" <> Text.pack (show s)
      showResponse' x s | x >= 400 = "bad request:" <> Text.pack (show s)
      showResponse' _x s = "ok:" <> Text.pack (show s)
      theStatus = responseStatus resp
      theCode = statusCode theStatus
   in showResponse' theCode theStatus


-- | Build the full set of Apple OAuth request headers for an authenticated call.
authHeaders :: Api -> SavedHeaders -> RequestHeaders
authHeaders api savedHdrs =
  let Api{apiSession = session, apiEndpoints = ep} = api
      Session{sessionClientId = cid} = session
      headerOf name x = (name, toS x)
      maybeHeaderOf name = fmap (headerOf name)
      cidHeader = [(hClientId, toS cid)]
      sdHeaders =
        catMaybes
          [ maybeHeaderOf hCounter $ shCounter savedHdrs
          , maybeHeaderOf hSessionId $ shSessionId savedHdrs
          ]
   in withAppleOauthHeaders (epWidgetKey ep) $ homeHeaders ep <> sdHeaders <> cidHeader


-- | Build the minimal request headers required by the iCloud API: the widget key, session counter, and session ID.
requiredHeaders :: ByteString -> SavedHeaders -> RequestHeaders
requiredHeaders key savedHdrs =
  let headerOf name x = (name, toS x)
      maybeHeaderOf name = fmap (headerOf name)
      sdHeaders =
        catMaybes
          [ maybeHeaderOf hCounter $ shCounter savedHdrs
          , maybeHeaderOf hSessionId $ shSessionId savedHdrs
          ]
   in withAcceptJson . withWidgetKey key $ sdHeaders


-- | Make an authenticated API call with the required session headers, returning the decoded response body or throwing on error.
callRequiredHeaders :: (FromJSON a) => Api -> Request -> IO a
callRequiredHeaders api@Api{apiSession = s, apiEndpoints = ep} req = do
  savedHdrs <- loadSavedHeaders s
  callApi api (withHeaders (requiredHeaders (epWidgetKey ep) savedHdrs) req) >>= extractOr'


-- | Convert a @Maybe@ to a JSON 'Value', using 'Null' for 'Nothing'.
maybeValue :: (a -> Value) -> Maybe a -> Value
maybeValue = maybe Null


-- | Build a JSON 'Object' 'Value' from a list of key-value pairs.
asObject :: [(Key, Value)] -> Value
asObject = Object . fromList


-- | Build a JSON request by combining a base-request builder, a body encoder, and their respective inputs.
mkJsonRequest :: (a -> Request) -> (b -> Value) -> a -> b -> Request
mkJsonRequest mkBase mkBody baseSrc bodySrc =
  withJsonRequestHeaders . withBody (encode $ mkBody bodySrc) $ mkBase baseSrc


-- | Set @Accept: application/json@ and @Content-Type: application/json@ on a request.
withJsonRequestHeaders :: Request -> Request
withJsonRequestHeaders = withHeaders [(hAccept, "application/json"), (hContentType, "application/json")]


-- | Build and execute an authenticated API call, applying an optional request modifier, and decode the response.
callHandlingResponse
  :: (FromJSON a)
  => (Endpoints -> b -> Request)
  -> (Request -> Request)
  -> Api
  -> b
  -> IO a
callHandlingResponse mkReq modReq api@Api{apiEndpoints} x =
  callApi api (modReq $ mkReq apiEndpoints x) >>= extractOr'


-- | @HeaderName@ used to represent API session data
hClientId :: HeaderName
hClientId = mk "X-Apple-OAuth-State"


{- | The code sent to a user device that the user must enter to confirm
authenticity
-}
type AuthCode = Text