diff --git a/AGENTS.md b/AGENTS.md index a49f49d..d2d88d3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -46,7 +46,7 @@ githubauth help Flags shared by both commands: `--client-id` (or `--app-id` for a legacy App), `--key`, `--json`, `--exec`, `--agent`. `token` adds `--installation`, `--repos`, -`--permissions`, `--keep`, `--base-url`, `--enterprise-url`. `jwt` adds `--expiry`. +`--owner`, `--permissions`, `--keep`, `--base-url`, `--enterprise-url`. `jwt` adds `--expiry`. Every flag falls back to an environment variable — `GITHUB_APP_CLIENT_ID`, `GITHUB_APP_ID`, `GITHUB_APP_PRIVATE_KEY`, `GITHUB_APP_INSTALLATION_ID` — so a @@ -80,7 +80,7 @@ Under `--json`, or when a coding agent is detected, a failure is one JSON document on **stdout** and stderr stays empty: ```json -{"type":"githubauth.error","schema_version":"1","error":{"summary":"GitHub API returned status 404: {\"message\":\"Not Found\"}","exit_code":5,"status_code":404,"suggestions":["Check --installation: the App may not be installed on that account."]}} +{"type":"githubauth.error","schema_version":"1","error":{"summary":"GitHub API returned status 404: {\"message\":\"Not Found\"}","exit_code":5,"status_code":404,"suggestions":["Check --installation or --owner: the App may not be installed on that account."]}} ``` Check `type` and `schema_version` before reading the rest. @@ -107,7 +107,9 @@ httpClient := oauth2.NewClient(context.Background(), installSrc) is inferred. Both sources cache and refresh 30s before expiry. `RevokeInstallationToken(ctx, token)` ends an installation token before its hour -is up. +is up. `FindInstallationID(ctx, appSrc, owner)` finds the installation on a user +or organization when only the account is known. `InstallationPermissions.Other` +requests a permission GitHub added after the struct was last updated. Errors worth branching on: `*githubauth.RateLimitError` (carries `RetryAfter`, unwraps to `ErrRateLimited`) and `*githubauth.APIError` (carries `StatusCode`). @@ -129,7 +131,8 @@ Full API surface: [`llms.txt`](llms.txt). Runnable examples: pkg.go.dev. table-driven. Follow the shape already in the file you are editing. - **Scope**: this library authenticates and stops there. API coverage belongs to [go-github](https://github.com/google/go-github), which it pairs with — do not - add installation, repository, or organization helpers here. + add installation, repository, or organization helpers here, beyond what minting + or ending a token needs (finding the installation ID, revoking the token). - Exported symbols keep a doc comment opening with the symbol name. Comments say why, in a sentence or two. - The exit codes and the `githubauth.error` envelope are a public contract. diff --git a/README.md b/README.md index 9ce854c..7315dc3 100644 --- a/README.md +++ b/README.md @@ -107,6 +107,12 @@ less than the App was granted: githubauth token --installation 12345 --repos api,web --permissions contents:read ``` +If you know the account but not the installation ID, `--owner` looks it up: + +```bash +githubauth token --owner acme --repos api +``` + Run `githubauth help` for the rest. ### Keeping the token out of your logs diff --git a/auth.go b/auth.go index 4958b17..5df5394 100644 --- a/auth.go +++ b/auth.go @@ -8,9 +8,11 @@ import ( "crypto/sha256" "encoding/base64" "errors" + "fmt" "net/http" "reflect" "strconv" + "strings" "sync" "time" @@ -479,6 +481,24 @@ func RevokeInstallationToken(ctx context.Context, token string, opts ...Installa return i.client.revokeInstallationToken(ctx) } +// FindInstallationID returns the ID of the App's installation on owner, a user +// or organization login, for a caller who knows the account but not the ID. src +// is the App's JWT source. WithBaseURL, WithEnterpriseURL and WithHTTPClient +// apply as they do to NewInstallationTokenSource. The other options are ignored. +// +// See https://docs.github.com/en/rest/apps/apps?apiVersion=2022-11-28#get-a-user-installation-for-the-authenticated-app +func FindInstallationID(ctx context.Context, src oauth2.TokenSource, owner string, opts ...InstallationTokenSourceOpt) (int64, error) { + // A slash or dot segment would move the request to another endpoint. + if owner == "" || owner == "." || owner == ".." || strings.Contains(owner, "/") { + return 0, fmt.Errorf("invalid owner %q: want a user or organization login", owner) + } + i := newInstallationTokenSource(0, src, opts) + if i.configErr != nil { + return 0, i.configErr + } + return i.client.findInstallationID(ctx, owner) +} + // newInstallationTokenSource applies opts to a source whose requests src // authenticates. func newInstallationTokenSource(id int64, src oauth2.TokenSource, opts []InstallationTokenSourceOpt) *installationTokenSource { diff --git a/cmd/githubauth/errors.go b/cmd/githubauth/errors.go index 4fe1c84..98f96f7 100644 --- a/cmd/githubauth/errors.go +++ b/cmd/githubauth/errors.go @@ -101,7 +101,7 @@ func classify(err error) failure { case http.StatusBadRequest, http.StatusUnprocessableEntity: f.code = exitUsage f.suggestions = []string{ - "GitHub rejected the request itself: check --repos, every name must be a repository the installation can access.", + "GitHub rejected the request itself: check --repos, every name must be a repository the installation can access, and --permissions, every one must be granted to the App.", } case http.StatusUnauthorized: f.code = exitCredentials @@ -117,7 +117,7 @@ func classify(err error) failure { case http.StatusNotFound: f.code = exitNotFound f.suggestions = []string{ - "Check --installation: the App may not be installed on that account.", + "Check --installation or --owner: the App may not be installed on that account.", `List the App's installations: githubauth jwt --exec -- sh -c 'curl -sH "Authorization: Bearer $GITHUB_TOKEN" https://api.github.com/app/installations'`, wrongHostHint, } diff --git a/cmd/githubauth/exec.go b/cmd/githubauth/exec.go index a1072a9..21f23e6 100644 --- a/cmd/githubauth/exec.go +++ b/cmd/githubauth/exec.go @@ -108,22 +108,40 @@ func runWithToken(argv []string, tok *oauth2.Token, host string, stdin io.Reader // for the library's full minute. const revokeTimeout = 10 * time.Second +// holdSignals keeps SIGINT and SIGTERM from killing githubauth until the +// returned func runs. Held from before the command starts until the token is +// revoked, a signal in between cannot leave the token valid. revokeTimeout +// bounds how long they stay held once the command has exited. +func holdSignals() (release func()) { + held := make(chan os.Signal, 1) + signal.Notify(held, os.Interrupt, syscall.SIGTERM) + return func() { signal.Stop(held) } +} + // revoke ends the token once the command it was minted for has exited, so a -// copy left in a child's environment or output stops working. A failure only -// warns: the exit code stays the command's. -func revoke(tok *oauth2.Token, opts []githubauth.InstallationTokenSourceOpt, stderr io.Writer) { +// copy left in a child's environment or output stops working. err is the +// command's result, returned with a revocation failure added only when +// githubauth itself failed: one failure report, JSON under --agent, then +// carries both. Otherwise a failure only warns, and the exit code stays the +// command's. +func revoke(err error, tok *oauth2.Token, opts []githubauth.InstallationTokenSourceOpt, stderr io.Writer) error { ctx, cancel := context.WithTimeout(context.Background(), revokeTimeout) defer cancel() - err := githubauth.RevokeInstallationToken(ctx, tok.AccessToken, opts...) + revokeErr := githubauth.RevokeInstallationToken(ctx, tok.AccessToken, opts...) // A 401 means the token is already dead: past its hour, or revoked by the // command itself. var apiErr *githubauth.APIError - if errors.As(err, &apiErr) && apiErr.StatusCode == http.StatusUnauthorized { - return + if revokeErr == nil || errors.As(revokeErr, &apiErr) && apiErr.StatusCode == http.StatusUnauthorized { + return err } - if err != nil { - _, _ = fmt.Fprintf(stderr, "githubauth: the token was not revoked and stays valid until %s: %v\n", tok.Expiry.Format(time.RFC3339), err) + + revokeErr = fmt.Errorf("the token was not revoked and stays valid until %s: %w", tok.Expiry.Format(time.RFC3339), revokeErr) + var status exitStatus + if err != nil && !errors.As(err, &status) { + return errors.Join(err, revokeErr) } + _, _ = fmt.Fprintf(stderr, "githubauth: %v\n", revokeErr) + return err } // ghHost names the instance behind an API URL the way gh does. github.com and diff --git a/cmd/githubauth/exec_test.go b/cmd/githubauth/exec_test.go index d52e9f8..1241480 100644 --- a/cmd/githubauth/exec_test.go +++ b/cmd/githubauth/exec_test.go @@ -4,6 +4,7 @@ import ( "bytes" "errors" "fmt" + "net/http" "os" "runtime" "strconv" @@ -26,6 +27,15 @@ func TestExecHelper(t *testing.T) { for _, key := range append(execTokenEnv, append(execEnterpriseEnv, "GH_HOST")...) { fmt.Printf("%s=%s\n", key, os.Getenv(key)) } + // Use the token the way a real command would, so a test can tell whether it + // was still valid while the command ran. + if url := os.Getenv("GITHUBAUTH_EXEC_CALL"); url != "" { + req, _ := http.NewRequest(http.MethodGet, url, nil) + req.Header.Set("Authorization", "Bearer "+os.Getenv("GH_TOKEN")) + if resp, err := http.DefaultClient.Do(req); err == nil { + _ = resp.Body.Close() + } + } if os.Getenv("GITHUBAUTH_EXEC_SELF_TERM") != "" { p, _ := os.FindProcess(os.Getpid()) _ = p.Signal(syscall.SIGTERM) diff --git a/cmd/githubauth/main.go b/cmd/githubauth/main.go index 4e7b3ca..3da53ac 100644 --- a/cmd/githubauth/main.go +++ b/cmd/githubauth/main.go @@ -7,6 +7,7 @@ package main import ( "bytes" + "context" "encoding/json" "errors" "flag" @@ -233,7 +234,10 @@ func asCredential(src oauth2.TokenSource, err error) (oauth2.TokenSource, error) } func tokenCmd(args []string, stdin io.Reader, stdout, stderr io.Writer, out *output) error { - fs := newFlagSet("token", "Print an installation access token.", ` # Scope the token to two repositories, with read access to their contents. + fs := newFlagSet("token", "Print an installation access token.", ` # Find the installation from the account it is on. + githubauth token --owner acme + + # Scope the token to two repositories, with read access to their contents. githubauth token --installation 12345 --repos api,web --permissions contents:read # Keep the token out of the terminal: it only ever reaches the child, and is @@ -251,6 +255,7 @@ func tokenCmd(args []string, stdin io.Reader, stdout, stderr io.Writer, out *out installation := fs.Int64("installation", envInt64("GITHUB_APP_INSTALLATION_ID"), "installation ID to mint the token for ($GITHUB_APP_INSTALLATION_ID)") repos := fs.String("repos", "", "comma-separated repository names to scope the token to") + owner := fs.String("owner", "", "user or organization the App is installed on, to look up the installation instead of passing --installation") permissions := fs.String("permissions", "", "comma-separated name:level pairs to scope the token to, e.g. contents:read,pull_requests:write") keep := fs.Bool("keep", false, "under --exec, leave the token valid when the command exits instead of revoking it") baseURL := fs.String("base-url", "", "API base URL, used verbatim (Enterprise Cloud with data residency)") @@ -265,11 +270,18 @@ func tokenCmd(args []string, stdin io.Reader, stdout, stderr io.Writer, out *out if *keep && !app.exec { return usagef("--keep applies only under --exec, which revokes the token when the command exits") } - if *installation <= 0 { + switch { + case *owner != "" && isSet(fs, "installation"): + return usagef("set --installation or --owner, not both") + case strings.Contains(*owner, "/"): + return usagef("--owner %q is a repository: pass its account as --owner and the repository as --repos", *owner) + case *owner == "." || *owner == "..": + return usagef("--owner %q is not an account: pass a user or organization login", *owner) + case *owner == "" && *installation <= 0: if err := badEnvNumber(fs, "installation", "GITHUB_APP_INSTALLATION_ID"); err != nil { return err } - return usagef("missing installation ID: set --installation") + return usagef("missing installation ID: set --installation, or --owner to look it up") } if *baseURL != "" && *enterpriseURL != "" { return usagef("set --base-url or --enterprise-url, not both") @@ -308,6 +320,14 @@ func tokenCmd(args []string, stdin io.Reader, stdout, stderr io.Writer, out *out case *enterpriseURL != "": urlOpts = append(urlOpts, githubauth.WithEnterpriseURL(*enterpriseURL)) } + + id := *installation + if *owner != "" { + if id, err = githubauth.FindInstallationID(context.Background(), appSrc, *owner, urlOpts...); err != nil { + return err + } + } + opts := slices.Clone(urlOpts) if len(names) > 0 || perms != nil { opts = append(opts, githubauth.WithInstallationTokenOptions(&githubauth.InstallationTokenOptions{ @@ -316,14 +336,17 @@ func tokenCmd(args []string, stdin io.Reader, stdout, stderr io.Writer, out *out })) } - tok, err := githubauth.NewInstallationTokenSource(*installation, appSrc, opts...).Token() + tok, err := githubauth.NewInstallationTokenSource(id, appSrc, opts...).Token() if err != nil { return err } + if app.exec && !*keep { + defer holdSignals()() + } err = app.emit(tok, fs.Args(), stdin, stdout, stderr) // Even a command that failed to start leaves a minted token behind. if app.exec && !*keep { - revoke(tok, urlOpts, stderr) + err = revoke(err, tok, urlOpts, stderr) } return err } @@ -515,7 +538,9 @@ func splitRepos(v string) []string { // rather than as a 422 that does not say which one. // // shortcut: only the names InstallationPermissions has fields for are known, so a -// newer GitHub permission is refused until the struct gains it. +// newer GitHub permission is refused until the struct gains it. Unknown names +// could go through Other once GitHub is known to refuse a name it does not +// recognize, rather than ignore it and mint a token with every permission. func parsePermissions(v string) (*githubauth.InstallationPermissions, error) { pairs := splitRepos(v) if len(pairs) == 0 { diff --git a/cmd/githubauth/main_test.go b/cmd/githubauth/main_test.go index 58923ba..1aee3b1 100644 --- a/cmd/githubauth/main_test.go +++ b/cmd/githubauth/main_test.go @@ -16,7 +16,10 @@ import ( "net/http/httptest" "os" "path/filepath" + "runtime" "strings" + "sync" + "syscall" "testing" "time" @@ -415,6 +418,67 @@ func TestRun(t *testing.T) { setupServer: tokenHandler("ghs_failing"), wantCode: 12, }, + { + name: "owner and installation together are misuse", + args: func(string) []string { + return []string{"token", "--client-id", "Iv1.abc", "--key", keyPath, "--installation", "9", "--owner", "acme"} + }, + wantCode: exitUsage, + wantStdout: isEmpty, + wantStderr: "set --installation or --owner, not both", + }, + { + name: "an owner naming a repository is misuse", + args: func(string) []string { + return []string{"token", "--client-id", "Iv1.abc", "--key", keyPath, "--owner", "acme/api"} + }, + wantCode: exitUsage, + wantStdout: isEmpty, + wantStderr: `--owner "acme/api" is a repository`, + }, + { + name: "a dot segment is not an owner", + args: func(string) []string { + return []string{"token", "--client-id", "Iv1.abc", "--key", keyPath, "--owner", ".."} + }, + wantCode: exitUsage, + wantStdout: isEmpty, + wantStderr: `--owner ".." is not an account`, + }, + { + // The command never ran, so the failure is githubauth's own and its + // one JSON report has to carry the token it left valid. + name: "under an agent a failed revocation joins the failure report", + args: func(url string) []string { + return []string{ + "token", "--client-id", "Iv1.abc", "--key", keyPath, "--installation", "99", + "--base-url", url, "--agent", "--exec", "--", "githubauth-no-such-command", + } + }, + setupServer: func() *httptest.Server { + return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodDelete { + w.WriteHeader(http.StatusInternalServerError) + return + } + w.WriteHeader(http.StatusCreated) + _ = json.NewEncoder(w).Encode(map[string]any{"token": "ghs_unrevoked", "expires_at": time.Now().Add(time.Hour)}) + })) + }, + wantCode: exitUsage, + wantStdout: contains("the token was not revoked"), + quietStderr: true, + }, + { + name: "an owner the App is not installed on exits as not installed", + args: func(url string) []string { + return []string{"token", "--client-id", "Iv1.abc", "--key", keyPath, "--owner", "acme", "--base-url", url} + }, + setupServer: statusHandler(http.StatusNotFound), + wantCode: exitNotFound, + wantStdout: isEmpty, + wantStderr: "Check --installation or --owner", + }, { name: "keep without exec is misuse", args: func(string) []string { @@ -664,8 +728,9 @@ func TestRun(t *testing.T) { } } -// TestTokenRequests checks what reaches GitHub: the scope asked for, and the -// revocation --exec sends once the command exits, whatever it exited with. +// TestTokenRequests checks what reaches GitHub: the installation and scope +// asked for, and the revocation --exec sends once the command exits, whatever +// it exited with. func TestTokenRequests(t *testing.T) { keyPath := writeFile(t, "app.pem", testKeyPEM(t)) @@ -674,11 +739,24 @@ func TestTokenRequests(t *testing.T) { args []string code string revokeWith int - wantCode int + // termDuringRevoke sends githubauth a SIGTERM while the revocation is + // in flight. + termDuringRevoke bool + wantCode int + wantMint string wantBody string wantRevoke bool wantStderr string }{ + { + name: "the installation comes from the environment", + wantMint: "/app/installations/99/access_tokens", + }, + { + name: "an owner is looked up, ahead of the environment", + args: []string{"--owner", "acme"}, + wantMint: "/app/installations/123/access_tokens", + }, { name: "repos and permissions go in one request", args: []string{"--repos", "api", "--permissions", "contents:read, pull_requests:write"}, @@ -708,6 +786,13 @@ func TestTokenRequests(t *testing.T) { args: []string{"--exec", "--keep", "--", os.Args[0], "-test.run=^TestExecHelper$"}, code: "0", }, + { + name: "a SIGTERM during the revocation does not stop it", + args: []string{"--exec", "--", os.Args[0], "-test.run=^TestExecHelper$"}, + code: "0", + termDuringRevoke: true, + wantRevoke: true, + }, { name: "a token already dead is not reported as valid", args: []string{"--exec", "--", os.Args[0], "-test.run=^TestExecHelper$"}, @@ -731,25 +816,58 @@ func TestTokenRequests(t *testing.T) { t.Setenv("GITHUBAUTH_EXEC_HELPER", "1") t.Setenv("GITHUBAUTH_EXEC_CODE", tt.code) - var body, revokedWith string + t.Setenv("GITHUB_APP_INSTALLATION_ID", "99") + if tt.termDuringRevoke && runtime.GOOS == "windows" { + t.Skip("no signals on windows") + } + + // events orders the child's use of the token against its revocation. + var mu sync.Mutex + var events []string + record := func(e string) { + mu.Lock() + defer mu.Unlock() + events = append(events, e) + } + + var mint, body, revokedWith string srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.Method == http.MethodDelete && r.URL.Path == "/installation/token" { + switch { + case r.Method == http.MethodGet && r.URL.Path == "/child": + record("child used " + r.Header.Get("Authorization")) + return + case r.Method == http.MethodDelete && r.URL.Path == "/installation/token": + record("revoked") revokedWith = r.Header.Get("Authorization") + if tt.termDuringRevoke { + p, _ := os.FindProcess(os.Getpid()) + _ = p.Signal(syscall.SIGTERM) + // Signal delivery is asynchronous: let it land before + // the revocation returns. + time.Sleep(200 * time.Millisecond) + } w.WriteHeader(cmp.Or(tt.revokeWith, http.StatusNoContent)) return + case r.Method == http.MethodGet && r.URL.Path == "/users/acme/installation": + _, _ = io.WriteString(w, `{"id":123}`) + return } b, _ := io.ReadAll(r.Body) - body = string(b) + mint, body = r.URL.Path, string(b) w.WriteHeader(http.StatusCreated) _ = json.NewEncoder(w).Encode(map[string]any{"token": "ghs_minted", "expires_at": time.Now().Add(time.Hour)}) })) defer srv.Close() + t.Setenv("GITHUBAUTH_EXEC_CALL", srv.URL+"/child") - args := append([]string{"token", "--client-id", "Iv1.abc", "--key", keyPath, "--installation", "99", "--base-url", srv.URL}, tt.args...) + args := append([]string{"token", "--client-id", "Iv1.abc", "--key", keyPath, "--base-url", srv.URL}, tt.args...) var stdout, stderr bytes.Buffer if got := run(args, strings.NewReader(""), &stdout, &stderr); got != tt.wantCode { t.Errorf("run() = %d, want %d (stderr: %s)", got, tt.wantCode, stderr.String()) } + if tt.wantMint != "" && mint != tt.wantMint { + t.Errorf("minted at %s, want %s", mint, tt.wantMint) + } if tt.wantBody != "" && body != tt.wantBody { t.Errorf("request body = %s, want %s", body, tt.wantBody) } @@ -759,6 +877,13 @@ func TestTokenRequests(t *testing.T) { if tt.wantRevoke && revokedWith != "Bearer ghs_minted" { t.Errorf("revoked with Authorization %q, want the minted token", revokedWith) } + // A command that ran used the token before it was revoked, not after. + mu.Lock() + got := strings.Join(events, ", ") + mu.Unlock() + if tt.wantRevoke && tt.code != "" && got != "child used Bearer ghs_minted, revoked" { + t.Errorf("events = %q, want the child's use of the token before its revocation", got) + } if !strings.Contains(stderr.String(), tt.wantStderr) { t.Errorf("stderr = %q, want it to contain %q", stderr.String(), tt.wantStderr) } diff --git a/github.go b/github.go index 976efdb..6f06dbb 100644 --- a/github.go +++ b/github.go @@ -155,6 +155,32 @@ type InstallationPermissions struct { OrganizationSelfHostedRunners *string `json:"organization_self_hosted_runners,omitempty"` OrganizationUserBlocking *string `json:"organization_user_blocking,omitempty"` TeamDiscussions *string `json:"team_discussions,omitempty"` + + // Other holds permissions with no field above, keyed by GitHub's name, such + // as "installation_repositories", so one GitHub adds can be requested before + // a field exists. Names are sent as given, so GitHub has the last word on a + // misspelt one. Naming a permission whose field is also set is an error. + Other map[string]string `json:"-"` +} + +// MarshalJSON sends Other alongside the fields. +func (p InstallationPermissions) MarshalJSON() ([]byte, error) { + type fields InstallationPermissions + // Only *string fields, which always marshal. + b, _ := json.Marshal(fields(p)) + if len(p.Other) == 0 { + return b, nil + } + + all := make(map[string]string, len(p.Other)) + _ = json.Unmarshal(b, &all) + for name, level := range p.Other { + if _, ok := all[name]; ok { + return nil, fmt.Errorf("permission %q is set both as a field and in Other", name) + } + all[name] = level + } + return json.Marshal(all) } // InstallationToken represents a GitHub App installation token. @@ -247,9 +273,7 @@ func (c *githubClient) createInstallationToken(ctx context.Context, installation if opts != nil { var err error bodyBytes, err = json.Marshal(opts) - // Unreachable guard: InstallationTokenOptions holds only strings, - // int64s and pointers to them, none of which json.Marshal can reject. - // Kept so a future field that can fail is not silently sent as no body. + // A permission named both as a field and in Other. if err != nil { return nil, fmt.Errorf("failed to marshal request body: %w", err) } @@ -335,6 +359,43 @@ func (c *githubClient) revokeInstallationToken(ctx context.Context) error { return c.responseError(resp) } +// findInstallationID looks up the App's installation on owner. The users +// endpoint answers for organizations too, which actions/create-github-app-token +// relies on as well. +func (c *githubClient) findInstallationID(ctx context.Context, owner string) (int64, error) { + // JoinPath takes escaped elements: a raw "%" would leave the path at the + // base URL, and an escaped slash would add a segment. + u := c.baseURL.JoinPath("users", url.PathEscape(owner), "installation") + req, err := http.NewRequestWithContext(ctx, http.MethodGet, u.String(), nil) + if err != nil { + return 0, fmt.Errorf("failed to create request: %w", err) + } + req.Header.Set("Accept", "application/vnd.github+json") + + resp, err := c.httpClient.Do(req) + if err != nil { + return 0, fmt.Errorf("failed to execute request: %w", err) + } + defer func() { + _ = resp.Body.Close() + }() + + if resp.StatusCode != http.StatusOK { + return 0, c.responseError(resp) + } + var installation struct { + ID int64 `json:"id"` + } + if err := json.NewDecoder(resp.Body).Decode(&installation); err != nil { + return 0, fmt.Errorf("failed to decode response: %w", err) + } + // Minting with 0 would fail as a missing ID and hide this answer. + if installation.ID <= 0 { + return 0, fmt.Errorf("the installation for %q came back without an ID", owner) + } + return installation.ID, nil +} + // responseError turns a rejected response into a *RateLimitError when GitHub // throttled it, and an *APIError otherwise. func (c *githubClient) responseError(resp *http.Response) error { diff --git a/github_test.go b/github_test.go index b6dde25..69bba42 100644 --- a/github_test.go +++ b/github_test.go @@ -492,6 +492,16 @@ func Test_createInstallationToken_SendsOptionsBody(t *testing.T) { }, wantBody: `{"repositories":["repo1","repo2"],"permissions":{"contents":"read","issues":"write"}}`, }, + { + name: "a permission with no field is sent with the others", + opts: &InstallationTokenOptions{ + Permissions: &InstallationPermissions{ + Contents: Ptr("read"), + Other: map[string]string{"installation_repositories": "write"}, + }, + }, + wantBody: `{"permissions":{"contents":"read","installation_repositories":"write"}}`, + }, } for _, tt := range tests { @@ -1511,3 +1521,115 @@ func TestRevokeInstallationToken(t *testing.T) { }) } } + +// Test_createInstallationToken_RefusesAmbiguousPermission checks a permission +// named twice is refused before anything is sent, rather than one silently +// winning. +func Test_createInstallationToken_RefusesAmbiguousPermission(t *testing.T) { + t.Parallel() + + var called atomic.Bool + server := httptest.NewServer(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { + called.Store(true) + })) + defer server.Close() + + opts := &InstallationTokenOptions{Permissions: &InstallationPermissions{ + Contents: Ptr("read"), + Other: map[string]string{"contents": "write"}, + }} + _, err := newClientForServer(t, server).createInstallationToken(context.Background(), 1, opts) + + if err == nil || !strings.Contains(err.Error(), `"contents" is set both`) { + t.Errorf("err = %v, want the ambiguous permission named", err) + } + if called.Load() { + t.Error("the request was sent") + } +} + +func TestFindInstallationID(t *testing.T) { + t.Parallel() + + jwt := oauth2.StaticTokenSource(&oauth2.Token{AccessToken: "app-jwt", Expiry: time.Now().Add(time.Hour)}) + failing := roundTripperFunc(func(*http.Request) (*http.Response, error) { + return nil, errors.New("connection refused") + }) + + tests := []struct { + name string + owner string + status int + body string + opts func(serverURL string) []InstallationTokenSourceOpt + nilCtx bool + wantPath string + wantID int64 + wantErr string + }{ + {name: "found", owner: "acme", status: http.StatusOK, body: `{"id":123,"account":{"login":"acme"}}`, wantPath: "/users/acme/installation", wantID: 123}, + { + name: "an Enterprise Server base path is kept", owner: "acme", status: http.StatusOK, body: `{"id":7}`, + opts: func(u string) []InstallationTokenSourceOpt { + return []InstallationTokenSourceOpt{WithEnterpriseURL(u)} + }, + wantPath: "/api/v3/users/acme/installation", wantID: 7, + }, + {name: "an owner is escaped into one segment", owner: "%2F..%", status: http.StatusOK, body: `{"id":5}`, wantPath: "/users/%2F..%/installation", wantID: 5}, + {name: "not installed", owner: "acme", status: http.StatusNotFound, body: `{"message":"Not Found"}`, wantPath: "/users/acme/installation", wantErr: "status 404"}, + {name: "a body that is not JSON", owner: "acme", status: http.StatusOK, body: ``, wantPath: "/users/acme/installation", wantErr: "failed to decode response"}, + {name: "an answer without an ID", owner: "acme", status: http.StatusOK, body: `{"token":"ghs_x"}`, wantPath: "/users/acme/installation", wantErr: "without an ID"}, + {name: "a repository is not an owner", owner: "acme/api", wantErr: "invalid owner"}, + {name: "a dot segment is not an owner", owner: "..", wantErr: "invalid owner"}, + {name: "no owner", wantErr: "invalid owner"}, + { + name: "a base URL that does not parse is not sent to github.com", owner: "acme", + opts: func(string) []InstallationTokenSourceOpt { + return []InstallationTokenSourceOpt{WithBaseURL("://bad")} + }, + wantErr: "parse", + }, + { + name: "transport fails the round trip", owner: "acme", + opts: func(u string) []InstallationTokenSourceOpt { + return []InstallationTokenSourceOpt{WithBaseURL(u), WithHTTPClient(&http.Client{Transport: failing})} + }, + wantErr: "failed to execute request", + }, + {name: "no context", owner: "acme", nilCtx: true, wantErr: "failed to create request"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + var path, auth string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + path, auth = r.URL.Path, r.Header.Get("Authorization") + w.WriteHeader(tt.status) + _, _ = io.WriteString(w, tt.body) + })) + defer server.Close() + + opts := []InstallationTokenSourceOpt{WithBaseURL(server.URL)} + if tt.opts != nil { + opts = tt.opts(server.URL) + } + ctx := context.Background() + if tt.nilCtx { + ctx = nil + } + + id, err := FindInstallationID(ctx, jwt, tt.owner, opts...) + if tt.wantErr == "" && err != nil || tt.wantErr != "" && (err == nil || !strings.Contains(err.Error(), tt.wantErr)) { + t.Fatalf("FindInstallationID() err = %v, want %q", err, tt.wantErr) + } + if id != tt.wantID { + t.Errorf("id = %d, want %d", id, tt.wantID) + } + if tt.wantPath != "" && (path != tt.wantPath || auth != "Bearer app-jwt") { + t.Errorf("request = %s with Authorization %q, want %s with the App JWT", path, auth, tt.wantPath) + } + }) + } +} diff --git a/llms.txt b/llms.txt index 368d411..7199fff 100644 --- a/llms.txt +++ b/llms.txt @@ -19,8 +19,9 @@ Core API (package `githubauth`): - `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)`. +- `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)` (`o.Permissions.Other` names a permission the struct has no field for), `WithContext(ctx)`. - `RevokeInstallationToken(ctx, token string, opts...) error` — revokes an installation token before it expires, so a leaked copy stops working. `WithBaseURL`, `WithEnterpriseURL` and `WithHTTPClient` apply. +- `FindInstallationID(ctx, appSource, owner string, opts...) (int64, error)` — the App's installation ID on a user or organization, for when only the account is known. A 404 means the App is not installed there. - `NewPersonalAccessTokenSource(token string) oauth2.TokenSource` — classic (`ghp_...`) or fine-grained (`github_pat_...`) PATs. - `ReuseTokenSourceWithSkew(t, src, skew) oauth2.TokenSource` — caching wrapper that refreshes `skew` before expiry (both constructors apply it with a 30s default, eliminating in-flight 401s near expiry). @@ -59,7 +60,7 @@ 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`, `--permissions` (e.g. `contents:read,pull_requests:write`), `--keep`, `--base-url`, `--enterprise-url`. +- `githubauth token [flags]` — print an installation access token. Flags: `--installation` (or `--owner` to look it up), `--repos`, `--permissions` (e.g. `contents:read,pull_requests:write`), `--keep`, `--base-url`, `--enterprise-url`. - `githubauth jwt [flags]` — print the App JWT, for the endpoints that take one. Flags: `--expiry`. - Shared flags: `--client-id` (or `--app-id` for a legacy App), `--key`, `--json`, `--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`.