packages feed

swarm-0.6.0.0: src/swarm-tournament/Swarm/Web/Auth.hs

{-# LANGUAGE DataKinds #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE RecordWildCards #-}
{-# OPTIONS_GHC -fno-warn-orphans #-}

-- |
-- SPDX-License-Identifier: BSD-3-Clause
--
-- Authentication logic for Swarm tournament server.
module Swarm.Web.Auth where

import Control.Monad.Catch
import Control.Monad.IO.Class (MonadIO, liftIO)
import Data.Aeson
import Data.ByteString qualified as BS
import Data.ByteString.Lazy qualified as LBS
import Data.ByteString.UTF8 as BSU
import Data.Map qualified as M
import Data.Text qualified as T
import Data.Text.Encoding qualified as DTE
import Data.Text.Lazy qualified as TL
import Database.SQLite.Simple.ToField
import GHC.Generics (Generic)
import Network.HTTP.Client qualified as HC
import Network.HTTP.Types (hAccept, hUserAgent, parseSimpleQuery, renderSimpleQuery)
import Servant
import Text.Read (readMaybe)

data GitHubCredentials = GitHubCredentials
  { clientId :: BS.ByteString
  , clientSecret :: BS.ByteString
  }

instance FromJSON GitHubCredentials where
  parseJSON = withObject "GitHubCredentials" $ \v -> do
    clientId <- BSU.fromString <$> v .: "CLIENT_ID"
    clientSecret <- BSU.fromString <$> v .: "CLIENT_SECRET"
    pure GitHubCredentials {..}

newtype TokenExchangeCode = TokenExchangeCode BS.ByteString

instance FromHttpApiData TokenExchangeCode where
  parseUrlPiece = return . TokenExchangeCode . DTE.encodeUtf8

newtype AccessToken = AccessToken BS.ByteString

instance ToField AccessToken where
  toField (AccessToken x) = toField x

newtype RefreshToken = RefreshToken BS.ByteString

instance ToField RefreshToken where
  toField (RefreshToken x) = toField x

data UserApiResponse = UserApiResponse
  { login :: TL.Text
  , id :: Int
  , name :: TL.Text
  }
  deriving (Generic, FromJSON)

data Expirable a = Expirable
  { token :: a
  , expirationSeconds :: Int
  }

fetchAuthenticatedUser ::
  (MonadIO m, MonadThrow m, MonadFail m) =>
  HC.Manager ->
  AccessToken ->
  m UserApiResponse
fetchAuthenticatedUser manager (AccessToken tok) = do
  req <- HC.parseUrlThrow "https://api.github.com/user"
  resp <-
    liftIO
      . flip HC.httpLbs manager
      . HC.applyBearerAuth tok
      $ req
        { HC.requestHeaders =
            [ (hAccept, "application/vnd.github+json")
            , (hUserAgent, "Swarm Gaming Hub")
            , ("X-GitHub-Api-Version", "2022-11-28")
            ]
        }
  either fail return $ eitherDecode $ HC.responseBody resp

data ReceivedTokens = ReceivedTokens
  { accessToken :: Expirable AccessToken
  , refreshToken :: Expirable RefreshToken
  }

packExchangeResponse ::
  M.Map ByteString ByteString ->
  Maybe ReceivedTokens
packExchangeResponse valMap =
  ReceivedTokens
    <$> (Expirable <$> atVal <*> toInt "expires_in")
    <*> (Expirable <$> rtVal <*> toInt "refresh_token_expires_in")
 where
  toInt k = readMaybe . BSU.toString =<< M.lookup k valMap

  atVal = AccessToken <$> M.lookup "access_token" valMap
  rtVal = RefreshToken <$> M.lookup "refresh_token" valMap

exchangeCode ::
  (MonadIO m, MonadThrow m, MonadFail m) =>
  HC.Manager ->
  GitHubCredentials ->
  TokenExchangeCode ->
  m ReceivedTokens
exchangeCode manager creds (TokenExchangeCode code) = do
  let qParms =
        T.unpack . DTE.decodeUtf8 $
          renderSimpleQuery
            True
            [ ("client_id", clientId creds)
            , ("client_secret", clientSecret creds)
            , ("code", code)
            ]
  req <- HC.parseUrlThrow $ "https://github.com/login/oauth/access_token" <> qParms
  resp <- liftIO $ flip HC.httpLbs manager $ req {HC.method = "POST"}

  let parms = parseSimpleQuery $ LBS.toStrict $ HC.responseBody resp
      valMap = M.fromList parms

  maybe
    (fail "Response did not include access token")
    return
    $ packExchangeResponse valMap