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