Skip to content

Commit 10f7d70

Browse files
committed
Add support for GitHub app authentication
Signed-off-by: Liam Wyllie <[email protected]>
1 parent 5e40d47 commit 10f7d70

31 files changed

+2491
-14
lines changed

docs/dev/testing_github_app_auth.md

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
# Testing GitHub app auth
2+
3+
## Step 1: Create and install a dummy GitHub app for testing with
4+
5+
Go to https://github.com/settings/apps/new
6+
7+
1. Enter a name for the app (needs to be unique across GitHub).
8+
2. Set the required `homepage URL` field (can be any valid URL).
9+
3. Under `Webhook`, uncheck the `Active` checkbox.
10+
4. Click on `Repository permissions` under `Permissions`, and set `Contents` to `Read-only`
11+
5. Click on `Create GitHub App` at the bottom of the page.
12+
6. You should be navigated to a new page with a `Registration successful. You must generate a private key in order to install your GitHub App.` message. Click on the `generate a private key` link, and then the `Generate a private key` button, and save it somewhere; it will be used to test the app authentication.
13+
7. Click on the `Install App` tab on the left, and then click on `Install` on the right.
14+
8. Select `Only select repositories`, and pick any private repository that contains a "LICENSE" file (may need to be created beforehand).
15+
16+
## Step 2: Export the necessary environment variables
17+
18+
The following environment variables are *required* to run the git-sync github app auth test:
19+
- `GITHUB_APP_PRIVATE_KEY`
20+
- `GITHUB_APP_APPLICATION_ID`
21+
- `GITHUB_APP_INSTALLATION_ID`
22+
- `GITHUB_APP_AUTH_TEST_REPO`
23+
24+
### GITHUB_APP_PRIVATE_KEY
25+
Should have been saved when creating the app
26+
27+
### GITHUB_APP_APPLICATION_ID
28+
The value after "App ID" in the app's settings page
29+
30+
### GITHUB_APP_INSTALLATION_ID
31+
Found in the URL of the app's installation page if you installed it to a repository: https://github.com/settings/installations/<installation_id>
32+
33+
### GITHUB_APP_AUTH_TEST_REPO
34+
Should be set to the repository that the github app is installed to.

go.mod

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ module k8s.io/git-sync
22

