diff --git a/AGENTS.md b/AGENTS.md index 08ce2f8..edfe64f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -35,6 +35,10 @@ narrow scope when you do: githubauth token --installation 12345 --repos api,web --permissions contents:read ``` +`--repos acme/api` also finds the installation, as `--owner acme` does. `--json` +reports the permissions and repositories GitHub actually granted, which is how to +check the scope rather than assume it. + ## The CLI ``` @@ -109,7 +113,9 @@ is inferred. Both sources cache and refresh 30s before expiry. `RevokeInstallationToken(ctx, token)` ends an installation token before its hour 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. +requests a permission GitHub added after the struct was last updated. An +installation token carries what GitHub granted in `tok.Extra(githubauth.ExtraPermissions)` +(`map[string]string`) and `tok.Extra(githubauth.ExtraRepositories)` (`[]string`). Errors worth branching on: `*githubauth.RateLimitError` (carries `RetryAfter`, unwraps to `ErrRateLimited`) and `*githubauth.APIError` (carries `StatusCode`). diff --git a/README.md b/README.md index 7315dc3..4d51d2d 100644 --- a/README.md +++ b/README.md @@ -99,18 +99,21 @@ vault kv get -field=pem secret/github-app | githubauth token --key - --installation 12345 ``` -`githubauth jwt` prints the App JWT for the few endpoints that need one, `--json` adds the -expiry, and `--repos` and `--permissions` scope the token to named repositories and to -less than the App was granted: +`githubauth jwt` prints the App JWT for the few endpoints that need one, and `--repos` and +`--permissions` scope the token to named repositories and to less than the App was granted. +`--json` adds the expiry and, for an installation token, the permissions and repositories +GitHub granted, so you can check the scope you got: ```bash githubauth token --installation 12345 --repos api,web --permissions contents:read ``` -If you know the account but not the installation ID, `--owner` looks it up: +If you know the account but not the installation ID, `--owner` looks it up, and so does an +`owner/repo` name in `--repos`: ```bash githubauth token --owner acme --repos api +githubauth token --repos acme/api # the same ``` Run `githubauth help` for the rest. diff --git a/auth.go b/auth.go index 5df5394..8a9575b 100644 --- a/auth.go +++ b/auth.go @@ -32,6 +32,15 @@ const ( // by a request starting near exp and arriving at GitHub after exp. DefaultExpirySkew = 30 * time.Second + // ExtraPermissions is the oauth2.Token Extra key under which an installation + // token carries the permissions GitHub granted it, as a map[string]string. + ExtraPermissions = "permissions" + + // ExtraRepositories is the oauth2.Token Extra key under which an + // installation token limited to some repositories carries their names, as a + // []string. It is absent for a token covering the whole installation. + ExtraRepositories = "repositories" + // applicationTokenBackdate is how far into the past the iat claim of an // application JWT is set, so a fast local clock cannot make GitHub see an // iat in the future. It is subtracted from exp as well, so it is also the @@ -542,11 +551,31 @@ func (t *installationTokenSource) Token() (*oauth2.Token, error) { return nil, err } - return &oauth2.Token{ + tok := &oauth2.Token{ AccessToken: token.Token, TokenType: bearerTokenType, Expiry: token.ExpiresAt, - }, nil + } + + // GitHub's answer says what the token can do, which may differ from what + // was asked: metadata:read comes with every token. + extra := map[string]any{} + if token.Permissions != nil { + extra[ExtraPermissions] = token.Permissions.levels() + } + if len(token.Repositories) > 0 { + names := make([]string, 0, len(token.Repositories)) + for _, r := range token.Repositories { + if r.Name != nil { + names = append(names, *r.Name) + } + } + extra[ExtraRepositories] = names + } + if len(extra) == 0 { + return tok, nil + } + return tok.WithExtra(extra), nil } // personalAccessTokenSource represents a static GitHub personal access token source diff --git a/auth_test.go b/auth_test.go index b7840c2..e8b0361 100644 --- a/auth_test.go +++ b/auth_test.go @@ -663,12 +663,14 @@ func Test_installationTokenSource_Token(t *testing.T) { ExpiresAt: expiration, Permissions: &InstallationPermissions{ PullRequests: Ptr("read"), + Other: map[string]string{"installation_repositories": "write"}, }, Repositories: []Repository{ { Name: Ptr("mocked-repo-1"), ID: Ptr(int64(1)), }, + {ID: Ptr(int64(2))}, }, }, ), @@ -730,11 +732,15 @@ func Test_installationTokenSource_Token(t *testing.T) { WithHTTPClient(mockedHTTPClient), }, }, - want: &oauth2.Token{ + // GitHub's answer rides along, including a permission with no field. + want: (&oauth2.Token{ AccessToken: "mocked-installation-token", TokenType: "Bearer", Expiry: expiration, - }, + }).WithExtra(map[string]any{ + ExtraPermissions: map[string]string{"pull_requests": "read", "installation_repositories": "write"}, + ExtraRepositories: []string{"mocked-repo-1"}, + }), }, } for _, tt := range tests { diff --git a/cmd/githubauth/main.go b/cmd/githubauth/main.go index 28f6a75..b935b61 100644 --- a/cmd/githubauth/main.go +++ b/cmd/githubauth/main.go @@ -164,7 +164,7 @@ func (a *appFlags) bind(fs *flag.FlagSet, out *output) { 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(&out.json, "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, with the permissions and repositories GitHub granted an installation token") fs.BoolVar(&a.exec, "exec", false, "run the command after -- with the credential in $GITHUB_TOKEN and $GH_TOKEN, printing it nowhere; $GH_HOST is set for an Enterprise or data residency host") fs.BoolVar(&out.agent, "agent", out.agent, "report failures as one JSON document on stdout (on when a coding agent is detected)") } @@ -254,7 +254,7 @@ func tokenCmd(args []string, stdin io.Reader, stdout, stderr io.Writer, out *out 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") + repos := fs.String("repos", "", "comma-separated repository names to scope the token to; owner/repo also finds the installation, like --owner") 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") @@ -270,6 +270,25 @@ 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") } + // An empty scope is no scope: GitHub would mint a token for every + // repository, the opposite of what passing --repos asked for. + names, repoOwner, err := splitOwner(splitRepos(*repos)) + if err != nil { + return err + } + if isSet(fs, "repos") && len(names) == 0 { + return usagef("--repos names no repository; omit it for a token covering the whole installation") + } + // An owner/repo name finds the installation the way --owner does. + if repoOwner != "" { + switch { + case isSet(fs, "installation"): + return usagef("--repos names the owner %s, which finds the installation: drop --installation, or the %s/ prefix", repoOwner, repoOwner) + case *owner != "" && !strings.EqualFold(*owner, repoOwner): + return usagef("--repos names repositories of %s, but --owner is %s", repoOwner, *owner) + } + *owner = repoOwner + } switch { case *owner != "" && isSet(fs, "installation"): return usagef("set --installation or --owner, not both") @@ -292,12 +311,6 @@ func tokenCmd(args []string, stdin io.Reader, stdout, stderr io.Writer, out *out if err := checkURL("--enterprise-url", *enterpriseURL); err != nil { return err } - // An empty scope is no scope: GitHub would mint a token for every - // repository, the opposite of what passing --repos asked for. - names := splitRepos(*repos) - if isSet(fs, "repos") && len(names) == 0 { - return usagef("--repos names no repository; omit it for a token covering the whole installation") - } perms, err := parsePermissions(*permissions) if err != nil { return err @@ -517,10 +530,16 @@ func write(w io.Writer, tok *oauth2.Token, asJSON bool) error { _, err := fmt.Fprintln(w, tok.AccessToken) return err } + // What GitHub granted, so a caller can check the scope it got. A JWT + // carries neither. + perms, _ := tok.Extra(githubauth.ExtraPermissions).(map[string]string) + repos, _ := tok.Extra(githubauth.ExtraRepositories).([]string) return json.NewEncoder(w).Encode(struct { - Token string `json:"token"` - ExpiresAt time.Time `json:"expires_at"` - }{tok.AccessToken, tok.Expiry}) + Token string `json:"token"` + ExpiresAt time.Time `json:"expires_at"` + Permissions map[string]string `json:"permissions,omitempty"` + Repositories []string `json:"repositories,omitempty"` + }{tok.AccessToken, tok.Expiry, perms, repos}) } func splitRepos(v string) []string { @@ -533,6 +552,29 @@ func splitRepos(v string) []string { return out } +// splitOwner strips the owner from names written owner/repo, the form +// actions/create-github-app-token takes, and returns the owner they share. +// GitHub scopes a token by bare repository names. +func splitOwner(names []string) ([]string, string, error) { + var owner string + out := make([]string, len(names)) + for i, name := range names { + o, repo, ok := strings.Cut(name, "/") + if !ok { + out[i] = name + continue + } + if o == "" || repo == "" || strings.Contains(repo, "/") { + return nil, "", usagef("--repos %q is neither a repository name nor owner/repo", name) + } + if owner != "" && !strings.EqualFold(owner, o) { + return nil, "", usagef("--repos names repositories of %s and of %s; a token covers one installation", owner, o) + } + owner, out[i] = o, repo + } + return out, owner, nil +} + // parsePermissions reads --permissions into GitHub's permission object, or nil // when it names none. Every name goes through Other, so a permission GitHub // adds works before the library has a field for it. GitHub answers a name it diff --git a/cmd/githubauth/main_test.go b/cmd/githubauth/main_test.go index cfc9e2d..7c05169 100644 --- a/cmd/githubauth/main_test.go +++ b/cmd/githubauth/main_test.go @@ -479,6 +479,60 @@ func TestRun(t *testing.T) { wantStdout: isEmpty, wantStderr: "Check --installation or --owner", }, + { + name: "an owner prefix and --installation are misuse", + args: func(string) []string { + return []string{"token", "--client-id", "Iv1.abc", "--key", keyPath, "--repos", "acme/api", "--installation", "9"} + }, + wantCode: exitUsage, + wantStdout: isEmpty, + wantStderr: "drop --installation, or the acme/ prefix", + }, + { + name: "an owner prefix and a different --owner are misuse", + args: func(string) []string { + return []string{"token", "--client-id", "Iv1.abc", "--key", keyPath, "--repos", "acme/api", "--owner", "other"} + }, + wantCode: exitUsage, + wantStdout: isEmpty, + wantStderr: "--repos names repositories of acme, but --owner is other", + }, + { + name: "repositories of two owners are misuse", + args: func(string) []string { + return []string{"token", "--client-id", "Iv1.abc", "--key", keyPath, "--repos", "acme/api,other/web"} + }, + wantCode: exitUsage, + wantStdout: isEmpty, + wantStderr: "repositories of acme and of other", + }, + { + name: "an owner prefix without a repository is misuse", + args: func(string) []string { + return []string{"token", "--client-id", "Iv1.abc", "--key", keyPath, "--repos", "acme/"} + }, + wantCode: exitUsage, + wantStdout: isEmpty, + wantStderr: `--repos "acme/" is neither`, + }, + { + name: "a path deeper than owner/repo is misuse", + args: func(string) []string { + return []string{"token", "--client-id", "Iv1.abc", "--key", keyPath, "--repos", "acme/api/x"} + }, + wantCode: exitUsage, + wantStdout: isEmpty, + wantStderr: `--repos "acme/api/x" is neither`, + }, + { + name: "a dot owner prefix is not an account", + args: func(string) []string { + return []string{"token", "--client-id", "Iv1.abc", "--key", keyPath, "--repos", "../api"} + }, + wantCode: exitUsage, + wantStdout: isEmpty, + wantStderr: `--owner ".." is not an account`, + }, { name: "keep without exec is misuse", args: func(string) []string { @@ -736,6 +790,7 @@ func TestTokenRequests(t *testing.T) { wantCode int wantMint string wantBody string + wantStdout string wantRevoke bool wantStderr string }{ @@ -748,6 +803,22 @@ func TestTokenRequests(t *testing.T) { args: []string{"--owner", "acme"}, wantMint: "/app/installations/123/access_tokens", }, + { + name: "an owner prefix on --repos finds the installation, ahead of the environment", + args: []string{"--repos", "acme/api,web"}, + wantMint: "/app/installations/123/access_tokens", + wantBody: `{"repositories":["api","web"]}`, + }, + { + name: "an owner prefix agrees with --owner", + args: []string{"--repos", "acme/api", "--owner", "acme"}, + wantMint: "/app/installations/123/access_tokens", + }, + { + name: "json shows what GitHub granted", + args: []string{"--json"}, + wantStdout: `"permissions":{"metadata":"read","pull_requests":"write"},"repositories":["api"]}`, + }, { name: "repos and permissions go in one request", args: []string{"--repos", "api", "--permissions", "contents:read, pull_requests:write"}, @@ -851,7 +922,12 @@ func TestTokenRequests(t *testing.T) { b, _ := io.ReadAll(r.Body) 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)}) + _ = json.NewEncoder(w).Encode(map[string]any{ + "token": "ghs_minted", + "expires_at": time.Now().Add(time.Hour), + "permissions": map[string]string{"metadata": "read", "pull_requests": "write"}, + "repositories": []map[string]any{{"id": 1, "name": "api", "full_name": "acme/api"}}, + }) })) defer srv.Close() t.Setenv("GITHUBAUTH_EXEC_CALL", srv.URL+"/child") @@ -864,6 +940,9 @@ func TestTokenRequests(t *testing.T) { if tt.wantMint != "" && mint != tt.wantMint { t.Errorf("minted at %s, want %s", mint, tt.wantMint) } + if !strings.Contains(stdout.String(), tt.wantStdout) { + t.Errorf("stdout = %q, want it to contain %q", stdout.String(), tt.wantStdout) + } if tt.wantBody != "" && body != tt.wantBody { t.Errorf("request body = %s, want %s", body, tt.wantBody) } diff --git a/github.go b/github.go index 6f06dbb..0f2e255 100644 --- a/github.go +++ b/github.go @@ -7,8 +7,10 @@ import ( "errors" "fmt" "io" + "maps" "net/http" "net/url" + "reflect" "strconv" "strings" "time" @@ -158,7 +160,7 @@ type InstallationPermissions struct { // 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 + // a field exists, and one GitHub grants is not lost when a token is decoded. 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:"-"` } @@ -175,7 +177,7 @@ func (p InstallationPermissions) MarshalJSON() ([]byte, error) { all := make(map[string]string, len(p.Other)) _ = json.Unmarshal(b, &all) for name, level := range p.Other { - if _, ok := all[name]; ok { + if _, ok := all[strings.ToLower(name)]; ok { return nil, fmt.Errorf("permission %q is set both as a field and in Other", name) } all[name] = level @@ -183,6 +185,51 @@ func (p InstallationPermissions) MarshalJSON() ([]byte, error) { return json.Marshal(all) } +// UnmarshalJSON keeps the permissions that have no field in Other. +func (p *InstallationPermissions) UnmarshalJSON(b []byte) error { + type fields InstallationPermissions + if err := json.Unmarshal(b, (*fields)(p)); err != nil { + return err + } + + // b decoded into the fields above, so it is a JSON object or null. + var all map[string]any + _ = json.Unmarshal(b, &all) + for name, level := range all { + if s, ok := level.(string); ok && !permissionFields[strings.ToLower(name)] { + if p.Other == nil { + p.Other = make(map[string]string) + } + p.Other[name] = s + } + } + return nil +} + +// permissionFields holds the names InstallationPermissions has a field for, +// lowercased: encoding/json matches a key to a field ignoring case, so a name +// that differs only in case belongs to the field, not to Other. +var permissionFields = func() map[string]bool { + t := reflect.TypeFor[InstallationPermissions]() + names := make(map[string]bool, t.NumField()) + for i := range t.NumField() { + if name, _, _ := strings.Cut(t.Field(i).Tag.Get("json"), ","); name != "-" { + names[strings.ToLower(name)] = true + } + } + return names +}() + +// levels is every permission set, by GitHub's name. +func (p InstallationPermissions) levels() map[string]string { + type fields InstallationPermissions + b, _ := json.Marshal(fields(p)) + levels := make(map[string]string, len(p.Other)) + _ = json.Unmarshal(b, &levels) + maps.Copy(levels, p.Other) + return levels +} + // InstallationToken represents a GitHub App installation token. type InstallationToken struct { Token string `json:"token"` diff --git a/github_test.go b/github_test.go index 69bba42..969db11 100644 --- a/github_test.go +++ b/github_test.go @@ -5,6 +5,7 @@ import ( "encoding/json" "errors" "io" + "maps" "net/http" "net/http/httptest" "strconv" @@ -1633,3 +1634,75 @@ func TestFindInstallationID(t *testing.T) { }) } } + +func TestInstallationPermissions_UnmarshalJSON(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + body string + wantField *string + wantOther map[string]string + wantErr bool + }{ + {name: "a field", body: `{"contents":"read"}`, wantField: Ptr("read")}, + {name: "a permission with no field", body: `{"installation_repositories":"write"}`, wantOther: map[string]string{"installation_repositories": "write"}}, + {name: "a value that is not a level is dropped", body: `{"future":{"nested":true}}`}, + {name: "null", body: `null`}, + {name: "a field of the wrong type", body: `{"contents":1}`, wantErr: true}, + {name: "an empty level stays on its field", body: `{"contents":""}`, wantField: Ptr("")}, + {name: "a field's name in another case stays on the field", body: `{"Contents":"read"}`, wantField: Ptr("read")}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + var p InstallationPermissions + err := json.Unmarshal([]byte(tt.body), &p) + if (err != nil) != tt.wantErr { + t.Fatalf("Unmarshal() err = %v, wantErr %v", err, tt.wantErr) + } + if tt.wantErr { + return + } + if (p.Contents == nil) != (tt.wantField == nil) || p.Contents != nil && *p.Contents != *tt.wantField { + t.Errorf("Contents = %v, want %v", p.Contents, tt.wantField) + } + if !maps.Equal(p.Other, tt.wantOther) { + t.Errorf("Other = %v, want %v", p.Other, tt.wantOther) + } + // What was decoded marshals again. + if _, err := json.Marshal(p); err != nil { + t.Errorf("Marshal() after Unmarshal err = %v", err) + } + }) + } +} + +// TestInstallationPermissions_UnmarshalJSONUpdatesOther checks a second decode +// into the same value replaces a level, as it does for a field. +func TestInstallationPermissions_UnmarshalJSONUpdatesOther(t *testing.T) { + t.Parallel() + + var p InstallationPermissions + for _, body := range []string{`{"future":"write"}`, `{"future":"read"}`} { + if err := json.Unmarshal([]byte(body), &p); err != nil { + t.Fatalf("Unmarshal(%s) err = %v", body, err) + } + } + if got := p.Other["future"]; got != "read" { + t.Errorf(`Other["future"] = %q, want "read"`, got) + } +} + +// TestInstallationPermissions_MarshalJSONRefusesCaseVariant checks Other cannot +// sneak in a second level for a field by changing the name's case. +func TestInstallationPermissions_MarshalJSONRefusesCaseVariant(t *testing.T) { + t.Parallel() + + p := InstallationPermissions{Contents: Ptr("read"), Other: map[string]string{"Contents": "write"}} + if _, err := json.Marshal(p); err == nil { + t.Error("Marshal() err = nil, want the permission named twice refused") + } +} diff --git a/llms.txt b/llms.txt index 7199fff..e802017 100644 --- a/llms.txt +++ b/llms.txt @@ -21,6 +21,7 @@ Core API (package `githubauth`): - `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)` (`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. +- `ExtraPermissions`, `ExtraRepositories` — `oauth2.Token.Extra` keys on an installation token: the permissions GitHub granted (`map[string]string`, `metadata` included) and, for a token limited to some, the repository names (`[]string`). - `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). @@ -62,7 +63,7 @@ The same credentials without writing Go. Install with `go install github.com/jfe - `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`. +- Shared flags: `--client-id` (or `--app-id` for a legacy App), `--key`, `--json` (adds the expiry, and for an installation token the permissions and repositories GitHub granted), `--exec`, `--agent`. `--repos acme/api` finds the installation like `--owner acme`. - 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`.