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
11 changes: 7 additions & 4 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ githubauth help

Flags shared by both commands: `--client-id` (or `--app-id` for a legacy App),
`--key`, `--json`, `--exec`, `--agent`. `token` adds `--installation`, `--repos`,
`--permissions`, `--keep`, `--base-url`, `--enterprise-url`. `jwt` adds `--expiry`.
`--owner`, `--permissions`, `--keep`, `--base-url`, `--enterprise-url`. `jwt` adds `--expiry`.

Every flag falls back to an environment variable — `GITHUB_APP_CLIENT_ID`,
`GITHUB_APP_ID`, `GITHUB_APP_PRIVATE_KEY`, `GITHUB_APP_INSTALLATION_ID` — so a
Expand Down Expand Up @@ -80,7 +80,7 @@ Under `--json`, or when a coding agent is detected, a failure is one JSON
document on **stdout** and stderr stays empty:

```json
{"type":"githubauth.error","schema_version":"1","error":{"summary":"GitHub API returned status 404: {\"message\":\"Not Found\"}","exit_code":5,"status_code":404,"suggestions":["Check --installation: the App may not be installed on that account."]}}
{"type":"githubauth.error","schema_version":"1","error":{"summary":"GitHub API returned status 404: {\"message\":\"Not Found\"}","exit_code":5,"status_code":404,"suggestions":["Check --installation or --owner: the App may not be installed on that account."]}}
```

Check `type` and `schema_version` before reading the rest.
Expand All @@ -107,7 +107,9 @@ httpClient := oauth2.NewClient(context.Background(), installSrc)
is inferred. Both sources cache and refresh 30s before expiry.

`RevokeInstallationToken(ctx, token)` ends an installation token before its hour
is up.
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.

