packages feed

oauth2-jwt-bearer (empty) → 0.0.1

raw patch · 13 files changed

+836/−0 lines, 13 filesdep +Spock-coredep +aesondep +asyncsetup-changed

Dependencies added: Spock-core, aeson, async, base, bytestring, cryptonite, hedgehog, http-client, http-client-tls, http-types, jose, lens, mmorph, network, oauth2-jwt-bearer, streaming-commons, text, time, transformers, transformers-bifunctors, unordered-containers, warp, x509, x509-store

Files

+ ChangeLog.md view
@@ -0,0 +1,5 @@+# Revision history for oauth2-jwt-bearer++## 0.1.0.0  -- YYYY-mm-dd++* First version. Released on an unsuspecting world.
+ LICENSE view
@@ -0,0 +1,30 @@+Copyright (c) 2018, HotelKilo++All rights reserved.++Redistribution and use in source and binary forms, with or without+modification, are permitted provided that the following conditions are met:++    * Redistributions of source code must retain the above copyright+      notice, this list of conditions and the following disclaimer.++    * Redistributions in binary form must reproduce the above+      copyright notice, this list of conditions and the following+      disclaimer in the documentation and/or other materials provided+      with the distribution.++    * Neither the name of Mark Hibberd nor the names of other+      contributors may be used to endorse or promote products derived+      from this software without specific prior written permission.++THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS+"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT+LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR+A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT+OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,+SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT+LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,+DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY+THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT+(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE+OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ README.md view
@@ -0,0 +1,80 @@+# oauth2-jwt-bearer++This is an implementation of the jwt-bearer authorization grant flow+that is specified by the OAuth2 JWT profile in+[rfc7523](https://tools.ietf.org/html/rfc7523).++The goal is to implement a portable implementation of this flow that+can be used against multiple servers. Its goal is to be pretty+general, and has been tested against the [Google Cloud Platform OAuth2+implementation](https://developers.google.com/identity/protocols/OAuth2ServiceAccount),+and the [Smith implementation](http://smith.st/) as well as a generic+test server, but there may be a way to go. If you find a server that+this implementation doesn't work with, let me know and I will add a+test and address it.++### Why?++OAuth2 / OIDC flows are complicated enough that it warrants having an+implementation to fall back on. The scope of this library is one+specific flow to make the implementation manageable. It would be nice+to have a complete set of flow implentations, but the reality is that+OAuth2 doesn't really offer much in the terms of interoperability - it+is about consistency/security, not about interchangable+implementations - this means that implementing everything at once is a+somewhat lost battle. Restricting ourselves to this specific flow+allows us to provide something useful and possible.++### Stability++This library is new, and should have the disclaimers that normally+comes with that, but the API should be stable and is currently in+production level usage. The library will be maintained going forward.+++### Example++A crude example:++```+{-# LANGUAGE OverloadedStrings #-}+module Network.OAuth2.JWT.Client.Example where++import           Crypto.JWT (JWK)+import           Network.OAuth2.JWT.Client+import           Network.HTTP.Client (Manager)+++example :: Manager -> JWK -> IO (Either GrantError AccessToken)+example manager key =  do+  let+    endpoint = TokenEndpoint "https://www.googleapis.com/oauth2/v4/token"+    iss = Issuer "example@example.org"+    scopes = [Scope "profile"]+    aud = Audience "https://www.googleapis.com/oauth2/v4/token"+    expiry = ExpiresIn 3600+    claims = Claims iss Nothing aud scopes expiry []+  store <- newStore manager endpoint claims key+  grant store+```++The key function here is the `grant` function which is what you call+to get your access token.++The `grant` function obtains an access token, if we have already+aquired one (and it is still valid) we will re-use that token, if we+don't already have a token or the token has expired, we go and ask for+a new one.++This operation is safe to call from multiple threads. If we are using+a current token reads will happen concurrently, If we have to go to+the network the request will be serialised so that only one request is+made for a new token.++The access token can be used as a bearer token in an `Authorization`+header. See the specification for more details but it would be+something like:++```+Authorization: Bearer ${ACCESS_TOKEN}+```
+ Setup.hs view
@@ -0,0 +1,2 @@+import Distribution.Simple+main = defaultMain
+ oauth2-jwt-bearer.cabal view
@@ -0,0 +1,78 @@+name: oauth2-jwt-bearer+version: 0.0.1+synopsis: OAuth2 jwt-bearer client flow as per rfc7523+homepage: https://github.com/smith-security/oauth2-jwt-bearer+license:  BSD3+license-file: LICENSE+author: Mark Hibberd+maintainer: mth@smith.st+bug-reports: https://github.com/smith-security/oauth2-jwt-bearer/issues+copyright: (c) 2018, HotelKilo+category: Network+build-type: Simple+extra-source-files: ChangeLog.md, README.md+cabal-version: >= 1.10+description:+  This is an implementation of the jwt-bearer authorization grant flow+  that is specified by the OAuth2 JWT profile in+  <https://tools.ietf.org/html/rfc7523 rfc7523>.+source-repository head+  type:     git+  location: git@github.com:smith-security/oauth2-jwt-bearer.git++library+  default-language: Haskell2010+  build-depends:+      aeson >= 1.0 && < 1.5+    , base >= 4.10 && < 5+    , bytestring == 0.10.*+    , http-client >= 0.5 && < 0.6+    , http-client-tls >= 0.2 && < 0.4+    , http-types == 0.*+    , lens == 4.*+    , text == 1.*+    , time == 1.*+    , transformers >= 0.4 && < 0.6+    , transformers-bifunctors == 0.*+    , unordered-containers == 0.2.*+    , jose == 0.7.*++  hs-source-dirs:+    src++  exposed-modules:+    Network.OAuth2.JWT.Client+    Network.OAuth2.JWT.Client.Data+    Network.OAuth2.JWT.Client.Example+    Network.OAuth2.JWT.Client.Serial+    Network.OAuth2.JWT.Client.AuthorizationGrant++test-suite test+  default-language: Haskell2010+  type: exitcode-stdio-1.0+  main-is: test.hs+  hs-source-dirs: test+  build-depends:+      aeson+    , async+    , base >= 4.10 && < 5+    , bytestring+    , cryptonite+    , hedgehog == 0.6.*+    , http-client+    , http-client-tls+    , http-types+    , jose+    , mmorph == 1.*+    , network+    , oauth2-jwt-bearer+    , Spock-core+    , streaming-commons+    , text+    , warp+    , x509+    , x509-store++  other-modules:+    Test.Network.OAuth2.JWT.Client.TestServer+    Test.Network.OAuth2.JWT.Client.AuthorizationGrant
+ src/Network/OAuth2/JWT/Client.hs view
@@ -0,0 +1,71 @@+-- |+--+-- This is an implementation of the jwt-bearer authorization grant+-- flow that is specified by the OAuth2 JWT profile in+-- <https://tools.ietf.org/html/rfc7523 rfc7523>.+--+-- This module includes everything you should need to implement an+-- integration and obtain an access token.+--+-- > {-# LANGUAGE OverloadedStrings #-}+-- >+-- > import           Crypto.JWT (JWK)+-- > import           Network.OAuth2.JWT.Client+-- > import           Network.HTTP.Client (Manager)+--+-- The key function here is the 'grant' function which is what you call+-- to get your access token.+--+-- The 'grant' function obtains an access token, if we have already+-- aquired one (and it is still valid) we will re-use that token, if we+-- don't already have a token or the token has expired, we go and ask for+-- a new one.+--+-- > example :: Manager -> JWK -> IO (Either GrantError AccessToken)+-- > example manager key =  do+-- >   let+-- >     endpoint = TokenEndpoint "https://www.googleapis.com/oauth2/v4/token"+-- >     iss = Issuer "example@example.org"+-- >     scopes = [Scope "profile"]+-- >     aud = Audience "https://www.googleapis.com/oauth2/v4/token"+-- >     expiry = ExpiresIn 3600+-- >     claims = Claims iss Nothing aud scopes expiry []+-- >   store <- newStore manager endpoint claims key+-- >   grant store+--+-- This operation is safe to call from multiple threads. If we are using+-- a current token reads will happen concurrently, If we have to go to+-- the network the request will be serialised so that only one request is+-- made for a new token.+--+-- The access token can be used as a bearer token in an @Authorization@+-- header. See the specification for more details but it would be something+-- like:+--+-- @+-- Authorization: Bearer ${ACCESS_TOKEN}+-- @+--+--+module Network.OAuth2.JWT.Client (+  -- * Obtain an access token+    GrantError (..)+  , AccessToken (..)+  , grant++  -- * Claims+  , Issuer (..)+  , Scope (..)+  , Audience (..)+  , Subject (..)+  , ExpiresIn (..)+  , Claims (..)++  -- * Configuration+  , TokenEndpoint (..)+  , Store+  , newStore+  ) where++import Network.OAuth2.JWT.Client.Data+import Network.OAuth2.JWT.Client.AuthorizationGrant
+ src/Network/OAuth2/JWT/Client/AuthorizationGrant.hs view
@@ -0,0 +1,147 @@+-- |+-- Authorization grant flow implementation. You probably want 'Network.OAuth2.JWT.Client'.+--+{-# LANGUAGE OverloadedStrings #-}+module Network.OAuth2.JWT.Client.AuthorizationGrant (+    GrantError (..)+  , sign+  , refresh+  , local+  , grant+  ) where+++import qualified Control.Concurrent.MVar as MVar+import           Control.Lens ((.~), (&))+import           Control.Monad.IO.Class (MonadIO (..))+import           Control.Monad.Trans.Bifunctor (BifunctorTrans (..))+import           Control.Monad.Trans.Except (ExceptT (..), runExceptT)++import           Crypto.JWT (JWK, JWTError)+import qualified Crypto.JWT as JWT++import qualified Data.Aeson as Aeson+import           Data.Bifunctor as X (Bifunctor(..))+import qualified Data.ByteString.Lazy as LazyByteString+import qualified Data.HashMap.Strict as HashMap+import           Data.String (IsString (..))+import           Data.Text (Text)+import qualified Data.Text as Text+import qualified Data.Text.Encoding as Text+import           Data.Time (UTCTime)+import qualified Data.Time as Time++import           Network.OAuth2.JWT.Client.Data+import qualified Network.OAuth2.JWT.Client.Serial as Serial++import qualified Network.HTTP.Client as HTTP+import qualified Network.HTTP.Types as HTTP++data GrantError =+    SerialisationGrantError Text+  | JWTGrantError JWT.JWTError+  | EndpointGrantError Text+  | StatusGrantError Int Text+    deriving (Eq, Show)++-- |+-- Obtain an access token, if we have already aquired one (and+-- it is still valid) we will re-use that token, if we don't+-- already have a token or the token has expired, we go and+-- ask for a new one.+--+-- This operation is safe to call from multiple threads. If we are+-- using a current token reads will happen concurrently, If we have to+-- go to the network the request will be serialised so that only one+-- request is made for a new token.+--+grant :: Store -> IO (Either GrantError AccessToken)+grant (Store manager endpoint claims jwk store) = do+  now <- Time.getCurrentTime+  t <- local now <$> MVar.readMVar store+  case t of+    Just token ->+      pure . Right $ token+    Nothing -> do+      MVar.modifyMVar store $ \state -> do+        case local now state of+          Just token ->+            pure (state, Right token)+          Nothing ->+            runExceptT (refresh now manager endpoint claims jwk) >>= \e -> case e of+               Left err ->+                 pure (state, Left err)+               Right (Response token expiry) ->+                 pure (HasToken token (Time.addUTCTime (getExpiresIn expiry) now), Right token)++-- |+-- Obtain an already aquired access token iff it is still valid.+--+local :: UTCTime -> TokenState -> Maybe AccessToken+local now state =+  case state of+    HasToken token time | now < time ->+      Just token+    HasToken _ _ ->+      Nothing+    NoToken ->+      Nothing++-- |+-- Request a new access token as per the specified claims.+--+-- This request is defined in <https://tools.ietf.org/html/rfc7523#section-2.1 2.1> of+-- <https://tools.ietf.org/html/rfc7523#section-2.1 rfc7523>.+--+refresh :: UTCTime -> HTTP.Manager -> TokenEndpoint -> Claims -> JWK -> ExceptT GrantError IO Response+refresh now manager endpoint claims jwk = do+  assertion <- firstT JWTGrantError $+    sign now claims jwk+  req <- ExceptT . pure . first (EndpointGrantError . Text.pack . show) $+    HTTP.parseRequest (Text.unpack . getTokenEndpoint $ endpoint)+  res <- liftIO $ flip HTTP.httpLbs manager $+    HTTP.urlEncodedBody [+        ("grant_type", "urn:ietf:params:oauth:grant-type:jwt-bearer")+      , ("assertion", getAssertion assertion)+      ] $ req { HTTP.requestHeaders = [+        ("Accept", "application/json")+      ] }+  case HTTP.statusCode . HTTP.responseStatus $ res of+    200 ->+      ExceptT . pure . first SerialisationGrantError $+        Serial.response (HTTP.responseBody res)+    status ->+      ExceptT . pure . Left $ StatusGrantError status (Text.decodeUtf8 . LazyByteString.toStrict . HTTP.responseBody $ res)++-- |+-- Sign a JWT with the specified claims and key.+--+-- The format and signature of the JWT are defined by+-- <https://tools.ietf.org/html/rfc7519 rfc7519>.+--+-- The specific of the claims are defined by the OAuth2+-- JWT Profile <https://tools.ietf.org/html/rfc7523 rfc7523>.+--+sign :: UTCTime -> Claims -> JWK -> ExceptT JWTError IO Assertion+sign now (Claims issuer subject audience scopes expires custom) jwk = do+  let+    format =+      fromString . Text.unpack++    header =+      JWT.newJWSHeader ((), JWT.RS256)+        & JWT.typ .~ Just (JWT.HeaderParam () "JWT")++    claims =+      JWT.emptyClaimsSet+        & JWT.claimIss .~ Just (format . getIssuer $ issuer)+        & JWT.claimSub .~ fmap (format . getSubject) subject+        & JWT.claimAud .~ Just (JWT.Audience [format . getAudience $ audience])+        & JWT.claimIat .~ Just (JWT.NumericDate now)+        & JWT.claimExp .~ Just (JWT.NumericDate $ Time.addUTCTime (getExpiresIn expires) now)+        & JWT.unregisteredClaims .~ (HashMap.fromList $ [+            ("scope", Aeson.toJSON . Text.intercalate " " $ getScope <$> scopes)+          ] ++ custom)++  signed <- JWT.signClaims jwk header claims+  pure . Assertion . LazyByteString.toStrict . JWT.encodeCompact $ signed
+ src/Network/OAuth2/JWT/Client/Data.hs view
@@ -0,0 +1,115 @@+-- |+-- Data types. You probably want 'Network.OAuth2.JWT.Client'.+--+module Network.OAuth2.JWT.Client.Data (+  -- * Claims+    Issuer (..)+  , Scope (..)+  , Audience (..)+  , Subject (..)+  , Claims (..)++  -- * Configuration+  , TokenEndpoint (..)++  -- * Protocol+  , Assertion (..)+  , AccessToken (..)+  , ExpiresIn (..)+  , Response (..)++  -- * Client State+  , TokenState (..)+  , Store (..)+  , newStore+  ) where++import           Crypto.JWT (JWK)+import           Control.Concurrent.MVar (MVar)+import qualified Control.Concurrent.MVar as MVar++import qualified Data.Aeson as Aeson+import           Data.ByteString (ByteString)+import           Data.Text (Text)+import           Data.Time (NominalDiffTime, UTCTime)++import qualified Network.HTTP.Client as HTTP+++-- Claims --++newtype Issuer =+  Issuer {+      getIssuer :: Text+    } deriving (Eq, Ord, Show)++newtype Scope =+  Scope {+      getScope :: Text+    } deriving (Eq, Ord, Show)++newtype Audience =+  Audience {+      getAudience :: Text+    } deriving (Eq, Ord, Show)++newtype Subject =+  Subject {+      getSubject :: Text+    } deriving (Eq, Ord, Show)++data Claims =+  Claims {+      claimsIssuer :: Issuer+    , claimsSubject :: Maybe Subject+    , claimsAudience :: Audience+    , claimsScopes :: [Scope]+    , claimsExpires :: ExpiresIn+    , claimsCustom :: [(Text, Aeson.Value)]+    } deriving (Eq, Show)+++-- Configuration --++newtype TokenEndpoint =+  TokenEndpoint {+      getTokenEndpoint :: Text+    } deriving (Eq, Ord, Show)+++-- Protocol --++newtype Assertion =+  Assertion {+      getAssertion :: ByteString+    } deriving (Eq, Ord, Show)++newtype AccessToken =+  AccessToken {+      renderAccessToken :: Text+    } deriving (Eq, Ord, Show)++newtype ExpiresIn =+  ExpiresIn {+      getExpiresIn :: NominalDiffTime+    } deriving (Eq, Ord, Show)++data Response =+  Response {+      responseToken :: AccessToken+    , responseExpiry :: ExpiresIn+    } deriving (Eq, Ord, Show)+++-- Client State --++data TokenState =+    NoToken+  | HasToken AccessToken UTCTime++data Store =+  Store HTTP.Manager TokenEndpoint Claims JWK (MVar TokenState)++newStore :: HTTP.Manager -> TokenEndpoint -> Claims -> JWK -> IO Store+newStore manager endpoint claims jwk =+  Store manager endpoint claims jwk <$> MVar.newMVar NoToken
+ src/Network/OAuth2/JWT/Client/Example.hs view
@@ -0,0 +1,23 @@+-- |+-- Example, this is mainly for ensuring example compiles. You probably+-- want 'Network.OAuth2.JWT.Client'.+--+{-# LANGUAGE OverloadedStrings #-}+module Network.OAuth2.JWT.Client.Example where++import           Crypto.JWT (JWK)+import           Network.OAuth2.JWT.Client+import           Network.HTTP.Client (Manager)+++example :: Manager -> JWK -> IO (Either GrantError AccessToken)+example manager key =  do+  let+    endpoint = TokenEndpoint "https://www.googleapis.com/oauth2/v4/token"+    iss = Issuer "example@example.org"+    scopes = [Scope "profile"]+    aud = Audience "https://www.googleapis.com/oauth2/v4/token"+    expiry = ExpiresIn 3600+    claims = Claims iss Nothing aud scopes expiry []+  store <- newStore manager endpoint claims key+  grant store
+ src/Network/OAuth2/JWT/Client/Serial.hs view
@@ -0,0 +1,40 @@+-- |+-- Serialisation of token response. You probably want 'Network.OAuth2.JWT.Client'.+--+{-# LANGUAGE OverloadedStrings #-}+module Network.OAuth2.JWT.Client.Serial (+    response+  , parser+  ) where++import           Control.Monad (when)++import           Data.Aeson ((.:))+import qualified Data.Aeson as Aeson+import qualified Data.Aeson.Types as Aeson+import           Data.Bifunctor as X (Bifunctor(..))+import qualified Data.ByteString.Lazy as LazyByteString+import           Data.Text (Text)+import qualified Data.Text as Text++import           Network.OAuth2.JWT.Client.Data+++response :: LazyByteString.ByteString -> Either Text Response+response bytes =+  first Text.pack (Aeson.eitherDecode bytes) >>= \a' -> case Aeson.parse parser a' of+    Aeson.Success a -> pure a+    Aeson.Error msg -> Left . Text.pack $ msg++parser :: Aeson.Value -> Aeson.Parser Response+parser =+  Aeson.withObject "Response" $ \o ->+    Response+      <$> (fmap AccessToken $ o .: "access_token")+      <*> (fmap ExpiresIn $ o .: "expires_in")+      <* (o .: "token_type" >>= tokenType)++tokenType :: Text -> Aeson.Parser ()+tokenType t =+  when (t /= "Bearer") $+    fail "Unknown 'token_type' expected: 'Bearer'."
+ test/Test/Network/OAuth2/JWT/Client/AuthorizationGrant.hs view
@@ -0,0 +1,138 @@+{-# LANGUAGE OverloadedStrings #-}+{-# LANGUAGE TemplateHaskell #-}+module Test.Network.OAuth2.JWT.Client.AuthorizationGrant where++import           Control.Monad.IO.Class (MonadIO (..))++import qualified Crypto.JWT as JWT+import qualified Crypto.PubKey.RSA as Cryptonite++import           Data.Aeson ((.:))+import qualified Data.Aeson as Aeson+import qualified Data.ByteString as ByteString+import qualified Data.Maybe as Maybe+import           Data.Text (Text)+import qualified Data.Text as Text+import qualified Data.Text.Encoding as Text+import qualified Data.Text.IO as Text+import qualified Data.X509 as X509+import qualified Data.X509.Memory as X509++import           Hedgehog++import           Network.OAuth2.JWT.Client+import qualified Network.HTTP.Client.TLS as HTTP+import qualified Network.HTTP.Client as HTTP++import           System.IO (IO)+import qualified System.Environment as Environment++import qualified Test.Network.OAuth2.JWT.Client.TestServer as TestServer+++prop_success :: Property+prop_success =+  withTests 1 . property $ do+    token <- TestServer.withServer [] $ \endpoint -> do+      (_public, private) <- Cryptonite.generate 512 0x10001+      manager <- HTTP.newManager HTTP.tlsManagerSettings+      let+        iss = Issuer "iss"+        scopes = [Scope "profile"]+        aud = Audience "aud"+        expiry = ExpiresIn 3600+        claims = Claims iss Nothing aud scopes expiry []+        jwk = JWT.fromRSA private+      store <- newStore manager endpoint claims jwk+      grant store+    token === Right (AccessToken "default-token-3600")++prop_cache :: Property+prop_cache =+  withTests 1 . property $ do+    (token1, token2) <- TestServer.withServer [TestServer.good 3600 "a", TestServer.good 3600 "b"] $ \endpoint -> do+      (_public, private) <- Cryptonite.generate 512 0x10001+      manager <- HTTP.newManager HTTP.tlsManagerSettings+      let+        iss = Issuer "iss"+        scopes = [Scope "profile"]+        aud = Audience "aud"+        expiry = ExpiresIn 3600+        claims = Claims iss Nothing aud scopes expiry []+        jwk = JWT.fromRSA private+      store <- newStore manager endpoint claims jwk+      (,) <$> grant store <*> grant store+    token1 === Right (AccessToken "a")+    token1 === token2++prop_refresh :: Property+prop_refresh =+  withTests 1 . property $ do+    (token1, token2) <- TestServer.withServer [TestServer.good 0 "a", TestServer.good 3600 "b"] $ \endpoint -> do+      (_public, private) <- Cryptonite.generate 512 0x10001+      manager <- HTTP.newManager HTTP.tlsManagerSettings+      let+        iss = Issuer "iss"+        scopes = [Scope "profile"]+        aud = Audience "aud"+        expiry = ExpiresIn 0+        claims = Claims iss Nothing aud scopes expiry []+        jwk = JWT.fromRSA private+      store <- newStore manager endpoint claims jwk+      (,) <$> grant store <*> grant store+    token1 === Right (AccessToken "a")+    token2 === Right (AccessToken "b")++data ServiceAccountKey =+  ServiceAccountKey {+      clientEmail :: Text+    , clientId :: Text+    , privateKeyId :: Text+    , privateKey :: Text+    } deriving (Eq, Ord, Show)++instance Aeson.FromJSON ServiceAccountKey where+  parseJSON =+    Aeson.withObject "ServiceAccountKey" $ \o ->+      ServiceAccountKey+        <$> o .: "client_email"+        <*> o .: "client_id"+        <*> o .: "private_key_id"+        <*> o .: "private_key"++prop_google :: Property+prop_google =+  withTests 1 . property $ do+    e <- liftIO $ Environment.lookupEnv "GOOGLE_CREDENTIALS_JSON"+    case e of+      Nothing ->+        success+      Just file -> do+        manager <- liftIO $ HTTP.newManager HTTP.tlsManagerSettings+        blob <- liftIO $ ByteString.readFile file+        key <- evalEither  $ Aeson.eitherDecodeStrict blob+        jwk <- case Maybe.listToMaybe . X509.readKeyFileFromMemory . Text.encodeUtf8 . privateKey $ key of+          Just (X509.PrivKeyRSA k) ->+            pure $ JWT.fromRSA k+          _ ->+            failure+        let+          endpoint = TokenEndpoint "https://www.googleapis.com/oauth2/v4/token"+          iss = Issuer (clientEmail key)+          scopes = [Scope "profile"]+          aud = Audience "https://www.googleapis.com/oauth2/v4/token"+          expiry = ExpiresIn 3600+          claims = Claims iss Nothing aud scopes expiry []+        store <- liftIO $ newStore manager endpoint claims jwk+        token <- liftIO (grant store) >>= evalEither+        liftIO $+          Text.putStrLn "=== BEGIN TOKEN ==="+        liftIO $+          Text.putStrLn . Text.pack . show $ token+        liftIO $+          Text.putStrLn "=== END TOKEN ==="+++tests :: IO Bool+tests =+  checkParallel $$(discover)
+ test/Test/Network/OAuth2/JWT/Client/TestServer.hs view
@@ -0,0 +1,90 @@+{-# LANGUAGE OverloadedStrings #-}+{-# LANGUAGE ScopedTypeVariables #-}+module Test.Network.OAuth2.JWT.Client.TestServer (+    good+  , good3600+  , bad+  , withServer+  , withServerIO+  ) where++import           Control.Exception (bracket)+import qualified Control.Concurrent.Async as Async+import           Control.Monad.IO.Class (MonadIO (..))++import           Data.IORef (IORef)+import qualified Data.IORef as IORef+import           Data.Text (Text)+import qualified Data.Text as Text+import           Data.Aeson ((.=))+import qualified Data.Aeson as Aeson+import qualified Data.Streaming.Network as Network++import           Hedgehog++import           Network.OAuth2.JWT.Client+import qualified Network.Socket as Socket+import qualified Network.Wai.Handler.Warp as Warp++import qualified Network.HTTP.Types as HTTP++import qualified Web.Spock.Core as Spock++good :: Int -> Text -> (HTTP.Status, Aeson.Value)+good expires token =+  (HTTP.status200, Aeson.object [+      "token_type" .= ("Bearer" :: Text)+    , "expires_in" .= expires+    , "access_token" .= token+    ])++good3600 :: (HTTP.Status, Aeson.Value)+good3600 =+  good 3600 "default-token-3600"++bad :: (HTTP.Status, Aeson.Value)+bad =+  (HTTP.status400, Aeson.object [+      "error" .= ("invalid_grant" :: Text)+    ])++routes :: IORef [(HTTP.Status, Aeson.Value)] -> Spock.SpockT IO ()+routes responses = do+  Spock.post "oauth/token" $ do+    mflow <- Spock.param "grant_type"+    massertion <- Spock.param "assertion"+    case (massertion, mflow) of+      (Just _assertion, Just "urn:ietf:params:oauth:grant-type:jwt-bearer") -> do+        (status, response) <- liftIO $ IORef.atomicModifyIORef responses $ \rs -> case rs of+          [] ->+            ([], good3600)+          (x:xs) ->+            (xs, x)+        Spock.setStatus status+        Spock.json response+      (_ :: Maybe Text, _ :: Maybe Text) -> do+        Spock.setStatus HTTP.status400+        Spock.json $ Aeson.object [+            "error" .= ("invalid_grant" :: Text)+          ]++withServer :: (MonadIO m, MonadTest m) => [(HTTP.Status, Aeson.Value)] -> (TokenEndpoint -> IO a) -> m a+withServer responses =+  evalIO . withServerIO responses++withServerIO :: [(HTTP.Status, Aeson.Value)] -> (TokenEndpoint -> IO a) -> IO a+withServerIO responses testing = do+  r <- IORef.newIORef responses+  app <- Spock.spockAsApp $ Spock.spockConfigT Spock.defaultSpockConfig id (routes r)+  Socket.withSocketsDo $+    bracket+      (Network.bindPortTCP 0 "127.0.0.1")+      Socket.close+      (\socket -> do+        name <- Socket.getSocketName socket+        case name of+          Socket.SockAddrInet port _ -> do+            Async.withAsync (Warp.runSettingsSocket Warp.defaultSettings socket app) $ \_ -> do+              testing . TokenEndpoint . mconcat $ ["http://localhost:", Text.pack . show $ port, "/oauth/token"]+          _ -> do+            error "<invariant> forcing inet above.")
+ test/test.hs view
@@ -0,0 +1,17 @@+{-# LANGUAGE NoImplicitPrelude #-}++import           Control.Monad ((>>=), (>>), when, mapM)++import           Prelude (($), (.), not, all, id)++import qualified System.Exit as Exit+import           System.IO (IO)+import qualified System.IO as IO++import qualified Test.Network.OAuth2.JWT.Client.AuthorizationGrant++main :: IO ()+main =+  IO.hSetBuffering IO.stdout IO.LineBuffering >> mapM id [+      Test.Network.OAuth2.JWT.Client.AuthorizationGrant.tests+    ] >>= \rs -> when (not . all id $ rs) Exit.exitFailure