pax_global_header00006660000000000000000000000064145770511360014523gustar00rootroot0000000000000052 comment=2811df909bfc6c9438d65c1b03b4fe4f0b2bbf48 ghinstallation-2.10.0/000077500000000000000000000000001457705113600146235ustar00rootroot00000000000000ghinstallation-2.10.0/.github/000077500000000000000000000000001457705113600161635ustar00rootroot00000000000000ghinstallation-2.10.0/.github/dependabot.yml000066400000000000000000000001371457705113600210140ustar00rootroot00000000000000version: 2 updates: - package-ecosystem: gomod directory: / schedule: interval: weekly ghinstallation-2.10.0/.github/workflows/000077500000000000000000000000001457705113600202205ustar00rootroot00000000000000ghinstallation-2.10.0/.github/workflows/go.yml000066400000000000000000000007531457705113600213550ustar00rootroot00000000000000name: Go on: push: branches: [master] pull_request: branches: [master] jobs: build: name: Build runs-on: ubuntu-latest steps: - name: Set up Go 1.17 uses: actions/setup-go@v2 with: go-version: 1.17 - name: Check out code uses: actions/checkout@v2 - name: Get dependencies run: go get -v -t -d ./... - name: Build run: go build -v ./... - name: Test run: go test -v ./... ghinstallation-2.10.0/AUTHORS000066400000000000000000000004101457705113600156660ustar00rootroot00000000000000Billy Lynch Bradley Falzon Philippe Modard Ricardo Chimal, Jr Tatsuya Kamohara <17017563+kamontia@users.noreply.github.com> rob boll ghinstallation-2.10.0/LICENSE000066400000000000000000000250201457705113600156270ustar00rootroot00000000000000 Apache License Version 2.0, January 2004 http://www.apache.org/licenses/ TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 1. Definitions. "License" shall mean the terms and conditions for use, reproduction, and distribution as defined by Sections 1 through 9 of this document. "Licensor" shall mean the copyright owner or entity authorized by the copyright owner that is granting the License. "Legal Entity" shall mean the union of the acting entity and all other entities that control, are controlled by, or are under common control with that entity. For the purposes of this definition, "control" means (i) the power, direct or indirect, to cause the direction or management of such entity, whether by contract or otherwise, or (ii) ownership of fifty percent (50%) or more of the outstanding shares, or (iii) beneficial ownership of such entity. "You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. "Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. "Object" form shall mean any form resulting from mechanical transformation or translation of a Source form, including but not limited to compiled object code, generated documentation, and conversions to other media types. "Work" shall mean the work of authorship, whether in Source or Object form, made available under the License, as indicated by a copyright notice that is included in or attached to the work (an example is provided in the Appendix below). "Derivative Works" shall mean any work, whether in Source or Object form, that is based on (or derived from) the Work and for which the editorial revisions, annotations, elaborations, or other modifications represent, as a whole, an original work of authorship. For the purposes of this License, Derivative Works shall not include works that remain separable from, or merely link (or bind by name) to the interfaces of, the Work and Derivative Works thereof. "Contribution" shall mean any work of authorship, including the original version of the Work and any modifications or additions to that Work or Derivative Works thereof, that is intentionally submitted to Licensor for inclusion in the Work by the copyright owner or by an individual or Legal Entity authorized to submit on behalf of the copyright owner. For the purposes of this definition, "submitted" means any form of electronic, verbal, or written communication sent to the Licensor or its representatives, including but not limited to communication on electronic mailing lists, source code control systems, and issue tracking systems that are managed by, or on behalf of, the Licensor for the purpose of discussing and improving the Work, but excluding communication that is conspicuously marked or otherwise designated in writing by the copyright owner as "Not a Contribution." "Contributor" shall mean Licensor and any individual or Legal Entity on behalf of whom a Contribution has been received by Licensor and subsequently incorporated within the Work. 2. Grant of Copyright License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable copyright license to reproduce, prepare Derivative Works of, publicly display, publicly perform, sublicense, and distribute the Work and such Derivative Works in Source or Object form. 3. Grant of Patent License. Subject to the terms and conditions of this License, each Contributor hereby grants to You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, irrevocable (except as stated in this section) patent license to make, have made, use, offer to sell, sell, import, and otherwise transfer the Work, where such license applies only to those patent claims licensable by such Contributor that are necessarily infringed by their Contribution(s) alone or by combination of their Contribution(s) with the Work to which such Contribution(s) was submitted. If You institute patent litigation against any entity (including a cross-claim or counterclaim in a lawsuit) alleging that the Work or a Contribution incorporated within the Work constitutes direct or contributory patent infringement, then any patent licenses granted to You under this License for that Work shall terminate as of the date such litigation is filed. 4. Redistribution. You may reproduce and distribute copies of the Work or Derivative Works thereof in any medium, with or without modifications, and in Source or Object form, provided that You meet the following conditions: (a) You must give any other recipients of the Work or Derivative Works a copy of this License; and (b) You must cause any modified files to carry prominent notices stating that You changed the files; and (c) You must retain, in the Source form of any Derivative Works that You distribute, all copyright, patent, trademark, and attribution notices from the Source form of the Work, excluding those notices that do not pertain to any part of the Derivative Works; and (d) If the Work includes a "NOTICE" text file as part of its distribution, then any Derivative Works that You distribute must include a readable copy of the attribution notices contained within such NOTICE file, excluding those notices that do not pertain to any part of the Derivative Works, in at least one of the following places: within a NOTICE text file distributed as part of the Derivative Works; within the Source form or documentation, if provided along with the Derivative Works; or, within a display generated by the Derivative Works, if and wherever such third-party notices normally appear. The contents of the NOTICE file are for informational purposes only and do not modify the License. You may add Your own attribution notices within Derivative Works that You distribute, alongside or as an addendum to the NOTICE text from the Work, provided that such additional attribution notices cannot be construed as modifying the License. You may add Your own copyright statement to Your modifications and may provide additional or different license terms and conditions for use, reproduction, or distribution of Your modifications, or for any such Derivative Works as a whole, provided Your use, reproduction, and distribution of the Work otherwise complies with the conditions stated in this License. 5. Submission of Contributions. Unless You explicitly state otherwise, any Contribution intentionally submitted for inclusion in the Work by You to the Licensor shall be under the terms and conditions of this License, without any additional terms or conditions. Notwithstanding the above, nothing herein shall supersede or modify the terms of any separate license agreement you may have executed with Licensor regarding such Contributions. 6. Trademarks. This License does not grant permission to use the trade names, trademarks, service marks, or product names of the Licensor, except as required for reasonable and customary use in describing the origin of the Work and reproducing the content of the NOTICE file. 7. Disclaimer of Warranty. Unless required by applicable law or agreed to in writing, Licensor provides the Work (and each Contributor provides its Contributions) on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied, including, without limitation, any warranties or conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A PARTICULAR PURPOSE. You are solely responsible for determining the appropriateness of using or redistributing the Work and assume any risks associated with Your exercise of permissions under this License. 8. Limitation of Liability. In no event and under no legal theory, whether in tort (including negligence), contract, or otherwise, unless required by applicable law (such as deliberate and grossly negligent acts) or agreed to in writing, shall any Contributor be liable to You for damages, including any direct, indirect, special, incidental, or consequential damages of any character arising as a result of this License or out of the use or inability to use the Work (including but not limited to damages for loss of goodwill, work stoppage, computer failure or malfunction, or any and all other commercial damages or losses), even if such Contributor has been advised of the possibility of such damages. 9. Accepting Warranty or Additional Liability. While redistributing the Work or Derivative Works thereof, You may choose to offer, and charge a fee for, acceptance of support, warranty, indemnity, or other liability obligations and/or rights consistent with this License. However, in accepting such obligations, You may act only on Your own behalf and on Your sole responsibility, not on behalf of any other Contributor, and only if You agree to indemnify, defend, and hold each Contributor harmless for any liability incurred by, or claims asserted against, such Contributor by reason of your accepting any such warranty or additional liability. END OF TERMS AND CONDITIONS Copyright 2019 ghinstallation AUTHORS Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 Unless required by applicable law or agreed to in writing, software distributed under the License is distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. ghinstallation-2.10.0/README.md000066400000000000000000000062651457705113600161130ustar00rootroot00000000000000# ghinstallation [![GoDoc](https://godoc.org/github.com/bradleyfalzon/ghinstallation?status.svg)](https://godoc.org/github.com/bradleyfalzon/ghinstallation/v2) `ghinstallation` provides `Transport`, which implements `http.RoundTripper` to provide authentication as an installation for GitHub Apps. This library is designed to provide automatic authentication for https://github.com/google/go-github or your own HTTP client. See https://developer.github.com/apps/building-integrations/setting-up-and-registering-github-apps/about-authentication-options-for-github-apps/ # Installation Get the package: ```bash GO111MODULE=on go get -u github.com/bradleyfalzon/ghinstallation/v2 ``` # GitHub Example ```go import "github.com/bradleyfalzon/ghinstallation/v2" func main() { // Shared transport to reuse TCP connections. tr := http.DefaultTransport // Wrap the shared transport for use with the app ID 1 authenticating with installation ID 99. itr, err := ghinstallation.NewKeyFromFile(tr, 1, 99, "2016-10-19.private-key.pem") if err != nil { log.Fatal(err) } // Use installation transport with github.com/google/go-github client := github.NewClient(&http.Client{Transport: itr}) } ``` You can also use [`New()`](https://pkg.go.dev/github.com/bradleyfalzon/ghinstallation/v2#New) to load a key directly from a `[]byte`. # GitHub Enterprise Example For clients using GitHub Enterprise, set the base URL as follows: ```go import "github.com/bradleyfalzon/ghinstallation/v2" const GitHubEnterpriseURL = "https://github.example.com/api/v3" func main() { // Shared transport to reuse TCP connections. tr := http.DefaultTransport // Wrap the shared transport for use with the app ID 1 authenticating with installation ID 99. itr, err := ghinstallation.NewKeyFromFile(tr, 1, 99, "2016-10-19.private-key.pem") if err != nil { log.Fatal(err) } itr.BaseURL = GitHubEnterpriseURL // Use installation transport with github.com/google/go-github client := github.NewEnterpriseClient(GitHubEnterpriseURL, GitHubEnterpriseURL, &http.Client{Transport: itr}) } ``` ## What is app ID and installation ID `app ID` is the GitHub App ID. \ You can check as following : \ Settings > Developer > settings > GitHub App > About item `installation ID` is a part of WebHook request. \ You can get the number to check the request. \ Settings > Developer > settings > GitHub Apps > Advanced > Payload in Request tab ``` WebHook request ... "installation": { "id": `installation ID` } ``` # Customizing signing behavior Users can customize signing behavior by passing in a [Signer](https://pkg.go.dev/github.com/bradleyfalzon/ghinstallation/v2#Signer) implementation when creating an [AppsTransport](https://pkg.go.dev/github.com/bradleyfalzon/ghinstallation/v2#AppsTransport). For example, this can be used to create tokens backed by keys in a KMS system. ```go signer := &myCustomSigner{ key: "https://url/to/key/vault", } atr := NewAppsTransportWithOptions(http.DefaultTransport, 1, WithSigner(signer)) tr := NewFromAppsTransport(atr, 99) ``` # License [Apache 2.0](LICENSE) # Dependencies - [github.com/golang-jwt/jwt-go](https://github.com/golang-jwt/jwt-go) ghinstallation-2.10.0/appsTransport.go000066400000000000000000000076571457705113600200510ustar00rootroot00000000000000package ghinstallation import ( "crypto/rsa" "errors" "fmt" "io/ioutil" "net/http" "strconv" "time" jwt "github.com/golang-jwt/jwt/v4" ) // AppsTransport provides a http.RoundTripper by wrapping an existing // http.RoundTripper and provides GitHub Apps authentication as a // GitHub App. // // Client can also be overwritten, and is useful to change to one which // provides retry logic if you do experience retryable errors. // // See https://developer.github.com/apps/building-integrations/setting-up-and-registering-github-apps/about-authentication-options-for-github-apps/ type AppsTransport struct { BaseURL string // BaseURL is the scheme and host for GitHub API, defaults to https://api.github.com Client Client // Client to use to refresh tokens, defaults to http.Client with provided transport tr http.RoundTripper // tr is the underlying roundtripper being wrapped signer Signer // signer signs JWT tokens. appID int64 // appID is the GitHub App's ID } // NewAppsTransportKeyFromFile returns a AppsTransport using a private key from file. func NewAppsTransportKeyFromFile(tr http.RoundTripper, appID int64, privateKeyFile string) (*AppsTransport, error) { privateKey, err := ioutil.ReadFile(privateKeyFile) if err != nil { return nil, fmt.Errorf("could not read private key: %s", err) } return NewAppsTransport(tr, appID, privateKey) } // NewAppsTransport returns a AppsTransport using private key. The key is parsed // and if any errors occur the error is non-nil. // // The provided tr http.RoundTripper should be shared between multiple // installations to ensure reuse of underlying TCP connections. // // The returned Transport's RoundTrip method is safe to be used concurrently. func NewAppsTransport(tr http.RoundTripper, appID int64, privateKey []byte) (*AppsTransport, error) { key, err := jwt.ParseRSAPrivateKeyFromPEM(privateKey) if err != nil { return nil, fmt.Errorf("could not parse private key: %s", err) } return NewAppsTransportFromPrivateKey(tr, appID, key), nil } // NewAppsTransportFromPrivateKey returns an AppsTransport using a crypto/rsa.(*PrivateKey). func NewAppsTransportFromPrivateKey(tr http.RoundTripper, appID int64, key *rsa.PrivateKey) *AppsTransport { return &AppsTransport{ BaseURL: apiBaseURL, Client: &http.Client{Transport: tr}, tr: tr, signer: NewRSASigner(jwt.SigningMethodRS256, key), appID: appID, } } func NewAppsTransportWithOptions(tr http.RoundTripper, appID int64, opts ...AppsTransportOption) (*AppsTransport, error) { t := &AppsTransport{ BaseURL: apiBaseURL, Client: &http.Client{Transport: tr}, tr: tr, appID: appID, } for _, fn := range opts { fn(t) } if t.signer == nil { return nil, errors.New("no signer provided") } return t, nil } // RoundTrip implements http.RoundTripper interface. func (t *AppsTransport) RoundTrip(req *http.Request) (*http.Response, error) { // GitHub rejects expiry and issue timestamps that are not an integer, // while the jwt-go library serializes to fractional timestamps. // Truncate them before passing to jwt-go. iss := time.Now().Add(-30 * time.Second).Truncate(time.Second) exp := iss.Add(2 * time.Minute) claims := &jwt.RegisteredClaims{ IssuedAt: jwt.NewNumericDate(iss), ExpiresAt: jwt.NewNumericDate(exp), Issuer: strconv.FormatInt(t.appID, 10), } ss, err := t.signer.Sign(claims) if err != nil { return nil, fmt.Errorf("could not sign jwt: %s", err) } req.Header.Set("Authorization", "Bearer "+ss) req.Header.Add("Accept", acceptHeader) resp, err := t.tr.RoundTrip(req) return resp, err } // AppID returns the appID of the transport func (t *AppsTransport) AppID() int64 { return t.appID } type AppsTransportOption func(*AppsTransport) // WithSigner configures the AppsTransport to use the given Signer for generating JWT tokens. func WithSigner(signer Signer) AppsTransportOption { return func(at *AppsTransport) { at.signer = signer } } ghinstallation-2.10.0/appsTransport_test.go000066400000000000000000000067021457705113600210760ustar00rootroot00000000000000package ghinstallation import ( "bytes" "fmt" "io/ioutil" "net/http" "net/http/httptest" "os" "strings" "testing" jwt "github.com/golang-jwt/jwt/v4" "github.com/google/go-cmp/cmp" ) func TestNewAppsTransportKeyFromFile(t *testing.T) { tmpfile, err := ioutil.TempFile("", "example") if err != nil { t.Fatal(err) } defer os.Remove(tmpfile.Name()) // clean up if _, err := tmpfile.Write(key); err != nil { t.Fatal(err) } if err := tmpfile.Close(); err != nil { t.Fatal(err) } _, err = NewAppsTransportKeyFromFile(&http.Transport{}, appID, tmpfile.Name()) if err != nil { t.Fatal("unexpected error:", err) } } type RoundTrip struct { rt func(*http.Request) (*http.Response, error) } func (r RoundTrip) RoundTrip(req *http.Request) (*http.Response, error) { return r.rt(req) } func TestAppsTransport(t *testing.T) { customHeader := "my-header" check := RoundTrip{ rt: func(req *http.Request) (*http.Response, error) { h, ok := req.Header["Accept"] if !ok { t.Error("Header Accept not set") } want := []string{customHeader, acceptHeader} if diff := cmp.Diff(want, h); diff != "" { t.Errorf("HTTP Accept headers want->got: %s", diff) } return nil, nil }, } tr, err := NewAppsTransport(check, appID, key) if err != nil { t.Fatalf("error creating transport: %v", err) } if tr.appID != appID { t.Errorf("appID want->got: %d->%d", appID, tr.appID) } req := httptest.NewRequest(http.MethodGet, "http://example.com", new(bytes.Buffer)) req.Header.Add("Accept", customHeader) if _, err := tr.RoundTrip(req); err != nil { t.Fatalf("error calling RoundTrip: %v", err) } } func TestJWTExpiry(t *testing.T) { key, err := jwt.ParseRSAPrivateKeyFromPEM(key) if err != nil { t.Fatal(err) } customHeader := "my-header" check := RoundTrip{ rt: func(req *http.Request) (*http.Response, error) { token := strings.Fields(req.Header.Get("Authorization"))[1] tok, err := jwt.ParseWithClaims(token, &jwt.StandardClaims{}, func(t *jwt.Token) (interface{}, error) { if t.Header["alg"] != "RS256" { return nil, fmt.Errorf("unexpected signing method: %v, expected: %v", t.Header["alg"], "RS256") } return &key.PublicKey, nil }) if err != nil { t.Fatalf("jwt parse: %v", err) } c := tok.Claims.(*jwt.StandardClaims) if c.ExpiresAt == 0 { t.Fatalf("missing exp claim") } return nil, nil }, } tr := NewAppsTransportFromPrivateKey(check, appID, key) req := httptest.NewRequest(http.MethodGet, "http://example.com", new(bytes.Buffer)) req.Header.Add("Accept", customHeader) if _, err := tr.RoundTrip(req); err != nil { t.Fatalf("error calling RoundTrip: %v", err) } } func TestCustomSigner(t *testing.T) { check := RoundTrip{ rt: func(req *http.Request) (*http.Response, error) { h, ok := req.Header["Authorization"] if !ok { t.Error("Header Accept not set") } want := []string{"Bearer hunter2"} if diff := cmp.Diff(want, h); diff != "" { t.Errorf("HTTP Accept headers want->got: %s", diff) } return nil, nil }, } tr, err := NewAppsTransportWithOptions(check, appID, WithSigner(&noopSigner{})) if err != nil { t.Fatalf("NewAppsTransportWithOptions: %v", err) } req := httptest.NewRequest(http.MethodGet, "http://example.com", new(bytes.Buffer)) if _, err := tr.RoundTrip(req); err != nil { t.Fatalf("error calling RoundTrip: %v", err) } } type noopSigner struct{} func (noopSigner) Sign(jwt.Claims) (string, error) { return "hunter2", nil } ghinstallation-2.10.0/go.mod000066400000000000000000000002671457705113600157360ustar00rootroot00000000000000module github.com/bradleyfalzon/ghinstallation/v2 go 1.13 require ( github.com/golang-jwt/jwt/v4 v4.5.0 github.com/google/go-cmp v0.6.0 github.com/google/go-github/v60 v60.0.0 ) ghinstallation-2.10.0/go.sum000066400000000000000000000016121457705113600157560ustar00rootroot00000000000000github.com/golang-jwt/jwt/v4 v4.5.0 h1:7cYmW1XlMY7h7ii7UhUyChSgS5wUJEnm9uZVTGqOWzg= github.com/golang-jwt/jwt/v4 v4.5.0/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/google/go-github/v60 v60.0.0 h1:oLG98PsLauFvvu4D/YPxq374jhSxFYdzQGNCyONLfn8= github.com/google/go-github/v60 v60.0.0/go.mod h1:ByhX2dP9XT9o/ll2yXAu2VD8l5eNVg8hD4Cr0S/LmQk= github.com/google/go-querystring v1.1.0 h1:AnCroh3fv4ZBgVIf1Iwtovgjaw/GiKJo8M8yD/fhyJ8= github.com/google/go-querystring v1.1.0/go.mod h1:Kcdr2DB4koayq7X8pmAG4sNG59So17icRSOU623lUBU= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= ghinstallation-2.10.0/sign.go000066400000000000000000000014651457705113600161200ustar00rootroot00000000000000package ghinstallation import ( "crypto/rsa" jwt "github.com/golang-jwt/jwt/v4" ) // Signer is a JWT token signer. This is a wrapper around [jwt.SigningMethod] with predetermined // key material. type Signer interface { // Sign signs the given claims and returns a JWT token string, as specified // by [jwt.Token.SignedString] Sign(claims jwt.Claims) (string, error) } // RSASigner signs JWT tokens using RSA keys. type RSASigner struct { method *jwt.SigningMethodRSA key *rsa.PrivateKey } func NewRSASigner(method *jwt.SigningMethodRSA, key *rsa.PrivateKey) *RSASigner { return &RSASigner{ method: method, key: key, } } // Sign signs the JWT claims with the RSA key. func (s *RSASigner) Sign(claims jwt.Claims) (string, error) { return jwt.NewWithClaims(s.method, claims).SignedString(s.key) } ghinstallation-2.10.0/transport.go000066400000000000000000000216251457705113600172140ustar00rootroot00000000000000package ghinstallation import ( "bytes" "context" "encoding/json" "errors" "fmt" "io" "io/ioutil" "net/http" "strings" "sync" "time" "github.com/google/go-github/v60/github" ) const ( acceptHeader = "application/vnd.github.v3+json" apiBaseURL = "https://api.github.com" ) // Transport provides a http.RoundTripper by wrapping an existing // http.RoundTripper and provides GitHub Apps authentication as an // installation. // // Client can also be overwritten, and is useful to change to one which // provides retry logic if you do experience retryable errors. // // See https://developer.github.com/apps/building-integrations/setting-up-and-registering-github-apps/about-authentication-options-for-github-apps/ type Transport struct { BaseURL string // BaseURL is the scheme and host for GitHub API, defaults to https://api.github.com Client Client // Client to use to refresh tokens, defaults to http.Client with provided transport tr http.RoundTripper // tr is the underlying roundtripper being wrapped appID int64 // appID is the GitHub App's ID installationID int64 // installationID is the GitHub App Installation ID InstallationTokenOptions *github.InstallationTokenOptions // parameters restrict a token's access appsTransport *AppsTransport mu *sync.Mutex // mu protects token token *accessToken // token is the installation's access token } // accessToken is an installation access token response from GitHub type accessToken struct { Token string `json:"token"` ExpiresAt time.Time `json:"expires_at"` Permissions github.InstallationPermissions `json:"permissions,omitempty"` Repositories []github.Repository `json:"repositories,omitempty"` } // HTTPError represents a custom error for failing HTTP operations. // Example in our usecase: refresh access token operation. // It enables the caller to inspect the root cause and response. type HTTPError struct { Message string RootCause error InstallationID int64 Response *http.Response } func (e *HTTPError) Error() string { return e.Message } // Unwrap implements the standard library's error wrapping. It unwraps to the root cause. func (e *HTTPError) Unwrap() error { return e.RootCause } var _ http.RoundTripper = &Transport{} // NewKeyFromFile returns a Transport using a private key from file. func NewKeyFromFile(tr http.RoundTripper, appID, installationID int64, privateKeyFile string) (*Transport, error) { privateKey, err := ioutil.ReadFile(privateKeyFile) if err != nil { return nil, fmt.Errorf("could not read private key: %s", err) } return New(tr, appID, installationID, privateKey) } // Client is a HTTP client which sends a http.Request and returns a http.Response // or an error. type Client interface { Do(*http.Request) (*http.Response, error) } // New returns an Transport using private key. The key is parsed // and if any errors occur the error is non-nil. // // The provided tr http.RoundTripper should be shared between multiple // installations to ensure reuse of underlying TCP connections. // // The returned Transport's RoundTrip method is safe to be used concurrently. func New(tr http.RoundTripper, appID, installationID int64, privateKey []byte) (*Transport, error) { atr, err := NewAppsTransport(tr, appID, privateKey) if err != nil { return nil, err } return NewFromAppsTransport(atr, installationID), nil } // NewFromAppsTransport returns a Transport using an existing *AppsTransport. func NewFromAppsTransport(atr *AppsTransport, installationID int64) *Transport { return &Transport{ BaseURL: atr.BaseURL, Client: &http.Client{Transport: atr.tr}, tr: atr.tr, appID: atr.appID, installationID: installationID, appsTransport: atr, mu: &sync.Mutex{}, } } // RoundTrip implements http.RoundTripper interface. func (t *Transport) RoundTrip(req *http.Request) (*http.Response, error) { reqBodyClosed := false if req.Body != nil { defer func() { if !reqBodyClosed { req.Body.Close() } }() } token, err := t.Token(req.Context()) if err != nil { return nil, err } creq := cloneRequest(req) // per RoundTripper contract creq.Header.Set("Authorization", "token "+token) if creq.Header.Get("Accept") == "" { // We only add an "Accept" header to avoid overwriting the expected behavior. creq.Header.Add("Accept", acceptHeader) } reqBodyClosed = true // req.Body is assumed to be closed by the tr RoundTripper. resp, err := t.tr.RoundTrip(creq) return resp, err } func (at *accessToken) getRefreshTime() time.Time { return at.ExpiresAt.Add(-time.Minute) } func (at *accessToken) isExpired() bool { return at == nil || at.getRefreshTime().Before(time.Now()) } // Token checks the active token expiration and renews if necessary. Token returns // a valid access token. If renewal fails an error is returned. func (t *Transport) Token(ctx context.Context) (string, error) { t.mu.Lock() defer t.mu.Unlock() if t.token.isExpired() { // Token is not set or expired/nearly expired, so refresh if err := t.refreshToken(ctx); err != nil { return "", fmt.Errorf("could not refresh installation id %v's token: %w", t.installationID, err) } } return t.token.Token, nil } // Permissions returns a transport token's GitHub installation permissions. func (t *Transport) Permissions() (github.InstallationPermissions, error) { if t.token == nil { return github.InstallationPermissions{}, fmt.Errorf("Permissions() = nil, err: nil token") } return t.token.Permissions, nil } // Repositories returns a transport token's GitHub repositories. func (t *Transport) Repositories() ([]github.Repository, error) { if t.token == nil { return nil, fmt.Errorf("Repositories() = nil, err: nil token") } return t.token.Repositories, nil } // Expiry returns a transport token's expiration time and refresh time. There is a small grace period // built in where a token will be refreshed before it expires. expiresAt is the actual token expiry, // and refreshAt is when a call to Token() will cause it to be refreshed. func (t *Transport) Expiry() (expiresAt time.Time, refreshAt time.Time, err error) { if t.token == nil { return time.Time{}, time.Time{}, errors.New("Expiry() = unknown, err: nil token") } return t.token.ExpiresAt, t.token.getRefreshTime(), nil } // AppID returns the app ID associated with the transport func (t *Transport) AppID() int64 { return t.appID } // InstallationID returns the installation ID associated with the transport func (t *Transport) InstallationID() int64 { return t.installationID } func (t *Transport) refreshToken(ctx context.Context) error { // Convert InstallationTokenOptions into a ReadWriter to pass as an argument to http.NewRequest. body, err := GetReadWriter(t.InstallationTokenOptions) if err != nil { return fmt.Errorf("could not convert installation token parameters into json: %s", err) } requestURL := fmt.Sprintf("%s/app/installations/%v/access_tokens", strings.TrimRight(t.BaseURL, "/"), t.installationID) req, err := http.NewRequest("POST", requestURL, body) if err != nil { return fmt.Errorf("could not create request: %s", err) } // Set Content and Accept headers. if body != nil { req.Header.Set("Content-Type", "application/json") } req.Header.Set("Accept", acceptHeader) if ctx != nil { req = req.WithContext(ctx) } t.appsTransport.BaseURL = t.BaseURL t.appsTransport.Client = t.Client resp, err := t.appsTransport.RoundTrip(req) e := &HTTPError{ RootCause: err, InstallationID: t.installationID, Response: resp, } if err != nil { e.Message = fmt.Sprintf("could not get access_tokens from GitHub API for installation ID %v: %v", t.installationID, err) return e } if resp.StatusCode/100 != 2 { e.Message = fmt.Sprintf("received non 2xx response status %q when fetching %v", resp.Status, req.URL) return e } // Closing body late, to provide caller a chance to inspect body in an error / non-200 response status situation defer resp.Body.Close() return json.NewDecoder(resp.Body).Decode(&t.token) } // GetReadWriter converts a body interface into an io.ReadWriter object. func GetReadWriter(i interface{}) (io.ReadWriter, error) { var buf io.ReadWriter if i != nil { buf = new(bytes.Buffer) enc := json.NewEncoder(buf) err := enc.Encode(i) if err != nil { return nil, err } } return buf, nil } // cloneRequest returns a clone of the provided *http.Request. // The clone is a shallow copy of the struct and its Header map. func cloneRequest(r *http.Request) *http.Request { // shallow copy of the struct r2 := new(http.Request) *r2 = *r // deep copy of the Header r2.Header = make(http.Header, len(r.Header)) for k, s := range r.Header { r2.Header[k] = append([]string(nil), s...) } return r2 } ghinstallation-2.10.0/transport_test.go000066400000000000000000000335311457705113600202520ustar00rootroot00000000000000package ghinstallation import ( "bytes" "encoding/json" "errors" "fmt" "io/ioutil" "net/http" "net/http/httptest" "os" "strings" "sync" "testing" "time" "github.com/google/go-cmp/cmp" "github.com/google/go-github/v60/github" ) const ( installationID = 1 appID = 2 token = "abc123" ) var key = []byte(`-----BEGIN RSA PRIVATE KEY----- MIIEpQIBAAKCAQEA0BUezcR7uycgZsfVLlAf4jXP7uFpVh4geSTY39RvYrAll0yh q7uiQypP2hjQJ1eQXZvkAZx0v9lBYJmX7e0HiJckBr8+/O2kARL+GTCJDJZECpjy 97yylbzGBNl3s76fZ4CJ+4f11fCh7GJ3BJkMf9NFhe8g1TYS0BtSd/sauUQEuG/A 3fOJxKTNmICZr76xavOQ8agA4yW9V5hKcrbHzkfecg/sQsPMmrXixPNxMsqyOMmg jdJ1aKr7ckEhd48ft4bPMO4DtVL/XFdK2wJZZ0gXJxWiT1Ny41LVql97Odm+OQyx tcayMkGtMb1nwTcVVl+RG2U5E1lzOYpcQpyYFQIDAQABAoIBAAfUY55WgFlgdYWo i0r81NZMNBDHBpGo/IvSaR6y/aX2/tMcnRC7NLXWR77rJBn234XGMeQloPb/E8iw vtjDDH+FQGPImnQl9P/dWRZVjzKcDN9hNfNAdG/R9JmGHUz0JUddvNNsIEH2lgEx C01u/Ntqdbk+cDvVlwuhm47MMgs6hJmZtS1KDPgYJu4IaB9oaZFN+pUyy8a1w0j9 RAhHpZrsulT5ThgCra4kKGDNnk2yfI91N9lkP5cnhgUmdZESDgrAJURLS8PgInM4 YPV9L68tJCO4g6k+hFiui4h/4cNXYkXnaZSBUoz28ICA6e7I3eJ6Y1ko4ou+Xf0V csM8VFkCgYEA7y21JfECCfEsTHwwDg0fq2nld4o6FkIWAVQoIh6I6o6tYREmuZ/1 s81FPz/lvQpAvQUXGZlOPB9eW6bZZFytcuKYVNE/EVkuGQtpRXRT630CQiqvUYDZ 4FpqdBQUISt8KWpIofndrPSx6JzI80NSygShQsScWFw2wBIQAnV3TpsCgYEA3reL L7AwlxCacsPvkazyYwyFfponblBX/OvrYUPPaEwGvSZmE5A/E4bdYTAixDdn4XvE ChwpmRAWT/9C6jVJ/o1IK25dwnwg68gFDHlaOE+B5/9yNuDvVmg34PWngmpucFb/ 6R/kIrF38lEfY0pRb05koW93uj1fj7Uiv+GWRw8CgYEAn1d3IIDQl+kJVydBKItL tvoEur/m9N8wI9B6MEjhdEp7bXhssSvFF/VAFeQu3OMQwBy9B/vfaCSJy0t79uXb U/dr/s2sU5VzJZI5nuDh67fLomMni4fpHxN9ajnaM0LyI/E/1FFPgqM+Rzb0lUQb yqSM/ptXgXJls04VRl4VjtMCgYEAprO/bLx2QjxdPpXGFcXbz6OpsC92YC2nDlsP 3cfB0RFG4gGB2hbX/6eswHglLbVC/hWDkQWvZTATY2FvFps4fV4GrOt5Jn9+rL0U elfC3e81Dw+2z7jhrE1ptepprUY4z8Fu33HNcuJfI3LxCYKxHZ0R2Xvzo+UYSBqO ng0eTKUCgYEAxW9G4FjXQH0bjajntjoVQGLRVGWnteoOaQr/cy6oVii954yNMKSP rezRkSNbJ8cqt9XQS+NNJ6Xwzl3EbuAt6r8f8VO1TIdRgFOgiUXRVNZ3ZyW8Hegd kGTL0A6/0yAu9qQZlFbaD5bWhQo7eyx63u4hZGppBhkTSPikOYUPCH8= -----END RSA PRIVATE KEY-----`) func TestNew(t *testing.T) { var authed bool ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.Header.Get("Accept") != acceptHeader { t.Fatalf("Request URI %q accept header got %q want: %q", r.RequestURI, r.Header.Get("Accept"), acceptHeader) } switch r.RequestURI { case fmt.Sprintf("/app/installations/%d/access_tokens", installationID): // respond with any token to installation transport js, _ := json.Marshal(accessToken{ Token: token, ExpiresAt: time.Now().Add(5 * time.Minute), }) fmt.Fprintln(w, string(js)) authed = true case "/auth/with/installation/token/endpoint": if want := "token " + token; r.Header.Get("Authorization") != want { t.Fatalf("Installation token got: %q want: %q", r.Header.Get("Authorization"), want) } default: t.Fatalf("unexpected URI: %q", r.RequestURI) } })) defer ts.Close() tr, err := New(&http.Transport{}, appID, installationID, key) if err != nil { t.Fatal("unexpected error:", err) } tr.BaseURL = ts.URL // test id getter methods if tr.AppID() != appID { t.Fatalf("appID got: %q want: %q", tr.AppID(), appID) } if tr.InstallationID() != installationID { t.Fatalf("installationID got: %q want: %q", tr.InstallationID(), installationID) } client := http.Client{Transport: tr} _, err = client.Get(ts.URL + "/auth/with/installation/token/endpoint") if err != nil { t.Fatal("unexpected error from client:", err) } if !authed { t.Fatal("Expected fetch of access_token but none occurred") } // Check the token is reused by setting expires into far future tr.token.ExpiresAt = time.Now().Add(time.Hour) authed = false _, err = client.Get(ts.URL + "/auth/with/installation/token/endpoint") if err != nil { t.Fatal("unexpected error from client:", err) } if authed { t.Fatal("Unexpected fetch of access_token") } // Check the token is refreshed by setting expires into far past tr.token.ExpiresAt = time.Unix(0, 0) _, err = client.Get(ts.URL + "/auth/with/installation/token/endpoint") if err != nil { t.Fatal("unexpected error from client:", err) } if !authed { t.Fatal("Expected fetch of access_token but none occurred") } } func TestNewKeyFromFile(t *testing.T) { tmpfile, err := ioutil.TempFile("", "example") if err != nil { t.Fatal(err) } defer os.Remove(tmpfile.Name()) // clean up if _, err := tmpfile.Write(key); err != nil { t.Fatal(err) } if err := tmpfile.Close(); err != nil { t.Fatal(err) } _, err = NewKeyFromFile(&http.Transport{}, appID, installationID, tmpfile.Name()) if err != nil { t.Fatal("unexpected error:", err) } } func TestNew_appendHeader(t *testing.T) { var headers http.Header ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { headers = r.Header fmt.Fprintln(w, `{}`) // dummy response that looks like json })) defer ts.Close() // Create a new request adding our own Accept header myheader := "my-header" req, err := http.NewRequest("GET", ts.URL+"/auth/with/installation/token/endpoint", nil) if err != nil { t.Fatal("unexpected error from http.NewRequest:", err) } req.Header.Add("Accept", myheader) tr, err := New(&http.Transport{}, appID, installationID, key) if err != nil { t.Fatal("unexpected error:", err) } tr.BaseURL = ts.URL client := http.Client{Transport: tr} _, err = client.Do(req) if err != nil { t.Fatal("unexpected error from client:", err) } found := false for _, v := range headers["Accept"] { if v == myheader { found = true break } } if !found { t.Errorf("could not find %v in request's accept headers: %v", myheader, headers["Accept"]) } // Here we test that there isn't a second Accept header. // Though the Accept header 'application/vnd.github.v3+json' is used for most // interactions with the GitHub API, having this header will force the // GitHub API response as JSON, which we don't want when downloading a // release (octet-stream) for _, v := range headers["Accept"] { if v == acceptHeader { t.Errorf("accept header '%s' should not be present when accept header '%s' is set: %v", acceptHeader, myheader, headers["Accept"]) break } } } func TestRefreshTokenWithParameters(t *testing.T) { installationTokenOptions := &github.InstallationTokenOptions{ RepositoryIDs: []int64{1234}, Permissions: &github.InstallationPermissions{ Contents: github.String("write"), Issues: github.String("read"), }, } // Convert InstallationTokenOptions into a ReadWriter to pass as an argument to http.NewRequest. body, err := GetReadWriter(installationTokenOptions) if err != nil { t.Fatalf("error calling GetReadWriter: %v", err) } // Convert io.ReadWriter to String without deleting body data. wantBody, _ := GetReadWriter(installationTokenOptions) wantBodyBytes := new(bytes.Buffer) wantBodyBytes.ReadFrom(wantBody) wantBodyString := wantBodyBytes.String() roundTripper := RoundTrip{ rt: func(req *http.Request) (*http.Response, error) { // Convert io.ReadCloser to String without deleting body data. var gotBodyBytes []byte gotBodyBytes, _ = ioutil.ReadAll(req.Body) req.Body = ioutil.NopCloser(bytes.NewBuffer(gotBodyBytes)) gotBodyString := string(gotBodyBytes) // Compare request sent with request received. if diff := cmp.Diff(wantBodyString, gotBodyString); diff != "" { t.Errorf("HTTP body want->got: %s", diff) } // Return acceptable access token. accessToken := accessToken{ Token: "token_string", ExpiresAt: time.Now(), Repositories: []github.Repository{{ ID: github.Int64(1234), }}, Permissions: github.InstallationPermissions{ Contents: github.String("write"), Issues: github.String("read"), }, } tokenReadWriter, err := GetReadWriter(accessToken) if err != nil { return nil, fmt.Errorf("error converting token into io.ReadWriter: %+v", err) } tokenBody := ioutil.NopCloser(tokenReadWriter) return &http.Response{ Body: tokenBody, StatusCode: 200, }, nil }, } tr, err := New(roundTripper, appID, installationID, key) if err != nil { t.Fatal("unexpected error:", err) } tr.InstallationTokenOptions = installationTokenOptions req, err := http.NewRequest("POST", fmt.Sprintf("%s/app/installations/%v/access_tokens", tr.BaseURL, tr.installationID), body) if err != nil { t.Fatal("unexpected error:", err) } if _, err := tr.RoundTrip(req); err != nil { t.Fatalf("error calling RoundTrip: %v", err) } } func TestRefreshTokenWithTrailingSlashBaseURL(t *testing.T) { installationTokenOptions := &github.InstallationTokenOptions{ RepositoryIDs: []int64{1234}, Permissions: &github.InstallationPermissions{ Contents: github.String("write"), Issues: github.String("read"), }, } tokenToBe := "token_string" // Convert io.ReadWriter to String without deleting body data. wantBody, _ := GetReadWriter(installationTokenOptions) wantBodyBytes := new(bytes.Buffer) wantBodyBytes.ReadFrom(wantBody) wantBodyString := wantBodyBytes.String() roundTripper := RoundTrip{ rt: func(req *http.Request) (*http.Response, error) { if strings.Contains(req.URL.Path, "//") { return &http.Response{ Body: ioutil.NopCloser(strings.NewReader("Forbidden\n")), StatusCode: 401, }, fmt.Errorf("Got simulated 401 Github Forbidden response") } if req.URL.Path == "test_endpoint/" && req.Header.Get("Authorization") == fmt.Sprintf("token %s", tokenToBe) { return &http.Response{ Body: ioutil.NopCloser(strings.NewReader("Beautiful\n")), StatusCode: 200, }, nil } // Convert io.ReadCloser to String without deleting body data. var gotBodyBytes []byte gotBodyBytes, _ = ioutil.ReadAll(req.Body) req.Body = ioutil.NopCloser(bytes.NewBuffer(gotBodyBytes)) gotBodyString := string(gotBodyBytes) // Compare request sent with request received. if diff := cmp.Diff(wantBodyString, gotBodyString); diff != "" { t.Errorf("HTTP body want->got: %s", diff) } // Return acceptable access token. accessToken := accessToken{ Token: tokenToBe, ExpiresAt: time.Now(), Repositories: []github.Repository{{ ID: github.Int64(1234), }}, Permissions: github.InstallationPermissions{ Contents: github.String("write"), Issues: github.String("read"), }, } tokenReadWriter, err := GetReadWriter(accessToken) if err != nil { return nil, fmt.Errorf("error converting token into io.ReadWriter: %+v", err) } tokenBody := ioutil.NopCloser(tokenReadWriter) return &http.Response{ Body: tokenBody, StatusCode: 200, }, nil }, } tr, err := New(roundTripper, appID, installationID, key) if err != nil { t.Fatal("unexpected error:", err) } tr.InstallationTokenOptions = installationTokenOptions tr.BaseURL = "http://localhost/github/api/v3/" // Convert InstallationTokenOptions into a ReadWriter to pass as an argument to http.NewRequest. body, err := GetReadWriter(installationTokenOptions) if err != nil { t.Fatalf("error calling GetReadWriter: %v", err) } req, err := http.NewRequest("POST", "http://localhost/test_endpoint/", body) if err != nil { t.Fatal("unexpected error:", err) } res, err := tr.RoundTrip(req) if err != nil { t.Fatalf("error calling RoundTrip: %v", err) } if res.StatusCode != 200 { t.Fatalf("Unexpected RoundTrip response code: %d", res.StatusCode) } } func TestRoundTripperContract(t *testing.T) { tr := &Transport{ token: &accessToken{ ExpiresAt: time.Now().Add(1 * time.Hour), Token: "42", }, mu: &sync.Mutex{}, tr: roundTripperFunc(func(req *http.Request) (*http.Response, error) { if auth := req.Header.Get("Authorization"); auth != "token 42" { t.Errorf("got unexpected Authorization request header in parent RoundTripper: %q", auth) } return nil, nil }), } req, err := http.NewRequest("GET", "http://localhost", nil) if err != nil { t.Fatal(err) } req.Header.Set("Authorization", "xxx") _, err = tr.RoundTrip(req) if err != nil { t.Fatal(err) } if accept := req.Header.Get("Authorization"); accept != "xxx" { t.Errorf("got unexpected Authorization request header in caller: %q", accept) } } type roundTripperFunc func(*http.Request) (*http.Response, error) func (fn roundTripperFunc) RoundTrip(req *http.Request) (*http.Response, error) { return fn(req) } func TestExpiryAccessor(t *testing.T) { now := time.Now() future := now.Add(2 * time.Hour) futureRefresh := future.Add(-time.Minute) past := now.Add(-2 * time.Hour) pastRefresh := past.Add(-time.Minute) for _, tc := range []struct { name string token *accessToken expectErr string expectExpiry time.Time expectRefresh time.Time }{ { name: "valid", token: &accessToken{ Token: token, ExpiresAt: future, }, expectExpiry: future, expectRefresh: futureRefresh, }, { name: "expired", token: &accessToken{ Token: token, ExpiresAt: past, }, expectExpiry: past, expectRefresh: pastRefresh, }, { name: "unset", expectErr: "Expiry() = unknown, err: nil token", }, } { t.Run(tc.name, func(t *testing.T) { tr := &Transport{token: tc.token} expiresAt, refreshAt, err := tr.Expiry() if err != nil { if tc.expectErr != err.Error() { t.Errorf("wrong error, expected=%q, actual=%q", tc.expectErr, err.Error()) } } else { if tc.expectErr != "" { t.Fatalf("unexpected error: %v", err) } } if tc.expectExpiry != expiresAt { t.Errorf("expiresAt mismatch, expected=%v, actual=%v", tc.expectExpiry.String(), expiresAt.String()) } if tc.expectRefresh != refreshAt { t.Errorf("refreshAt mismatch, expected=%v, actual=%v", tc.expectRefresh, refreshAt) } }) } } func TestHTTPErrorUnwrap(t *testing.T) { wrappedError := errors.New("wrapped error") err := &HTTPError{ RootCause: wrappedError, } if !errors.Is(err, wrappedError) { t.Errorf("HTTPError should be unwrapped to the root cause") } }