Skip to content

Commit

Permalink
Merge pull request cli#2207 from cli/codespaces
Browse files Browse the repository at this point in the history
[Codespaces] Support "integration" tokens
  • Loading branch information
samcoe authored Oct 27, 2020
2 parents e74e431 + 35517eb commit edecb2e
Show file tree
Hide file tree
Showing 4 changed files with 66 additions and 131 deletions.
89 changes: 26 additions & 63 deletions api/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,6 @@ package api
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"io/ioutil"
Expand Down Expand Up @@ -99,58 +98,6 @@ func ReplaceTripper(tr http.RoundTripper) ClientOption {
}
}

var issuedScopesWarning bool

const (
httpOAuthAppID = "X-Oauth-Client-Id"
httpOAuthScopes = "X-Oauth-Scopes"
)

// CheckScopes checks whether an OAuth scope is present in a response
func CheckScopes(wantedScope string, cb func(string) error) ClientOption {
wantedCandidates := []string{wantedScope}
if strings.HasPrefix(wantedScope, "read:") {
wantedCandidates = append(wantedCandidates, "admin:"+strings.TrimPrefix(wantedScope, "read:"))
}

return func(tr http.RoundTripper) http.RoundTripper {
return &funcTripper{roundTrip: func(req *http.Request) (*http.Response, error) {
res, err := tr.RoundTrip(req)
if err != nil || res.StatusCode > 299 || issuedScopesWarning {
return res, err
}

_, hasHeader := res.Header[httpOAuthAppID]
if !hasHeader {
return res, nil
}

appID := res.Header.Get(httpOAuthAppID)
hasScopes := strings.Split(res.Header.Get(httpOAuthScopes), ",")

hasWanted := false
outer:
for _, s := range hasScopes {
for _, w := range wantedCandidates {
if w == strings.TrimSpace(s) {
hasWanted = true
break outer
}
}
}

if !hasWanted {
if err := cb(appID); err != nil {
return res, err
}
issuedScopesWarning = true
}

return res, nil
}}
}
}

type funcTripper struct {
roundTrip func(*http.Request) (*http.Response, error)
}
Expand Down Expand Up @@ -207,7 +154,20 @@ func (err HTTPError) Error() string {
}

type MissingScopesError struct {
error
MissingScopes []string
}

func (e MissingScopesError) Error() string {
var missing []string
for _, s := range e.MissingScopes {
missing = append(missing, fmt.Sprintf("'%s'", s))
}
scopes := strings.Join(missing, ", ")

if len(e.MissingScopes) == 1 {
return "missing required scope " + scopes
}
return "missing required scopes " + scopes
}

