Skip to content

Commit b73f236

Browse files
committed
github: add support for github apps
1 parent a6cffbf commit b73f236

File tree

9 files changed

+264
-8
lines changed

9 files changed

+264
-8
lines changed

cogito/ghcommitsink.go

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -36,17 +36,27 @@ func (sink GitHubCommitStatusSink) Send() error {
3636
ghState := ghAdaptState(sink.Request.Params.State)
3737
buildURL := concourseBuildURL(sink.Request.Env)
3838
context := ghMakeContext(sink.Request)
39+
server := github.ApiRoot(sink.Request.Source.GhHostname)
40+
41+
token := sink.Request.Source.AccessToken
42+
if token == "" {
43+
installationToken, err := github.GenerateInstallationToken(server, sink.Request.Source.GitHubApp)
44+
if err != nil {
45+
return err
46+
}
47+
token = installationToken
48+
}
3949

4050
target := &github.Target{
41-
Server: github.ApiRoot(sink.Request.Source.GhHostname),
51+
Server: server,
4252
Retry: retry.Retry{
4353
FirstDelay: retryFirstDelay,
4454
BackoffLimit: retryBackoffLimit,
4555
UpTo: retryUpTo,
4656
Log: sink.Log,
4757
},
4858
}
49-
commitStatus := github.NewCommitStatus(target, sink.Request.Source.AccessToken,
59+
commitStatus := github.NewCommitStatus(target, token,
5060
sink.Request.Source.Owner, sink.Request.Source.Repo, context, sink.Log)
5161
description := "Build " + sink.Request.Env.BuildName
5262

cogito/ghcommitsink_test.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ func TestSinkGitHubCommitStatusSendSuccess(t *testing.T) {
2828
Log: testhelp.MakeTestLog(),
2929
GitRef: wantGitRef,
3030
Request: cogito.PutRequest{
31-
Source: cogito.Source{GhHostname: gitHubSpyURL.Host},
31+
Source: cogito.Source{GhHostname: gitHubSpyURL.Host, AccessToken: "dummy-token"},
3232
Params: cogito.PutParams{State: wantState},
3333
Env: cogito.Environment{BuildJobName: jobName},
3434
},
@@ -55,7 +55,7 @@ func TestSinkGitHubCommitStatusSendFailure(t *testing.T) {
5555
Log: testhelp.MakeTestLog(),
5656
GitRef: "deadbeefdeadbeef",
5757
Request: cogito.PutRequest{
58-
Source: cogito.Source{GhHostname: gitHubSpyURL.Host},
58+
Source: cogito.Source{GhHostname: gitHubSpyURL.Host, AccessToken: "dummy-token"},
5959
Params: cogito.PutParams{State: cogito.StatePending},
6060
},
6161
}

cogito/protocol.go

Lines changed: 37 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -162,9 +162,14 @@ type Source struct {
162162
//
163163
// Mandatory
164164
//
165-
Owner string `json:"owner"`
166-
Repo string `json:"repo"`
165+
Owner string `json:"owner"`
166+
Repo string `json:"repo"`
167+
// Mandatory if not using github app auth
167168
AccessToken string `json:"access_token"` // SENSITIVE
169+
170+
// Mandatory if using github app auth
171+
GitHubApp *github.GitHubApp `json:"github_app,omitempty"`
172+
168173
//
169174
// Optional
170175
//
@@ -193,6 +198,21 @@ func (src Source) String() string {
193198
fmt.Fprintf(&bld, "omit_target_url: %t\n", src.OmitTargetURL)
194199
fmt.Fprintf(&bld, "chat_append_summary: %t\n", src.ChatAppendSummary)
195200
fmt.Fprintf(&bld, "chat_notify_on_states: %s\n", src.ChatNotifyOnStates)
201+
202+
// fmt.Fprintf(&bld, "owner: %s\n", src.Owner)
203+
// fmt.Fprintf(&bld, "repo: %s\n", src.Repo)
204+
// fmt.Fprintf(&bld, "github_hostname: %s\n", src.GhHostname)
205+
// fmt.Fprintf(&bld, "access_token: %s\n", redact(src.AccessToken))
206+
// // FIX: avoid panic is src.GitHubApp is nil
207+
// fmt.Fprintf(&bld, "github_app.client_id: %s\n", src.GitHubApp.ClientId)
208+
// fmt.Fprintf(&bld, "github_app.installation_id: %d\n", src.GitHubApp.InstallationId)
209+
// fmt.Fprintf(&bld, "github_app.private_key: %s\n", redact(src.GitHubApp.PrivateKey))
210+
// fmt.Fprintf(&bld, "gchat_webhook: %s\n", redact(src.GChatWebHook))
211+
// fmt.Fprintf(&bld, "log_level: %s\n", src.LogLevel)
212+
// fmt.Fprintf(&bld, "context_prefix: %s\n", src.ContextPrefix)
213+
// fmt.Fprintf(&bld, "omit_target_url: %t\n", src.OmitTargetURL)
214+
// fmt.Fprintf(&bld, "chat_append_summary: %t\n", src.ChatAppendSummary)
215+
// fmt.Fprintf(&bld, "chat_notify_on_states: %s\n", src.ChatNotifyOnStates)
196216
// Last one: no newline.
197217
fmt.Fprintf(&bld, "sinks: %s", src.Sinks)
198218

@@ -245,9 +265,23 @@ func (src *Source) Validate() error {
245265
if src.Repo == "" {
246266
mandatory = append(mandatory, "repo")
247267
}
248-
if src.AccessToken == "" {
268+
if src.AccessToken == "" && src.GitHubApp == nil {
269+
// missing access token or github_app
249270
mandatory = append(mandatory, "access_token")
250271
}
272+
if src.AccessToken == "" && src.GitHubApp != nil {
273+
if src.GitHubApp.ClientId == "" {
274+
mandatory = append(mandatory, "github_app.client_id")
275+
}
276+
if src.GitHubApp.InstallationId == 0 {
277+
mandatory = append(mandatory, "github_app.installation_id")
278+
}
279+
if src.GitHubApp.PrivateKey == "" {
280+
mandatory = append(mandatory, "github_app.private_key")
281+
}
282+
}
283+
// if both src.AccessToken and src.GitHubApp are set; we should
284+
// fail the run
251285
}
252286

253287
if sinks.Contains("gchat") {

cogito/protocol_test.go

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import (
1212
"gotest.tools/v3/assert/cmp"
1313

1414
"github.com/Pix4D/cogito/cogito"
15+
"github.com/Pix4D/cogito/github"
1516
"github.com/Pix4D/cogito/testhelp"
1617
)
1718

@@ -63,6 +64,20 @@ func TestSourceValidationSuccess(t *testing.T) {
6364
return source
6465
},
6566
},
67+
{
68+
name: "git source: github app",
69+
mkSource: func() cogito.Source {
70+
return cogito.Source{
71+
Owner: "the-owner",
72+
Repo: "the-repo",
73+
GitHubApp: &github.GitHubApp{
74+
ClientId: "client-id-key",
75+
InstallationId: 12345,
76+
PrivateKey: "private-ssh-key",
77+
},
78+
}
79+
},
80+
},
6681
}
6782

6883
for _, tc := range testCases {
@@ -129,6 +144,7 @@ func TestSourceValidationFailure(t *testing.T) {
129144
},
130145
wantErr: "source: invalid github_api_hostname: https://github.foo.com/api/v3/. Don't configure the schema or the path",
131146
},
147+
// FIXME: add the failure tests for github app
132148
}
133149

134150
for _, tc := range testCases {
@@ -210,7 +226,8 @@ func TestSourcePrintLogRedaction(t *testing.T) {
210226
ChatAppendSummary: true,
211227
ChatNotifyOnStates: []cogito.BuildState{cogito.StateSuccess, cogito.StateFailure},
212228
}
213-
229+
// FIXME: extend the tests to verify that the source.github_app.private_key
230+
// is properly redacted
214231
t.Run("fmt.Print redacts fields", func(t *testing.T) {
215232
want := `owner: the-owner
216233
repo: the-repo

github/githubapp.go

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
package github
2+
3+
import (
4+
"encoding/json"
5+
"fmt"
6+
"io"
7+
"net/http"
8+
"path"
9+
"strconv"
10+
"time"
11+
12+
"github.com/golang-jwt/jwt/v4"
13+
)
14+
15+
type GitHubApp struct {
16+
ClientId string `json:"client_id"`
17+
InstallationId int64 `json:"installation_id"`
18+
PrivateKey string `json:"private_key"` // SENSITIVE
19+
}
20+
21+
// generateJWTtoken returns a signed JWT token used to authenticate as GitHub App
22+
func generateJWTtoken(clientId, privateKey string) (string, error) {
23+
key, err := jwt.ParseRSAPrivateKeyFromPEM([]byte(privateKey))
24+
if err != nil {
25+
return "", fmt.Errorf("could not parse private key: %w", err)
26+
}
27+
// GitHub rejects expiry and issue timestamps that are not an integer,
28+
// while the jwt-go library serializes to fractional timestamps.
29+
// Truncate them before passing to jwt-go.
30+
// Additionally, GitHub recommends setting this value 60 seconds in the past.
31+
iat := time.Now().Add(-60 * time.Second).Truncate(time.Second)
32+
// maximum validity 10 minutes. Here, we reduce it to 2 minutes.
33+
exp := iat.Add(2 * time.Minute)
34+
// Docs: https://docs.github.com/en/apps/creating-github-apps/authenticating-with-a-github-app/generating-a-json-web-token-jwt-for-a-github-app#about-json-web-tokens-jwts
35+
claims := &jwt.RegisteredClaims{
36+
IssuedAt: jwt.NewNumericDate(iat),
37+
ExpiresAt: jwt.NewNumericDate(exp),
38+
// The client ID or application ID of your GitHub App.
39+
// Use of the client ID is recommended.
40+
Issuer: clientId,
41+
}
42+
43+
// GitHub JWT must be signed using the RS256 algorithm.
44+
token, err := jwt.NewWithClaims(jwt.SigningMethodRS256, claims).SignedString(key)
45+
if err != nil {
46+
return "", fmt.Errorf("could not sign the JWT token: %w", err)
47+
}
48+
return token, nil
49+
}
50+
51+
// GenerateInstallationToken returns an installation token used to authenticate as GitHub App installation
52+
func GenerateInstallationToken(server string, app *GitHubApp) (string, error) {
53+
// FIXME: prevent panic if app pointer is nil
54+
// API: POST /app/installations/{installationId}/access_tokens
55+
installationId := strconv.FormatInt(app.InstallationId, 10)
56+
url := server + path.Join("/app/installations", installationId, "/access_tokens")
57+
58+
req, err := http.NewRequest(http.MethodPost, url, nil)
59+
if err != nil {
60+
return "", fmt.Errorf("github post: new request: %s", err)
61+
}
62+
req.Header.Add("Accept", "application/vnd.github.v3+json")
63+
64+
jtwToken, err := generateJWTtoken(app.ClientId, app.PrivateKey)
65+
if err != nil {
66+
return "", err
67+
}
68+
req.Header.Set("Authorization", "Bearer "+jtwToken)
69+
70+
client := &http.Client{Timeout: time.Second * 5}
71+
72+
// FIXME: add retry here...
73+
resp, err := client.Do(req)
74+
if err != nil {
75+
return "", fmt.Errorf("http client Do: %s", err)
76+
}
77+
defer resp.Body.Close()
78+
79+
body, errBody := io.ReadAll(resp.Body)
80+
if resp.StatusCode != http.StatusCreated {
81+
if errBody != nil {
82+
return "", fmt.Errorf("generate github app installation token: status code: %d (%s)", resp.StatusCode, errBody)
83+
}
84+
return "", fmt.Errorf("generate github app installation token: status code: %d (%s)", resp.StatusCode, string(body))
85+
}
86+
if errBody != nil {
87+
return string(body), fmt.Errorf("generate github app installation token: read body: %s", errBody)
88+
}
89+
90+
var token struct {
91+
Value string `json:"token"`
92+
}
93+
if err := json.Unmarshal(body, &token); err != nil {
94+
return "", fmt.Errorf("error: json unmarshal: %s", err)
95+
}
96+
return token.Value, nil
97+
}

github/githubapp_test.go

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
package github_test
2+
3+
import (
4+
"fmt"
5+
"net/http"
6+
"net/http/httptest"
7+
"testing"
8+
9+
"github.com/Pix4D/cogito/github"
10+
"github.com/Pix4D/cogito/testhelp"
11+
"gotest.tools/v3/assert"
12+
)
13+
14+
func TestGenerateInstallationToken(t *testing.T) {
15+
clientID := "abcd1234"
16+
var installationID int64 = 12345
17+
18+
privateKey, err := testhelp.GeneratePrivateKey(t, 2048)
19+
assert.NilError(t, err)
20+
21+
handler := func(w http.ResponseWriter, r *http.Request) {
22+
claims := testhelp.DecodeJWT(t, r, privateKey)
23+
if claims.Issuer != clientID {
24+
w.WriteHeader(http.StatusUnauthorized)
25+
fmt.Fprintln(w, "unauthorized: wrong JWT token")
26+
return
27+
}
28+
w.WriteHeader(http.StatusCreated)
29+
fmt.Fprintln(w, `{"token": "dummy_token"}`)
30+
}
31+
32+
ts := httptest.NewServer(http.HandlerFunc(handler))
33+
defer ts.Close()
34+
35+
gotToken, err := github.GenerateInstallationToken(
36+
ts.URL,
37+
&github.GitHubApp{
38+
ClientId: clientID,
39+
InstallationId: installationID,
40+
PrivateKey: string(testhelp.EncodePrivateKeyToPEM(privateKey)),
41+
},
42+
)
43+
44+
assert.NilError(t, err)
45+
assert.Equal(t, "dummy_token", gotToken)
46+
}

go.mod

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ require (
66
dario.cat/mergo v1.0.0
77
github.com/alexflint/go-arg v1.4.3
88
github.com/go-quicktest/qt v1.101.1-0.20240301121107-c6c8733fa1e6
9+
github.com/golang-jwt/jwt/v4 v4.5.2
910
github.com/google/go-cmp v0.6.0
1011
github.com/sasbury/mini v0.0.0-20181226232755-dc74af49394b
1112
gotest.tools/v3 v3.5.1

go.sum

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,8 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c
1111
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
1212
github.com/go-quicktest/qt v1.101.1-0.20240301121107-c6c8733fa1e6 h1:teYtXy9B7y5lHTp8V9KPxpYRAVA7dozigQcMiBust1s=
1313
github.com/go-quicktest/qt v1.101.1-0.20240301121107-c6c8733fa1e6/go.mod h1:p4lGIVX+8Wa6ZPNDvqcxq36XpUDLh42FLetFU7odllI=
14+
github.com/golang-jwt/jwt/v4 v4.5.2 h1:YtQM7lnr8iZ+j5q71MGKkNw9Mn7AjHM68uc9g5fXeUI=
15+
github.com/golang-jwt/jwt/v4 v4.5.2/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
1416
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
1517
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
1618
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=

testhelp/testhelper.go

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,15 @@ package testhelp
22

33
import (
44
"bytes"
5+
"crypto/rand"
6+
"crypto/rsa"
7+
"crypto/x509"
58
"encoding/json"
9+
"encoding/pem"
610
"errors"
711
"fmt"
812
"io"
13+
"net/http"
914
"os"
1015
"path"
1116
"path/filepath"
@@ -14,6 +19,7 @@ import (
1419
"text/template"
1520

1621
"dario.cat/mergo"
22+
"github.com/golang-jwt/jwt/v4"
1723
"gotest.tools/v3/assert"
1824
)
1925

@@ -293,3 +299,46 @@ type FailingWriter struct{}
293299
func (t *FailingWriter) Write([]byte) (n int, err error) {
294300
return 0, errors.New("test write error")
295301
}
302+
303+
// GeneratePrivateKey creates a RSA Private Key of specified byte size
304+
func GeneratePrivateKey(t *testing.T, bitSize int) (*rsa.PrivateKey, error) {
305+
// Private Key generation
306+
privateKey, err := rsa.GenerateKey(rand.Reader, bitSize)
307+
assert.NilError(t, err)
308+
309+
// Validate Private Key
310+
err = privateKey.Validate()
311+
assert.NilError(t, err)
312+
313+
return privateKey, nil
314+
}
315+
316+
// EncodePrivateKeyToPEM encodes Private Key from RSA to PEM format
317+
func EncodePrivateKeyToPEM(privateKey *rsa.PrivateKey) []byte {
318+
// Get ASN.1 DER format
319+
privDER := x509.MarshalPKCS1PrivateKey(privateKey)
320+
321+
// pem.Block
322+
privBlock := pem.Block{
323+
Type: "RSA PRIVATE KEY",
324+
Headers: nil,
325+
Bytes: privDER,
326+
}
327+
328+
return pem.EncodeToMemory(&privBlock)
329+
}
330+
331+
// DecodeJWT decodes the HTTP request authorization header with the given RSA key
332+
// and returns the registered claims of the decoded token.
333+
func DecodeJWT(t *testing.T, r *http.Request, key *rsa.PrivateKey) *jwt.RegisteredClaims {
334+
token := strings.Fields(r.Header.Get("Authorization"))[1]
335+
tok, err := jwt.ParseWithClaims(token, &jwt.RegisteredClaims{}, func(t *jwt.Token) (interface{}, error) {
336+
if t.Header["alg"] != "RS256" {
337+
return nil, fmt.Errorf("unexpected signing method: %v, expected: %v", t.Header["alg"], "RS256")
338+
}
339+
return &key.PublicKey, nil
340+
})
341+
assert.NilError(t, err)
342+
343+
return tok.Claims.(*jwt.RegisteredClaims)
344+
}

0 commit comments

Comments
 (0)