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/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; 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`.