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
4 changes: 3 additions & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,9 @@ 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`
`--repos acme/api` also finds the installation, as `--owner acme` does. With no
`--installation`, `--owner`, `--repos` or `$GITHUB_APP_INSTALLATION_ID`, a clone's
`origin` remote on the same host names the repository, and the token covers it alone. `--json`
reports the permissions and repositories GitHub actually granted, which is how to
check the scope rather than assume it.

Expand Down
44 changes: 44 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,50 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
> That `v2.0.2` tag is the only thing carrying the retraction — the `v2/` directory no
> longer exists on `main` — so the tag must not be deleted or the retraction is lost.

## [Unreleased]

`githubauth token --exec` now revokes the token when the command exits, and the CLI can
find the installation and narrow the token on its own. Three changes can affect existing
code or scripts; they are listed under Changed.

### Added

- `RevokeInstallationToken` ends an installation token before its hour is up.
`githubauth token --exec` calls it once the command exits; `--keep` leaves the token
valid. A revocation that fails is reported on stderr, the token then stays valid until
it expires, and the exit code stays the command's (#87, #88)
- `githubauth token --permissions contents:read,pull_requests:write` scopes the token
below what the App was granted. Any permission name GitHub has is accepted; GitHub
refuses one it does not know with a 422, which exits 2 (#87, #89)
- `InstallationPermissions.Other` requests, and keeps when decoding, a permission the
struct has no field for, such as `installation_repositories` (#88, #90)
- `FindInstallationID` and `githubauth token --owner acme` find the installation on a user
or organization. `--repos acme/api` does the same from an `owner/` prefix (#88, #90)
- Inside a clone, with no installation named, `githubauth token` scopes the token to the
repository its `origin` remote names, when that remote is on the same host (#92)
- An installation token carries what GitHub granted in its oauth2 Extra fields:
`ExtraPermissions` and, for a token limited to some repositories, `ExtraRepositories`.
`githubauth token --json` prints both (#90)
- The CLI is a GitHub CLI extension: `gh extension install jferrl/gh-githubauth`, run as
`gh githubauth` (#91)

### Changed

- **`githubauth token --exec` revokes the token when the command exits.** Anything the
command leaves running on the token starts getting 401s. `--keep` restores the old
behaviour (#87)
- **`InstallationPermissions` is no longer comparable**, because `Other` is a map. Code
that compares two values with `==` or uses one as a map key no longer compiles (#88)
- **Comparing installation token values, `*a == *b`, now panics** when GitHub's answer
carried their scope, because their Extra holds a map, as it does for any oauth2 token
decoded from JSON. Comparing the `*oauth2.Token` pointers is unaffected; to compare
tokens, compare `AccessToken` (#90)

### Fixed

- The exit-2 hint for a 422 names `--permissions` as well as `--repos`, and the exit-5
hint for a 404 names `--owner` (#88)

## [v1.11.0] - 2026-10-30

`--exec` now points `gh` at the instance the token is for. One change alters behaviour;
Expand Down
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,14 @@ githubauth token --owner acme --repos api
githubauth token --repos acme/api # the same
```

With none of those, run inside a clone and the token is for that repository alone, found
from its `origin` remote, the way `actions/create-github-app-token` defaults to the
current repository:

```bash
cd api && githubauth token --exec -- gh pr merge 7
```

Run `githubauth help` for the rest.

### Keeping the token out of your logs
Expand Down
77 changes: 66 additions & 11 deletions cmd/githubauth/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ package main

