Skip to content

Commit 8702b01

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

File tree

9 files changed

+265
-9
lines changed

9 files changed

+265
-9
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: 26 additions & 4 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
//
@@ -188,6 +193,9 @@ func (src Source) String() string {
188193
fmt.Fprintf(&bld, "github_hostname: %s\n", src.GhHostname)
189194
fmt.Fprintf(&bld, "access_token: %s\n", redact(src.AccessToken))
190195
fmt.Fprintf(&bld, "gchat_webhook: %s\n", redact(src.GChatWebHook))
196+
fmt.Fprintf(&bld, "github_app.client_id: %s\n", src.GitHubApp.ClientId)
197+
fmt.Fprintf(&bld, "github_app.installation_id: %d\n", src.GitHubApp.InstallationId)
198+
fmt.Fprintf(&bld, "github_app.private_key: %s\n", redact(src.GitHubApp.PrivateKey))
191199
fmt.Fprintf(&bld, "log_level: %s\n", src.LogLevel)
192200
fmt.Fprintf(&bld, "context_prefix: %s\n", src.ContextPrefix)
193201
fmt.Fprintf(&bld, "omit_target_url: %t\n", src.OmitTargetURL)
@@ -245,8 +253,22 @@ func (src *Source) Validate() error {
245253
if src.Repo == "" {
246254
mandatory = append(mandatory, "repo")
247255
}
248-
if src.AccessToken == "" {
249-
mandatory = append(mandatory, "access_token")
256+
257+
if src.GitHubApp != (github.GitHubApp{}) {
258+
if src.GitHubApp.ClientId == "" {
259+
mandatory = append(mandatory, "github_app.client_id")
260+
}
261+
if src.GitHubApp.InstallationId == 0 {
262+
mandatory = append(mandatory, "github_app.installation_id")
263+
}
264+
if src.GitHubApp.PrivateKey == "" {
265+
mandatory = append(mandatory, "github_app.private_key")
266+
}
267+
} else {
268+
if src.AccessToken == "" {
269+
// missing access token
270+
mandatory = append(mandatory, "access_token")
271+
}
250272
}
251273
}
252274

cogito/protocol_test.go

Lines changed: 25 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 {
@@ -204,19 +220,24 @@ func TestSourcePrintLogRedaction(t *testing.T) {
204220
Repo: "the-repo",
205221
GhHostname: "github.com",
206222
AccessToken: "sensitive-the-access-token",
223+
GitHubApp: github.GitHubApp{ClientId: "client-id", InstallationId: 1234, PrivateKey: "sensitive-private-rsa-key"},
207224
GChatWebHook: "sensitive-gchat-webhook",
208225
LogLevel: "debug",
209226
ContextPrefix: "the-prefix",
210227
ChatAppendSummary: true,
211228
ChatNotifyOnStates: []cogito.BuildState{cogito.StateSuccess, cogito.StateFailure},
212229
}
213-
230+
// FIXME: extend the tests to verify that the source.github_app.private_key
231+
// is properly redacted
214232
t.Run("fmt.Print redacts fields", func(t *testing.T) {
215233
want := `owner: the-owner
216234
repo: the-repo
217235
github_hostname: github.com
218236
access_token: ***REDACTED***
219237
gchat_webhook: ***REDACTED***
238+
github_app.client_id: client-id
239+
github_app.installation_id: 1234
240+
github_app.private_key: ***REDACTED***
220241
log_level: debug
221242
context_prefix: the-prefix
222243
omit_target_url: false
@@ -238,6 +259,9 @@ repo:
238259
github_hostname:
239260
access_token:
240261
gchat_webhook:
262+
github_app.client_id:
263+
github_app.installation_id: 0
264+
github_app.private_key:
241265
log_level:
242266
context_prefix:
243267
omit_target_url: false

github/githubapp.go

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
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+
// API: POST /app/installations/{installationId}/access_tokens
54+
installationId := strconv.FormatInt(app.InstallationId, 10)
55+
url := server + path.Join("/app/installations", installationId, "/access_tokens")
56+
57+
req, err := http.NewRequest(http.MethodPost, url, nil)
58+
if err != nil {
59+
return "", fmt.Errorf("github post: new request: %s", err)
60+
}
61+
req.Header.Add("Accept", "application/vnd.github.v3+json")
62+
63+
jtwToken, err := generateJWTtoken(app.ClientId, app.PrivateKey)
64+
if err != nil {
65+
return "", err
66+
}
67+
req.Header.Set("Authorization", "Bearer "+jtwToken)
68+
69+
client := &http.Client{Timeout: time.Second * 5}
70+
71+
// FIXME: add retry here...
72+
resp, err := client.Do(req)
73+
if err != nil {
74+
return "", fmt.Errorf("http client Do: %s", err)
75+
}
76+
defer resp.Body.Close()
77+
78+
body, errBody := io.ReadAll(resp.Body)
79+
if resp.StatusCode != http.StatusCreated {
80+
if errBody != nil {
81+
return "", fmt.Errorf("generate github app installation token: status code: %d (%s)", resp.StatusCode, errBody)
82+
}
83+
return "", fmt.Errorf("generate github app installation token: status code: %d (%s)", resp.StatusCode, string(body))
84+
}
85+
if errBody != nil {
86+
return string(body), fmt.Errorf("generate github app installation token: read body: %s", errBody)
87+
}
88+
89+
var token struct {
90+
Value string `json:"token"`
91+
}
92+
if err := json.Unmarshal(body, &token); err != nil {
93+
return "", fmt.Errorf("error: json unmarshal: %s", err)
94+
}
95+
return token.Value, nil
96+
}

github/githubapp_test.go

Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
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+
if r.Method != http.MethodPost {
23+
w.WriteHeader(http.StatusMethodNotAllowed)
24+
fmt.Fprintln(w, "wrong HTTP emethod")
25+
return
26+
}
27+
28+
claims := testhelp.DecodeJWT(t, r, privateKey)
29+
if claims.Issuer != clientID {
30+
w.WriteHeader(http.StatusUnauthorized)
31+
fmt.Fprintln(w, "unauthorized: wrong JWT token")
32+
return
33+
}
34+
w.WriteHeader(http.StatusCreated)
35+
fmt.Fprintln(w, `{"token": "dummy_token"}`)
36+
}
37+
38+
ts := httptest.NewServer(http.HandlerFunc(handler))
39+
defer ts.Close()
40+
41+
gotToken, err := github.GenerateInstallationToken(
42+
ts.URL,
43+
github.GitHubApp{
44+
ClientId: clientID,
45+
InstallationId: installationID,
46+
PrivateKey: string(testhelp.EncodePrivateKeyToPEM(privateKey)),
47+
},
48+
)
49+
50+
assert.NilError(t, err)
51+
assert.Equal(t, "dummy_token", gotToken)
52+
}

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=

0 commit comments

Comments
 (0)