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 <[email protected]>
  • Loading branch information
risset committed Jun 10, 2024
1 parent 5e40d47 commit 10f7d70
Show file tree
Hide file tree
Showing 31 changed files with 2,491 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
190 changes: 176 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>)")

flGithubBaseURL := pflag.String("github-base-url",
envString("https://api.github.com/", "GITSYNC_GITHUB_BASE_URL"),
"the GitHub base URL to use when making requests to GitHub when using app auth")
flGithubAppPrivateKeyFile := pflag.String("github-app-private-key-file",
envString("", "GITSYNC_GITHUB_APP_PRIVATE_KEY_FILE"),
"the file from which the private key for GitHub app auth will be sourced")
flGithubAppApplicationID := pflag.Int("github-app-application-id",
envInt(0, "GTSYNC_GITHUB_APP_APPLICATION_ID"),
"the GitHub app application ID to use for GitHub app auth")
flGithubAppInstallationID := pflag.Int("github-app-installation-id",
envInt(0, "GITSYNC_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 @@ -486,6 +502,7 @@ func main() {
handleConfigError(log, true, "ERROR: credentials may not be specified in --repo when --username is specified")
}
}

} else {
if *flPassword != "" {
handleConfigError(log, true, "ERROR: --password may only be specified when --username is specified")
Expand All @@ -495,6 +512,31 @@ func main() {
}
}

if *flGithubAppPrivateKeyFile != "" {
if *flGithubAppApplicationID == 0 {
handleConfigError(log, true, "ERROR: --github-app-application-id must be specified when --github-app-private-key-file is specified")
}
if *flGithubAppInstallationID == 0 {
handleConfigError(log, true, "ERROR: --github-app-installation-id must be specified when --github-app-private-key-file is specified")
}
if *flUsername != "" {
handleConfigError(log, true, "ERROR: --username may not be specified when --github-app-private-key-file is specified")
}
if *flPassword != "" {
handleConfigError(log, true, "ERROR: --password may not be specified when --github-app-private-key-file is specified")
}
if *flPasswordFile != "" {
handleConfigError(log, true, "ERROR: --password-file may not be specified when --github-app-private-key-file is specified")
}
} else {
if *flGithubAppApplicationID != 0 {
handleConfigError(log, true, "ERROR: --github-app-application-id may only be specified when --github-app-private-key-file is specified")
}
if *flGithubAppInstallationID != 0 {
handleConfigError(log, true, "ERROR: --github-app-installation-id may only be specified when --github-app-private-key-file is specified")
}
}

if len(*flCredentials) > 0 {
for _, cred := range *flCredentials {
if cred.URL == "" {
Expand Down Expand Up @@ -780,6 +822,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 +832,16 @@ func main() {
}
metricAskpassCount.WithLabelValues(metricKeySuccess).Inc()
}

if *flGithubAppPrivateKeyFile != "" && *flGithubAppInstallationID != 0 && *flGithubAppApplicationID != 0 {
if git.appTokenExpiry.Before(time.Now().Add(30 * time.Second)) {
if err := git.RefreshGitHubAppToken(ctx, *flGithubBaseURL, *flGithubAppPrivateKeyFile, *flGithubAppApplicationID, *flGithubAppInstallationID); err != nil {
metricAskpassCount.WithLabelValues(metricKeyError).Inc()
return err
}
}
}

return nil
}

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

// RefreshGitHubAppToken generates a new installation token for a GitHub app and stores it as a credential
func (git *repoSync) RefreshGitHubAppToken(ctx context.Context, githubBaseURL, privateKeyFile string, appID, installationID int) error {
git.log.V(3).Info("refreshing GitHub app token")

privateKeyBytes, err := os.ReadFile(privateKeyFile)
if err != nil {
git.log.Error(err, "can't read private key file", "file", privateKeyFile)
os.Exit(1)
}

pkey, err := jwt.ParseRSAPrivateKeyFromPEM([]byte(privateKeyBytes))
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, err := url.JoinPath(githubBaseURL, fmt.Sprintf("app/installations/%d/access_tokens", installationID))
if err != nil {
return err
}

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 Expand Up @@ -2209,6 +2339,22 @@ OPTIONS
- off: Disable explicit git garbage collection, which may be a good
fit when also using --one-time.
--github-base-url <string>, $GITSYNC_BASE_URL
The GitHub base URL to use in GitHub requests when GitHub app
authentication is used. If not specified, defaults to
https://api.github.com/.
--github-app-private-key-file <string>, $GITSYNC_APP_PRIVATE_KEY_FILE
The file from which the private key to use for GitHub app
authentication will be read.
--github-app-installation-id <int>, $GITSYNC_APP_INSTALLATION_ID
The installation ID of the GitHub app used for GitHub app
authentication.
--github-app-application-id <int>, $GITSYNC_APP_APPLICATION_ID
The app ID of the GitHub app used for GitHub app authentication.
--group-write, $GITSYNC_GROUP_WRITE
Ensure that data written to disk (including the git repo metadata,
checked out files, worktrees, and symlink) are all group writable.
Expand Down Expand Up @@ -2427,6 +2573,22 @@ AUTHENTICATION
When --cookie-file (GITSYNC_COOKIE_FILE) is specified, the
associated cookies can contain authentication information.
github app
When --github-app-private-key-file (GITSYNC_GITHUB_APP_PRIVATE_KEY_FILE),
--github-app-application-id (GITSYNC_GITHUB_APP_APPLICATION_ID)
and --github-app-installation_id (GITSYNC_GITHUB_APP_INSTALLATION_ID)
are specified, GitHub app authentication will be used.
These credentials are used to request a short-lived token which
is used for authentication. The base URL of the GitHub request made
to retrieve the token can also be specified via
--github-base-url (GITSYNC_GITHUB_BASE_URL), which defaults to
https://api.github.com/.
The GitHub app must have sufficient access to the repository to sync.
It should be installed to the repository or organization containing
the repository, and given read access (see github docs).
HOOKS
Webhooks and exechooks are executed asynchronously from the main git-sync
Expand Down
15 changes: 15 additions & 0 deletions test_e2e.sh
Original file line number Diff line number Diff line change
Expand Up @@ -2183,6 +2183,21 @@ function e2e::auth_askpass_url_slow_start() {
assert_file_eq "$ROOT/link/file" "$FUNCNAME"
}

##############################################
# Test github app auth
##############################################
function e2e::auth_github_app() {
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-file "$GITHUB_APP_PRIVATE_KEY_FILE" \
--root="$ROOT" \
--link="link"
assert_file_exists "$ROOT/link/LICENSE"
}

##############################################
# Test exechook-success
##############################################
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 10f7d70

Please sign in to comment.