Skip to content

Commit

Permalink
Add support for GitHub app authentication
Browse files Browse the repository at this point in the history
Signed-off-by: Liam Wyllie <risset@mailbox.org>
  • Loading branch information
risset committed May 19, 2024
1 parent 5e40d47 commit 7e947b5
Show file tree
Hide file tree
Showing 31 changed files with 2,422 additions and 14 deletions.
34 changes: 34 additions & 0 deletions docs/dev/testing_github_app_auth.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
# Testing GitHub app auth

## Step 1: Create and install a dummy GitHub app for testing with

Go to https://github.com/settings/apps/new

1. Enter a name for the app (needs to be unique across GitHub).
2. Set the required `homepage URL` field (can be any valid URL).
3. Under `Webhook`, uncheck the `Active` checkbox.
4. Click on `Repository permissions` under `Permissions`, and set `Contents` to `Read-only`
5. Click on `Create GitHub App` at the bottom of the page.
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.
7. Click on the `Install App` tab on the left, and then click on `Install` on the right.
8. Select `Only select repositories`, and pick any private repository that contains a "LICENSE" file (may need to be created beforehand).

## Step 2: Export the necessary environment variables

The following environment variables are *required* to run the git-sync github app auth test:
- `GITHUB_APP_PRIVATE_KEY`
- `GITHUB_APP_APPLICATION_ID`
- `GITHUB_APP_INSTALLATION_ID`
- `GITHUB_APP_AUTH_TEST_REPO`

### GITHUB_APP_PRIVATE_KEY
Should have been saved when creating the app

### GITHUB_APP_APPLICATION_ID
The value after "App ID" in the app's settings page

### GITHUB_APP_INSTALLATION_ID
Found in the URL of the app's installation page if you installed it to a repository: https://github.com/settings/installations/<installation_id>

### GITHUB_APP_AUTH_TEST_REPO
Should be set to the repository that the github app is installed to.
1 change: 1 addition & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ module k8s.io/git-sync

