Skip to content

Commit 2c03c3c

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

File tree

7 files changed

+161
-8
lines changed

7 files changed

+161
-8
lines changed

cogito/ghcommitsink.go

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package cogito
22

33
import (
4+
"fmt"
45
"log/slog"
56
"time"
67

@@ -36,17 +37,28 @@ func (sink GitHubCommitStatusSink) Send() error {
3637
ghState := ghAdaptState(sink.Request.Params.State)
3738
buildURL := concourseBuildURL(sink.Request.Env)
3839
context := ghMakeContext(sink.Request)
40+
server := github.ApiRoot(sink.Request.Source.GhHostname)
41+
42+
token := sink.Request.Source.AccessToken
43+
fmt.Println(token)
44+
if token == "" {
45+
installationToken, err := github.GenerateInstallationToken(server, sink.Request.Source.GitHubApp)
46+
if err != nil {
47+
return err
48+
}
49+
token = installationToken
50+
}
3951

4052
target := &github.Target{
41-
Server: github.ApiRoot(sink.Request.Source.GhHostname),
53+
Server: server,
4254
Retry: retry.Retry{
4355
FirstDelay: retryFirstDelay,
4456
BackoffLimit: retryBackoffLimit,
4557
UpTo: retryUpTo,
4658
Log: sink.Log,
4759
},
4860
}
49-
commitStatus := github.NewCommitStatus(target, sink.Request.Source.AccessToken,
61+
commitStatus := github.NewCommitStatus(target, token,
5062
sink.Request.Source.Owner, sink.Request.Source.Repo, context, sink.Log)
5163
description := "Build " + sink.Request.Env.BuildName
5264

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: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
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/v5"
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, _ := io.ReadAll(resp.Body)
80+
var token struct {
81+
Value string `json:"token"`
82+
}
83+
if err := json.Unmarshal(body, &token); err != nil {
84+
return "", fmt.Errorf("error: json unmarshal: %s", err)
85+
}
86+
return token.Value, nil
87+
}

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/v5 v5.2.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/v5 v5.2.2 h1:Rl4B7itRWVtYIHFrSNd7vhTiz9UpLdi6gZhZ3wEeDy8=
15+
github.com/golang-jwt/jwt/v5 v5.2.2/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk=
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)