Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
123 changes: 123 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
@@ -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`.
35 changes: 35 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand Down
51 changes: 51 additions & 0 deletions cmd/githubauth/agent.go
Original file line number Diff line number Diff line change
@@ -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
}
92 changes: 92 additions & 0 deletions cmd/githubauth/agent_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
})
}
}
Loading
Loading