require (
github.com/go-logr/logr v1.2.3
github.com/golang-jwt/jwt/v4 v4.5.0
github.com/prometheus/client_golang v1.14.0
github.com/spf13/pflag v1.0.5
go.uber.org/goleak v1.2.1
Expand Down
2 changes: 2 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,8 @@ github.com/go-logr/logr v1.2.3 h1:2DntVwHkVopvECVRSlL5PSo9eG+cAkDCuckLubN+rq0=
github.com/go-logr/logr v1.2.3/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY=
github.com/gogo/protobuf v1.1.1/go.mod h1:r8qH/GZQm5c6nD/R0oafs1akxWv10x8SbQlK7atdtwQ=
github.com/golang-jwt/jwt/v4 v4.5.0 h1:7cYmW1XlMY7h7ii7UhUyChSgS5wUJEnm9uZVTGqOWzg=
github.com/golang-jwt/jwt/v4 v4.5.0/go.mod h1:m21LjoU+eqJr34lmDMbreY2eSTRJ1cv77w39/MY0Ch0=
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
github.com/golang/groupcache v0.0.0-20190702054246-869f871628b6/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
github.com/golang/groupcache v0.0.0-20191227052852-215e87163ea7/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
Expand Down
121 changes: 107 additions & 14 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ package main // import "k8s.io/git-sync/cmd/git-sync"
import (
"context"
"crypto/md5"
"encoding/json"
"errors"
"fmt"
"io"
Expand All @@ -40,6 +41,7 @@ import (
"syscall"
"time"

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

// repoSync represents the remote repo and the local sync of it.
type repoSync struct {
cmd string // the git command to run
root absPath // absolute path to the root directory
repo string // remote repo to sync
ref string // the ref to sync
depth int // for shallow sync
submodules submodulesMode // how to handle submodules
gc gcMode // garbage collection
link absPath // absolute path to the symlink to publish
authURL string // a URL to re-fetch credentials, or ""
sparseFile string // path to a sparse-checkout file
syncCount int // how many times have we synced?
log *logging.Logger
run cmd.Runner
staleTimeout time.Duration // time for worktrees to be cleaned up
cmd string // the git command to run
root absPath // absolute path to the root directory
repo string // remote repo to sync
ref string // the ref to sync
depth int // for shallow sync
submodules submodulesMode // how to handle submodules
gc gcMode // garbage collection
link absPath // absolute path to the symlink to publish
authURL string // a URL to re-fetch credentials, or ""
sparseFile string // path to a sparse-checkout file
syncCount int // how many times have we synced?
log *logging.Logger
run cmd.Runner
staleTimeout time.Duration // time for worktrees to be cleaned up
appTokenExpiry time.Time // time when github app auth token expires
}

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

flGithubAPIURL := pflag.String("github-api-url",
envString("api.github.com", "GITHUB_API_URL"),
"the API URL to use when making requests to GitHub when using app auth")
flGithubAppPrivateKey := pflag.String("github-app-private-key",
envString("", "GITHUB_APP_PRIVATE_KEY"),
"the GitHub app private key to use for GitHub app auth")
flGithubAppApplicationID := pflag.Int("github-app-application-id",
envInt(-1, "GITHUB_APP_APPLICATION_ID"),
"the GitHub app application ID to use for GitHub app auth")
flGithubAppInstallationID := pflag.Int("github-app-installation-id",
envInt(-1, "GITHUB_APP_INSTALLATION_ID"),
"the GitHub app installation ID to use for GitHub app auth")

flGitCmd := pflag.String("git",
envString("git", "GITSYNC_GIT", "GIT_SYNC_GIT"),
"the git command to run (subject to PATH search, mostly for testing)")
Expand Down Expand Up @@ -780,6 +796,7 @@ func main() {
return err
}
}

if *flAskPassURL != "" {
// When using an auth URL, the credentials can be dynamic, and need
// to be re-fetched each time.
Expand All @@ -789,6 +806,16 @@ func main() {
}
metricAskpassCount.WithLabelValues(metricKeySuccess).Inc()
}

if *flGithubAppPrivateKey != "" && *flGithubAppInstallationID != -1 && *flGithubAppApplicationID != -1 {
if git.appTokenExpiry.Before(time.Now()) {
if err := git.RefreshAppToken(ctx, *flGithubAPIURL, *flGithubAppPrivateKey, *flGithubAppApplicationID, *flGithubAppInstallationID); err != nil {
metricAskpassCount.WithLabelValues(metricKeyError).Inc()
return err
}
}
}

return nil
}

Expand Down Expand Up @@ -1851,6 +1878,72 @@ func (git *repoSync) CallAskPassURL(ctx context.Context) error {
return nil
}

func (git *repoSync) RefreshAppToken(ctx context.Context, githubBaseURL, privateKey string, appID, installationID int) error {
git.log.V(3).Info("refreshing GitHub app token")

pkey, err := jwt.ParseRSAPrivateKeyFromPEM([]byte(privateKey))
if err != nil {
return err
}

now := time.Now()

claims := jwt.RegisteredClaims{
Issuer: fmt.Sprintf("%d", appID),
IssuedAt: jwt.NewNumericDate(now),
ExpiresAt: jwt.NewNumericDate(now.Add(10 * time.Minute)),
}

jwt, err := jwt.NewWithClaims(jwt.SigningMethodRS256, claims).SignedString(pkey)
if err != nil {
return err
}

url := fmt.Sprintf("https://%s/app/installations/%d/access_tokens", githubBaseURL, installationID)
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, nil)
if err != nil {
return err
}

req.Header.Set("Authorization", "Bearer "+jwt)
req.Header.Set("Accept", "application/vnd.github+json")

resp, err := http.DefaultClient.Do(req)
if err != nil {
return err
}
defer func() {
_ = resp.Body.Close()
}()
if resp.StatusCode != 201 {
errMessage, err := io.ReadAll(resp.Body)
if err != nil {
return fmt.Errorf("GitHub app installation endpoint returned status %d, failed to read body: %w", resp.StatusCode, err)
}
return fmt.Errorf("GitHub app installation endpoint returned status %d, body: %q", resp.StatusCode, string(errMessage))
}

tokenResponse := struct {
Token string `json:"token"`
ExpiresAt time.Time `json:"expires_at"`
}{}
if err := json.NewDecoder(resp.Body).Decode(&tokenResponse); err != nil {
return err
}

git.appTokenExpiry = tokenResponse.ExpiresAt

// username must be non-empty
username := "-"
password := tokenResponse.Token

if err := git.StoreCredentials(ctx, git.repo, username, password); err != nil {
return err
}

return nil
}

// SetupDefaultGitConfigs configures the global git environment with some
// default settings that we need.
func (git *repoSync) SetupDefaultGitConfigs(ctx context.Context) error {
Expand Down
15 changes: 15 additions & 0 deletions test_e2e.sh
Original file line number Diff line number Diff line change
Expand Up @@ -3250,6 +3250,21 @@ function e2e::github_https() {
assert_file_exists "$ROOT/link/LICENSE"
}

##############################################
# Test github app auth
##############################################
function e2e::github_app_auth() {
GIT_SYNC \
--one-time \
--repo="$GITHUB_APP_AUTH_TEST_REPO" \
--github-app-application-id "$GITHUB_APP_APPLICATION_ID" \
--github-app-installation-id "$GITHUB_APP_INSTALLATION_ID" \
--github-app-private-key "$GITHUB_APP_PRIVATE_KEY" \
--root="$ROOT" \
--link="link"
assert_file_exists "$ROOT/link/LICENSE"
}

##############################################
# Test git-gc default
##############################################
Expand Down
4 changes: 4 additions & 0 deletions vendor/github.com/golang-jwt/jwt/v4/.gitignore

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

9 changes: 9 additions & 0 deletions vendor/github.com/golang-jwt/jwt/v4/LICENSE

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

22 changes: 22 additions & 0 deletions vendor/github.com/golang-jwt/jwt/v4/MIGRATION_GUIDE.md

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

Loading

0 comments on commit 7e947b5

Please sign in to comment.