33
require (
44
github.com/go-logr/logr v1.2.3
5+
github.com/golang-jwt/jwt/v4 v4.5.0
56
github.com/prometheus/client_golang v1.14.0
67
github.com/spf13/pflag v1.0.5
78
go.uber.org/goleak v1.2.1

go.sum

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,8 @@ github.com/go-logr/logr v1.2.3 h1:2DntVwHkVopvECVRSlL5PSo9eG+cAkDCuckLubN+rq0=
7373
github.com/go-logr/logr v1.2.3/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
7474
github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY=
7575
github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ=
76+
github.com/golang-jwt/jwt/v4 v4.5.0 h1:7cYmW1XlMY7h7ii7UhUyChSgS5wUJEnm9uZVTGqOWzg=
77+
github.com/golang-jwt/jwt/v4 v4.5.0/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
7678
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
7779
github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
7880
github.com/golang/groupcache v0.0.0-20191227052852-215e87163ea7/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=

main.go

Lines changed: 176 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ package main // import "k8s.io/git-sync/cmd/git-sync"
2121
import (
2222
"context"
2323
"crypto/md5"
24+
"encoding/json"
2425
"errors"
2526
"fmt"
2627
"io"
@@ -40,6 +41,7 @@ import (
4041
"syscall"
4142
"time"
4243

44+
"github.com/golang-jwt/jwt/v4"
4345
"github.com/prometheus/client_golang/prometheus"
4446
"github.com/prometheus/client_golang/prometheus/promhttp"
4547
"github.com/spf13/pflag"
@@ -107,20 +109,21 @@ const defaultDirMode = os.FileMode(0775) // subject to umask
107109

108110
// repoSync represents the remote repo and the local sync of it.
109111
type repoSync struct {
110-
cmd string // the git command to run
111-
root absPath // absolute path to the root directory
112-
repo string // remote repo to sync
113-
ref string // the ref to sync
114-
depth int // for shallow sync
115-
submodules submodulesMode // how to handle submodules
116-
gc gcMode // garbage collection
117-
link absPath // absolute path to the symlink to publish
118-
authURL string // a URL to re-fetch credentials, or ""
119-
sparseFile string // path to a sparse-checkout file
120-
syncCount int // how many times have we synced?
121-
log *logging.Logger
122-
run cmd.Runner
123-
staleTimeout time.Duration // time for worktrees to be cleaned up
112+
cmd string // the git command to run
113+
root absPath // absolute path to the root directory
114+
repo string // remote repo to sync
115+
ref string // the ref to sync
116+
depth int // for shallow sync
117+
submodules submodulesMode // how to handle submodules
118+
gc gcMode // garbage collection
119+
link absPath // absolute path to the symlink to publish
120+
authURL string // a URL to re-fetch credentials, or ""
121+
sparseFile string // path to a sparse-checkout file
122+
syncCount int // how many times have we synced?
123+
log *logging.Logger
124+
run cmd.Runner
125+
staleTimeout time.Duration // time for worktrees to be cleaned up
126+
appTokenExpiry time.Time // time when github app auth token expires
124127
}
125128

126129
func main() {
@@ -254,6 +257,19 @@ func main() {
254257
envString("", "GITSYNC_ASKPASS_URL", "GIT_SYNC_ASKPASS_URL", "GIT_ASKPASS_URL"),
255258
"a URL to query for git credentials (username=<value> and password=<value>)")
256259

260+
flGithubBaseURL := pflag.String("github-base-url",
261+
envString("https://api.github.com/", "GITSYNC_GITHUB_BASE_URL"),
262+
"the GitHub base URL to use when making requests to GitHub when using app auth")
263+
flGithubAppPrivateKeyFile := pflag.String("github-app-private-key-file",
264+
envString("", "GITSYNC_GITHUB_APP_PRIVATE_KEY_FILE"),
265+
"the file from which the private key for GitHub app auth will be sourced")
266+
flGithubAppApplicationID := pflag.Int("github-app-application-id",
267+
envInt(0, "GTSYNC_GITHUB_APP_APPLICATION_ID"),
268+
"the GitHub app application ID to use for GitHub app auth")
269+
flGithubAppInstallationID := pflag.Int("github-app-installation-id",
270+
envInt(0, "GITSYNC_GITHUB_APP_INSTALLATION_ID"),
271+
"the GitHub app installation ID to use for GitHub app auth")
272+
257273
flGitCmd := pflag.String("git",
258274
envString("git", "GITSYNC_GIT", "GIT_SYNC_GIT"),
259275
"the git command to run (subject to PATH search, mostly for testing)")
@@ -486,6 +502,7 @@ func main() {
486502
handleConfigError(log, true, "ERROR: credentials may not be specified in --repo when --username is specified")
487503
}
488504
}
505+
489506
} else {
490507
if *flPassword != "" {
491508
handleConfigError(log, true, "ERROR: --password may only be specified when --username is specified")
@@ -495,6 +512,31 @@ func main() {
495512
}
496513
}
497514

515+
if *flGithubAppPrivateKeyFile != "" {
516+
if *flGithubAppApplicationID == 0 {
517+
handleConfigError(log, true, "ERROR: --github-app-application-id must be specified when --github-app-private-key-file is specified")
518+
}
519+
if *flGithubAppInstallationID == 0 {
520+
handleConfigError(log, true, "ERROR: --github-app-installation-id must be specified when --github-app-private-key-file is specified")
521+
}
522+
if *flUsername != "" {
523+
handleConfigError(log, true, "ERROR: --username may not be specified when --github-app-private-key-file is specified")
524+
}
525+
if *flPassword != "" {
526+
handleConfigError(log, true, "ERROR: --password may not be specified when --github-app-private-key-file is specified")
527+
}
528+
if *flPasswordFile != "" {
529+
handleConfigError(log, true, "ERROR: --password-file may not be specified when --github-app-private-key-file is specified")
530+
}
531+
} else {
532+
if *flGithubAppApplicationID != 0 {
533+
handleConfigError(log, true, "ERROR: --github-app-application-id may only be specified when --github-app-private-key-file is specified")
534+
}
535+
if *flGithubAppInstallationID != 0 {
536+
handleConfigError(log, true, "ERROR: --github-app-installation-id may only be specified when --github-app-private-key-file is specified")
537+
}
538+
}
539+
498540
if len(*flCredentials) > 0 {
499541
for _, cred := range *flCredentials {
500542
if cred.URL == "" {
@@ -780,6 +822,7 @@ func main() {
780822
return err
781823
}
782824
}
825+
783826
if *flAskPassURL != "" {
784827
// When using an auth URL, the credentials can be dynamic, and need
785828
// to be re-fetched each time.
@@ -789,6 +832,16 @@ func main() {
789832
}
790833
metricAskpassCount.WithLabelValues(metricKeySuccess).Inc()
791834
}
835+
836+
if *flGithubAppPrivateKeyFile != "" && *flGithubAppInstallationID != 0 && *flGithubAppApplicationID != 0 {
837+
if git.appTokenExpiry.Before(time.Now().Add(30 * time.Second)) {
838+
if err := git.RefreshGitHubAppToken(ctx, *flGithubBaseURL, *flGithubAppPrivateKeyFile, *flGithubAppApplicationID, *flGithubAppInstallationID); err != nil {
839+
metricAskpassCount.WithLabelValues(metricKeyError).Inc()
840+
return err
841+
}
842+
}
843+
}
844+
792845
return nil
793846
}
794847

@@ -1851,6 +1904,83 @@ func (git *repoSync) CallAskPassURL(ctx context.Context) error {
18511904
return nil
18521905
}
18531906

1907+
// RefreshGitHubAppToken generates a new installation token for a GitHub app and stores it as a credential
1908+
func (git *repoSync) RefreshGitHubAppToken(ctx context.Context, githubBaseURL, privateKeyFile string, appID, installationID int) error {
1909+
git.log.V(3).Info("refreshing GitHub app token")
1910+
1911+
privateKeyBytes, err := os.ReadFile(privateKeyFile)
1912+
if err != nil {
1913+
git.log.Error(err, "can't read private key file", "file", privateKeyFile)
1914+
os.Exit(1)
1915+
}
1916+
1917+
pkey, err := jwt.ParseRSAPrivateKeyFromPEM([]byte(privateKeyBytes))
1918+
if err != nil {
1919+
return err
1920+
}
1921+
1922+
now := time.Now()
1923+
1924+
claims := jwt.RegisteredClaims{
1925+
Issuer: fmt.Sprintf("%d", appID),
1926+
IssuedAt: jwt.NewNumericDate(now),
1927+
ExpiresAt: jwt.NewNumericDate(now.Add(10 * time.Minute)),
1928+
}
1929+
1930+
jwt, err := jwt.NewWithClaims(jwt.SigningMethodRS256, claims).SignedString(pkey)
1931+
if err != nil {
1932+
return err
1933+
}
1934+
1935+
url, err := url.JoinPath(githubBaseURL, fmt.Sprintf("app/installations/%d/access_tokens", installationID))
1936+
if err != nil {
1937+
return err
1938+
}
1939+
1940+
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, nil)
1941+
if err != nil {
1942+
return err
1943+
}
1944+
1945+
req.Header.Set("Authorization", "Bearer "+jwt)
1946+
req.Header.Set("Accept", "application/vnd.github+json")
1947+
1948+
resp, err := http.DefaultClient.Do(req)
1949+
if err != nil {
1950+
return err
1951+
}
1952+
defer func() {
1953+
_ = resp.Body.Close()
1954+
}()
1955+
if resp.StatusCode != 201 {
1956+
errMessage, err := io.ReadAll(resp.Body)
1957+
if err != nil {
1958+
return fmt.Errorf("GitHub app installation endpoint returned status %d, failed to read body: %w", resp.StatusCode, err)
1959+
}
1960+
return fmt.Errorf("GitHub app installation endpoint returned status %d, body: %q", resp.StatusCode, string(errMessage))
1961+
}
1962+
1963+
tokenResponse := struct {
1964+
Token string `json:"token"`
1965+
ExpiresAt time.Time `json:"expires_at"`
1966+
}{}
1967+
if err := json.NewDecoder(resp.Body).Decode(&tokenResponse); err != nil {
1968+
return err
1969+
}
1970+
1971+
git.appTokenExpiry = tokenResponse.ExpiresAt
1972+
1973+
// username must be non-empty
1974+
username := "-"
1975+
password := tokenResponse.Token
1976+
1977+
if err := git.StoreCredentials(ctx, git.repo, username, password); err != nil {
1978+
return err
1979+
}
1980+
1981+
return nil
1982+
}
1983+
18541984
// SetupDefaultGitConfigs configures the global git environment with some
18551985
// default settings that we need.
18561986
func (git *repoSync) SetupDefaultGitConfigs(ctx context.Context) error {
@@ -2209,6 +2339,22 @@ OPTIONS
22092339
- off: Disable explicit git garbage collection, which may be a good
22102340
fit when also using --one-time.
22112341
2342+
--github-base-url <string>, $GITSYNC_BASE_URL
2343+
The GitHub base URL to use in GitHub requests when GitHub app
2344+
authentication is used. If not specified, defaults to
2345+
https://api.github.com/.
2346+
2347+
--github-app-private-key-file <string>, $GITSYNC_APP_PRIVATE_KEY_FILE
2348+
The file from which the private key to use for GitHub app
2349+
authentication will be read.
2350+
2351+
--github-app-installation-id <int>, $GITSYNC_APP_INSTALLATION_ID
2352+
The installation ID of the GitHub app used for GitHub app
2353+
authentication.
2354+
2355+
--github-app-application-id <int>, $GITSYNC_APP_APPLICATION_ID
2356+
The app ID of the GitHub app used for GitHub app authentication.
2357+
22122358
--group-write, $GITSYNC_GROUP_WRITE
22132359
Ensure that data written to disk (including the git repo metadata,
22142360
checked out files, worktrees, and symlink) are all group writable.
@@ -2427,6 +2573,22 @@ AUTHENTICATION
24272573
When --cookie-file (GITSYNC_COOKIE_FILE) is specified, the
24282574
associated cookies can contain authentication information.
24292575
2576+
github app
2577+
When --github-app-private-key-file (GITSYNC_GITHUB_APP_PRIVATE_KEY_FILE),
2578+
--github-app-application-id (GITSYNC_GITHUB_APP_APPLICATION_ID)
2579+
and --github-app-installation_id (GITSYNC_GITHUB_APP_INSTALLATION_ID)
2580+
are specified, GitHub app authentication will be used.
2581+
2582+
These credentials are used to request a short-lived token which
2583+
is used for authentication. The base URL of the GitHub request made
2584+
to retrieve the token can also be specified via
2585+
--github-base-url (GITSYNC_GITHUB_BASE_URL), which defaults to
2586+
https://api.github.com/.
2587+
2588+
The GitHub app must have sufficient access to the repository to sync.
2589+
It should be installed to the repository or organization containing
2590+
the repository, and given read access (see github docs).
2591+
24302592
HOOKS
24312593
24322594
Webhooks and exechooks are executed asynchronously from the main git-sync

test_e2e.sh

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2183,6 +2183,21 @@ function e2e::auth_askpass_url_slow_start() {
21832183
assert_file_eq "$ROOT/link/file" "$FUNCNAME"
21842184
}
21852185

2186+
##############################################
2187+
# Test github app auth
2188+
##############################################
2189+
function e2e::auth_github_app() {
2190+
GIT_SYNC \
2191+
--one-time \
2192+
--repo="$GITHUB_APP_AUTH_TEST_REPO" \
2193+
--github-app-application-id "$GITHUB_APP_APPLICATION_ID" \
2194+
--github-app-installation-id "$GITHUB_APP_INSTALLATION_ID" \
2195+
--github-app-private-key-file "$GITHUB_APP_PRIVATE_KEY_FILE" \
2196+
--root="$ROOT" \
2197+
--link="link"
2198+
assert_file_exists "$ROOT/link/LICENSE"
2199+
}
2200+
21862201
##############################################
21872202
# Test exechook-success
21882203
##############################################

vendor/github.com/golang-jwt/jwt/v4/.gitignore

Lines changed: 4 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

vendor/github.com/golang-jwt/jwt/v4/LICENSE

Lines changed: 9 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

vendor/github.com/golang-jwt/jwt/v4/MIGRATION_GUIDE.md

Lines changed: 22 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)