diff --git a/README.md b/README.md index ef6bd69..287adea 100644 --- a/README.md +++ b/README.md @@ -36,6 +36,23 @@ Fetches and creates versioned GitHub releases. fine-grained access token you create is only required to have the content permission. For classic access tokens, you need the repo or public_repo permission. + Mutually exclusive with app_id/private_key. + + + + app_id (Optional) + + The ID of the GitHub App to authenticate as. Must be used together with + private_key. The resource will automatically discover the App + installation for the configured owner/repository. + Mutually exclusive with access_token. + + + + private_key (Optional) + + The PEM-encoded private key of the GitHub App. Must be used together with + app_id. Mutually exclusive with access_token. @@ -148,6 +165,18 @@ Fetches and creates versioned GitHub releases. access_token: abcdef1234567890 ``` +Or using a GitHub App: + +``` yaml +- name: gh-release + type: github-release + source: + owner: concourse + repository: concourse + app_id: ((github-app-id)) + private_key: ((github-app-private-key)) +``` + ``` yaml - get: gh-release ``` diff --git a/github.go b/github.go index 71a3c36..6009751 100644 --- a/github.go +++ b/github.go @@ -11,6 +11,7 @@ import ( "os" "strings" + "github.com/bradleyfalzon/ghinstallation/v2" "github.com/google/go-github/v66/github" "github.com/shurcooL/githubv4" "golang.org/x/oauth2" @@ -38,29 +39,54 @@ type GitHubClient struct { client *github.Client clientV4 *githubv4.Client - owner string - repository string - accessToken string + owner string + repository string + hasAuth bool + tokenFunc func(ctx context.Context) (string, error) } func NewGitHubClient(source Source) (*GitHubClient, error) { - httpClient := &http.Client{} + if err := validateAuth(source); err != nil { + return nil, err + } + + var baseTransport http.RoundTripper = http.DefaultTransport ctx := context.TODO() if source.Insecure { - httpClient.Transport = &http.Transport{ + baseTransport = &http.Transport{ Proxy: http.ProxyFromEnvironment, TLSClientConfig: &tls.Config{InsecureSkipVerify: true}, } - ctx = context.WithValue(ctx, oauth2.HTTPClient, httpClient) + ctx = context.WithValue(ctx, oauth2.HTTPClient, &http.Client{Transport: baseTransport}) } - if source.AccessToken != "" { + var httpClient *http.Client + var hasAuth bool + var tokenFunc func(ctx context.Context) (string, error) + + switch { + case source.AccessToken != "": var err error httpClient, err = oauthClient(ctx, source) if err != nil { return nil, err } + hasAuth = true + tokenFunc = func(_ context.Context) (string, error) { + return source.AccessToken, nil + } + + case source.AppID != 0: + var err error + httpClient, tokenFunc, err = appInstallationClient(ctx, baseTransport, source) + if err != nil { + return nil, err + } + hasAuth = true + + default: + httpClient = &http.Client{Transport: baseTransport} } client := github.NewClient(httpClient) @@ -109,16 +135,17 @@ func NewGitHubClient(source Source) (*GitHubClient, error) { } return &GitHubClient{ - client: client, - clientV4: clientV4, - owner: owner, - repository: source.Repository, - accessToken: source.AccessToken, + client: client, + clientV4: clientV4, + owner: owner, + repository: source.Repository, + hasAuth: hasAuth, + tokenFunc: tokenFunc, }, nil } func (g *GitHubClient) ListReleases() ([]*github.RepositoryRelease, error) { - if g.accessToken != "" { + if g.hasAuth { return g.listReleasesV4() } opt := &github.ListOptions{PerPage: 100} @@ -266,8 +293,12 @@ func (g *GitHubClient) DownloadReleaseAsset(asset github.ReleaseAsset) (io.ReadC return nil, err } req.Header.Set("Accept", "application/octet-stream") - if g.accessToken != "" && req.URL.Host == g.client.BaseURL.Host { - req.Header.Set("Authorization", "Bearer "+g.accessToken) + if g.hasAuth && req.URL.Host == g.client.BaseURL.Host { + token, err := g.tokenFunc(context.TODO()) + if err != nil { + return nil, fmt.Errorf("obtaining auth token for asset download: %w", err) + } + req.Header.Set("Authorization", "Bearer "+token) } httpClient := &http.Client{} @@ -345,6 +376,81 @@ func (g *GitHubClient) ResolveTagToCommitSHA(tagName string) (string, error) { return "", fmt.Errorf("could not resolve tag %q to commit: exceeded maximum tag chain depth of %d", tagName, maxDepth) } +func validateAuth(source Source) error { + hasToken := source.AccessToken != "" + hasAppID := source.AppID != 0 + hasKey := source.PrivateKey != "" + + if hasToken && (hasAppID || hasKey) { + return errors.New("cannot specify both access_token and app credentials (app_id/private_key)") + } + if hasAppID != hasKey { + return errors.New("both app_id and private_key must be specified for GitHub App auth") + } + return nil +} + +func appInstallationClient(ctx context.Context, baseTransport http.RoundTripper, source Source) (*http.Client, func(context.Context) (string, error), error) { + privateKey := []byte(source.PrivateKey) + apiURL := normalizeURL(source.GitHubAPIURL) + + // Create an app-level transport to discover the installation + appsTransport, err := ghinstallation.NewAppsTransport(baseTransport, int64(source.AppID), privateKey) + if err != nil { + return nil, nil, fmt.Errorf("creating GitHub App transport: %w", err) + } + + if apiURL != "" { + appsTransport.BaseURL = apiURL + } + + // Discover the installation ID for this repository + appClient := github.NewClient(&http.Client{Transport: appsTransport}) + if apiURL != "" { + appClient.BaseURL, err = url.Parse(apiURL) + if err != nil { + return nil, nil, fmt.Errorf("parsing GitHub API URL: %w", err) + } + } + + owner := source.Owner + if source.User != "" { + owner = source.User + } + + installation, _, err := appClient.Apps.FindRepositoryInstallation(ctx, owner, source.Repository) + if err != nil { + return nil, nil, fmt.Errorf("finding GitHub App installation for %s/%s: %w", owner, source.Repository, err) + } + + // Create installation-level transport for authenticated API access + itr, err := ghinstallation.New(baseTransport, int64(source.AppID), installation.GetID(), privateKey) + if err != nil { + return nil, nil, fmt.Errorf("creating installation transport: %w", err) + } + + if apiURL != "" { + itr.BaseURL = apiURL + } + + httpClient := &http.Client{Transport: itr} + tokenFunc := func(ctx context.Context) (string, error) { + return itr.Token(ctx) + } + + return httpClient, tokenFunc, nil +} + +func normalizeURL(rawURL string) string { + if rawURL == "" { + return "" + } + if !strings.HasSuffix(rawURL, "/") { + return rawURL + "/" + } + return rawURL +} + func oauthClient(ctx context.Context, source Source) (*http.Client, error) { ts := oauth2.StaticTokenSource(&oauth2.Token{ AccessToken: source.AccessToken, diff --git a/github_test.go b/github_test.go index 953e2ba..62c71d8 100644 --- a/github_test.go +++ b/github_test.go @@ -1,6 +1,11 @@ package resource_test import ( + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "encoding/json" + "encoding/pem" "fmt" "io" "net/http" @@ -922,4 +927,164 @@ var _ = Describe("GitHub Client", func() { }) }) }) + + Describe("FlexInt64 unmarshaling", func() { + It("unmarshals app_id from a JSON number", func() { + input := `{"owner":"o","repository":"r","app_id":12345,"private_key":"k"}` + var s Source + err := json.Unmarshal([]byte(input), &s) + Expect(err).NotTo(HaveOccurred()) + Expect(int64(s.AppID)).To(Equal(int64(12345))) + }) + + It("unmarshals app_id from a JSON string", func() { + input := `{"owner":"o","repository":"r","app_id":"67890","private_key":"k"}` + var s Source + err := json.Unmarshal([]byte(input), &s) + Expect(err).NotTo(HaveOccurred()) + Expect(int64(s.AppID)).To(Equal(int64(67890))) + }) + + It("returns an error for a non-numeric string", func() { + input := `{"owner":"o","repository":"r","app_id":"not-a-number","private_key":"k"}` + var s Source + err := json.Unmarshal([]byte(input), &s) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("not a valid integer")) + }) + }) + + Describe("Auth validation", func() { + It("returns an error when both access_token and app credentials are set", func() { + _, err := NewGitHubClient(Source{ + Owner: "concourse", + Repository: "concourse", + AccessToken: "some-token", + AppID: 12345, + PrivateKey: "some-key", + GitHubAPIURL: server.URL() + "/", + }) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("cannot specify both access_token and app credentials")) + }) + + It("returns an error when app_id is set without private_key", func() { + _, err := NewGitHubClient(Source{ + Owner: "concourse", + Repository: "concourse", + AppID: 12345, + GitHubAPIURL: server.URL() + "/", + }) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("both app_id and private_key must be specified")) + }) + + It("returns an error when private_key is set without app_id", func() { + _, err := NewGitHubClient(Source{ + Owner: "concourse", + Repository: "concourse", + PrivateKey: "some-key", + GitHubAPIURL: server.URL() + "/", + }) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("both app_id and private_key must be specified")) + }) + }) + + Describe("GitHub App authentication", func() { + var testKeyPEM string + + BeforeEach(func() { + key, err := rsa.GenerateKey(rand.Reader, 2048) + Expect(err).NotTo(HaveOccurred()) + + testKeyPEM = string(pem.EncodeToMemory(&pem.Block{ + Type: "RSA PRIVATE KEY", + Bytes: x509.MarshalPKCS1PrivateKey(key), + })) + }) + + Context("when the installation is found", func() { + It("creates a client that uses the installation token for GraphQL", func() { + // Mock: discover installation for repo + server.AppendHandlers( + ghttp.CombineHandlers( + ghttp.VerifyRequest("GET", "/repos/concourse/concourse/installation"), + ghttp.RespondWithJSONEncoded(200, map[string]any{ + "id": 1234, + "app_id": 99, + }), + ), + ) + + // Mock: create installation access token + server.AppendHandlers( + ghttp.CombineHandlers( + ghttp.VerifyRequest("POST", "/app/installations/1234/access_tokens"), + ghttp.RespondWithJSONEncoded(201, map[string]any{ + "token": "ghs_testinstalltoken", + "expires_at": time.Now().Add(1 * time.Hour).Format(time.RFC3339), + }), + ), + ) + + // Mock: the actual GraphQL call + server.AppendHandlers( + ghttp.CombineHandlers( + ghttp.VerifyRequest("POST", "/graphql"), + ghttp.VerifyHeaderKV("Authorization", "token ghs_testinstalltoken"), + ghttp.RespondWith(200, singlePageRespEnterprise), + ), + ) + + appClient, err := NewGitHubClient(Source{ + Owner: "concourse", + Repository: "concourse", + AppID: 99, + PrivateKey: testKeyPEM, + GitHubAPIURL: server.URL() + "/", + }) + Expect(err).NotTo(HaveOccurred()) + + releases, err := appClient.ListReleases() + Expect(err).NotTo(HaveOccurred()) + Expect(releases).To(HaveLen(1)) + }) + }) + + Context("when the installation is not found", func() { + It("returns an error", func() { + server.AppendHandlers( + ghttp.CombineHandlers( + ghttp.VerifyRequest("GET", "/repos/concourse/concourse/installation"), + ghttp.RespondWith(404, `{"message": "Not Found"}`), + ), + ) + + _, err := NewGitHubClient(Source{ + Owner: "concourse", + Repository: "concourse", + AppID: 99, + PrivateKey: testKeyPEM, + GitHubAPIURL: server.URL() + "/", + }) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("finding GitHub App installation for concourse/concourse")) + }) + }) + + Context("when the private key is invalid", func() { + It("returns an error", func() { + _, err := NewGitHubClient(Source{ + Owner: "concourse", + Repository: "concourse", + AppID: 99, + PrivateKey: "not-a-valid-pem-key", + GitHubAPIURL: server.URL() + "/", + }) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("creating GitHub App transport")) + }) + }) + }) }) diff --git a/go.mod b/go.mod index 763e882..6003b27 100644 --- a/go.mod +++ b/go.mod @@ -4,6 +4,7 @@ go 1.25.0 require ( github.com/Masterminds/semver v1.5.0 + github.com/bradleyfalzon/ghinstallation/v2 v2.19.0 github.com/cppforlife/go-semi-semantic v0.0.0-20160921010311-576b6af77ae4 github.com/google/go-github/v66 v66.0.0 github.com/maxbrunsfeld/counterfeiter/v6 v6.12.2 @@ -18,7 +19,9 @@ require ( github.com/Masterminds/semver/v3 v3.4.0 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-task/slim-sprig/v3 v3.0.0 // indirect + github.com/golang-jwt/jwt/v4 v4.5.2 // indirect github.com/google/go-cmp v0.7.0 // indirect + github.com/google/go-github/v88 v88.0.0 // indirect github.com/google/go-querystring v1.2.0 // indirect github.com/google/pprof v0.0.0-20260402051712-545e8a4df936 // indirect github.com/nxadm/tail v1.4.5 // indirect diff --git a/go.sum b/go.sum index d9a6fb7..1f7ed60 100644 --- a/go.sum +++ b/go.sum @@ -2,6 +2,8 @@ github.com/Masterminds/semver v1.5.0 h1:H65muMkzWKEuNDnfl9d70GUjFniHKHRbFPGBuZ3Q github.com/Masterminds/semver v1.5.0/go.mod h1:MB6lktGJrhw8PrUyiEoblNEGEQ+RzHPF078ddwwvV3Y= github.com/Masterminds/semver/v3 v3.4.0 h1:Zog+i5UMtVoCU8oKka5P7i9q9HgrJeGzI9SA1Xbatp0= github.com/Masterminds/semver/v3 v3.4.0/go.mod h1:4V+yj/TJE1HU9XfppCwVMZq3I84lprf4nC11bSS5beM= +github.com/bradleyfalzon/ghinstallation/v2 v2.19.0 h1:KQfD+43pRw9NUJhGycGrFr9vF1MubZacksKol1gomFI= +github.com/bradleyfalzon/ghinstallation/v2 v2.19.0/go.mod h1:fe5ECIhCdEnxwLiBlNTxx9CP455wt42BELnlDVMvaAA= github.com/cppforlife/go-semi-semantic v0.0.0-20160921010311-576b6af77ae4 h1:J+ghqo7ZubTzelkjo9hntpTtP/9lUCWH9icEmAW+B+Q= github.com/cppforlife/go-semi-semantic v0.0.0-20160921010311-576b6af77ae4/go.mod h1:socxpf5+mELPbosI149vWpNlHK6mbfWFxSWOoSndXR8= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= @@ -21,6 +23,8 @@ github.com/go-task/slim-sprig/v3 v3.0.0 h1:sUs3vkvUymDpBKi3qH1YSqBQk9+9D/8M2mN1v github.com/go-task/slim-sprig/v3 v3.0.0/go.mod h1:W848ghGpv3Qj3dhTPRyJypKRiqCdHZiAzKg9hl15HA8= github.com/goccy/go-yaml v1.18.0 h1:8W7wMFS12Pcas7KU+VVkaiCng+kG8QiFeFwzFb+rwuw= github.com/goccy/go-yaml v1.18.0/go.mod h1:XBurs7gK8ATbW4ZPGKgcbrY1Br56PdM69F7LkFRi1kA= +github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI= +github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0= github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8= github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA= @@ -36,6 +40,8 @@ github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= github.com/google/go-github/v66 v66.0.0 h1:ADJsaXj9UotwdgK8/iFZtv7MLc8E8WBl62WLd/D/9+M= github.com/google/go-github/v66 v66.0.0/go.mod h1:+4SO9Zkuyf8ytMj0csN1NR/5OTR+MfqPp8P8dVlcvY4= +github.com/google/go-github/v88 v88.0.0 h1:dZA9IKkPK1eXZj4ypngnpRj5FwdpTv4whix2PrQMP7M= +github.com/google/go-github/v88 v88.0.0/go.mod h1:rufTDgn2N45wjhukLTyxmvc9nilSp3mr3Rgtt6b1MPw= github.com/google/go-querystring v1.2.0 h1:yhqkPbu2/OH+V9BfpCVPZkNmUXhb2gBxJArfhIxNtP0= github.com/google/go-querystring v1.2.0/go.mod h1:8IFJqpSRITyJ8QhQ13bmbeMBDfmeEJZD5A0egEOmkqU= github.com/google/pprof v0.0.0-20260402051712-545e8a4df936 h1:EwtI+Al+DeppwYX2oXJCETMO23COyaKGP6fHVpkpWpg= diff --git a/resources.go b/resources.go index 22b3042..35b1993 100644 --- a/resources.go +++ b/resources.go @@ -1,9 +1,36 @@ package resource import ( + "encoding/json" + "fmt" + "strconv" "time" ) +// FlexInt64 unmarshals from both JSON numbers and strings. +// Concourse credential managers often interpolate all values as strings. +type FlexInt64 int64 + +func (f *FlexInt64) UnmarshalJSON(data []byte) error { + var n int64 + if err := json.Unmarshal(data, &n); err == nil { + *f = FlexInt64(n) + return nil + } + + var s string + if err := json.Unmarshal(data, &s); err != nil { + return fmt.Errorf("app_id must be a number or numeric string, got: %s", string(data)) + } + + n, err := strconv.ParseInt(s, 10, 64) + if err != nil { + return fmt.Errorf("app_id is not a valid integer: %q", s) + } + *f = FlexInt64(n) + return nil +} + type Source struct { Owner string `json:"owner"` Repository string `json:"repository"` @@ -14,7 +41,9 @@ type Source struct { GitHubAPIURL string `json:"github_api_url"` GitHubV4APIURL string `json:"github_v4_api_url"` GitHubUploadsURL string `json:"github_uploads_url"` - AccessToken string `json:"access_token"` + AccessToken string `json:"access_token"` + AppID FlexInt64 `json:"app_id"` + PrivateKey string `json:"private_key"` Drafts bool `json:"drafts"` PreRelease bool `json:"pre_release"` Release bool `json:"release"`