packages feed

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

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

{- |
Module      : Google.Cloud.Common.Core
License     : MIT
Maintainer  : maintainers@google-cloud-haskell
Stability   : experimental

Shared functionality and types used across Google Cloud client packages.

This module provides:

- Access token generation using either application credentials or metadata server
- A thin HTTP request layer with JSON helpers
- Shared request option types and utilities
- Convenience helpers for path building
-}
module Google.Cloud.Common.Core
  ( -- * Authentication
    genAccessToken

    -- * Request API
  , RequestOptions (..)
  , RequestMethod (..)
  , doRequest
  , doRequestJSON
  , doRequestRaw

    -- * Types
  , AccessTokenResp (..)
  , GoogleApplicationCred (..)

    -- * Utilities
  , toPath
  ) where

import Control.Exception (try)
import Data.Aeson
import qualified Data.ByteString.Char8 as BS
import qualified Data.ByteString.Lazy.Char8 as BSL
import Data.IORef
import qualified Data.Map.Strict as Map
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 qualified Web.JWT as JWT

{- | Google application credential JSON as produced by a service account key.

This structure corresponds to the JSON file pointed to by
the @GOOGLE_APPLICATION_CREDENTIALS@ environment variable.
Field names match the JSON keys, with minor adaptations for Haskell naming.
-}
data GoogleApplicationCred = GooGleApplicationCred
  { type_ :: Text
  -- ^ Type of the credential (usually @service_account@)
  , projectId :: Text
  -- ^ Project ID associated with the credential
  , privateKeyId :: Text
  -- ^ Private key ID
  , privateKey :: Text
  -- ^ PEM-encoded RSA private key
  , clientEmail :: Text
  -- ^ Service account email address
  , clientId :: Text
  -- ^ Client ID
  , authUri :: Text
  -- ^ OAuth2 authorization URI
  , tokenUri :: Text
  -- ^ OAuth2 token exchange URI
  , auth_provider_x509_cert_url :: Text
  -- ^ Auth provider certificate URL
  , client_x509_cert_url :: Text
  -- ^ Client certificate URL
  , universe_domain :: Text
  -- ^ Universe domain
  }
  deriving (Eq, Show)

-- | Response returned by OAuth2 token endpoints.
data AccessTokenResp = AccessTokenResp
  { accessToken :: String
  -- ^ Bearer access token
  , expiresIn :: Integer
  -- ^ Lifetime in seconds
  , tokenType :: String
  -- ^ Token type (usually @Bearer@)
  }
  deriving (Eq, Show)

instance FromJSON AccessTokenResp where
  parseJSON = withObject "AccessTokenResp" $ \v ->
    AccessTokenResp
      <$> v .: "access_token"
      <*> v .: "expires_in"
      <*> v .: "token_type"

instance FromJSON GoogleApplicationCred where
  parseJSON = withObject "GoogleApplicationCred" $ \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"

{- | Generate a Google Cloud access token.

Resolution order:

1. If @GOOGLE_APPLICATION_CREDENTIALS@ is set, read the JSON file and
   exchange a JWT for an OAuth2 access token.
2. Otherwise, fetch a token from the Compute metadata server (suitable for
   GCE/GKE environments with a default service account).

Returns 'Right' token on success, or 'Left' with an error message.
-}
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 -> getTokenFromMetadataServer

-- | In-memory cache of the latest token and its expiry epoch seconds.
{-# NOINLINE tokenCache #-}
tokenCache :: IORef (Maybe (BS.ByteString, Integer))
tokenCache = unsafePerformIO (newIORef Nothing)

-- | Create a signed JWT for the given credential with Cloud Platform scope.
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 a JWT for an OAuth2 access token. Returns token bytes and expiry.
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
  let rBody = eitherDecodeStrict (BSL.toStrict $ getResponseBody response)
  case rBody of
    Right AccessTokenResp {..} -> return (Just (BS.pack accessToken, now + 3600))
    Left err -> do
      print err
      return Nothing

-- | Get a valid token, reusing a cached one if it has not expired yet.
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)
    _ -> 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))
              return (Right token)
            Nothing -> return $ Left "Failed to exchange JWT for access token"
        Nothing -> return $ Left "Failed to create JWT from credentials"

-- | Read and decode a JSON credentials file into 'GoogleApplicationCred'.
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)

-- | Fetch an access token from the metadata server (GCE/GKE default SA).
getTokenFromMetadataServer :: IO (Either String BS.ByteString)
getTokenFromMetadataServer = do
  initialRequest <-
    parseRequest
      "http://metadata.google.internal/computeMetadata/v1/instance/service-accounts/default/token"
  let request =
        setRequestHeaders
          [("Metadata-Flavor", "Google")]
          ( setRequestMethod
              "GET"
              initialRequest
          )
  eResp <- try $ httpLbs request
  case eResp of
    Left (_ :: HttpException) ->
      pure $
        Left
          "No application credentials found and metadata server is unavailable. Set GOOGLE_APPLICATION_CREDENTIALS to a service account JSON or run on GCE/GKE with a default service account."
    Right response -> do
      let rBody = eitherDecodeStrict (BSL.toStrict $ getResponseBody response)
      case rBody of
        Right AccessTokenResp {..} -> return . Right $ BS.pack accessToken
        Left _ ->
          pure $ Left "Unexpected response from metadata server while fetching access token."

-- | Custom HTTP methods used in requests.
data RequestMethod = GET | POST | PATCH | DELETE | PUT
  deriving (Eq, Show)

{- | Options for performing an HTTP request.

You can construct this record and pass it to 'doRequest' or 'doRequestJSON'.
Unused optional fields can be 'Nothing'.
-}
data RequestOptions = RequestOptions
  { reqMethod :: RequestMethod
  -- ^ HTTP method to use
  , reqUrl :: String
  -- ^ Base URL (e.g. https://example.googleapis.com)
  , mbReqHeaders :: Maybe RequestHeaders
  -- ^ Optional request headers
  , mbQueryParams :: Maybe Query
  -- ^ Optional query string parameters
  , mbReqBody :: Maybe BSL.ByteString
  -- ^ Optional lazy bytestring request body
  , mbReqPath :: Maybe String
  -- ^ Optional path to append to 'reqUrl'
  }
  deriving (Eq, Show)

{- | Perform an HTTP request and return the raw 'Response' body on success.
Returns a textual error on failure.
-}
doRequestRaw :: RequestOptions -> IO (Either String (Response BSL.ByteString))
doRequestRaw RequestOptions {..} = do
  token <- genAccessToken
  case token of
    Left err -> pure (Left 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 -> pure $ Right resp

{- | Perform an HTTP request and return the response body.
Fails with an "HTTP <status>: <body>" error if the status is non-2xx.
-}
doRequest :: RequestOptions -> IO (Either String BSL.ByteString)
doRequest reqOpts = do
  eResp <- doRequestRaw reqOpts
  case eResp of
    Left err -> pure $ Left err
    Right resp -> do
      let respStatus = getResponseStatusCode resp
          respBody = getResponseBody resp
      if respStatus <= 299 && respStatus >= 200
        then return $ Right respBody
        else return $ Left ("HTTP " <> show respStatus <> ": " <> T.unpack (decodeUtf8 respBody))

{- | Perform an HTTP request and decode the response body as JSON.
An empty body is treated as @{}@ for convenience.
-}
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

{- | Build a URL path from path segments.

Example:

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