on master help sign in
11 files changed+271−39
since splitsnapshots
unifiedsplit
.gitignore+2−1 README.md+25−1 github-app.cabal+74 package.yaml+6−2 src/Crypto/PubKey/RSA/Read.hs+27 src/GitHub/App/Auth.hs+37−30 src/GitHub/App/Request.hs+4−2 src/GitHub/Data/Apps.hs+31 src/GitHub/Data/Installations.hs+34 stack.yaml+5−3 stack.yaml.lock+26
.gitignore+2−1
@@ -3,4 +3,5 @@
33cabal.project.local
44cabal.project.local~
55.ghc.environment.*
6−*.cabal
6+
7+.stack-work
README.md+25−1
@@ -1 +1,25 @@
1−# github-app
1+# Authenticate as a GitHub App
2+
3+This library provides functions that help with authenticating as a [GitHub App][ghapp].
4+
5+
6+## Use
7+
8+1. [Register][reg] your App with GitHub to obtain an App ID and a private key.
9+2. Using the data from the step above, create `AppAuth`.
10+3. For each installation of your app, get an `InstallationAuth` using `mkInstallationAuth`.
11+4. Use the `executeAppRequest` function to execute requests.
12+ Behind the scenes, this function exchanges the installation auth token for
13+ a regular access token, caches it, and renews as needed.
14+
15+See [serokell/github-ops-access][ghopsa] for a real-life example use.
16+
17+[ghapp]: https://developer.github.com/apps/
18+[reg]: https://github.com/settings/apps
19+[ghopsa]: https://github.com/serokell/github-ops-access/blob/test/webhook/Main.hs
20+
21+
22+## About Serokell
23+
24+This library is maintained and funded with ❤️ by [Serokell](https://serokell.io/).
25+The names and logo for Serokell are trademark of Serokell OÜ.
github-app.cabalnew file+74
@@ -0,0 +1,74 @@
1+cabal-version: 1.12
2+
3+-- This file has been generated from package.yaml by hpack version 0.38.1.
4+--
5+-- see: https://github.com/sol/hpack
6+
7+name: github-app
8+version: 0.0.1
9+synopsis: Authetnicate as a GitHub App
10+description: Please see the README on GitHub at <https://github.com/serokell/github-app#readme>
11+category: Network
12+homepage: https://github.com/serokell/github-app#readme
13+bug-reports: https://github.com/serokell/github-app/issues
14+author: Serokell
15+maintainer: Kirill Elagin <kirelagin@serokell.io>
16+copyright: 2018 Serokell
17+license: MPL-2.0
18+license-file: LICENSE
19+build-type: Simple
20+extra-source-files:
21+ README.md
22+ ChangeLog.md
23+
24+source-repository head
25+ type: git
26+ location: https://github.com/serokell/github-app
27+
28+library
29+ exposed-modules:
30+ Crypto.PubKey.RSA.Read
31+ GitHub.App.Auth
32+ GitHub.App.Request
33+ GitHub.Data.Apps
34+ GitHub.Data.Installations
35+ other-modules:
36+ Paths_github_app
37+ hs-source-dirs:
38+ src
39+ default-extensions:
40+ ApplicativeDo
41+ DeriveDataTypeable
42+ DeriveGeneric
43+ FlexibleContexts
44+ FlexibleInstances
45+ GeneralizedNewtypeDeriving
46+ LambdaCase
47+ MultiParamTypeClasses
48+ NamedFieldPuns
49+ OverloadedStrings
50+ RankNTypes
51+ RecordWildCards
52+ ScopedTypeVariables
53+ StandaloneDeriving
54+ TemplateHaskell
55+ TupleSections
56+ TypeApplications
57+ build-depends:
58+ aeson
59+ , base >=4.7 && <5
60+ , bytestring
61+ , cryptonite
62+ , github
63+ , http-client
64+ , http-client-tls
65+ , http-types
66+ , jwt >=0.8.0
67+ , mtl
68+ , safe-exceptions
69+ , tagged
70+ , text
71+ , time
72+ , x509
73+ , x509-store
74+ default-language: Haskell2010
package.yaml+6−2
@@ -15,6 +15,7 @@ extra-source-files:
1515
1616default-extensions:
1717 - ApplicativeDo
18+ - DeriveDataTypeable
1819 - DeriveGeneric
1920 - FlexibleContexts
2021 - FlexibleInstances
@@ -39,13 +40,16 @@ library:
3940 dependencies:
4041 - aeson
4142 - bytestring
42− - crypto-pubkey-types
43+ - cryptonite
4344 - github
4445 - http-client
4546 - http-client-tls
4647 - http-types
47− - jwt
48+ - jwt >= 0.8.0
4849 - mtl
4950 - safe-exceptions
51+ - tagged
5052 - text
5153 - time
54+ - x509
55+ - x509-store
src/Crypto/PubKey/RSA/Read.hsnew file+27
@@ -0,0 +1,27 @@
1+{- This Source Code Form is subject to the terms of the Mozilla Public
2+ - License, v. 2.0. If a copy of the MPL was not distributed with this
3+ - file, You can obtain one at http://mozilla.org/MPL/2.0/.
4+ -}
5+
6+module Crypto.PubKey.RSA.Read
7+ ( ReadRsaKeyError (..)
8+ , readRsaPem
9+ ) where
10+
11+import Crypto.PubKey.RSA (PrivateKey)
12+import Data.X509 (PrivKey (PrivKeyRSA))
13+import Data.ByteString (ByteString)
14+import Data.X509.Memory (readKeyFileFromMemory)
15+
16+
17+data ReadRsaKeyError
18+ = SingleKeyExpected { rrkeActualKeysCount :: Int }
19+ | NotRsaKey
20+
21+readRsaPem :: ByteString -> Either ReadRsaKeyError PrivateKey
22+readRsaPem bs =
23+ case readKeyFileFromMemory bs of
24+ [k] -> case k of
25+ PrivKeyRSA rsaKey -> Right rsaKey
26+ _ -> Left NotRsaKey
27+ l -> Left $ SingleKeyExpected (length l)
src/GitHub/App/Auth.hs+37−30
@@ -7,47 +7,48 @@
77{-# LANGUAGE DataKinds #-}
88
99module GitHub.App.Auth
10− ( InstallationAuth
10+ ( AppAuth (..)
11+
12+ , InstallationAuth
1113 , mkInstallationAuth
1214
1315 , obtainAccessToken
14−
15− , AppId
16− , InstallationId
1716 ) where
1817
1918import Prelude hiding (exp)
2019
2120import Control.Concurrent (MVar, newMVar, putMVar, readMVar, takeMVar)
2221import Control.Exception.Safe (bracketOnError, catch)
23−import Control.Monad.Except (MonadError (throwError), ExceptT, runExceptT)
24−import Data.Functor (($>))
25−import Crypto.Types.PubKey.RSA (PrivateKey)
22+import Control.Monad.Except (ExceptT, MonadError (throwError), runExceptT)
23+import Control.Monad.Trans (lift)
24+import Crypto.PubKey.RSA (PrivateKey)
2625import Data.Aeson (FromJSON (..), withObject, (.:))
26+import qualified Data.ByteString.Lazy as LBS
27+import Data.Functor (($>))
2728import Data.Maybe (fromMaybe)
2829import Data.Semigroup ((<>))
30+import Data.Tagged
2931import Data.Text (Text)
3032import qualified Data.Text as T
3133import Data.Text.Encoding (encodeUtf8)
3234import Data.Time (NominalDiffTime, UTCTime, diffUTCTime, getCurrentTime)
3335import Data.Time.Clock.POSIX (utcTimeToPOSIXSeconds)
34−import Control.Monad.Trans (lift)
35−import Data.Word (Word64)
3636import GitHub.Auth (Auth (OAuth))
37+import GitHub.Data.Apps (App)
3738import GitHub.Data.Definitions (Error (HTTPError))
38−import GitHub.Data.Request (StatusMap)
39−import GitHub.Request (parseResponse)
40−import Web.JWT (JWTClaimsSet (exp, iat, iss), Signer (RSAPrivateKey), encodeSigned,
41− numericDate, stringOrURI)
39+import GitHub.Data.Id (Id, untagId)
40+import GitHub.Data.Installations (Installation)
41+import GitHub.Request (StatusMap, parseResponseJSON)
4242import Network.HTTP.Client as HTTP
4343import qualified Network.HTTP.Client.Internal as HTTP
4444import qualified Network.HTTP.Types as HTTP
45−import qualified Data.ByteString.Lazy as LBS
45+import Web.JWT (JWTClaimsSet (exp, iat, iss), Signer (RSAPrivateKey), encodeSigned, numericDate,
46+ stringOrURI)
4647
4748
4849-- | JWT expiration time. Maximum accepted by GitHub is 10 minutes
4950jwtExpTime :: NominalDiffTime
50−jwtExpTime = 600
51+jwtExpTime = 300
5152
5253-- | Installation access token expiration time. It is fixed by GitHub and is equal to 1 hour
5354instKeyExpTime :: NominalDiffTime
@@ -64,10 +65,16 @@ baseUrl :: Text
6465baseUrl = "https://api.github.com"
6566
6667
68+-- | Credentials of a GitHub App
69+data AppAuth = AppAuth
70+ { aaAppId :: !(Id App)
71+ , aaPrivateKey :: !PrivateKey
72+ }
73+
6774-- | Cached GitHub installation access token
6875data InstallationToken = InstallationToken
69− { itToken :: Auth
70− , itExpirationTime :: UTCTime
76+ { itToken :: !Auth
77+ , itExpirationTime :: !UTCTime
7178 } deriving (Show)
7279
7380instance FromJSON InstallationToken where
@@ -77,7 +84,7 @@ instance FromJSON InstallationToken where
7784 <*> o .: "expires_at"
7885
7986
80−-- | Credentials required for an app to authenticate as an installation
87+-- | Credentials required for an App to authenticate as an installation
8188data InstallationAuth = InstallationAuth
8289 { iaClaimsSet :: !JWTClaimsSet -- ^ Prefilled claims set
8390 , iaAppPrivateKey :: !PrivateKey -- ^ Private key to sign token requests
@@ -86,20 +93,16 @@ data InstallationAuth = InstallationAuth
8693 }
8794
8895
89−type AppId = Word64
90−type InstallationId = Word64
91−
9296-- | Smart constructor for 'InstallationAuth'
9397mkInstallationAuth
94− :: AppId -- ^ GitHub App ID
95− -> PrivateKey -- ^ GitHub App Private Key
96− -> InstallationId -- ^ Installation ID
98+ :: AppAuth
99+ -> Id Installation
97100 -> IO InstallationAuth
98−mkInstallationAuth appId pk instId = do
101+mkInstallationAuth AppAuth{aaAppId, aaPrivateKey} instId = do
99102 varToken <- newMVar Nothing
100− let issuer = fromMaybe (error "impossible") . stringOrURI . T.pack . show $ appId
103+ let issuer = fromMaybe (error "impossible") . stringOrURI . T.pack . show . untagId $ aaAppId
101104 claimsSet = mempty { iss = Just issuer }
102− pure $ InstallationAuth claimsSet pk (T.pack . show $ instId) varToken
105+ pure $ InstallationAuth claimsSet aaPrivateKey (T.pack . show . untagId $ instId) varToken
103106
104107-- | Create a request which, when executed, will obtain a new access token
105108createAccessTokenR :: InstallationAuth -> IO HTTP.Request
@@ -110,11 +113,15 @@ createAccessTokenR InstallationAuth{..} = do
110113 { iat = Just $ toJsonTime currentTime
111114 , exp = Just $ toJsonTime expiryTime
112115 }
113− jwt = encodeSigned (RSAPrivateKey iaAppPrivateKey) claims
116+ jwt = encodeSigned (RSAPrivateKey iaAppPrivateKey) mempty claims
114117
115118 req <- HTTP.parseRequest . T.unpack $ url
116119 pure req
117− { HTTP.requestHeaders = [("Authorization", "Bearer " <> encodeUtf8 jwt)]
120+ { HTTP.requestHeaders =
121+ [ ("Authorization", "Bearer " <> encodeUtf8 jwt)
122+ , ("Accept", "application/vnd.github.machine-man-preview+json")
123+ , ("User-Agent", "github-app/Haskell")
124+ ]
118125 , HTTP.checkResponse = successOrMissing Nothing
119126 , HTTP.method = "POST"
120127 }
@@ -141,7 +148,7 @@ obtainAccessToken mgr ia@InstallationAuth{..} = readMVar iaToken >>= \case
141148 renew :: IO (Either Error Auth)
142149 renew = bracketOnError (takeMVar iaToken) (putMVar iaToken) $ \_ -> do
143150 req <- createAccessTokenR ia
144− result <- runExceptT $ httpLbs' req >>= parseResponse
151+ result <- runExceptT $ httpLbs' req >>= parseResponseJSON
145152 case result of
146153 Right newToken -> putMVar iaToken (Just newToken) $> Right (itToken newToken)
147154 Left err -> putMVar iaToken Nothing $> Left err
src/GitHub/App/Request.hs+4−2
@@ -10,6 +10,7 @@ module GitHub.App.Request
1010 , executeAppRequestWithMgr
1111 ) where
1212
13+import Data.Aeson (FromJSON)
1314import GitHub.Data (Error (..))
1415import GitHub.Data.Request (Request)
1516import GitHub.Request (executeRequestWithMgr)
@@ -19,7 +20,7 @@ import Network.HTTP.Client.TLS (newTlsManager)
1920import GitHub.App.Auth (InstallationAuth, obtainAccessToken)
2021
2122
22−executeAppRequest :: InstallationAuth -> Request k a -> IO (Either Error a)
23+executeAppRequest :: FromJSON a => InstallationAuth -> Request k a -> IO (Either Error a)
2324executeAppRequest instAuth req = do
2425 manager <- newTlsManager
2526 x <- executeAppRequestWithMgr manager instAuth req
@@ -29,7 +30,8 @@ executeAppRequest instAuth req = do
2930 pure x
3031
3132executeAppRequestWithMgr
32− :: Manager
33+ :: FromJSON a
34+ => Manager
3335 -> InstallationAuth
3436 -> Request k a
3537 -> IO (Either Error a)
src/GitHub/Data/Apps.hsnew file+31
@@ -0,0 +1,31 @@
1+{- This Source Code Form is subject to the terms of the Mozilla Public
2+ - License, v. 2.0. If a copy of the MPL was not distributed with this
3+ - file, You can obtain one at http://mozilla.org/MPL/2.0/.
4+ -}
5+
6+{-# LANGUAGE CPP #-}
7+
8+module GitHub.Data.Apps where
9+
10+import GitHub.Data.Definitions (SimpleOwner)
11+import GitHub.Data.Id (Id)
12+import GitHub.Data.Name (Name)
13+import GitHub.Data.URL (URL)
14+import GitHub.Internal.Prelude
15+import Prelude ()
16+
17+
18+data App = App
19+ { appId :: !(Id App)
20+ , appOwner :: !SimpleOwner
21+ , appName :: !(Name App)
22+ , appDescription :: !(Maybe Text)
23+ , appExternalUrl :: !URL
24+ , appHtmlUrl :: !URL
25+ , appCreatedAt :: !(Maybe UTCTime)
26+ , appUpdatedAt :: !(Maybe UTCTime)
27+ }
28+ deriving (Show, Data, Typeable, Eq, Ord, Generic)
29+
30+instance NFData App where rnf = genericRnf
31+instance Binary App
src/GitHub/Data/Installations.hsnew file+34
@@ -0,0 +1,34 @@
1+{- This Source Code Form is subject to the terms of the Mozilla Public
2+ - License, v. 2.0. If a copy of the MPL was not distributed with this
3+ - file, You can obtain one at http://mozilla.org/MPL/2.0/.
4+ -}
5+
6+{-# LANGUAGE CPP #-}
7+
8+module GitHub.Data.Installations where
9+
10+import GitHub.Data.Apps (App)
11+import GitHub.Data.Id (Id)
12+import GitHub.Data.URL (URL)
13+import GitHub.Internal.Prelude
14+import Prelude ()
15+
16+
17+data Installation = Installation
18+ { installationId :: !(Id Installation)
19+-- , installationAccount
20+ , installationAccesssTokensUrl :: !URL
21+ , installationRepositoriesUrl :: !URL
22+ , installationHtmlUrl :: !URL
23+ , installationAppId :: (Id App)
24+-- , installationTargetId
25+-- , installationTargetType
26+-- , installationPermissions
27+-- , installationEvents
28+-- , installationSingleFileName
29+-- , installationRepositorySelection
30+ }
31+ deriving (Show, Data, Typeable, Eq, Ord, Generic)
32+
33+instance NFData Installation where rnf = genericRnf
34+instance Binary Installation
stack.yaml+5−3
@@ -1,9 +1,11 @@
1−resolver: lts-11.5
1+resolver: lts-14.19
22
33packages:
44- .
55# Dependency packages to be pulled from upstream that are not in the resolver
66# using the same syntax as the packages field.
77# (e.g., acme-missiles-0.3)
8−extra-deps:
9− - jwt-0.8.0
8+extra-deps:
9+ - github-0.24
10+ - binary-instances-1
11+ # - jwt-0.8.0
stack.yaml.locknew file+26
@@ -0,0 +1,26 @@
1+# This file was autogenerated by Stack.
2+# You should not edit this file by hand.
3+# For more information, please see the documentation at:
4+# https://docs.haskellstack.org/en/stable/topics/lock_files
5+
6+packages:
7+- completed:
8+ hackage: github-0.24@sha256:4bf5a06289d36f78bf347b6a4f9906f659be7335cde78386a6c95568714a730b,6955
9+ pantry-tree:
10+ sha256: e0f84d7a3cdd76c6bd5395c8c72d24a1dda9616f857291820b08947c8290f042
11+ size: 7105
12+ original:
13+ hackage: github-0.24
14+- completed:
15+ hackage: binary-instances-1@sha256:b17565598b8df3241f9b46fa8e3a3368ecc8e3f2eb175d7c28f319042a6f5c79,2613
16+ pantry-tree:
17+ sha256: 938ffc6990cac12681c657f7afa93737eecf335e6f0212d8c0b7c1ea3e0f40f4
18+ size: 1035
19+ original:
20+ hackage: binary-instances-1
21+snapshots:
22+- completed:
23+ sha256: 9f79f6494473c9b46911364b94c4b5ef19ca8d35ebf62e46697cf651f198ee19
24+ size: 524155
25+ url: https://raw.githubusercontent.com/commercialhaskell/stackage-snapshots/master/lts/14/19.yaml
26+ original: lts-14.19