From 4c6e3298bd692e68a6de8a089441f1cd8a37525c Mon Sep 17 00:00:00 2001 From: jferrl Date: Sat, 31 Oct 2026 09:41:26 +0100 Subject: [PATCH 1/2] feat: scope the token to the clone it runs in when nothing else is named githubauth token with no --installation, --owner, --repos or $GITHUB_APP_INSTALLATION_ID exited 2. Inside a clone it now reads the origin remote and, when that remote is on the target host, finds the installation of its owner and scopes the token to that one repository, the way actions/create-github-app-token defaults to the current repository. Outside a clone, or with a remote on another host, it exits 2 as before, so no call that worked changes. An --owner given empty, as from an unset script variable, does not count as nothing, since falling back would mint for a repository the script never named. ssh.github.com, GitHub's SSH over port 443, counts as github.com. GIT_DIR is honoured, as it is by the git push the token is usually for. The CLI tests now run from an empty directory with GIT_DIR, GIT_WORK_TREE and the caller's git config cleared: run from this clone, they reached api.github.com through its origin, and with GIT_DIR set they could have added a remote to it. --- AGENTS.md | 4 +- README.md | 8 +++ cmd/githubauth/main.go | 77 +++++++++++++++++++++++---- cmd/githubauth/main_test.go | 101 ++++++++++++++++++++++++++++++++++-- llms.txt | 2 +- 5 files changed, 176 insertions(+), 16 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index edfe64f..413db93 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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. diff --git a/README.md b/README.md index 2453420..700c940 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/cmd/githubauth/main.go b/cmd/githubauth/main.go index b935b61..1f4e97c 100644 --- a/cmd/githubauth/main.go +++ b/cmd/githubauth/main.go @@ -7,6 +7,7 @@ package main import ( "bytes" + "cmp" "context" "encoding/json" "errors" @@ -15,6 +16,7 @@ import ( "io" "net/url" "os" + "os/exec" "runtime/debug" "slices" "strconv" @@ -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 @@ -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") @@ -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 { @@ -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 { @@ -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 diff --git a/cmd/githubauth/main_test.go b/cmd/githubauth/main_test.go index 7c05169..913ba92 100644 --- a/cmd/githubauth/main_test.go +++ b/cmd/githubauth/main_test.go @@ -15,6 +15,7 @@ import ( "net/http" "net/http/httptest" "os" + "os/exec" "path/filepath" "runtime" "strings" @@ -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 { @@ -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, @@ -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 @@ -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"}, @@ -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 @@ -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) + } + }) + } +} diff --git a/llms.txt b/llms.txt index cca44c0..eab2759 100644 --- a/llms.txt +++ b/llms.txt @@ -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`. From 86747bf5cc38eb3bf62f19ef198de918afca973d Mon Sep 17 00:00:00 2001 From: jferrl Date: Sat, 31 Oct 2026 09:43:24 +0100 Subject: [PATCH 2/2] docs: collect the unreleased changes in the changelog Four PRs have landed since v1.11.0 with their release notes spread over their descriptions. An Unreleased section gathers them, so cutting v1.12.0 is a date and a tag. Three changes can affect existing code or scripts and are listed under Changed: --exec revoking the token, InstallationPermissions no longer being comparable now that it holds a map, and token values with a scope panicking under ==. --- CHANGELOG.md | 44 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index c9d3e04..75bdc8b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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;