Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 7 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,10 @@ narrow scope when you do:
githubauth token --installation 12345 --repos api,web --permissions contents:read
```

`--repos acme/api` also finds the installation, as `--owner acme` does. `--json`
reports the permissions and repositories GitHub actually granted, which is how to
check the scope rather than assume it.

## The CLI

```
Expand Down Expand Up @@ -109,7 +113,9 @@ is inferred. Both sources cache and refresh 30s before expiry.
`RevokeInstallationToken(ctx, token)` ends an installation token before its hour
is up. `FindInstallationID(ctx, appSrc, owner)` finds the installation on a user
or organization when only the account is known. `InstallationPermissions.Other`
requests a permission GitHub added after the struct was last updated.
requests a permission GitHub added after the struct was last updated. An
installation token carries what GitHub granted in `tok.Extra(githubauth.ExtraPermissions)`
(`map[string]string`) and `tok.Extra(githubauth.ExtraRepositories)` (`[]string`).

Errors worth branching on: `*githubauth.RateLimitError` (carries `RetryAfter`,
unwraps to `ErrRateLimited`) and `*githubauth.APIError` (carries `StatusCode`).
Expand Down
11 changes: 7 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -99,18 +99,21 @@ vault kv get -field=pem secret/github-app |
githubauth token --key - --installation 12345
```

`githubauth jwt` prints the App JWT for the few endpoints that need one, `--json` adds the
expiry, and `--repos` and `--permissions` scope the token to named repositories and to
less than the App was granted:
`githubauth jwt` prints the App JWT for the few endpoints that need one, and `--repos` and
`--permissions` scope the token to named repositories and to less than the App was granted.
`--json` adds the expiry and, for an installation token, the permissions and repositories
GitHub granted, so you can check the scope you got:

```bash
githubauth token --installation 12345 --repos api,web --permissions contents:read
```

If you know the account but not the installation ID, `--owner` looks it up:
If you know the account but not the installation ID, `--owner` looks it up, and so does an
`owner/repo` name in `--repos`:

```bash
githubauth token --owner acme --repos api
githubauth token --repos acme/api # the same
```

Run `githubauth help` for the rest.
Expand Down
33 changes: 31 additions & 2 deletions auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,15 @@ const (
// by a request starting near exp and arriving at GitHub after exp.
DefaultExpirySkew = 30 * time.Second

// ExtraPermissions is the oauth2.Token Extra key under which an installation
// token carries the permissions GitHub granted it, as a map[string]string.
ExtraPermissions = "permissions"

// ExtraRepositories is the oauth2.Token Extra key under which an
// installation token limited to some repositories carries their names, as a
// []string. It is absent for a token covering the whole installation.
ExtraRepositories = "repositories"

// applicationTokenBackdate is how far into the past the iat claim of an
// application JWT is set, so a fast local clock cannot make GitHub see an
// iat in the future. It is subtracted from exp as well, so it is also the
Expand Down Expand Up @@ -542,11 +551,31 @@ func (t *installationTokenSource) Token() (*oauth2.Token, error) {
return nil, err
}

return &oauth2.Token{
tok := &oauth2.Token{
AccessToken: token.Token,
TokenType: bearerTokenType,
Expiry: token.ExpiresAt,
}, nil
}

// GitHub's answer says what the token can do, which may differ from what
// was asked: metadata:read comes with every token.
extra := map[string]any{}
if token.Permissions != nil {
extra[ExtraPermissions] = token.Permissions.levels()
}
if len(token.Repositories) > 0 {
names := make([]string, 0, len(token.Repositories))
for _, r := range token.Repositories {
if r.Name != nil {
names = append(names, *r.Name)
}
}
extra[ExtraRepositories] = names
}
if len(extra) == 0 {
return tok, nil
}
return tok.WithExtra(extra), nil
}

// personalAccessTokenSource represents a static GitHub personal access token source
Expand Down
10 changes: 8 additions & 2 deletions auth_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -663,12 +663,14 @@ func Test_installationTokenSource_Token(t *testing.T) {
ExpiresAt: expiration,
Permissions: &InstallationPermissions{
PullRequests: Ptr("read"),
Other: map[string]string{"installation_repositories": "write"},
},
Repositories: []Repository{
{
Name: Ptr("mocked-repo-1"),
ID: Ptr(int64(1)),
},
{ID: Ptr(int64(2))},
},
},
),
Expand Down Expand Up @@ -730,11 +732,15 @@ func Test_installationTokenSource_Token(t *testing.T) {
WithHTTPClient(mockedHTTPClient),
},
},
want: &oauth2.Token{
// GitHub's answer rides along, including a permission with no field.
want: (&oauth2.Token{
AccessToken: "mocked-installation-token",
TokenType: "Bearer",
Expiry: expiration,
},
}).WithExtra(map[string]any{
ExtraPermissions: map[string]string{"pull_requests": "read", "installation_repositories": "write"},
ExtraRepositories: []string{"mocked-repo-1"},
}),
},
}
for _, tt := range tests {
Expand Down
64 changes: 53 additions & 11 deletions cmd/githubauth/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -164,7 +164,7 @@ func (a *appFlags) bind(fs *flag.FlagSet, out *output) {
fs.StringVar(&a.clientID, "client-id", os.Getenv("GITHUB_APP_CLIENT_ID"), "App client ID, e.g. Iv1.1234567890abcdef ($GITHUB_APP_CLIENT_ID)")
fs.Int64Var(&a.appID, "app-id", envInt64("GITHUB_APP_ID"), "legacy numeric App ID, for an App with no client ID ($GITHUB_APP_ID)")
fs.StringVar(&a.key, "key", os.Getenv("GITHUB_APP_PRIVATE_KEY"), "PEM private key: file path, \"-\" for stdin, or the PEM ($GITHUB_APP_PRIVATE_KEY)")
fs.BoolVar(&out.json, "json", false, "print {\"token\":...,\"expires_at\":...} instead of the bare token")
fs.BoolVar(&out.json, "json", false, "print {\"token\":...,\"expires_at\":...} instead of the bare token, with the permissions and repositories GitHub granted an installation token")
fs.BoolVar(&a.exec, "exec", false, "run the command after -- with the credential in $GITHUB_TOKEN and $GH_TOKEN, printing it nowhere; $GH_HOST is set for an Enterprise or data residency host")
fs.BoolVar(&out.agent, "agent", out.agent, "report failures as one JSON document on stdout (on when a coding agent is detected)")
}
Expand Down Expand Up @@ -254,7 +254,7 @@ func tokenCmd(args []string, stdin io.Reader, stdout, stderr io.Writer, out *out
app.bind(fs, out)

installation := fs.Int64("installation", envInt64("GITHUB_APP_INSTALLATION_ID"), "installation ID to mint the token for ($GITHUB_APP_INSTALLATION_ID)")
repos := fs.String("repos", "", "comma-separated repository names to scope the token to")
repos := fs.String("repos", "", "comma-separated repository names to scope the token to; owner/repo also finds the installation, like --owner")
owner := fs.String("owner", "", "user or organization the App is installed on, to look up the installation instead of passing --installation")
permissions := fs.String("permissions", "", "comma-separated name:level pairs to scope the token to, e.g. contents:read,pull_requests:write")
keep := fs.Bool("keep", false, "under --exec, leave the token valid when the command exits instead of revoking it")
Expand All @@ -270,6 +270,25 @@ func tokenCmd(args []string, stdin io.Reader, stdout, stderr io.Writer, out *out
if *keep && !app.exec {
return usagef("--keep applies only under --exec, which revokes the token when the command exits")
}
// An empty scope is no scope: GitHub would mint a token for every
// repository, the opposite of what passing --repos asked for.
names, repoOwner, err := splitOwner(splitRepos(*repos))
if err != nil {
return err
}
if isSet(fs, "repos") && len(names) == 0 {
return usagef("--repos names no repository; omit it for a token covering the whole installation")
}
// An owner/repo name finds the installation the way --owner does.
if repoOwner != "" {
switch {
case isSet(fs, "installation"):
return usagef("--repos names the owner %s, which finds the installation: drop --installation, or the %s/ prefix", repoOwner, repoOwner)
case *owner != "" && !strings.EqualFold(*owner, repoOwner):
return usagef("--repos names repositories of %s, but --owner is %s", repoOwner, *owner)
}
*owner = repoOwner
}
switch {
case *owner != "" && isSet(fs, "installation"):
return usagef("set --installation or --owner, not both")
Expand All @@ -292,12 +311,6 @@ func tokenCmd(args []string, stdin io.Reader, stdout, stderr io.Writer, out *out
if err := checkURL("--enterprise-url", *enterpriseURL); err != nil {
return err
}
// An empty scope is no scope: GitHub would mint a token for every
// repository, the opposite of what passing --repos asked for.
names := splitRepos(*repos)
if isSet(fs, "repos") && len(names) == 0 {
return usagef("--repos names no repository; omit it for a token covering the whole installation")
}
perms, err := parsePermissions(*permissions)
if err != nil {
return err
Expand Down Expand Up @@ -517,10 +530,16 @@ func write(w io.Writer, tok *oauth2.Token, asJSON bool) error {
_, err := fmt.Fprintln(w, tok.AccessToken)
return err
}
// What GitHub granted, so a caller can check the scope it got. A JWT
// carries neither.
perms, _ := tok.Extra(githubauth.ExtraPermissions).(map[string]string)
repos, _ := tok.Extra(githubauth.ExtraRepositories).([]string)
return json.NewEncoder(w).Encode(struct {
Token string `json:"token"`
ExpiresAt time.Time `json:"expires_at"`
}{tok.AccessToken, tok.Expiry})
Token string `json:"token"`
ExpiresAt time.Time `json:"expires_at"`
Permissions map[string]string `json:"permissions,omitempty"`
Repositories []string `json:"repositories,omitempty"`
}{tok.AccessToken, tok.Expiry, perms, repos})
}

func splitRepos(v string) []string {
Expand All @@ -533,6 +552,29 @@ func splitRepos(v string) []string {
return out
}

// splitOwner strips the owner from names written owner/repo, the form
// actions/create-github-app-token takes, and returns the owner they share.
// GitHub scopes a token by bare repository names.
func splitOwner(names []string) ([]string, string, error) {
var owner string
out := make([]string, len(names))
for i, name := range names {
o, repo, ok := strings.Cut(name, "/")
if !ok {
out[i] = name
continue
}
if o == "" || repo == "" || strings.Contains(repo, "/") {
return nil, "", usagef("--repos %q is neither a repository name nor owner/repo", name)
}
if owner != "" && !strings.EqualFold(owner, o) {
return nil, "", usagef("--repos names repositories of %s and of %s; a token covers one installation", owner, o)
}
owner, out[i] = o, repo
}
return out, owner, nil
}

// parsePermissions reads --permissions into GitHub's permission object, or nil
// when it names none. Every name goes through Other, so a permission GitHub
// adds works before the library has a field for it. GitHub answers a name it
Expand Down
81 changes: 80 additions & 1 deletion cmd/githubauth/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -479,6 +479,60 @@ func TestRun(t *testing.T) {
wantStdout: isEmpty,
wantStderr: "Check --installation or --owner",
},
{
name: "an owner prefix and --installation are misuse",
args: func(string) []string {
return []string{"token", "--client-id", "Iv1.abc", "--key", keyPath, "--repos", "acme/api", "--installation", "9"}
},
wantCode: exitUsage,
wantStdout: isEmpty,
wantStderr: "drop --installation, or the acme/ prefix",
},
{
name: "an owner prefix and a different --owner are misuse",
args: func(string) []string {
return []string{"token", "--client-id", "Iv1.abc", "--key", keyPath, "--repos", "acme/api", "--owner", "other"}
},
wantCode: exitUsage,
wantStdout: isEmpty,
wantStderr: "--repos names repositories of acme, but --owner is other",
},
{
name: "repositories of two owners are misuse",
args: func(string) []string {
return []string{"token", "--client-id", "Iv1.abc", "--key", keyPath, "--repos", "acme/api,other/web"}
},
wantCode: exitUsage,
wantStdout: isEmpty,
wantStderr: "repositories of acme and of other",
},
{
name: "an owner prefix without a repository is misuse",
args: func(string) []string {
return []string{"token", "--client-id", "Iv1.abc", "--key", keyPath, "--repos", "acme/"}
},
wantCode: exitUsage,
wantStdout: isEmpty,
wantStderr: `--repos "acme/" is neither`,
},
{
name: "a path deeper than owner/repo is misuse",
args: func(string) []string {
return []string{"token", "--client-id", "Iv1.abc", "--key", keyPath, "--repos", "acme/api/x"}
},
wantCode: exitUsage,
wantStdout: isEmpty,
wantStderr: `--repos "acme/api/x" is neither`,
},
{
name: "a dot owner prefix is not an account",
args: func(string) []string {
return []string{"token", "--client-id", "Iv1.abc", "--key", keyPath, "--repos", "../api"}
},
wantCode: exitUsage,
wantStdout: isEmpty,
wantStderr: `--owner ".." is not an account`,
},
{
name: "keep without exec is misuse",
args: func(string) []string {
Expand Down Expand Up @@ -736,6 +790,7 @@ func TestTokenRequests(t *testing.T) {
wantCode int
wantMint string
wantBody string
wantStdout string
wantRevoke bool
wantStderr string
}{
Expand All @@ -748,6 +803,22 @@ func TestTokenRequests(t *testing.T) {
args: []string{"--owner", "acme"},
wantMint: "/app/installations/123/access_tokens",
},
{
name: "an owner prefix on --repos finds the installation, ahead of the environment",
args: []string{"--repos", "acme/api,web"},
wantMint: "/app/installations/123/access_tokens",
wantBody: `{"repositories":["api","web"]}`,
},
{
name: "an owner prefix agrees with --owner",
args: []string{"--repos", "acme/api", "--owner", "acme"},
wantMint: "/app/installations/123/access_tokens",
},
{
name: "json shows what GitHub granted",
args: []string{"--json"},
wantStdout: `"permissions":{"metadata":"read","pull_requests":"write"},"repositories":["api"]}`,
},
{
name: "repos and permissions go in one request",
args: []string{"--repos", "api", "--permissions", "contents:read, pull_requests:write"},
Expand Down Expand Up @@ -851,7 +922,12 @@ func TestTokenRequests(t *testing.T) {
b, _ := io.ReadAll(r.Body)
mint, body = r.URL.Path, string(b)
w.WriteHeader(http.StatusCreated)
_ = json.NewEncoder(w).Encode(map[string]any{"token": "ghs_minted", "expires_at": time.Now().Add(time.Hour)})
_ = json.NewEncoder(w).Encode(map[string]any{
"token": "ghs_minted",
"expires_at": time.Now().Add(time.Hour),
"permissions": map[string]string{"metadata": "read", "pull_requests": "write"},
"repositories": []map[string]any{{"id": 1, "name": "api", "full_name": "acme/api"}},
})
}))
defer srv.Close()
t.Setenv("GITHUBAUTH_EXEC_CALL", srv.URL+"/child")
Expand All @@ -864,6 +940,9 @@ func TestTokenRequests(t *testing.T) {
if tt.wantMint != "" && mint != tt.wantMint {
t.Errorf("minted at %s, want %s", mint, tt.wantMint)
}
if !strings.Contains(stdout.String(), tt.wantStdout) {
t.Errorf("stdout = %q, want it to contain %q", stdout.String(), tt.wantStdout)
}
if tt.wantBody != "" && body != tt.wantBody {
t.Errorf("request body = %s, want %s", body, tt.wantBody)
}
Expand Down
Loading
Loading