func (c Client) HasMinimumScopes(hostname string) error {
Expand Down Expand Up @@ -235,31 +195,34 @@ func (c Client) HasMinimumScopes(hostname string) error {
return HandleHTTPError(res)
}

hasScopes := strings.Split(res.Header.Get("X-Oauth-Scopes"), ",")
scopesHeader := res.Header.Get("X-Oauth-Scopes")
if scopesHeader == "" {
// if the token reports no scopes, assume that it's an integration token and give up on
// detecting its capabilities
return nil
}

search := map[string]bool{
"repo": false,
"read:org": false,
"admin:org": false,
}

for _, s := range hasScopes {
for _, s := range strings.Split(scopesHeader, ",") {
search[strings.TrimSpace(s)] = true
}

errorMsgs := []string{}
var missingScopes []string
if !search["repo"] {
errorMsgs = append(errorMsgs, "missing required scope 'repo'")
missingScopes = append(missingScopes, "repo")
}

if !search["read:org"] && !search["admin:org"] {
errorMsgs = append(errorMsgs, "missing required scope 'read:org'")
missingScopes = append(missingScopes, "read:org")
}

if len(errorMsgs) > 0 {
return &MissingScopesError{error: errors.New(strings.Join(errorMsgs, ";"))}
if len(missingScopes) > 0 {
return &MissingScopesError{MissingScopes: missingScopes}
}

return nil
}

Expand Down
103 changes: 36 additions & 67 deletions api/client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -105,97 +105,66 @@ func TestRESTError(t *testing.T) {
}
}

func Test_CheckScopes(t *testing.T) {
func Test_HasMinimumScopes(t *testing.T) {
tests := []struct {
name string
wantScope string
responseApp string
responseScopes string
responseError error
expectCallback bool
name string
header string
wantErr string
}{
{
name: "missing read:org",
wantScope: "read:org",
responseApp: "APPID",
responseScopes: "repo, gist",
expectCallback: true,
name: "no scopes",
header: "",
wantErr: "",
},
{
name: "has read:org",
wantScope: "read:org",
responseApp: "APPID",
responseScopes: "repo, read:org, gist",
expectCallback: false,
name: "default scopes",
header: "repo, read:org",
wantErr: "",
},
{
name: "has admin:org",
wantScope: "read:org",
responseApp: "APPID",
responseScopes: "repo, admin:org, gist",
expectCallback: false,
name: "admin:org satisfies read:org",
header: "repo, admin:org",
wantErr: "",
},
{
name: "no scopes in response",
wantScope: "read:org",
responseApp: "",
responseScopes: "",
expectCallback: false,
name: "insufficient scope",
header: "repo",
wantErr: "missing required scope 'read:org'",
},
{
name: "errored response",
wantScope: "read:org",
responseApp: "",
responseScopes: "",
responseError: errors.New("Network Failed"),
expectCallback: false,
name: "insufficient scopes",
header: "gist",
wantErr: "missing required scopes 'repo', 'read:org'",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
tr := &httpmock.Registry{}
tr.Register(httpmock.MatchAny, func(*http.Request) (*http.Response, error) {
if tt.responseError != nil {
return nil, tt.responseError
}
if tt.responseScopes == "" {
return &http.Response{StatusCode: 200}, nil
}
fakehttp := &httpmock.Registry{}
client := NewClient(ReplaceTripper(fakehttp))

fakehttp.Register(httpmock.REST("GET", ""), func(req *http.Request) (*http.Response, error) {
return &http.Response{
Request: req,
StatusCode: 200,
Header: http.Header{
"X-Oauth-Client-Id": []string{tt.responseApp},
"X-Oauth-Scopes": []string{tt.responseScopes},
Body: ioutil.NopCloser(&bytes.Buffer{}),
Header: map[string][]string{
"X-Oauth-Scopes": {tt.header},
},
}, nil
})

callbackInvoked := false
var gotAppID string
fn := CheckScopes(tt.wantScope, func(appID string) error {
callbackInvoked = true
gotAppID = appID
return nil
})

rt := fn(tr)
req, err := http.NewRequest("GET", "https://api.github.com/hello", nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}

issuedScopesWarning = false
_, err = rt.RoundTrip(req)
if err != nil && !errors.Is(err, tt.responseError) {
t.Fatalf("unexpected error: %v", err)
err := client.HasMinimumScopes("github.com")
if tt.wantErr == "" {
if err != nil {
t.Errorf("error: %v", err)
}
return
}
if err.Error() != tt.wantErr {
t.Errorf("want %q, got %q", tt.wantErr, err.Error())

if tt.expectCallback != callbackInvoked {
t.Fatalf("expected CheckScopes callback: %v", tt.expectCallback)
}
if tt.expectCallback && gotAppID != tt.responseApp {
t.Errorf("unexpected app ID: %q", gotAppID)
}
})
}

}
3 changes: 3 additions & 0 deletions cmd/gh/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,9 @@ func shouldCheckForUpdate() bool {
if os.Getenv("GH_NO_UPDATE_NOTIFIER") != "" {
return false
}
if os.Getenv("CODESPACES") != "" {
return false
}
return updaterEnabled != "" && !isCI() && !isCompletionCommand() && utils.IsTerminal(os.Stderr)
}

Expand Down
2 changes: 1 addition & 1 deletion pkg/cmd/auth/status/status.go
Original file line number Diff line number Diff line change
Expand Up @@ -98,7 +98,7 @@ func statusRun(opts *StatusOptions) error {
if err != nil {
var missingScopes *api.MissingScopesError
if errors.As(err, &missingScopes) {
addMsg("%s %s: %s", utils.Red("X"), hostname, err)
addMsg("%s %s: the token in %s is %s", utils.Red("X"), hostname, tokenSource, err)
if tokenIsWriteable {
addMsg("- To request missing scopes, run: %s %s\n",
utils.Bold("gh auth refresh -h"),
Expand Down

0 comments on commit edecb2e

Please sign in to comment.