From bf40e4ff2b0cea99e9287913c217b7ba4704d587 Mon Sep 17 00:00:00 2001 From: jferrl Date: Sat, 19 Sep 2026 19:23:06 +0200 Subject: [PATCH] feat: distinguish failures by exit code and add --exec to the CLI Every CLI failure exited 1, so a caller could not tell a rate limit from a revoked key from an installation that does not exist, and retried all of them. The library made that unavoidable: only throttling had a typed error, and every other rejection collapsed into a fmt.Errorf string the CLI would have had to pattern-match. APIError carries the status code instead. Its Error() renders the same string the fmt.Errorf did, so callers that log or match on the message keep working, and the CLI now maps status to an exit code: 3 for a credential GitHub refused, 4 for throttling, 5 for an App that is not installed where it was asked to be. A missing key file and a rejected key both exited 1 before and now exit 2 and 3. --exec runs a command with the credential in $GITHUB_TOKEN and prints it nowhere. A printed installation token stays valid for an hour in every log, scrollback and agent transcript that captured the output, and --exec is the way not to put it there. Agent mode was deliberately limited to errors. Grafana's gcx flips its default output format when it detects an agent, which works because its output is data; ours is a credential meant for $(githubauth token), and flipping it would have made the README's own curl example send a JSON object as the bearer token inside an agent session. So detection routes failures to stdout as one githubauth.error document and adds a stderr hint, and leaves the token line alone. Running the suite inside a coding agent showed why this matters: the tests inherited CLAUDECODE=1 and found errors on stdout. They now pin GITHUBAUTH_AGENT_MODE=0, which is what any downstream suite that shells out to githubauth will need. --- AGENTS.md | 123 +++++++++++++++++++ README.md | 35 ++++++ cmd/githubauth/agent.go | 51 ++++++++ cmd/githubauth/agent_test.go | 92 ++++++++++++++ cmd/githubauth/errors.go | 188 ++++++++++++++++++++++++++++ cmd/githubauth/errors_test.go | 225 ++++++++++++++++++++++++++++++++++ cmd/githubauth/exec.go | 59 +++++++++ cmd/githubauth/exec_test.go | 110 +++++++++++++++++ cmd/githubauth/main.go | 148 ++++++++++++++++------ cmd/githubauth/main_test.go | 106 ++++++++++++++-- doc.go | 9 +- github.go | 25 +++- github_test.go | 22 ++++ llms.txt | 18 ++- 14 files changed, 1164 insertions(+), 47 deletions(-) create mode 100644 AGENTS.md create mode 100644 cmd/githubauth/agent.go create mode 100644 cmd/githubauth/agent_test.go create mode 100644 cmd/githubauth/errors.go create mode 100644 cmd/githubauth/errors_test.go create mode 100644 cmd/githubauth/exec.go create mode 100644 cmd/githubauth/exec_test.go diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..becb055 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,123 @@ +# go-githubauth — notes for coding agents + +GitHub authentication for Go: GitHub App JWTs, installation tokens, and personal +access tokens, as standard `oauth2.TokenSource` implementations. Ships a library +and a `githubauth` CLI. Read this before reaching for either. + +## Getting a credential without leaking it + +The CLI prints a live credential to stdout. An installation token is valid for an +hour, and anything that captured your output still has it for that hour: a +transcript, a CI log, a scrollback buffer. + +Prefer `--exec`, which puts the token in the child's `$GITHUB_TOKEN` and prints it +nowhere: + +```bash +githubauth token --installation 12345 --exec -- gh pr list +``` + +Print it only when something downstream genuinely needs the string, and prefer a +short-lived scope when you do: + +```bash +githubauth token --installation 12345 --repos api,web +``` + +## The CLI + +``` +githubauth token [flags] # an installation access token +githubauth jwt [flags] # the App JWT, for the endpoints that take one +githubauth version +githubauth help +``` + +Flags shared by both commands: `--client-id` (or `--app-id` for a legacy App), +`--key`, `--json`, `--exec`, `--agent`. `token` adds `--installation`, `--repos`, +`--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 +configured environment reduces the call to `githubauth token`. + +`--key` takes a file path, `-` for stdin, or the PEM itself, which is what lets +the key come from a secret manager without touching disk: + +```bash +vault kv get -field=pem secret/github-app | githubauth token --key - --installation 12345 +``` + +### Exit codes + +Branch on these rather than on message text. Each one implies a different move: + +| Code | Meaning | What to do | +|---|---|---| +| 0 | a credential was printed | continue | +| 1 | something else failed | retrying may help | +| 2 | the invocation is wrong | fix the flags, do not retry unchanged | +| 3 | GitHub refused the key or the App's permissions | fix the credential, do not retry unchanged | +| 4 | rate limited | wait `retry_after_seconds`, then repeat | +| 5 | the App is not installed where it was asked to be | fix `--installation` | + +Under `--exec` the exit code is the one the command exited with. + +### Machine-readable failures + +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."]}} +``` + +Check `type` and `schema_version` before reading the rest. + +Detection reads `CLAUDECODE`, `CLAUDE_CODE`, `CURSOR_AGENT`, `GITHUB_COPILOT`, +`AMAZON_Q`, `OPENCODE`, and `PI_CODING_AGENT`. `GITHUBAUTH_AGENT_MODE` or +`--agent`/`--agent=false` decides on its own. Agent mode never changes the +success output: a bare token stays a bare token, so `$(githubauth token)` means +the same thing everywhere. + +**Pin it off in tests.** A test suite that shells out to `githubauth` while +running inside an agent will otherwise find errors on stdout instead of stderr. +Set `GITHUBAUTH_AGENT_MODE=0`. + +## The library + +```go +appSrc, err := githubauth.NewApplicationTokenSource(clientID, privateKeyPEM) +installSrc := githubauth.NewInstallationTokenSource(installationID, appSrc) +httpClient := oauth2.NewClient(context.Background(), installSrc) +``` + +`NewApplicationTokenSource` takes a string Client ID or an int64 App ID. The type +is inferred. Both sources cache and refresh 30s before expiry. + +Errors worth branching on: `*githubauth.RateLimitError` (carries `RetryAfter`, +unwraps to `ErrRateLimited`) and `*githubauth.APIError` (carries `StatusCode`). +Match with `errors.As`, never on the message. + +Full API surface: [`llms.txt`](llms.txt). Runnable examples: pkg.go.dev. + +## Working in this repo + +| Path | What | +|---|---| +| `auth.go` | token sources and their options | +| `github.go` | the installation token exchange, retries, error types | +| `http.go` | the shared HTTP client | +| `webhook/` | `X-Hub-Signature-256` verification and middleware | +| `cmd/githubauth/` | the CLI: `main.go` commands, `errors.go` exit codes, `exec.go`, `agent.go` | + +- `go test ./...` and `golangci-lint run ./...` both have to pass. Tests are + 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. +- 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. + Adding a code or a field is fine. Changing what an existing one means is not, + without bumping `schema_version`. diff --git a/README.md b/README.md index ce784fc..5ec1658 100644 --- a/README.md +++ b/README.md @@ -46,6 +46,8 @@ httpClient := oauth2.NewClient(context.Background(), installationTokenSource) - Webhook delivery verification (`X-Hub-Signature-256`, constant-time) with ready-made `http.Handler` middleware - GitHub Enterprise Server and GitHub Enterprise Cloud (data residency) support - Automatic single retry on throttled responses (`WithRetryOnThrottle`, enabled by default) +- Typed errors to branch on: `RateLimitError` (with `RetryAfter`) for a throttled request, `APIError` (with `StatusCode`) for every other rejection +- A `githubauth` CLI with a documented exit code per failure class and `--exec`, which passes the credential to a command without printing it - Two dependencies total: `golang-jwt/jwt` and `golang.org/x/oauth2` ## Used by @@ -101,6 +103,39 @@ vault kv get -field=pem secret/github-app | expiry, and `--repos` scopes the token to named repositories. Run `githubauth help` for the rest. +### Keeping the token out of your logs + +A printed credential stays valid for an hour, in every place your output landed: a CI log, +a terminal scrollback, a coding agent's transcript. `--exec` runs a command with the token +in its `$GITHUB_TOKEN` and prints it nowhere, exiting with whatever the command exited +with: + +```bash +githubauth token --installation 12345 --exec -- gh pr list +``` + +### Exit codes + +Scripts and agents branch on the code rather than on the message: + +| Code | Meaning | +|---|---| +| 0 | a credential was printed | +| 1 | something else failed, retrying may help | +| 2 | the invocation is wrong | +| 3 | GitHub refused the key or the App's permissions | +| 4 | rate limited, wait and repeat | +| 5 | the App is not installed where it was asked to be | + +Under `--json`, or when a coding agent is detected, a failure is one JSON document on +stdout — `{"type":"githubauth.error","schema_version":"1","error":{...}}`, carrying +`exit_code`, `status_code`, `retry_after_seconds` and suggestions — and stderr stays empty. +Detection reads the usual agent variables (`CLAUDECODE`, `CURSOR_AGENT`, and friends); +`GITHUBAUTH_AGENT_MODE` or `--agent`/`--agent=false` overrides it, and a test suite that +shells out to the CLI should set `GITHUBAUTH_AGENT_MODE=0`. The success output never +changes: a bare token stays a bare token, so `$(githubauth token)` means the same thing +everywhere. [AGENTS.md](AGENTS.md) has the full contract. + ## Comparison with ghinstallation [`ghinstallation`](https://github.com/bradleyfalzon/ghinstallation) is the long-standing library in this space and works well. The core difference is the integration model: `ghinstallation` is an `http.RoundTripper` you install as an HTTP transport, while `go-githubauth` implements `oauth2.TokenSource`, so credentials compose with anything that speaks oauth2 — `oauth2.NewClient`, [go-github](https://github.com/google/go-github), gRPC per-RPC credentials, or code that just needs the token string. diff --git a/cmd/githubauth/agent.go b/cmd/githubauth/agent.go new file mode 100644 index 0000000..0d3535f --- /dev/null +++ b/cmd/githubauth/agent.go @@ -0,0 +1,51 @@ +package main + +import ( + "os" + "strings" +) + +// agentTokenHint goes to stderr, where it cannot corrupt $(githubauth token). +// A printed credential outlives the command in the transcript. +const agentTokenHint = "hint: this credential is now in the session transcript; --exec passes it to a command without printing it\n" + +// agentEnv are the variables coding agents set in the shells they run commands +// in. Detecting them means no agent has to be told to pass a flag. The list +// follows the convention gcx established. +var agentEnv = []string{ + "CLAUDECODE", + "CLAUDE_CODE", + "CURSOR_AGENT", + "GITHUB_COPILOT", + "AMAZON_Q", + "OPENCODE", + "PI_CODING_AGENT", +} + +// agentModeEnv reports whether an agent is running this command. +// GITHUBAUTH_AGENT_MODE decides on its own when set, in either direction, so a +// CI job that inherits an agent's environment can still turn it off. +func agentModeEnv() bool { + if on, ok := truthy(os.Getenv("GITHUBAUTH_AGENT_MODE")); ok { + return on + } + for _, key := range agentEnv { + if on, ok := truthy(os.Getenv(key)); ok && on { + return true + } + } + return false +} + +// truthy reads a flag-shaped environment variable. A value that is set to +// something unrecognized counts as on: agents spell these differently +// ("CURSOR_AGENT=cursor"), and only an explicit denial should mean off. +func truthy(v string) (on, set bool) { + switch strings.ToLower(strings.TrimSpace(v)) { + case "": + return false, false + case "0", "false", "no", "off": + return false, true + } + return true, true +} diff --git a/cmd/githubauth/agent_test.go b/cmd/githubauth/agent_test.go new file mode 100644 index 0000000..989283e --- /dev/null +++ b/cmd/githubauth/agent_test.go @@ -0,0 +1,92 @@ +package main + +import "testing" + +func TestAgentModeEnv(t *testing.T) { + tests := []struct { + name string + env map[string]string + want bool + }{ + { + name: "no agent in sight", + want: false, + }, + { + name: "Claude Code sets CLAUDECODE", + env: map[string]string{"CLAUDECODE": "1"}, + want: true, + }, + { + name: "a name rather than a flag still counts", + env: map[string]string{"CURSOR_AGENT": "cursor"}, + want: true, + }, + { + name: "an explicit denial from the agent variable itself", + env: map[string]string{"CLAUDECODE": "0"}, + want: false, + }, + { + name: "the override wins over a detected agent", + env: map[string]string{"CLAUDECODE": "1", "GITHUBAUTH_AGENT_MODE": "0"}, + want: false, + }, + { + name: "the override turns it on with no agent present", + env: map[string]string{"GITHUBAUTH_AGENT_MODE": "yes"}, + want: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // The suite usually runs inside an agent, so every variable the + // detection reads has to be cleared before the case sets its own. + for _, key := range agentEnv { + t.Setenv(key, "") + } + t.Setenv("GITHUBAUTH_AGENT_MODE", "") + for key, value := range tt.env { + t.Setenv(key, value) + } + + if got := agentModeEnv(); got != tt.want { + t.Errorf("agentModeEnv() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestTruthy(t *testing.T) { + t.Parallel() + + tests := []struct { + in string + wantOn bool + wantSet bool + }{ + {in: "", wantOn: false, wantSet: false}, + {in: " ", wantOn: false, wantSet: false}, + {in: "1", wantOn: true, wantSet: true}, + {in: "true", wantOn: true, wantSet: true}, + {in: "TRUE", wantOn: true, wantSet: true}, + {in: "yes", wantOn: true, wantSet: true}, + {in: "0", wantOn: false, wantSet: true}, + {in: "false", wantOn: false, wantSet: true}, + {in: " No ", wantOn: false, wantSet: true}, + {in: "off", wantOn: false, wantSet: true}, + {in: "cursor", wantOn: true, wantSet: true}, + } + + for _, tt := range tests { + t.Run(tt.in, func(t *testing.T) { + t.Parallel() + + on, set := truthy(tt.in) + if on != tt.wantOn || set != tt.wantSet { + t.Errorf("truthy(%q) = (%v, %v), want (%v, %v)", tt.in, on, set, tt.wantOn, tt.wantSet) + } + }) + } +} diff --git a/cmd/githubauth/errors.go b/cmd/githubauth/errors.go new file mode 100644 index 0000000..8b8b2f3 --- /dev/null +++ b/cmd/githubauth/errors.go @@ -0,0 +1,188 @@ +package main + +import ( + "encoding/json" + "errors" + "fmt" + "io" + "math" + "net/http" + "time" + + "github.com/jferrl/go-githubauth" +) + +// Exit codes. Each names a different next move, so a caller can tell "wait and +// retry" from "this key will never work" without reading the message. +const ( + exitOK = 0 // the token was produced + exitFailure = 1 // something else went wrong, retrying may help + exitUsage = 2 // the invocation is wrong, fix the flags + exitCredentials = 3 // GitHub refused the key or the App's permissions + exitRateLimited = 4 // throttled, wait retry_after_seconds and repeat + exitNotFound = 5 // the App is not installed where it was asked to be +) + +// credentialError marks a failure to build the App token source: an unparseable +// PEM, or an identity GitHub could never accept. It is distinct from a rejected +// request because nothing was sent yet. +type credentialError struct{ err error } + +func (e credentialError) Error() string { return e.err.Error() } +func (e credentialError) Unwrap() error { return e.err } + +// exitStatus carries a child process's exit code out through the error path, so +// --exec exits as the command it ran did. +type exitStatus int + +func (e exitStatus) Error() string { return fmt.Sprintf("exit status %d", int(e)) } + +// failure is an error classified for reporting: the code to exit with, and what +// to do about it. +type failure struct { + summary string + code int + statusCode int + retryAfter *time.Duration + suggestions []string + // reported is set when the flag package has already written the message, + // so the prose rendering does not print it twice. + reported bool +} + +// classify maps an error to its exit code and the advice that goes with it. +// Every branch ends in a suggestion naming a command or a flag. +func classify(err error) failure { + var ue usageError + if errors.As(err, &ue) { + return failure{ + summary: err.Error(), + code: exitUsage, + reported: ue.reported, + suggestions: []string{ + `Run "githubauth --help" for the flags of a command.`, + }, + } + } + + var ce credentialError + if errors.As(err, &ce) { + return failure{ + summary: err.Error(), + code: exitCredentials, + suggestions: []string{ + "Check --key: it must be the App's PEM private key, a path to it, or - for stdin.", + "Check --client-id (or --app-id) against the App's settings page.", + }, + } + } + + var rle *githubauth.RateLimitError + if errors.As(err, &rle) { + return failure{ + summary: err.Error(), + code: exitRateLimited, + statusCode: rle.StatusCode, + retryAfter: &rle.RetryAfter, + suggestions: []string{ + fmt.Sprintf("Wait %s, then run the same command again.", rle.RetryAfter.Round(time.Second)), + }, + } + } + + var apiErr *githubauth.APIError + if errors.As(err, &apiErr) { + f := failure{summary: err.Error(), code: exitFailure, statusCode: apiErr.StatusCode} + switch apiErr.StatusCode { + case http.StatusUnauthorized: + f.code = exitCredentials + f.suggestions = []string{ + "GitHub rejected the App JWT: check that --key is the current key for --client-id and that the clock is right.", + } + case http.StatusForbidden: + f.code = exitCredentials + f.suggestions = []string{ + "The App lacks a permission this call needs. Grant it in the App's settings, then reinstall it.", + } + case http.StatusNotFound: + f.code = exitNotFound + f.suggestions = []string{ + "Check --installation: the App may not be installed on that account.", + `List the App's installations: curl -H "Authorization: Bearer $(githubauth jwt)" https://api.github.com/app/installations`, + } + } + return f + } + + return failure{ + summary: err.Error(), + code: exitFailure, + suggestions: []string{ + "Retry. If it persists, check https://www.githubstatus.com.", + }, + } +} + +// errorDocument is the JSON failure envelope. The type and schema_version make +// it identifiable in a stream that may carry anything else, and let the shape +// change later without breaking a reader that checks them. +type errorDocument struct { + Type string `json:"type"` + SchemaVersion string `json:"schema_version"` + Error errorBody `json:"error"` +} + +type errorBody struct { + Summary string `json:"summary"` + ExitCode int `json:"exit_code"` + StatusCode int `json:"status_code,omitempty"` + RetryAfterSeconds *int `json:"retry_after_seconds,omitempty"` + Suggestions []string `json:"suggestions,omitempty"` +} + +// report renders err and returns the exit code. +// +// Under --json or agent mode the failure is one JSON document on stdout and +// stderr stays quiet, so a machine reader finds one error on one stream. +// Otherwise it is prose on stderr, and stdout stays empty so +// $(githubauth token) never captures an error message. +func report(err error, stdout, stderr io.Writer, out output) int { + var status exitStatus + if errors.As(err, &status) { + return int(status) + } + + f := classify(err) + if out.json || out.agent { + return writeErrorDocument(stdout, stderr, f) + } + + if !f.reported { + _, _ = fmt.Fprintf(stderr, "githubauth: %s\n", f.summary) + } + for _, s := range f.suggestions { + _, _ = fmt.Fprintf(stderr, " %s\n", s) + } + return f.code +} + +// writeErrorDocument emits the JSON envelope. A closed stdout falls back to +// stderr prose, so a failure is still visible to someone. +func writeErrorDocument(stdout, stderr io.Writer, f failure) int { + body := errorBody{ + Summary: f.summary, + ExitCode: f.code, + StatusCode: f.statusCode, + Suggestions: f.suggestions, + } + if f.retryAfter != nil { + secs := int(math.Ceil(f.retryAfter.Seconds())) + body.RetryAfterSeconds = &secs + } + + doc := errorDocument{Type: "githubauth.error", SchemaVersion: "1", Error: body} + if err := json.NewEncoder(stdout).Encode(doc); err != nil { + _, _ = fmt.Fprintf(stderr, "githubauth: %s\n", f.summary) + } + return f.code +} diff --git a/cmd/githubauth/errors_test.go b/cmd/githubauth/errors_test.go new file mode 100644 index 0000000..ae6ec5f --- /dev/null +++ b/cmd/githubauth/errors_test.go @@ -0,0 +1,225 @@ +package main + +import ( + "bytes" + "encoding/json" + "errors" + "net/http" + "strings" + "testing" + "time" + + "github.com/jferrl/go-githubauth" +) + +func TestClassify(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + err error + wantCode int + wantStatus int + wantRetryAfter time.Duration + wantSuggestion string + }{ + { + name: "misuse", + err: usagef("set --a or --b, not both"), + wantCode: exitUsage, + wantSuggestion: "--help", + }, + { + name: "an unusable key never reaches GitHub", + err: credentialError{errors.New("invalid key")}, + wantCode: exitCredentials, + wantSuggestion: "Check --key", + }, + { + name: "throttled", + err: &githubauth.RateLimitError{StatusCode: http.StatusTooManyRequests, RetryAfter: 45 * time.Second}, + wantCode: exitRateLimited, + wantStatus: http.StatusTooManyRequests, + wantRetryAfter: 45 * time.Second, + wantSuggestion: "Wait 45s", + }, + { + name: "the JWT was rejected", + err: &githubauth.APIError{StatusCode: http.StatusUnauthorized}, + wantCode: exitCredentials, + wantStatus: http.StatusUnauthorized, + wantSuggestion: "rejected the App JWT", + }, + { + name: "the App lacks a permission", + err: &githubauth.APIError{StatusCode: http.StatusForbidden}, + wantCode: exitCredentials, + wantStatus: http.StatusForbidden, + wantSuggestion: "lacks a permission", + }, + { + name: "the App is not installed there", + err: &githubauth.APIError{StatusCode: http.StatusNotFound}, + wantCode: exitNotFound, + wantStatus: http.StatusNotFound, + wantSuggestion: "--installation", + }, + { + name: "an unclassified status is a general failure", + err: &githubauth.APIError{StatusCode: http.StatusInternalServerError}, + wantCode: exitFailure, + wantStatus: http.StatusInternalServerError, + }, + { + name: "anything else", + err: errors.New("dial tcp: connection refused"), + wantCode: exitFailure, + wantSuggestion: "githubstatus.com", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + got := classify(tt.err) + if got.code != tt.wantCode { + t.Errorf("code = %d, want %d", got.code, tt.wantCode) + } + if got.statusCode != tt.wantStatus { + t.Errorf("statusCode = %d, want %d", got.statusCode, tt.wantStatus) + } + if tt.wantRetryAfter != 0 { + if got.retryAfter == nil { + t.Fatal("retryAfter = nil, want a duration") + } + if *got.retryAfter != tt.wantRetryAfter { + t.Errorf("retryAfter = %v, want %v", *got.retryAfter, tt.wantRetryAfter) + } + } + if tt.wantSuggestion == "" { + return + } + if !strings.Contains(strings.Join(got.suggestions, "\n"), tt.wantSuggestion) { + t.Errorf("suggestions = %q, want one containing %q", got.suggestions, tt.wantSuggestion) + } + }) + } +} + +// TestReportRoutesByAudience pins where a failure goes: one JSON document on +// stdout for a machine, prose on stderr for a person, with stdout left clean +// for $(...). +func TestReportRoutesByAudience(t *testing.T) { + t.Parallel() + + err := &githubauth.APIError{StatusCode: http.StatusNotFound, Message: `{"message":"Not Found"}`} + + tests := []struct { + name string + out output + wantStdout bool + }{ + {name: "a person reads stderr", out: output{}, wantStdout: false}, + {name: "--json speaks to a machine", out: output{json: true}, wantStdout: true}, + {name: "agent mode does too", out: output{agent: true}, wantStdout: true}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + var stdout, stderr bytes.Buffer + code := report(err, &stdout, &stderr, tt.out) + + if code != exitNotFound { + t.Errorf("report() = %d, want %d", code, exitNotFound) + } + if !tt.wantStdout { + if stdout.Len() != 0 { + t.Errorf("stdout = %q, want it empty", stdout.String()) + } + if !strings.Contains(stderr.String(), "githubauth: GitHub API returned status 404") { + t.Errorf("stderr = %q, want the prose rendering", stderr.String()) + } + return + } + + if stderr.Len() != 0 { + t.Errorf("stderr = %q, want it empty when the error went to stdout", stderr.String()) + } + + var doc errorDocument + if jsonErr := json.Unmarshal(stdout.Bytes(), &doc); jsonErr != nil { + t.Fatalf("decoding %q: %v", stdout.String(), jsonErr) + } + if doc.Type != "githubauth.error" || doc.SchemaVersion != "1" { + t.Errorf("envelope = %+v, want the githubauth.error discriminators", doc) + } + if doc.Error.ExitCode != exitNotFound { + t.Errorf("exit_code = %d, want %d", doc.Error.ExitCode, exitNotFound) + } + if doc.Error.StatusCode != http.StatusNotFound { + t.Errorf("status_code = %d, want 404", doc.Error.StatusCode) + } + if len(doc.Error.Suggestions) == 0 { + t.Error("suggestions is empty, want at least one") + } + }) + } +} + +// TestReportRetryAfterIsActionable checks the field that tells a caller how +// long to wait. +func TestReportRetryAfterIsActionable(t *testing.T) { + t.Parallel() + + err := &githubauth.RateLimitError{StatusCode: http.StatusForbidden, RetryAfter: 1500 * time.Millisecond} + + var stdout, stderr bytes.Buffer + if code := report(err, &stdout, &stderr, output{agent: true}); code != exitRateLimited { + t.Errorf("report() = %d, want %d", code, exitRateLimited) + } + + var doc errorDocument + if jsonErr := json.Unmarshal(stdout.Bytes(), &doc); jsonErr != nil { + t.Fatalf("decoding %q: %v", stdout.String(), jsonErr) + } + if doc.Error.RetryAfterSeconds == nil { + t.Fatal("retry_after_seconds is absent, want it present for a rate limit") + } + // Rounded up: sleeping 1s when GitHub asked for 1.5 gets throttled again. + if *doc.Error.RetryAfterSeconds != 2 { + t.Errorf("retry_after_seconds = %d, want 2", *doc.Error.RetryAfterSeconds) + } +} + +// TestReportPassesThroughChildStatus covers --exec: the child's code is its +// own answer and must not be reclassified. +func TestReportPassesThroughChildStatus(t *testing.T) { + t.Parallel() + + var stdout, stderr bytes.Buffer + if code := report(exitStatus(7), &stdout, &stderr, output{agent: true}); code != 7 { + t.Errorf("report() = %d, want 7", code) + } + if stdout.Len() != 0 || stderr.Len() != 0 { + t.Errorf("wrote stdout=%q stderr=%q, want both empty", stdout.String(), stderr.String()) + } +} + +// TestReportSuppressesTheDoubleReport keeps the flag package's own message from +// being printed twice when it has already written to stderr. +func TestReportSuppressesTheDoubleReport(t *testing.T) { + t.Parallel() + + var stdout, stderr bytes.Buffer + code := report(usageError{err: errors.New("flag provided but not defined: -nope"), reported: true}, &stdout, &stderr, output{}) + + if code != exitUsage { + t.Errorf("report() = %d, want %d", code, exitUsage) + } + if strings.Contains(stderr.String(), "not defined") { + t.Errorf("stderr = %q, want the already-reported message left out", stderr.String()) + } +} diff --git a/cmd/githubauth/exec.go b/cmd/githubauth/exec.go new file mode 100644 index 0000000..0d04d7a --- /dev/null +++ b/cmd/githubauth/exec.go @@ -0,0 +1,59 @@ +package main + +import ( + "errors" + "fmt" + "io" + "os" + "os/exec" + "strings" + + "golang.org/x/oauth2" +) + +// execTokenEnv is where --exec puts the credential. GITHUB_TOKEN is what gh, +// the Actions toolkit, and most GitHub tooling already read, so the wrapped +// command usually needs no change. +const execTokenEnv = "GITHUB_TOKEN" + +// runWithToken runs argv with the credential in its environment and writes +// nothing to stdout itself. The token never reaches a terminal, a log, or an +// agent's transcript. +// +// The child's exit status becomes githubauth's, so --exec is transparent to +// whatever inspects the result. +func runWithToken(argv []string, tok *oauth2.Token, stdin io.Reader, stdout, stderr io.Writer) error { + cmd := exec.Command(argv[0], argv[1:]...) + cmd.Env = append(environWithout(os.Environ(), execTokenEnv), execTokenEnv+"="+tok.AccessToken) + cmd.Stdin, cmd.Stdout, cmd.Stderr = stdin, stdout, stderr + + err := cmd.Run() + if err == nil { + return nil + } + + // An exit status is the child's answer, not a failure of ours, so it is + // passed through rather than classified. + var ee *exec.ExitError + if errors.As(err, &ee) { + if code := ee.ExitCode(); code >= 0 { + return exitStatus(code) + } + // Killed by a signal: ExitCode is -1 and err says which. + return fmt.Errorf("%s: %w", argv[0], err) + } + return fmt.Errorf("running %s: %w", argv[0], err) +} + +// environWithout drops key from env so the token cannot be shadowed by one the +// caller already exported. +func environWithout(env []string, key string) []string { + prefix := key + "=" + out := make([]string, 0, len(env)) + for _, kv := range env { + if !strings.HasPrefix(kv, prefix) { + out = append(out, kv) + } + } + return out +} diff --git a/cmd/githubauth/exec_test.go b/cmd/githubauth/exec_test.go new file mode 100644 index 0000000..64571ad --- /dev/null +++ b/cmd/githubauth/exec_test.go @@ -0,0 +1,110 @@ +package main + +import ( + "bytes" + "errors" + "fmt" + "os" + "strconv" + "strings" + "testing" + "time" + + "golang.org/x/oauth2" +) + +// TestExecHelper is a helper rather than a test. The --exec cases re-run this +// binary under this name, which gives them a child process that exists on +// every platform the tests do, unlike a shell. +func TestExecHelper(t *testing.T) { + if os.Getenv("GITHUBAUTH_EXEC_HELPER") == "" { + t.Skip("helper process for the --exec tests") + } + + fmt.Printf("saw=%s\n", os.Getenv(execTokenEnv)) + code, _ := strconv.Atoi(os.Getenv("GITHUBAUTH_EXEC_CODE")) + os.Exit(code) +} + +// helperArgv is the command the --exec tests run: this test binary, limited to +// the helper above. +func helperArgv() []string { + return []string{os.Args[0], "-test.run=^TestExecHelper$"} +} + +func TestRunWithToken(t *testing.T) { + tok := &oauth2.Token{AccessToken: "ghs_secret", Expiry: time.Now().Add(time.Hour)} + + t.Run("the credential reaches the child and nothing else prints it", func(t *testing.T) { + t.Setenv("GITHUBAUTH_EXEC_HELPER", "1") + t.Setenv("GITHUBAUTH_EXEC_CODE", "0") + + var stdout, stderr bytes.Buffer + if err := runWithToken(helperArgv(), tok, strings.NewReader(""), &stdout, &stderr); err != nil { + t.Fatalf("runWithToken() error = %v (stderr: %s)", err, stderr.String()) + } + if !strings.Contains(stdout.String(), "saw=ghs_secret") { + t.Errorf("child stdout = %q, want it to report the token in $%s", stdout.String(), execTokenEnv) + } + }) + + t.Run("a token already in the environment is replaced", func(t *testing.T) { + t.Setenv("GITHUBAUTH_EXEC_HELPER", "1") + t.Setenv("GITHUBAUTH_EXEC_CODE", "0") + t.Setenv(execTokenEnv, "ghs_stale") + + var stdout, stderr bytes.Buffer + if err := runWithToken(helperArgv(), tok, strings.NewReader(""), &stdout, &stderr); err != nil { + t.Fatalf("runWithToken() error = %v", err) + } + if strings.Contains(stdout.String(), "ghs_stale") { + t.Errorf("child stdout = %q, want the minted token to win", stdout.String()) + } + }) + + t.Run("the child's exit code is passed through", func(t *testing.T) { + t.Setenv("GITHUBAUTH_EXEC_HELPER", "1") + t.Setenv("GITHUBAUTH_EXEC_CODE", "9") + + var stdout, stderr bytes.Buffer + err := runWithToken(helperArgv(), tok, strings.NewReader(""), &stdout, &stderr) + + var status exitStatus + if !errors.As(err, &status) { + t.Fatalf("runWithToken() error = %v, want an exitStatus", err) + } + if int(status) != 9 { + t.Errorf("exitStatus = %d, want 9", int(status)) + } + }) + + t.Run("a command that cannot be started is our failure, not the child's", func(t *testing.T) { + var stdout, stderr bytes.Buffer + err := runWithToken([]string{"githubauth-no-such-command"}, tok, strings.NewReader(""), &stdout, &stderr) + + if err == nil { + t.Fatal("runWithToken() = nil, want an error") + } + var status exitStatus + if errors.As(err, &status) { + t.Errorf("error is an exitStatus (%d), want a startup failure", int(status)) + } + }) +} + +func TestEnvironWithout(t *testing.T) { + t.Parallel() + + env := []string{"PATH=/bin", "GITHUB_TOKEN=stale", "GITHUB_TOKEN_EXTRA=kept", "HOME=/home/x"} + want := []string{"PATH=/bin", "GITHUB_TOKEN_EXTRA=kept", "HOME=/home/x"} + + got := environWithout(env, "GITHUB_TOKEN") + if len(got) != len(want) { + t.Fatalf("environWithout() = %q, want %q", got, want) + } + for i := range want { + if got[i] != want[i] { + t.Errorf("environWithout()[%d] = %q, want %q", i, got[i], want[i]) + } + } +} diff --git a/cmd/githubauth/main.go b/cmd/githubauth/main.go index fbb1cc1..5ce77b1 100644 --- a/cmd/githubauth/main.go +++ b/cmd/githubauth/main.go @@ -7,7 +7,6 @@ package main import ( "encoding/json" - "errors" "flag" "fmt" "io" @@ -50,6 +49,19 @@ EXAMPLES vault kv get -field=pem secret/github-app | githubauth token --key - --installation 12345 + # Run a command with the token in $GITHUB_TOKEN, printing it nowhere. + githubauth token --installation 12345 --exec -- gh pr list + +EXIT CODES + 0 a credential was printed + 1 something else failed, retrying may help + 2 the invocation is wrong, fix the flags + 3 GitHub refused the key or the App's permissions + 4 rate limited, wait and repeat + 5 the App is not installed where it was asked to be + + Under --exec the exit code is the one the command exited with. + Run "githubauth --help" for the flags of a command. ` @@ -60,42 +72,50 @@ func main() { func run(args []string, stdin io.Reader, stdout, stderr io.Writer) int { if len(args) == 0 { _, _ = fmt.Fprint(stderr, rootHelp) - return 2 + return exitUsage } + // Agent mode is read before the flags, so a flag parse error is still + // reported as JSON when an agent is running. + out := output{agent: agentModeEnv()} + var err error switch args[0] { case "token": - err = tokenCmd(args[1:], stdin, stdout, stderr) + err = tokenCmd(args[1:], stdin, stdout, stderr, &out) case "jwt": - err = jwtCmd(args[1:], stdin, stdout, stderr) + err = jwtCmd(args[1:], stdin, stdout, stderr, &out) case "version", "--version", "-version", "-v": _, _ = fmt.Fprintln(stdout, version()) - return 0 + return exitOK case "help", "--help", "-help", "-h": _, _ = fmt.Fprint(stdout, rootHelp) - return 0 + return exitOK default: - _, _ = fmt.Fprintf(stderr, "githubauth: unknown command %q\n", args[0]) - if near := nearest(args[0]); near != "" { - _, _ = fmt.Fprintf(stderr, "\nDid you mean \"githubauth %s\"?\n", near) - } - _, _ = fmt.Fprintf(stderr, "\nRun \"githubauth help\" for usage.\n") - return 2 + err = unknownCommand(args[0]) } if err != nil { - var ue usageError - if errors.As(err, &ue) { - if !ue.reported { - _, _ = fmt.Fprintf(stderr, "githubauth: %v\n", err) - } - return 2 - } - _, _ = fmt.Fprintf(stderr, "githubauth: %v\n", err) - return 1 + return report(err, stdout, stderr, out) + } + return exitOK +} + +// output is where the result and any failure go. Both commands fill it in +// while parsing flags, so run can report an error the way the command would +// have reported a success. +type output struct { + json bool + agent bool +} + +// unknownCommand keeps the typo hint on one line. Three stderr paragraphs read +// well to a person and badly to everything else. +func unknownCommand(name string) error { + if near := nearest(name); near != "" { + return usagef("unknown command %q; did you mean \"githubauth %s\"?", name, near) } - return 0 + return usagef("unknown command %q; run \"githubauth help\" for usage", name) } // usageError is CLI misuse — an unknown flag, or a missing or conflicting @@ -120,14 +140,49 @@ type appFlags struct { clientID string appID int64 key string - asJSON bool + exec bool + out *output } -func (a *appFlags) bind(fs *flag.FlagSet) { +func (a *appFlags) bind(fs *flag.FlagSet, out *output) { + a.out = out + 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(&a.asJSON, "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") + fs.BoolVar(&a.exec, "exec", false, "run the command after -- with the credential in $GITHUB_TOKEN, printing it nowhere") + fs.BoolVar(&out.agent, "agent", out.agent, "report failures as one JSON document on stdout (on when a coding agent is detected)") +} + +// checkExec validates the trailing arguments against --exec. Arguments without +// --exec are usually a forgotten flag, and --exec with --json would format a +// token that --exec exists to keep unprinted. +func (a *appFlags) checkExec(argv []string) error { + switch { + case a.exec && len(argv) == 0: + return usagef("--exec needs a command: githubauth token --exec -- gh pr list") + case a.exec && a.out.json: + return usagef("--exec prints no credential, so --json has nothing to format") + case !a.exec && len(argv) > 0: + return usagef("unexpected argument %q: pass --exec to run a command with the credential", argv[0]) + } + return nil +} + +// emit delivers the credential: into a child process under --exec, otherwise +// to stdout. +func (a *appFlags) emit(tok *oauth2.Token, argv []string, stdin io.Reader, stdout, stderr io.Writer) error { + if a.exec { + return runWithToken(argv, tok, stdin, stdout, stderr) + } + if err := write(stdout, tok, a.out.json); err != nil { + return err + } + if a.out.agent { + _, _ = fmt.Fprint(stderr, agentTokenHint) + } + return nil } // source builds the App JWT token source the other commands start from. @@ -147,20 +202,32 @@ func (a *appFlags) source(stdin io.Reader, opts ...githubauth.ApplicationTokenOp } if a.clientID != "" { - return githubauth.NewApplicationTokenSource(a.clientID, pem, opts...) + return asCredential(githubauth.NewApplicationTokenSource(a.clientID, pem, opts...)) + } + return asCredential(githubauth.NewApplicationTokenSource(a.appID, pem, opts...)) +} + +// asCredential labels a constructor failure. The key is parsed before any +// request, so a failure here is the credential, not GitHub's answer about it. +func asCredential(src oauth2.TokenSource, err error) (oauth2.TokenSource, error) { + if err != nil { + return nil, credentialError{err} } - return githubauth.NewApplicationTokenSource(a.appID, pem, opts...) + return src, nil } -func tokenCmd(args []string, stdin io.Reader, stdout, stderr io.Writer) error { +func tokenCmd(args []string, stdin io.Reader, stdout, stderr io.Writer, out *output) error { fs := newFlagSet("token", "Print an installation access token.", stderr, ` # Scope the token to two repositories. githubauth token --installation 12345 --repos api,web + # Keep the token out of the terminal: it only ever reaches the child. + githubauth token --installation 12345 --exec -- gh pr list + # GitHub Enterprise Server. githubauth token --installation 12345 --enterprise-url https://github.example.com`) var app appFlags - app.bind(fs) + 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") @@ -170,6 +237,9 @@ func tokenCmd(args []string, stdin io.Reader, stdout, stderr io.Writer) error { if err := fs.Parse(args); err != nil { return usageError{err: err, reported: true} } + if err := app.checkExec(fs.Args()); err != nil { + return err + } if *installation <= 0 { return usagef("missing installation ID: set --installation") } @@ -199,21 +269,27 @@ func tokenCmd(args []string, stdin io.Reader, stdout, stderr io.Writer) error { if err != nil { return err } - return write(stdout, tok, app.asJSON) + return app.emit(tok, fs.Args(), stdin, stdout, stderr) } -func jwtCmd(args []string, stdin io.Reader, stdout, stderr io.Writer) error { +func jwtCmd(args []string, stdin io.Reader, stdout, stderr io.Writer, out *output) error { fs := newFlagSet("jwt", "Print the App JWT, for the few endpoints that take one.", stderr, ` # A JWT valid for five minutes instead of ten. - githubauth jwt --client-id Iv1.abc --key app.pem --expiry 5m`) + githubauth jwt --client-id Iv1.abc --key app.pem --expiry 5m + + # List the App's installations without the JWT reaching the terminal. + githubauth jwt --exec -- sh -c 'curl -sH "Authorization: Bearer $GITHUB_TOKEN" https://api.github.com/app/installations'`) var app appFlags - app.bind(fs) + app.bind(fs, out) expiry := fs.Duration("expiry", 0, "JWT lifetime, over 90s and up to 10m (default 10m)") if err := fs.Parse(args); err != nil { return usageError{err: err, reported: true} } + if err := app.checkExec(fs.Args()); err != nil { + return err + } var opts []githubauth.ApplicationTokenOpt if *expiry > 0 { @@ -229,7 +305,7 @@ func jwtCmd(args []string, stdin io.Reader, stdout, stderr io.Writer) error { if err != nil { return err } - return write(stdout, tok, app.asJSON) + return app.emit(tok, fs.Args(), stdin, stdout, stderr) } // newFlagSet gives every command the same help layout: what it does, how it is @@ -284,7 +360,9 @@ func readKey(v string, stdin io.Reader) ([]byte, error) { } pem, err := os.ReadFile(v) if err != nil { - return nil, fmt.Errorf("reading private key: %w", err) + // The flag was well-formed but points nowhere, which is the caller's + // invocation to fix rather than anything GitHub was asked about. + return nil, usagef("reading private key: %v", err) } return pem, nil } diff --git a/cmd/githubauth/main_test.go b/cmd/githubauth/main_test.go index f5ac7ab..18992ee 100644 --- a/cmd/githubauth/main_test.go +++ b/cmd/githubauth/main_test.go @@ -37,6 +37,10 @@ func clearEnv(t *testing.T) { for _, key := range appEnv { t.Setenv(key, "") } + + // 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") } func testKeyPEM(t *testing.T) []byte { @@ -197,14 +201,14 @@ func TestRun(t *testing.T) { args: func(string) []string { return []string{"toekn"} }, wantCode: 2, wantStdout: isEmpty, - wantStderr: `Did you mean "githubauth token"?`, + wantStderr: `did you mean "githubauth token"?`, }, { name: "unrecognisable command offers help", args: func(string) []string { return []string{"xyzzy"} }, wantCode: 2, wantStdout: isEmpty, - wantStderr: `Run "githubauth help" for usage.`, + wantStderr: `run "githubauth help" for usage`, }, { name: "unknown flag is misuse", @@ -289,9 +293,9 @@ func TestRun(t *testing.T) { return []string{"token", "--client-id", "Iv1.abc", "--key", keyPath, "--installation", "99", "--base-url", url} }, setupServer: statusHandler(http.StatusUnauthorized), - wantCode: 1, + wantCode: exitCredentials, wantStdout: isEmpty, - wantStderr: "githubauth:", + wantStderr: "GitHub rejected the App JWT", }, { name: "token needs an installation ID", @@ -353,18 +357,102 @@ func TestRun(t *testing.T) { wantStderr: "missing private key", }, { - name: "an unreadable key is a runtime failure, not misuse", + name: "a key path that resolves to nothing is misuse", args: func(string) []string { return []string{"jwt", "--client-id", "Iv1.abc", "--key", "/no/such/key.pem"} }, - wantCode: 1, + wantCode: exitUsage, wantStdout: isEmpty, wantStderr: "reading private key", }, { - name: "a malformed PEM is a runtime failure", + name: "a malformed PEM is a credential failure", args: func(string) []string { return []string{"jwt", "--client-id", "Iv1.abc", "--key", badKeyPath} }, - wantCode: 1, + wantCode: exitCredentials, + wantStdout: isEmpty, + wantStderr: "Check --key", + }, + { + name: "exec runs a command instead of printing the token", + args: func(url string) []string { + return []string{ + "token", "--client-id", "Iv1.abc", "--key", keyPath, "--installation", "99", + "--base-url", url, "--exec", "--", os.Args[0], "-test.run=^TestExecHelper$", + } + }, + env: map[string]string{"GITHUBAUTH_EXEC_HELPER": "1", "GITHUBAUTH_EXEC_CODE": "0"}, + setupServer: tokenHandler("ghs_neverprinted"), + wantCode: 0, + wantStdout: contains("saw=ghs_neverprinted"), + }, + { + name: "exec exits as the command it ran did", + args: func(url string) []string { + return []string{ + "token", "--client-id", "Iv1.abc", "--key", keyPath, "--installation", "99", + "--base-url", url, "--exec", "--", os.Args[0], "-test.run=^TestExecHelper$", + } + }, + env: map[string]string{"GITHUBAUTH_EXEC_HELPER": "1", "GITHUBAUTH_EXEC_CODE": "12"}, + setupServer: tokenHandler("ghs_failing"), + wantCode: 12, + }, + { + name: "exec without a command is misuse", + args: func(string) []string { + return []string{"token", "--client-id", "Iv1.abc", "--key", keyPath, "--installation", "9", "--exec"} + }, + wantCode: exitUsage, + wantStdout: isEmpty, + wantStderr: "--exec needs a command", + }, + { + name: "a stray argument suggests the flag that would have used it", + args: func(string) []string { + return []string{"token", "--client-id", "Iv1.abc", "--key", keyPath, "--installation", "9", "gh", "pr", "list"} + }, + wantCode: exitUsage, wantStdout: isEmpty, - wantStderr: "githubauth:", + wantStderr: "pass --exec", + }, + { + name: "exec and json contradict each other", + args: func(string) []string { + return []string{"token", "--client-id", "Iv1.abc", "--key", keyPath, "--installation", "9", "--json", "--exec", "--", "true"} + }, + // --json asked for JSON, so the refusal arrives as JSON too. + wantCode: exitUsage, + wantStdout: contains("--json has nothing to format"), + }, + { + name: "agent mode says the credential is now in the transcript", + args: func(url string) []string { + return []string{"token", "--client-id", "Iv1.abc", "--key", keyPath, "--installation", "99", "--base-url", url} + }, + env: map[string]string{"GITHUBAUTH_AGENT_MODE": "1"}, + setupServer: tokenHandler("ghs_inthetranscript"), + wantCode: 0, + wantStdout: isExactly("ghs_inthetranscript"), + wantStderr: "--exec passes it to a command", + }, + { + name: "agent mode reports a failure as one JSON document on stdout", + args: func(url string) []string { + return []string{"token", "--client-id", "Iv1.abc", "--key", keyPath, "--installation", "99", "--base-url", url} + }, + env: map[string]string{"GITHUBAUTH_AGENT_MODE": "1"}, + setupServer: statusHandler(http.StatusNotFound), + wantCode: exitNotFound, + wantStdout: contains(`"type":"githubauth.error"`), + }, + { + name: "--agent=false opts out inside an agent", + args: func(url string) []string { + return []string{"token", "--client-id", "Iv1.abc", "--key", keyPath, "--installation", "99", "--base-url", url, "--agent=false"} + }, + env: map[string]string{"GITHUBAUTH_AGENT_MODE": "1"}, + setupServer: statusHandler(http.StatusNotFound), + wantCode: exitNotFound, + wantStdout: isEmpty, + wantStderr: "githubauth: GitHub API returned status 404", }, } diff --git a/doc.go b/doc.go index c5f3f66..55606e4 100644 --- a/doc.go +++ b/doc.go @@ -54,7 +54,14 @@ // // It unwraps to ErrRateLimited, so errors.Is(err, ErrRateLimited) keeps working. // A 403 permission failure is not a rate limit and matches neither, even though -// GitHub attaches rate-limit headers to it. +// GitHub attaches rate-limit headers to it. Every rejection other than +// throttling arrives as an [APIError], carrying the status code so a caller can +// tell a refused credential from an installation that does not exist: +// +// var apiErr *githubauth.APIError +// if errors.As(err, &apiErr) && apiErr.StatusCode == http.StatusNotFound { +// // the App is not installed on that installation ID +// } // // # Signing with external key stores // diff --git a/github.go b/github.go index 2a2b833..2c9c158 100644 --- a/github.go +++ b/github.go @@ -80,6 +80,29 @@ func (e *RateLimitError) Unwrap() error { return ErrRateLimited } // to it. Callers can branch with errors.Is. var ErrRateLimited = errors.New("github API rate limited") +// APIError is the concrete error returned when GitHub rejects a request for a +// reason other than throttling. It carries the status code so a caller can tell +// a credential that GitHub refused (401) from an installation that does not +// exist (404) without matching on the message: +// +// var apiErr *githubauth.APIError +// if errors.As(err, &apiErr) && apiErr.StatusCode == http.StatusNotFound { +// // the App is not installed on that installation ID +// } +// +// A throttled request returns RateLimitError instead. +type APIError struct { + // StatusCode is the HTTP status GitHub returned. + StatusCode int + + // Message is the response body, truncated to maxErrorBodyBytes. + Message string +} + +func (e *APIError) Error() string { + return fmt.Sprintf("GitHub API returned status %d: %s", e.StatusCode, e.Message) +} + // InstallationTokenOptions specifies options for creating an installation token. type InstallationTokenOptions struct { // Repositories is a list of repository names that the token should have access to. @@ -285,7 +308,7 @@ func (c *githubClient) doCreateInstallationToken(ctx context.Context, reqURL str } } - return nil, fmt.Errorf("GitHub API returned status %d: %s", resp.StatusCode, string(bodyResp)) + return nil, &APIError{StatusCode: resp.StatusCode, Message: string(bodyResp)} } // throttleDelay inspects a non-2xx response and reports the retry hint GitHub diff --git a/github_test.go b/github_test.go index c775c81..d2dd3f4 100644 --- a/github_test.go +++ b/github_test.go @@ -1335,6 +1335,28 @@ func Test_RateLimitError_NotReturnedForTerminalFailures(t *testing.T) { if errors.Is(err, ErrRateLimited) { t.Errorf("errors.Is(err, ErrRateLimited) = true for a terminal %d", tt.status) } + + var apiErr *APIError + if !errors.As(err, &apiErr) { + t.Fatalf("errors.As(*APIError) = false for a terminal %d: %v", tt.status, err) + } + if apiErr.StatusCode != tt.status { + t.Errorf("APIError.StatusCode = %d, want %d", apiErr.StatusCode, tt.status) + } + if apiErr.Message != tt.body { + t.Errorf("APIError.Message = %q, want %q", apiErr.Message, tt.body) + } }) } } + +// Test_APIError_MessageFormat pins the rendered message, which predates the +// type: it was a fmt.Errorf string callers already log and match on. +func Test_APIError_MessageFormat(t *testing.T) { + err := &APIError{StatusCode: http.StatusNotFound, Message: `{"message":"Not Found"}`} + + want := `GitHub API returned status 404: {"message":"Not Found"}` + if got := err.Error(); got != want { + t.Errorf("Error() =\n %q\nwant\n %q", got, want) + } +} diff --git a/llms.txt b/llms.txt index 1c32da5..f458634 100644 --- a/llms.txt +++ b/llms.txt @@ -2,7 +2,7 @@ > Go library for GitHub authentication — GitHub App JWTs, GitHub App installation tokens, and personal access tokens — exposed as standard `oauth2.TokenSource` implementations. Depends only on `github.com/golang-jwt/jwt` and `golang.org/x/oauth2`; no GitHub SDK required. Includes webhook signature verification. -Module path: `github.com/jferrl/go-githubauth` — install with `go get github.com/jferrl/go-githubauth`. Requires Go 1.25+. +Module path: `github.com/jferrl/go-githubauth` — install with `go get github.com/jferrl/go-githubauth`. Requires Go 1.26+. When to choose this library: @@ -17,6 +17,7 @@ Core API (package `githubauth`): - `NewApplicationTokenSource(id, privateKeyPEM, opts...) (oauth2.TokenSource, error)` — App JWT source; `id` is a string Client ID or int64 App ID. Options: `WithApplicationTokenExpiration(d)` (>90s, max 10m; outside that range falls back to 10m), `WithExpirySkew(d)`. - `NewApplicationTokenSourceFromSigner(id, signer crypto.Signer, opts...) (oauth2.TokenSource, error)` — App JWT source backed by an external RSA signer (KMS/HSM/Vault/ssh-agent). +- `APIError{StatusCode, Message}` — concrete error for a request GitHub rejected for any reason other than throttling. Extract with `errors.As` to tell a refused credential (401/403) from an installation that does not exist (404) without matching on the message. - `RateLimitError{StatusCode, RetryAfter, Message}` — concrete error for a throttled request; extract with `errors.As`. Unwraps to `ErrRateLimited`, so `errors.Is` keeps working. A 403 permission failure matches neither. - `NewInstallationTokenSource(installationID int64, appSource oauth2.TokenSource, opts...) oauth2.TokenSource` — exchanges the App JWT for an installation token. Options: `WithEnterpriseURL(url)` (GHES, appends /api/v3/), `WithBaseURL(url)` (verbatim; GHEC data residency or httptest), `WithHTTPClient(c)`, `WithRetryOnThrottle(bool)`, `WithInstallationExpirySkew(d)`, `WithInstallationTokenOptions(o)`, `WithContext(ctx)`. - `NewPersonalAccessTokenSource(token string) oauth2.TokenSource` — classic (`ghp_...`) or fine-grained (`github_pat_...`) PATs. @@ -53,11 +54,26 @@ installationSource := githubauth.NewInstallationTokenSource(installationID, appS httpClient := oauth2.NewClient(context.Background(), installationSource) ``` +Command line (`githubauth`, from `cmd/githubauth`): + +The same credentials without writing Go. Install with `go install github.com/jferrl/go-githubauth/cmd/githubauth@latest`, `brew install jferrl/tap/githubauth`, or a release binary. + +- `githubauth token [flags]` — print an installation access token. Flags: `--installation`, `--repos`, `--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`, `--exec`, `--agent`. +- 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`. +- `--exec` runs a command with the credential in its `$GITHUB_TOKEN` and prints it nowhere, which keeps it out of transcripts, logs and scrollback: `githubauth token --installation 12345 --exec -- gh pr list`. The exit code is then the command's own. +- Exit codes: 0 printed a credential, 1 general failure (retrying may help), 2 bad invocation, 3 GitHub refused the key or the App's permissions, 4 rate limited (wait and repeat), 5 the App is not installed where it was asked to be. Branch on these rather than on message text. +- Under `--json`, or when a coding agent is detected, a failure is one JSON document on stdout and stderr stays empty: `{"type":"githubauth.error","schema_version":"1","error":{"summary":...,"exit_code":5,"status_code":404,"retry_after_seconds":...,"suggestions":[...]}}`. Agent detection reads `CLAUDECODE`, `CLAUDE_CODE`, `CURSOR_AGENT`, `GITHUB_COPILOT`, `AMAZON_Q`, `OPENCODE`, `PI_CODING_AGENT`. `GITHUBAUTH_AGENT_MODE` or `--agent`/`--agent=false` overrides it. Set `GITHUBAUTH_AGENT_MODE=0` in a test suite that shells out to the CLI. Agent mode never changes the success output — a bare token stays a bare token. + ## Docs - [README](https://github.com/jferrl/go-githubauth/blob/main/README.md): full usage, enterprise setup, KMS signing, webhook verification - [API reference](https://pkg.go.dev/github.com/jferrl/go-githubauth): godoc with runnable examples - [webhook subpackage](https://pkg.go.dev/github.com/jferrl/go-githubauth/webhook): webhook verification reference +- [AGENTS.md](https://github.com/jferrl/go-githubauth/blob/main/AGENTS.md): using the CLI and library from a coding agent, and the repo's conventions ## Related