packages feed

google-cloud-common-0.1.0.0: src/Google/Cloud/Common/Core.hs

{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE RecordWildCards #-}

module Google.Cloud.Common.Core
  ( genAccessToken
  , RequestOptions (..)
  , RequestMethod (..)
  , AccessTokenResp (..)
  , GoogleApplicationCred (..)
  , doRequest
  , doRequestJSON
  , toPath 
  , googleLoggingUrl 
  ) where

import Control.Exception (try)
import Control.Monad.Error.Class (MonadError (throwError))
import Data.Aeson
import qualified Data.ByteString.Char8 as BS
import qualified Data.ByteString.Lazy.Char8 as BSL
import Data.IORef
import Data.Maybe (fromMaybe)
import Data.Text (Text)
import qualified Data.Text.Encoding as TE
import qualified Data.Text.Lazy as T
import Data.Text.Lazy.Encoding (decodeUtf8)
import Data.Time.Clock.POSIX
import GHC.IO (unsafePerformIO)
import Network.HTTP.Simple
import System.Environment (lookupEnv)
import System.Process
import qualified Web.JWT as JWT
import qualified Data.Map.Strict as Map

data GoogleApplicationCred = GooGleApplicationCred
  { type_ :: Text
  , projectId :: Text
  , privateKeyId :: Text
  , privateKey :: Text
  , clientEmail :: Text
  , clientId :: Text
  , authUri :: Text
  , tokenUri :: Text
  , auth_provider_x509_cert_url :: Text
  , client_x509_cert_url :: Text
  , universe_domain :: Text
  }
  deriving (Eq, Show)

data AccessTokenResp = AccessTokenResp
  { accessToken :: String
  , expiresIn :: Integer
  , tokenType :: String
  }
  deriving (Eq, Show)

instance FromJSON AccessTokenResp where
  parseJSON (Object v) =
    AccessTokenResp
      <$> v .: "access_token"
      <*> v .: "expires_in"
      <*> v .: "token_type"
  parseJSON _ = error "asd"

instance FromJSON GoogleApplicationCred where
  parseJSON (Object v) =
    GooGleApplicationCred
      <$> v .: "type"
      <*> v .: "project_id"
      <*> v .: "private_key_id"
      <*> v .: "private_key"
      <*> v .: "client_email"
      <*> v .: "client_id"
      <*> v .: "auth_uri"
      <*> v .: "token_uri"
      <*> v .: "auth_provider_x509_cert_url"
      <*> v .: "client_x509_cert_url"
      <*> v .: "universe_domain"
  parseJSON _ = error "asd"

{- | Authentication
 - There are two ways of authentication,
    1. Application will look for `GOOGLE_APPLICATION_CREDENTIALS` environment variable
        and read the JSON file path.
    2. If the env variable is not stored, it will use `print-access-token`.
        Make sure gcloud is installed and logged in for this to work.
-}
genAccessToken :: IO (Either String BS.ByteString)
genAccessToken = do
  mbEnv <- lookupEnv "GOOGLE_APPLICATION_CREDENTIALS"
  case mbEnv of
    Just jsonFilePath -> do
      eRes <- readJSONFile jsonFilePath
      case eRes of
        Left err -> pure $ Left err
        Right jsonVal -> getValidToken jsonVal
    Nothing -> genTokenViaPrintAccessToken

-- Global IORef to store the token in memory
{-# NOINLINE tokenCache #-}
tokenCache :: IORef (Maybe (BS.ByteString, Integer))
tokenCache = unsafePerformIO (newIORef Nothing)

-- Create JWT Token
createJWT :: GoogleApplicationCred -> IO (Maybe Text)
createJWT GooGleApplicationCred {..} = do
  now <- round <$> getPOSIXTime :: IO Integer
  let expiration = now + 3600 -- 1-hour expiry
  let claims =
        mempty
          { JWT.iss = JWT.stringOrURI clientEmail
          , JWT.aud = Left <$> JWT.stringOrURI tokenUri
          , JWT.exp = JWT.numericDate (fromIntegral expiration)
          , JWT.iat = JWT.numericDate (fromIntegral now)
          , JWT.unregisteredClaims = 
                JWT.ClaimsMap (Map.singleton "scope" "https://www.googleapis.com/auth/cloud-platform")
          }

      mbPrivateKeyBS = JWT.readRsaSecret (TE.encodeUtf8 privateKey)
      jwtHeader =
        JWT.JOSEHeader
          { JWT.alg = Just JWT.RS256
          , JWT.typ = Just "jwt"
          , JWT.kid = Just privateKeyId
          , JWT.cty = Nothing
          }
  case mbPrivateKeyBS of
    Nothing -> pure Nothing
    Just pKey ->
      return $
        Just $
          JWT.encodeSigned
            (JWT.EncodeRSAPrivateKey pKey)
            jwtHeader
            claims

-- Exchange JWT for OAuth2 Access Token
fetchAccessToken :: Text -> IO (Maybe (BS.ByteString, Integer))
fetchAccessToken jwt = do
  now <- round <$> getPOSIXTime
  let reqBody = 
        "grant_type=urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Ajwt-bearer&assertion=" <> jwt
  initialRequest <- parseRequest "https://oauth2.googleapis.com/token"
  let request =
        setRequestHeaders
          [("Content-Type", "application/x-www-form-urlencoded")]
          ( setRequestMethod
              "POST"
              (setRequestBodyLBS (BSL.fromStrict $ TE.encodeUtf8 reqBody) initialRequest)
          )
  response <- httpLbs request
  print response
  let rBody = eitherDecodeStrict (BS.toStrict $ getResponseBody response) 
  case rBody of
    Right AccessTokenResp {..} -> return (Just (BS.pack accessToken, now + 3600)) -- Expiry set to 1 hour
    Left err -> do 
        print err
        return Nothing

-- Get a valid token (reuses existing one if valid)
getValidToken :: GoogleApplicationCred -> IO (Either String BS.ByteString)
getValidToken json_ = do
  now <- round <$> getPOSIXTime
  cached <- readIORef tokenCache
  case cached of
    Just (token, expiry) | now < expiry -> return (Right token) -- Return cached token
    _ -> do
      maybeJwt <- createJWT json_
      case maybeJwt of
        Just jwt -> do
          maybeToken <- fetchAccessToken jwt
          case maybeToken of
            Just (token, expiry) -> do
              writeIORef tokenCache (Just (token, expiry)) -- Cache new token
              return (Right token)
            Nothing -> return $ Left "Something went wrong with token 1"
        Nothing -> return $ Left "Something went wrong with token 2"

readJSONFile :: FilePath -> IO (Either String GoogleApplicationCred)
readJSONFile jsonFilePath = do
  eRes <- try $ readFile jsonFilePath
  case eRes of
    Left err -> pure . Left $ show (err :: IOError)
    Right content -> pure $ eitherDecodeStrict (BS.pack content)

genTokenViaPrintAccessToken :: IO (Either String BS.ByteString)
genTokenViaPrintAccessToken = do
  eRes <- try $ readProcess "gcloud" (words "auth print-access-token") []
  case eRes of
    Right s -> pure . Right $ BS.pack (init s)
    Left err -> pure . Left $ show (err :: IOError)

data RequestMethod = GET | POST | PATCH | DELETE
  deriving (Eq, Show)

data RequestOptions = RequestOptions
  { reqMethod :: RequestMethod
  , reqUrl :: String
  , mbReqHeaders :: Maybe RequestHeaders
  , mbQueryParams :: Maybe Query
  , mbReqBody :: Maybe BSL.ByteString
  , mbReqPath :: Maybe String
  }
  deriving (Eq, Show)

doRequest :: RequestOptions -> IO (Either String BSL.ByteString)
doRequest RequestOptions {..} = do
  token <- genAccessToken
  case token of
    Left err -> throwError (userError err)
    Right tokenBS -> do
      initialRequest <- parseRequest (reqUrl <> fromMaybe "" mbReqPath)
      let request =
            foldl
              (flip ($))
              initialRequest
              [ 
               maybe Prelude.id setRequestHeaders mbReqHeaders
              , maybe Prelude.id setRequestQueryString mbQueryParams
              , maybe Prelude.id setRequestBodyLBS mbReqBody
              , addRequestHeader "Authorization" ("Bearer " <> tokenBS)
              , setRequestMethod (BS.pack $ show reqMethod)
              ]
      eResp <- try $ httpLbs request
      case eResp of
        Left err -> pure $ Left (show (err :: HttpException))
        Right resp -> do
          let respStatus = getResponseStatusCode resp
              respBody = getResponseBody resp
          if respStatus <= 299 && respStatus >= 200
            then return $ Right respBody
            else return $ Left (T.unpack $ decodeUtf8 respBody)

doRequestJSON :: (FromJSON b) => RequestOptions -> IO (Either String b)
doRequestJSON reqOpts = do
  eResp <- doRequest reqOpts
  case eResp of
    Left err -> return $ Left err
    Right respBody -> do
      let respBody' = if BSL.null respBody then "{}" else respBody
      case eitherDecode respBody' of
        Left err -> pure $ Left err
        Right res -> pure $ Right res

toPath :: [String] -> String
toPath = foldl (\acc x -> acc <> "/" <> x) ""

googleLoggingUrl :: String
googleLoggingUrl = "https://logging.googleapis.com/v2"