Errors worth branching on: `*githubauth.RateLimitError` (carries `RetryAfter`,
unwraps to `ErrRateLimited`) and `*githubauth.APIError` (carries `StatusCode`).
Expand All @@ -129,7 +131,8 @@ Full API surface: [`llms.txt`](llms.txt). Runnable examples: pkg.go.dev.
table-driven. Follow the shape already in the file you are editing.
- **Scope**: this library authenticates and stops there. API coverage belongs to
[go-github](https://github.com/google/go-github), which it pairs with — do not
add installation, repository, or organization helpers here.
add installation, repository, or organization helpers here, beyond what minting
or ending a token needs (finding the installation ID, revoking the token).
- Exported symbols keep a doc comment opening with the symbol name. Comments say
why, in a sentence or two.
- The exit codes and the `githubauth.error` envelope are a public contract.
Expand Down
6 changes: 6 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,12 @@ less than the App was granted:
githubauth token --installation 12345 --repos api,web --permissions contents:read
```

If you know the account but not the installation ID, `--owner` looks it up:

```bash
githubauth token --owner acme --repos api
```

Run `githubauth help` for the rest.

### Keeping the token out of your logs
Expand Down
20 changes: 20 additions & 0 deletions auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,11 @@ import (
"crypto/sha256"
"encoding/base64"
"errors"
"fmt"
"net/http"
"reflect"
"strconv"
"strings"
"sync"
"time"

Expand Down Expand Up @@ -479,6 +481,24 @@ func RevokeInstallationToken(ctx context.Context, token string, opts ...Installa
return i.client.revokeInstallationToken(ctx)
}

// FindInstallationID returns the ID of the App's installation on owner, a user
// or organization login, for a caller who knows the account but not the ID. src
// is the App's JWT source. WithBaseURL, WithEnterpriseURL and WithHTTPClient
// apply as they do to NewInstallationTokenSource. The other options are ignored.
//
// See https://docs.github.com/en/rest/apps/apps?apiVersion=2022-11-28#get-a-user-installation-for-the-authenticated-app
func FindInstallationID(ctx context.Context, src oauth2.TokenSource, owner string, opts ...InstallationTokenSourceOpt) (int64, error) {
// A slash or dot segment would move the request to another endpoint.
if owner == "" || owner == "." || owner == ".." || strings.Contains(owner, "/") {
return 0, fmt.Errorf("invalid owner %q: want a user or organization login", owner)
}
i := newInstallationTokenSource(0, src, opts)
if i.configErr != nil {
return 0, i.configErr
}
return i.client.findInstallationID(ctx, owner)
}

// newInstallationTokenSource applies opts to a source whose requests src
// authenticates.
func newInstallationTokenSource(id int64, src oauth2.TokenSource, opts []InstallationTokenSourceOpt) *installationTokenSource {
Expand Down
4 changes: 2 additions & 2 deletions cmd/githubauth/errors.go
Original file line number Diff line number Diff line change
Expand Up @@ -101,7 +101,7 @@ func classify(err error) failure {
case http.StatusBadRequest, http.StatusUnprocessableEntity:
f.code = exitUsage
f.suggestions = []string{
"GitHub rejected the request itself: check --repos, every name must be a repository the installation can access.",
"GitHub rejected the request itself: check --repos, every name must be a repository the installation can access, and --permissions, every one must be granted to the App.",
}
case http.StatusUnauthorized:
f.code = exitCredentials
Expand All @@ -117,7 +117,7 @@ func classify(err error) failure {
case http.StatusNotFound:
f.code = exitNotFound
f.suggestions = []string{
"Check --installation: the App may not be installed on that account.",
"Check --installation or --owner: the App may not be installed on that account.",
`List the App's installations: githubauth jwt --exec -- sh -c 'curl -sH "Authorization: Bearer $GITHUB_TOKEN" https://api.github.com/app/installations'`,
wrongHostHint,
}
Expand Down
34 changes: 26 additions & 8 deletions cmd/githubauth/exec.go
Original file line number Diff line number Diff line change
Expand Up @@ -108,22 +108,40 @@ func runWithToken(argv []string, tok *oauth2.Token, host string, stdin io.Reader
// for the library's full minute.
const revokeTimeout = 10 * time.Second

// holdSignals keeps SIGINT and SIGTERM from killing githubauth until the
// returned func runs. Held from before the command starts until the token is
// revoked, a signal in between cannot leave the token valid. revokeTimeout
// bounds how long they stay held once the command has exited.
func holdSignals() (release func()) {
held := make(chan os.Signal, 1)
signal.Notify(held, os.Interrupt, syscall.SIGTERM)
return func() { signal.Stop(held) }
}

// revoke ends the token once the command it was minted for has exited, so a
// copy left in a child's environment or output stops working. A failure only
// warns: the exit code stays the command's.
func revoke(tok *oauth2.Token, opts []githubauth.InstallationTokenSourceOpt, stderr io.Writer) {
// copy left in a child's environment or output stops working. err is the
// command's result, returned with a revocation failure added only when
// githubauth itself failed: one failure report, JSON under --agent, then
// carries both. Otherwise a failure only warns, and the exit code stays the
// command's.
func revoke(err error, tok *oauth2.Token, opts []githubauth.InstallationTokenSourceOpt, stderr io.Writer) error {
ctx, cancel := context.WithTimeout(context.Background(), revokeTimeout)
defer cancel()
err := githubauth.RevokeInstallationToken(ctx, tok.AccessToken, opts...)
revokeErr := githubauth.RevokeInstallationToken(ctx, tok.AccessToken, opts...)
// A 401 means the token is already dead: past its hour, or revoked by the
// command itself.
var apiErr *githubauth.APIError
if errors.As(err, &apiErr) && apiErr.StatusCode == http.StatusUnauthorized {
return
if revokeErr == nil || errors.As(revokeErr, &apiErr) && apiErr.StatusCode == http.StatusUnauthorized {
return err
}
if err != nil {
_, _ = fmt.Fprintf(stderr, "githubauth: the token was not revoked and stays valid until %s: %v\n", tok.Expiry.Format(time.RFC3339), err)

revokeErr = fmt.Errorf("the token was not revoked and stays valid until %s: %w", tok.Expiry.Format(time.RFC3339), revokeErr)
var status exitStatus
if err != nil && !errors.As(err, &status) {
return errors.Join(err, revokeErr)
}
_, _ = fmt.Fprintf(stderr, "githubauth: %v\n", revokeErr)
return err
}

// ghHost names the instance behind an API URL the way gh does. github.com and
Expand Down
10 changes: 10 additions & 0 deletions cmd/githubauth/exec_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"bytes"
"errors"
"fmt"
"net/http"
"os"
"runtime"
"strconv"
Expand All @@ -26,6 +27,15 @@ func TestExecHelper(t *testing.T) {
for _, key := range append(execTokenEnv, append(execEnterpriseEnv, "GH_HOST")...) {
fmt.Printf("%s=%s\n", key, os.Getenv(key))
}
// Use the token the way a real command would, so a test can tell whether it
// was still valid while the command ran.
if url := os.Getenv("GITHUBAUTH_EXEC_CALL"); url != "" {
req, _ := http.NewRequest(http.MethodGet, url, nil)
req.Header.Set("Authorization", "Bearer "+os.Getenv("GH_TOKEN"))
if resp, err := http.DefaultClient.Do(req); err == nil {
_ = resp.Body.Close()
}
}
if os.Getenv("GITHUBAUTH_EXEC_SELF_TERM") != "" {
p, _ := os.FindProcess(os.Getpid())
_ = p.Signal(syscall.SIGTERM)
Expand Down
37 changes: 31 additions & 6 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"
"context"
"encoding/json"
"errors"
"flag"
Expand Down Expand Up @@ -233,7 +234,10 @@ func asCredential(src oauth2.TokenSource, err error) (oauth2.TokenSource, error)
}

func tokenCmd(args []string, stdin io.Reader, stdout, stderr io.Writer, out *output) error {
fs := newFlagSet("token", "Print an installation access token.", ` # Scope the token to two repositories, with read access to their contents.
fs := newFlagSet("token", "Print an installation access token.", ` # Find the installation from the account it is on.
githubauth token --owner acme

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

# Keep the token out of the terminal: it only ever reaches the child, and is
Expand All @@ -251,6 +255,7 @@ func tokenCmd(args []string, stdin io.Reader, stdout, stderr io.Writer, out *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")
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")
baseURL := fs.String("base-url", "", "API base URL, used verbatim (Enterprise Cloud with data residency)")
Expand All @@ -265,11 +270,18 @@ 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")
}
if *installation <= 0 {
switch {
case *owner != "" && isSet(fs, "installation"):
return usagef("set --installation or --owner, not both")
case strings.Contains(*owner, "/"):
return usagef("--owner %q is a repository: pass its account as --owner and the repository as --repos", *owner)
case *owner == "." || *owner == "..":
return usagef("--owner %q is not an account: pass a user or organization login", *owner)
case *owner == "" && *installation <= 0:
if err := badEnvNumber(fs, "installation", "GITHUB_APP_INSTALLATION_ID"); err != nil {
return err
}
return usagef("missing installation ID: set --installation")
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")
Expand Down Expand Up @@ -308,6 +320,14 @@ func tokenCmd(args []string, stdin io.Reader, stdout, stderr io.Writer, out *out
case *enterpriseURL != "":
urlOpts = append(urlOpts, githubauth.WithEnterpriseURL(*enterpriseURL))
}

id := *installation
if *owner != "" {
if id, err = githubauth.FindInstallationID(context.Background(), appSrc, *owner, urlOpts...); err != nil {
return err
}
}

opts := slices.Clone(urlOpts)
if len(names) > 0 || perms != nil {
opts = append(opts, githubauth.WithInstallationTokenOptions(&githubauth.InstallationTokenOptions{
Expand All @@ -316,14 +336,17 @@ func tokenCmd(args []string, stdin io.Reader, stdout, stderr io.Writer, out *out
}))
}

tok, err := githubauth.NewInstallationTokenSource(*installation, appSrc, opts...).Token()
tok, err := githubauth.NewInstallationTokenSource(id, appSrc, opts...).Token()
if err != nil {
return err
}
if app.exec && !*keep {
defer holdSignals()()
}
err = app.emit(tok, fs.Args(), stdin, stdout, stderr)
// Even a command that failed to start leaves a minted token behind.
if app.exec && !*keep {
revoke(tok, urlOpts, stderr)
err = revoke(err, tok, urlOpts, stderr)
}
return err
}
Expand Down Expand Up @@ -515,7 +538,9 @@ func splitRepos(v string) []string {
// rather than as a 422 that does not say which one.
//
// shortcut: only the names InstallationPermissions has fields for are known, so a
// newer GitHub permission is refused until the struct gains it.
// newer GitHub permission is refused until the struct gains it. Unknown names
// could go through Other once GitHub is known to refuse a name it does not
// recognize, rather than ignore it and mint a token with every permission.
func parsePermissions(v string) (*githubauth.InstallationPermissions, error) {
pairs := splitRepos(v)
if len(pairs) == 0 {
Expand Down
Loading
Loading