import (
"bytes"
"cmp"
"context"
"encoding/json"
"errors"
Expand All @@ -15,6 +16,7 @@ import (
"io"
"net/url"
"os"
"os/exec"
"runtime/debug"
"slices"
"strconv"
Expand Down Expand Up @@ -237,6 +239,9 @@ func tokenCmd(args []string, stdin io.Reader, stdout, stderr io.Writer, out *out
fs := newFlagSet("token", "Print an installation access token.", ` # Find the installation from the account it is on.
githubauth token --owner acme

# Inside a clone, with no installation named: a token for that repository alone.
githubauth token --exec -- gh pr merge 7

# Scope the token to two repositories, with read access to their contents.
githubauth token --installation 12345 --repos api,web --permissions contents:read

Expand Down Expand Up @@ -289,6 +294,26 @@ func tokenCmd(args []string, stdin io.Reader, stdout, stderr io.Writer, out *out
}
*owner = repoOwner
}
if *baseURL != "" && *enterpriseURL != "" {
return usagef("set --base-url or --enterprise-url, not both")
}
if err := checkURL("--base-url", *baseURL); err != nil {
return err
}
if err := checkURL("--enterprise-url", *enterpriseURL); err != nil {
return err
}
app.host = ghHost(*baseURL + *enterpriseURL) // at most one is set
// Nothing names the installation, so the clone the command runs in does,
// the way actions/create-github-app-token defaults to the current
// repository. The token covers that repository alone.
// An --owner given empty, as from an unset script variable, is not
// nothing: falling back would mint for a repository the script never named.
if !isSet(fs, "owner") && !isSet(fs, "installation") && !isSet(fs, "repos") && os.Getenv("GITHUB_APP_INSTALLATION_ID") == "" {
if o, r, ok := originRepo(app.host); ok {
*owner, names = o, []string{r}
}
}
switch {
case *owner != "" && isSet(fs, "installation"):
return usagef("set --installation or --owner, not both")
Expand All @@ -300,16 +325,7 @@ func tokenCmd(args []string, stdin io.Reader, stdout, stderr io.Writer, out *out
if err := badEnvNumber(fs, "installation", "GITHUB_APP_INSTALLATION_ID"); err != nil {
return err
}
return usagef("missing installation ID: set --installation, or --owner to look it up")
}
if *baseURL != "" && *enterpriseURL != "" {
return usagef("set --base-url or --enterprise-url, not both")
}
if err := checkURL("--base-url", *baseURL); err != nil {
return err
}
if err := checkURL("--enterprise-url", *enterpriseURL); err != nil {
return err
return usagef("missing installation ID: set --installation, --owner to look it up, or run inside a clone of a repository the App is installed on")
}
perms, err := parsePermissions(*permissions)
if err != nil {
Expand All @@ -319,7 +335,6 @@ func tokenCmd(args []string, stdin io.Reader, stdout, stderr io.Writer, out *out
return usagef("--permissions names no permission; omit it for a token with every permission the App has")
}

app.host = ghHost(*baseURL + *enterpriseURL) // at most one is set

appSrc, err := app.source(stdin)
if err != nil {
Expand Down Expand Up @@ -575,6 +590,46 @@ func splitOwner(names []string) ([]string, string, error) {
return out, owner, nil
}

// originRepo names the repository the working directory is a clone of, from
// its origin remote, when that remote is on host, as gh names it: empty for
// github.com. A clone of another host says nothing about this one.
func originRepo(host string) (owner, repo string, ok bool) {
out, err := exec.Command("git", "remote", "get-url", "origin").Output()
if err != nil {
return "", "", false
}
remoteHost, owner, repo, ok := parseRemote(strings.TrimSpace(string(out)))
if !ok || !strings.EqualFold(remoteHost, cmp.Or(host, "github.com")) {
return "", "", false
}
return owner, repo, true
}

// parseRemote splits a GitHub remote, in its https, ssh:// or scp-like
// git@host:owner/repo form, into its host, owner and repository.
func parseRemote(remote string) (host, owner, repo string, ok bool) {
var path string
if u, err := url.Parse(remote); err == nil && u.Scheme != "" && u.Host != "" {
host, path = u.Hostname(), u.Path
} else if userHost, p, found := strings.Cut(remote, ":"); found && !strings.Contains(userHost, "/") {
_, host, _ = strings.Cut(userHost, "@")
host = cmp.Or(host, userHost)
path = p
} else {
return "", "", "", false
}

owner, repo, found := strings.Cut(strings.TrimSuffix(strings.Trim(path, "/"), ".git"), "/")
if !found || owner == "" || repo == "" || strings.Contains(repo, "/") {
return "", "", "", false
}
// GitHub's SSH over port 443.
if strings.EqualFold(host, "ssh.github.com") {
host = "github.com"
}
return host, owner, repo, true
}

// 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
101 changes: 98 additions & 3 deletions cmd/githubauth/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import (
"net/http"
"net/http/httptest"
"os"
"os/exec"
"path/filepath"
"runtime"
"strings"
Expand Down Expand Up @@ -45,6 +46,18 @@ func clearEnv(t *testing.T) {
// These tests usually run inside a coding agent, which would otherwise
// switch the error output to JSON on stdout under them.
t.Setenv("GITHUBAUTH_AGENT_MODE", "0")

// The tests run in a clone of this repository, whose origin would
// otherwise name an installation on github.com for them. GIT_DIR and
// GIT_WORK_TREE would point git back at it from anywhere, and the
// caller's own git config could rewrite the test remotes.
t.Chdir(t.TempDir())
for _, key := range []string{"GIT_DIR", "GIT_WORK_TREE"} {
t.Setenv(key, "")
_ = os.Unsetenv(key)
}
t.Setenv("GIT_CONFIG_GLOBAL", os.DevNull)
t.Setenv("GIT_CONFIG_NOSYSTEM", "1")
}

func testKeyPEM(t *testing.T) []byte {
Expand Down Expand Up @@ -708,8 +721,10 @@ func TestRun(t *testing.T) {
wantStdout: isExactly("ghs_flagwins"),
},
{
name: "an explicit --installation 0 is missing, whatever the environment holds",
args: func(string) []string { return []string{"token", "--client-id", "Iv1.abc", "--key", keyPath, "--installation", "0"} },
name: "an explicit --installation 0 is missing, whatever the environment holds",
args: func(string) []string {
return []string{"token", "--client-id", "Iv1.abc", "--key", keyPath, "--installation", "0"}
},
env: map[string]string{"GITHUB_APP_INSTALLATION_ID": "12abc"},
wantCode: exitUsage,
wantStdout: isEmpty,
Expand Down Expand Up @@ -787,7 +802,10 @@ func TestTokenRequests(t *testing.T) {
// termDuringRevoke sends githubauth a SIGTERM while the revocation is
// in flight.
termDuringRevoke bool
wantCode int
// origin, when set, runs the test in a clone with this origin remote
// and no installation in the environment.
origin func(serverURL string) string
wantCode int
wantMint string
wantBody string
wantStdout string
Expand Down Expand Up @@ -819,6 +837,29 @@ func TestTokenRequests(t *testing.T) {
args: []string{"--json"},
wantStdout: `"permissions":{"metadata":"read","pull_requests":"write"},"repositories":["api"]}`,
},
{
name: "a clone's origin on the same host scopes the token to it",
origin: func(u string) string { return u + "/acme/api.git" },
wantMint: "/app/installations/123/access_tokens",
wantBody: `{"repositories":["api"]}`,
},
{
name: "a clone's origin on another host is ignored",
origin: func(string) string { return "git@github.com:acme/api.git" },
wantCode: exitUsage,
},
{
name: "an --owner given empty is not nothing",
origin: func(u string) string { return u + "/acme/api.git" },
args: []string{"--owner", ""},
wantCode: exitUsage,
},
{
name: "an installation given beats the clone's origin",
origin: func(u string) string { return u + "/acme/api.git" },
args: []string{"--installation", "99"},
wantMint: "/app/installations/99/access_tokens",
},
{
name: "repos and permissions go in one request",
args: []string{"--repos", "api", "--permissions", "contents:read, pull_requests:write"},
Expand Down Expand Up @@ -931,6 +972,10 @@ func TestTokenRequests(t *testing.T) {
}))
defer srv.Close()
t.Setenv("GITHUBAUTH_EXEC_CALL", srv.URL+"/child")
if tt.origin != nil {
inClone(t, tt.origin(srv.URL))
t.Setenv("GITHUB_APP_INSTALLATION_ID", "")
}

args := append([]string{"token", "--client-id", "Iv1.abc", "--key", keyPath, "--base-url", srv.URL}, tt.args...)
var stdout, stderr bytes.Buffer
Expand Down Expand Up @@ -1298,3 +1343,53 @@ func TestUsageError(t *testing.T) {
})
}
}

// inClone runs the rest of the test in a fresh git clone whose origin is remote.
func inClone(t *testing.T, remote string) {
t.Helper()
if _, err := exec.LookPath("git"); err != nil {
t.Skip("git is not installed")
}
t.Chdir(t.TempDir())
for _, args := range [][]string{{"init", "-q"}, {"remote", "add", "origin", remote}} {
if out, err := exec.Command("git", args...).CombinedOutput(); err != nil {
t.Fatalf("git %v: %v: %s", args, err, out)
}
}
}

func TestParseRemote(t *testing.T) {
t.Parallel()

tests := []struct {
remote string
host, owner, repo string
ok bool
}{
{remote: "https://github.com/acme/api.git", host: "github.com", owner: "acme", repo: "api", ok: true},
{remote: "https://github.com/acme/api", host: "github.com", owner: "acme", repo: "api", ok: true},
{remote: "https://x-access-token:secret@github.com/acme/api.git", host: "github.com", owner: "acme", repo: "api", ok: true},
{remote: "git@github.com:acme/api.git", host: "github.com", owner: "acme", repo: "api", ok: true},
{remote: "github.com:acme/api", host: "github.com", owner: "acme", repo: "api", ok: true},
{remote: "ssh://git@ghe.example.com:2222/acme/api.git", host: "ghe.example.com", owner: "acme", repo: "api", ok: true},
{remote: "ssh://git@ssh.github.com:443/acme/api.git", host: "github.com", owner: "acme", repo: "api", ok: true},
{remote: "https://octo.ghe.com/acme/api/", host: "octo.ghe.com", owner: "acme", repo: "api", ok: true},
{remote: "https://github.com/acme"},
{remote: "https://github.com/acme/api/tree/main"},
{remote: "/srv/git/api.git"},
{remote: "file:///srv/git/acme/api.git"},
{remote: ""},
}

for _, tt := range tests {
t.Run(tt.remote, func(t *testing.T) {
t.Parallel()

host, owner, repo, ok := parseRemote(tt.remote)
if host != tt.host || owner != tt.owner || repo != tt.repo || ok != tt.ok {
t.Errorf("parseRemote(%q) = %q, %q, %q, %v, want %q, %q, %q, %v",
tt.remote, host, owner, repo, ok, tt.host, tt.owner, tt.repo, tt.ok)
}
})
}
}
2 changes: 1 addition & 1 deletion llms.txt
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ The same credentials without writing Go. Install with `go install github.com/jfe

- `githubauth token [flags]` — print an installation access token. Flags: `--installation` (or `--owner` to look it up), `--repos`, `--permissions` (e.g. `contents:read,pull_requests:write`), `--keep`, `--base-url`, `--enterprise-url`.
- `githubauth jwt [flags]` — print the App JWT, for the endpoints that take one. Flags: `--expiry`.
- Shared flags: `--client-id` (or `--app-id` for a legacy App), `--key`, `--json` (adds the expiry, and for an installation token the permissions and repositories GitHub granted), `--exec`, `--agent`. `--repos acme/api` finds the installation like `--owner acme`.
- Shared flags: `--client-id` (or `--app-id` for a legacy App), `--key`, `--json` (adds the expiry, and for an installation token the permissions and repositories GitHub granted), `--exec`, `--agent`. `--repos acme/api` finds the installation like `--owner acme`. With no `--installation`, `--owner`, `--repos` or `$GITHUB_APP_INSTALLATION_ID`, a clone's `origin` remote on the same host scopes the token to that repository.
- Environment fallbacks for every flag: `GITHUB_APP_CLIENT_ID`, `GITHUB_APP_ID`, `GITHUB_APP_PRIVATE_KEY`, `GITHUB_APP_INSTALLATION_ID`. A configured environment reduces the call to `githubauth token`.
- `--key` accepts a file path, `-` for stdin, or the PEM itself, so the key can come from a secret manager without touching disk.
- The token is the only thing on stdout, so it composes: `curl -H "Authorization: Bearer $(githubauth token)" https://api.github.com/installation/repositories`.
Expand Down
Loading