From e84eea4bcf4ce8a1a80ddb4e93c577fd156e56b8 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 29 Jun 2026 10:07:40 +0000 Subject: [PATCH 1/2] feat: add Fleet Engine (ODRD) REST client, config and JWT auth Add Kernel.External.FleetEngine.{Types,Config,Client,Auth} providing a Servant client for the Google Fleet Engine (On-demand Rides & Deliveries) trip API (createTrip / updateTrip / updateTripStatus / assignVehicleAndStart), a per-city FleetEngineCfg (encrypted service-account JSON), and self-signed scoped-JWT minting (consumer/driver/server) reusing Kernel.Utils.JWT. createTrip is keyed on the BPP ride id so re-issues are idempotent (ALREADY_EXISTS treated as success). Foundation for rider/driver journey sharing; no call sites yet. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01R4VptpJt2kd4GQSDXcWL1c --- lib/mobility-core/mobility-core.cabal | 4 + .../src/Kernel/External/FleetEngine/Auth.hs | 52 ++++++ .../src/Kernel/External/FleetEngine/Client.hs | 148 ++++++++++++++++++ .../src/Kernel/External/FleetEngine/Config.hs | 48 ++++++ .../src/Kernel/External/FleetEngine/Types.hs | 96 ++++++++++++ lib/mobility-core/src/Kernel/Utils/JWT.hs | 43 +++++ 6 files changed, 391 insertions(+) create mode 100644 lib/mobility-core/src/Kernel/External/FleetEngine/Auth.hs create mode 100644 lib/mobility-core/src/Kernel/External/FleetEngine/Client.hs create mode 100644 lib/mobility-core/src/Kernel/External/FleetEngine/Config.hs create mode 100644 lib/mobility-core/src/Kernel/External/FleetEngine/Types.hs diff --git a/lib/mobility-core/mobility-core.cabal b/lib/mobility-core/mobility-core.cabal index 6711d9134..429eeec4f 100644 --- a/lib/mobility-core/mobility-core.cabal +++ b/lib/mobility-core/mobility-core.cabal @@ -107,6 +107,10 @@ library Kernel.External.Infobip.API.WebengageWebhook Kernel.External.Infobip.Flow Kernel.External.Infobip.Types + Kernel.External.FleetEngine.Auth + Kernel.External.FleetEngine.Client + Kernel.External.FleetEngine.Config + Kernel.External.FleetEngine.Types Kernel.External.Insurance.Acko.Flow Kernel.External.Insurance.Acko.Types Kernel.External.Insurance.IffcoTokio.Config diff --git a/lib/mobility-core/src/Kernel/External/FleetEngine/Auth.hs b/lib/mobility-core/src/Kernel/External/FleetEngine/Auth.hs new file mode 100644 index 000000000..1c1b097ce --- /dev/null +++ b/lib/mobility-core/src/Kernel/External/FleetEngine/Auth.hs @@ -0,0 +1,52 @@ +{- + Copyright 2022-23, Juspay India Pvt Ltd + + This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License + + as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is + + distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS + + FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. You should have received a copy of the GNU Affero + + General Public License along with this program. If not, see . +-} + +module Kernel.External.FleetEngine.Auth + ( TokenScope (..), + fleetEngineAudience, + parseServiceAccount, + mintFleetEngineToken, + ) +where + +import qualified Data.Aeson as A +import qualified Data.Text.Encoding as TE +import Kernel.Prelude +import qualified Kernel.Utils.JWT as JWT + +-- | Audience for self-signed Fleet Engine JWTs (server, driver and consumer). +fleetEngineAudience :: Text +fleetEngineAudience = "https://fleetengine.googleapis.com/" + +-- | What a minted token is scoped to. Consumer/driver tokens carry an +-- @authorization@ claim restricting them to a single trip/vehicle; server +-- tokens carry no restriction (the service-account role grants access). +data TokenScope + = ConsumerToken Text -- ^ scoped to a tripId + | DriverToken Text -- ^ scoped to a vehicleId + | ServerToken + +-- | Decode the (decrypted) service-account JSON text. +parseServiceAccount :: Text -> Either String JWT.ServiceAccount +parseServiceAccount = A.eitherDecodeStrict . TE.encodeUtf8 + +-- | Mint a short-lived self-signed Fleet Engine JWT for the given scope. +mintFleetEngineToken :: JWT.ServiceAccount -> TokenScope -> Integer -> IO (Either String Text) +mintFleetEngineToken sa scope ttlSeconds = + JWT.createSignedJWTWithClaims sa fleetEngineAudience ttlSeconds (authClaims scope) + where + authClaims :: TokenScope -> [(Text, A.Value)] + authClaims (ConsumerToken tripId) = [("authorization", A.object ["tripid" A..= tripId])] + authClaims (DriverToken vehicleId) = [("authorization", A.object ["vehicleid" A..= vehicleId])] + authClaims ServerToken = [] diff --git a/lib/mobility-core/src/Kernel/External/FleetEngine/Client.hs b/lib/mobility-core/src/Kernel/External/FleetEngine/Client.hs new file mode 100644 index 000000000..24448f0b0 --- /dev/null +++ b/lib/mobility-core/src/Kernel/External/FleetEngine/Client.hs @@ -0,0 +1,148 @@ +{- + Copyright 2022-23, Juspay India Pvt Ltd + + This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License + + as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is + + distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS + + FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. You should have received a copy of the GNU Affero + + General Public License along with this program. If not, see . +-} + +module Kernel.External.FleetEngine.Client + ( defaultFleetEngineBaseUrl, + createTrip, + updateTrip, + updateTripStatus, + assignVehicleAndStart, + ) +where + +import qualified Data.Aeson as A +import qualified Data.Text as T +import EulerHS.Types (EulerClient, client) +import Kernel.External.FleetEngine.Types +import Kernel.Prelude +import Kernel.Tools.Metrics.CoreMetrics (CoreMetrics) +import Kernel.Types.Common +import Kernel.Utils.Common +import Servant hiding (throwError) +import Servant.Client (Scheme (..)) + +type CreateTripAPI = + "v1" + :> "providers" + :> Capture "providerId" Text + :> "trips" + :> MandatoryQueryParam "tripId" Text + :> Header "Authorization" Text + :> ReqBody '[JSON] Trip + :> Post '[JSON] A.Value + +type UpdateTripAPI = + "v1" + :> "providers" + :> Capture "providerId" Text + :> "trips" + :> Capture "tripId" Text + :> MandatoryQueryParam "updateMask" Text + :> Header "Authorization" Text + :> ReqBody '[JSON] Trip + :> Patch '[JSON] A.Value + +defaultFleetEngineBaseUrl :: BaseUrl +defaultFleetEngineBaseUrl = + BaseUrl + { baseUrlScheme = Https, + baseUrlHost = "fleetengine.googleapis.com", + baseUrlPort = 443, + baseUrlPath = "" + } + +createTripClient :: Text -> Text -> Maybe Text -> Trip -> EulerClient A.Value +createTripClient = client (Proxy :: Proxy CreateTripAPI) + +updateTripClient :: Text -> Text -> Text -> Maybe Text -> Trip -> EulerClient A.Value +updateTripClient = client (Proxy :: Proxy UpdateTripAPI) + +bearer :: Text -> Maybe Text +bearer token = Just ("Bearer " <> token) + +-- | Create a Fleet Engine trip. @tripId@ is the (1:1) BPP ride id, which makes +-- this idempotent: a re-issued CreateTrip for an existing trip returns +-- ALREADY_EXISTS and is treated as success. +createTrip :: + (CoreMetrics m, MonadFlow m, MonadReader r m, HasRequestId r) => + BaseUrl -> + Text -> -- providerId + Text -> -- token (server JWT) + Text -> -- tripId + Trip -> + m () +createTrip baseUrl providerId token tripId trip = do + result <- + callAPI + baseUrl + (createTripClient providerId tripId (bearer token) trip) + "fleetEngineCreateTrip" + (Proxy :: Proxy CreateTripAPI) + case result of + Right _ -> logInfo $ "FleetEngine: created trip " <> tripId + Left err + | "ALREADY_EXISTS" `T.isInfixOf` show err -> + logInfo $ "FleetEngine: trip already exists (idempotent no-op) " <> tripId + | otherwise -> logError $ "FleetEngine: createTrip failed for " <> tripId <> ": " <> show err + +-- | PATCH a trip with the given field mask and body. +updateTrip :: + (CoreMetrics m, MonadFlow m, MonadReader r m, HasRequestId r) => + BaseUrl -> + Text -> -- providerId + Text -> -- token (server JWT) + Text -> -- tripId + Text -> -- updateMask (comma-separated field paths) + Trip -> + m () +updateTrip baseUrl providerId token tripId updateMask trip = do + result <- + callAPI + baseUrl + (updateTripClient providerId tripId updateMask (bearer token) trip) + "fleetEngineUpdateTrip" + (Proxy :: Proxy UpdateTripAPI) + case result of + Right _ -> logInfo $ "FleetEngine: updated trip " <> tripId <> " [" <> updateMask <> "]" + Left err -> logError $ "FleetEngine: updateTrip failed for " <> tripId <> ": " <> show err + +-- | Convenience: advance a trip's status. +updateTripStatus :: + (CoreMetrics m, MonadFlow m, MonadReader r m, HasRequestId r) => + BaseUrl -> + Text -> + Text -> + Text -> + TripStatus -> + m () +updateTripStatus baseUrl providerId token tripId status = + updateTrip baseUrl providerId token tripId "tripStatus" (emptyTrip {tripStatus = Just status}) + +-- | Convenience: assign the vehicle to the trip and move it to ENROUTE_TO_PICKUP. +assignVehicleAndStart :: + (CoreMetrics m, MonadFlow m, MonadReader r m, HasRequestId r) => + BaseUrl -> + Text -> + Text -> + Text -> -- tripId + Text -> -- vehicleId + m () +assignVehicleAndStart baseUrl providerId token tripId vehicleId = + updateTrip + baseUrl + providerId + token + tripId + "tripStatus,vehicleId" + (emptyTrip {tripStatus = Just ENROUTE_TO_PICKUP, vehicleId = Just vehicleId}) diff --git a/lib/mobility-core/src/Kernel/External/FleetEngine/Config.hs b/lib/mobility-core/src/Kernel/External/FleetEngine/Config.hs new file mode 100644 index 000000000..90c7fa643 --- /dev/null +++ b/lib/mobility-core/src/Kernel/External/FleetEngine/Config.hs @@ -0,0 +1,48 @@ +{- + Copyright 2022-23, Juspay India Pvt Ltd + + This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License + + as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is + + distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS + + FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. You should have received a copy of the GNU Affero + + General Public License along with this program. If not, see . +-} + +module Kernel.External.FleetEngine.Config where + +import Kernel.External.Encryption +import Kernel.Prelude + +-- | Per-merchant-operating-city Fleet Engine (On-demand Rides & Deliveries) +-- configuration. Modelled on 'Kernel.External.Maps.Google.Config.GoogleCfg': +-- the service-account JSON is stored encrypted and decrypted server-side only +-- (it is never shipped to apps; the backend mints short-lived scoped JWTs from +-- it). Absence of this config in a city is treated as "feature off" by callers. +data FleetEngineCfg = FleetEngineCfg + { -- | GCP project id that owns the Fleet Engine provider (the @providers/{id}@ path segment) + providerId :: Text, + -- | Fleet Engine REST host; defaults to https://fleetengine.googleapis.com when unset + fleetEngineUrl :: Maybe BaseUrl, + -- | Service-account JSON (raw JSON text), encrypted at rest + serviceAccountJson :: EncryptedField 'AsEncrypted Text, + -- | TTL (seconds) for consumer (rider) SDK tokens; defaults to 'defaultConsumerTokenTtl' + consumerTokenTtlSeconds :: Maybe Integer, + -- | TTL (seconds) for driver SDK tokens; defaults to 'defaultDriverTokenTtl' + driverTokenTtlSeconds :: Maybe Integer, + -- | TTL (seconds) for server-to-server tokens; defaults to 'defaultServerTokenTtl' + serverTokenTtlSeconds :: Maybe Integer + } + deriving (Generic, Show, Eq, ToJSON, FromJSON) + +defaultConsumerTokenTtl :: Integer +defaultConsumerTokenTtl = 15 * 60 + +defaultDriverTokenTtl :: Integer +defaultDriverTokenTtl = 60 * 60 + +defaultServerTokenTtl :: Integer +defaultServerTokenTtl = 60 * 60 diff --git a/lib/mobility-core/src/Kernel/External/FleetEngine/Types.hs b/lib/mobility-core/src/Kernel/External/FleetEngine/Types.hs new file mode 100644 index 000000000..a57916efc --- /dev/null +++ b/lib/mobility-core/src/Kernel/External/FleetEngine/Types.hs @@ -0,0 +1,96 @@ +{- + Copyright 2022-23, Juspay India Pvt Ltd + + This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General Public License + + as published by the Free Software Foundation, either version 3 of the License, or (at your option) any later version. This program is + + distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS + + FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more details. You should have received a copy of the GNU Affero + + General Public License along with this program. If not, see . +-} + +module Kernel.External.FleetEngine.Types where + +import qualified Data.Aeson as A +import Kernel.Prelude + +-- | Lifecycle of a Fleet Engine trip. JSON values must match the Fleet Engine +-- REST enum spelling exactly (the constructor names are used verbatim). +data TripStatus + = UNKNOWN_TRIP_STATUS + | NEW + | ENROUTE_TO_PICKUP + | ARRIVED_AT_PICKUP + | ENROUTE_TO_INTERMEDIATE_DESTINATION + | ARRIVED_AT_INTERMEDIATE_DESTINATION + | ENROUTE_TO_DROPOFF + | COMPLETE + | CANCELED + deriving (Show, Eq, Generic, ToJSON, FromJSON) + +data TripType + = UNKNOWN_TRIP_TYPE + | SHARED + | EXCLUSIVE + deriving (Show, Eq, Generic, ToJSON, FromJSON) + +data LatLng = LatLng + { latitude :: Double, + longitude :: Double + } + deriving (Show, Eq, Generic, ToJSON, FromJSON) + +-- | A Fleet Engine TerminalLocation (only the @point@ is required for our use). +newtype TerminalLocation = TerminalLocation + { point :: LatLng + } + deriving (Show, Eq, Generic, ToJSON, FromJSON) + +-- | The subset of the Fleet Engine @Trip@ resource we read/write. All fields are +-- optional so the same record serves as both the CreateTrip body and the +-- (masked) UpdateTrip body. @Nothing@ fields are omitted from the JSON so they +-- never clobber server state on a PATCH. +data Trip = Trip + { tripType :: Maybe TripType, + tripStatus :: Maybe TripStatus, + vehicleId :: Maybe Text, + numberOfPassengers :: Maybe Int, + pickupPoint :: Maybe TerminalLocation, + dropoffPoint :: Maybe TerminalLocation + } + deriving (Show, Eq, Generic) + +tripJSONOptions :: A.Options +tripJSONOptions = A.defaultOptions {A.omitNothingFields = True} + +instance ToJSON Trip where + toJSON = A.genericToJSON tripJSONOptions + +instance FromJSON Trip where + parseJSON = A.genericParseJSON tripJSONOptions + +emptyTrip :: Trip +emptyTrip = + Trip + { tripType = Nothing, + tripStatus = Nothing, + vehicleId = Nothing, + numberOfPassengers = Nothing, + pickupPoint = Nothing, + dropoffPoint = Nothing + } + +-- | Build the CreateTrip body. Fleet Engine requires @tripType@; pickup/dropoff +-- are optional but improve ETA quality. +mkCreateTripBody :: TripType -> Maybe LatLng -> Maybe LatLng -> Maybe Int -> Trip +mkCreateTripBody tType mbPickup mbDropoff mbPassengers = + emptyTrip + { tripType = Just tType, + tripStatus = Just NEW, + pickupPoint = TerminalLocation <$> mbPickup, + dropoffPoint = TerminalLocation <$> mbDropoff, + numberOfPassengers = mbPassengers + } diff --git a/lib/mobility-core/src/Kernel/Utils/JWT.hs b/lib/mobility-core/src/Kernel/Utils/JWT.hs index bb840bb5d..0098d7faf 100644 --- a/lib/mobility-core/src/Kernel/Utils/JWT.hs +++ b/lib/mobility-core/src/Kernel/Utils/JWT.hs @@ -106,6 +106,49 @@ createJWT sa additionalClaims = do } pure $ Right (searchRequest, encodeSigned key jwtHeader searchRequest) +-- | Mint a self-signed RS256 JWT for a service account with a caller-supplied +-- audience, TTL and arbitrary additional (unregistered) claims. +-- +-- Unlike 'createJWT'/'doRefreshToken' (which target Google's OAuth token +-- endpoint and exchange the assertion for an access token), this returns the +-- signed JWT directly. It is used for APIs that accept self-signed service +-- account JWTs as bearer credentials (e.g. Fleet Engine), where the audience is +-- the API host and custom claims scope the token (e.g. @authorization.tripid@). +createSignedJWTWithClaims :: + ServiceAccount -> + T.Text -> -- audience (e.g. "https://fleetengine.googleapis.com/") + Integer -> -- ttl in seconds + [(Text, Value)] -> -- additional unregistered claims + IO (Either String Text) +createSignedJWTWithClaims sa audience ttlSeconds additionalClaims = do + let issuer = stringOrURI . saClientEmail $ sa + let subject = stringOrURI . saClientEmail $ sa + let audience' = Left <$> stringOrURI audience + let unregisteredClaims = ClaimsMap $ Map.fromList additionalClaims + let jwtHeader = + JOSEHeader + { typ = Just "JWT", + cty = Nothing, + alg = Just RS256, + kid = Just $ saPrivateKeyId sa + } + case readRsaSecret . C8.pack $ saPrivateKey sa of + Nothing -> pure $ Left "Bad RSA key!" + Just pkey -> do + let key = EncodeRSAPrivateKey pkey + iat <- numericDate <$> getPOSIXTime + exp <- numericDate . (+ fromInteger ttlSeconds) <$> getPOSIXTime + let claims = + mempty + { exp = exp, + iat = iat, + iss = issuer, + sub = subject, + aud = audience', + unregisteredClaims = unregisteredClaims + } + pure $ Right (encodeSigned key jwtHeader claims) + -- | Prepare a request to the token URL jwtRequest :: T.Text -> BL.ByteString -> IO Request jwtRequest tokenUri body = do From 800c68c1bdac97be968209cf3587311ee3c9a728 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 1 Jul 2026 11:18:28 +0000 Subject: [PATCH 2/2] chore: apply ormolu formatting and hpack module ordering Satisfy the pre-commit CI hook: sort the FleetEngine modules alphabetically in the cabal, convert TokenScope trailing haddocks to leading, and fix the createTrip guard-body indentation. Co-Authored-By: Claude Opus 4.8 Claude-Session: https://claude.ai/code/session_01R4VptpJt2kd4GQSDXcWL1c --- lib/mobility-core/mobility-core.cabal | 8 ++++---- lib/mobility-core/src/Kernel/External/FleetEngine/Auth.hs | 6 ++++-- .../src/Kernel/External/FleetEngine/Client.hs | 2 +- lib/mobility-core/src/Kernel/Utils/JWT.hs | 4 ++-- 4 files changed, 11 insertions(+), 9 deletions(-) diff --git a/lib/mobility-core/mobility-core.cabal b/lib/mobility-core/mobility-core.cabal index 429eeec4f..070b8dff7 100644 --- a/lib/mobility-core/mobility-core.cabal +++ b/lib/mobility-core/mobility-core.cabal @@ -85,6 +85,10 @@ library Kernel.External.EventTracking.Moengage.Flow Kernel.External.EventTracking.Moengage.Types Kernel.External.EventTracking.Types + Kernel.External.FleetEngine.Auth + Kernel.External.FleetEngine.Client + Kernel.External.FleetEngine.Config + Kernel.External.FleetEngine.Types Kernel.External.GoogleTranslate.API Kernel.External.GoogleTranslate.Client Kernel.External.GoogleTranslate.Types @@ -107,10 +111,6 @@ library Kernel.External.Infobip.API.WebengageWebhook Kernel.External.Infobip.Flow Kernel.External.Infobip.Types - Kernel.External.FleetEngine.Auth - Kernel.External.FleetEngine.Client - Kernel.External.FleetEngine.Config - Kernel.External.FleetEngine.Types Kernel.External.Insurance.Acko.Flow Kernel.External.Insurance.Acko.Types Kernel.External.Insurance.IffcoTokio.Config diff --git a/lib/mobility-core/src/Kernel/External/FleetEngine/Auth.hs b/lib/mobility-core/src/Kernel/External/FleetEngine/Auth.hs index 1c1b097ce..1f1a12470 100644 --- a/lib/mobility-core/src/Kernel/External/FleetEngine/Auth.hs +++ b/lib/mobility-core/src/Kernel/External/FleetEngine/Auth.hs @@ -33,8 +33,10 @@ fleetEngineAudience = "https://fleetengine.googleapis.com/" -- @authorization@ claim restricting them to a single trip/vehicle; server -- tokens carry no restriction (the service-account role grants access). data TokenScope - = ConsumerToken Text -- ^ scoped to a tripId - | DriverToken Text -- ^ scoped to a vehicleId + = -- | scoped to a tripId + ConsumerToken Text + | -- | scoped to a vehicleId + DriverToken Text | ServerToken -- | Decode the (decrypted) service-account JSON text. diff --git a/lib/mobility-core/src/Kernel/External/FleetEngine/Client.hs b/lib/mobility-core/src/Kernel/External/FleetEngine/Client.hs index 24448f0b0..9b79d36a8 100644 --- a/lib/mobility-core/src/Kernel/External/FleetEngine/Client.hs +++ b/lib/mobility-core/src/Kernel/External/FleetEngine/Client.hs @@ -93,7 +93,7 @@ createTrip baseUrl providerId token tripId trip = do Right _ -> logInfo $ "FleetEngine: created trip " <> tripId Left err | "ALREADY_EXISTS" `T.isInfixOf` show err -> - logInfo $ "FleetEngine: trip already exists (idempotent no-op) " <> tripId + logInfo $ "FleetEngine: trip already exists (idempotent no-op) " <> tripId | otherwise -> logError $ "FleetEngine: createTrip failed for " <> tripId <> ": " <> show err -- | PATCH a trip with the given field mask and body. diff --git a/lib/mobility-core/src/Kernel/Utils/JWT.hs b/lib/mobility-core/src/Kernel/Utils/JWT.hs index 0098d7faf..c65d1a0b7 100644 --- a/lib/mobility-core/src/Kernel/Utils/JWT.hs +++ b/lib/mobility-core/src/Kernel/Utils/JWT.hs @@ -138,7 +138,7 @@ createSignedJWTWithClaims sa audience ttlSeconds additionalClaims = do let key = EncodeRSAPrivateKey pkey iat <- numericDate <$> getPOSIXTime exp <- numericDate . (+ fromInteger ttlSeconds) <$> getPOSIXTime - let claims = + let jwtClaims = mempty { exp = exp, iat = iat, @@ -147,7 +147,7 @@ createSignedJWTWithClaims sa audience ttlSeconds additionalClaims = do aud = audience', unregisteredClaims = unregisteredClaims } - pure $ Right (encodeSigned key jwtHeader claims) + pure $ Right (encodeSigned key jwtHeader jwtClaims) -- | Prepare a request to the token URL jwtRequest :: T.Text -> BL.ByteString -> IO Request