Skip to content

Commit d908320

Browse files
author
Nate Smith
authored
Merge pull request cli#786 from cli/oauth-read-org
Ask for `read:org` OAuth scope, warn for outdated tokens
2 parents ef5932f + fb7b544 commit d908320

File tree

4 files changed

+120
-19
lines changed

4 files changed

+120
-19
lines changed

api/client.go

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,16 @@ func AddHeader(name, value string) ClientOption {
3737
}
3838
}
3939

40+
// AddHeaderFunc is an AddHeader that gets the string value from a function
41+
func AddHeaderFunc(name string, value func() string) ClientOption {
42+
return func(tr http.RoundTripper) http.RoundTripper {
43+
return &funcTripper{roundTrip: func(req *http.Request) (*http.Response, error) {
44+
req.Header.Add(name, value())
45+
return tr.RoundTrip(req)
46+
}}
47+
}
48+
}
49+
4050
// VerboseLog enables request/response logging within a RoundTripper
4151
func VerboseLog(out io.Writer, logTraffic bool, colorize bool) ClientOption {
4252
logger := &httpretty.Logger{
@@ -63,6 +73,40 @@ func ReplaceTripper(tr http.RoundTripper) ClientOption {
6373
}
6474
}
6575

76+
var issuedScopesWarning bool
77+
78+
// CheckScopes checks whether an OAuth scope is present in a response
79+
func CheckScopes(wantedScope string, cb func(string) error) ClientOption {
80+
return func(tr http.RoundTripper) http.RoundTripper {
81+
return &funcTripper{roundTrip: func(req *http.Request) (*http.Response, error) {
82+
res, err := tr.RoundTrip(req)
83+
if err != nil || res.StatusCode > 299 || issuedScopesWarning {
84+
return res, err
85+
}
86+
87+
appID := res.Header.Get("X-Oauth-Client-Id")
88+
hasScopes := strings.Split(res.Header.Get("X-Oauth-Scopes"), ",")
89+
90+
hasWanted := false
91+
for _, s := range hasScopes {
92+
if wantedScope == strings.TrimSpace(s) {
93+
hasWanted = true
94+
break
95+
}
96+
}
97+
98+
if !hasWanted {
99+
if err := cb(appID); err != nil {
100+
return res, err
101+
}
102+
issuedScopesWarning = true
103+
}
104+
105+
return res, nil
106+
}}
107+
}
108+
}
109+
66110
type funcTripper struct {
67111
roundTrip func(*http.Request) (*http.Response, error)
68112
}

auth/oauth.go

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import (
1111
"net/http"
1212
"net/url"
1313
"os"
14+
"strings"
1415

1516
"github.com/cli/cli/pkg/browser"
1617
)
@@ -29,6 +30,7 @@ type OAuthFlow struct {
2930
Hostname string
3031
ClientID string
3132
ClientSecret string
33+
Scopes []string
3234
WriteSuccessHTML func(io.Writer)
3335
VerboseStream io.Writer
3436
}
@@ -45,11 +47,15 @@ func (oa *OAuthFlow) ObtainAccessToken() (accessToken string, err error) {
4547
}
4648
port := listener.Addr().(*net.TCPAddr).Port
4749

50+
scopes := "repo"
51+
if oa.Scopes != nil {
52+
scopes = strings.Join(oa.Scopes, " ")
53+
}
54+
4855
q := url.Values{}
4956
q.Set("client_id", oa.ClientID)
5057
q.Set("redirect_uri", fmt.Sprintf("http://127.0.0.1:%d/callback", port))
51-
// TODO: make scopes configurable
52-
q.Set("scope", "repo")
58+
q.Set("scope", scopes)
5359
q.Set("state", state)
5460

5561
startURL := fmt.Sprintf("https://%s/login/oauth/authorize?%s", oa.Hostname, q.Encode())

command/root.go

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -132,12 +132,47 @@ var apiClientForContext = func(ctx context.Context) (*api.Client, error) {
132132
if err != nil {
133133
return nil, err
134134
}
135+
135136
var opts []api.ClientOption
136137
if verbose := os.Getenv("DEBUG"); verbose != "" {
137138
opts = append(opts, apiVerboseLog())
138139
}
140+
141+
getAuthValue := func() string {
142+
return fmt.Sprintf("token %s", token)
143+
}
144+
145+
checkScopesFunc := func(appID string) error {
146+
if config.IsGitHubApp(appID) && utils.IsTerminal(os.Stdin) && utils.IsTerminal(os.Stderr) {
147+
newToken, loginHandle, err := config.AuthFlow("Notice: additional authorization required")
148+
if err != nil {
149+
return err
150+
}
151+
cfg, err := ctx.Config()
152+
if err != nil {
153+
return err
154+
}
155+
_ = cfg.Set(defaultHostname, "oauth_token", newToken)
156+
_ = cfg.Set(defaultHostname, "user", loginHandle)
157+
// update config file on disk
158+
err = cfg.Write()
159+
if err != nil {
160+
return err
161+
}
162+
// update configuration in memory
163+
token = newToken
164+
config.AuthFlowComplete()
165+
} else {
166+
fmt.Fprintln(os.Stderr, "Warning: gh now requires the `read:org` OAuth scope.")
167+
fmt.Fprintln(os.Stderr, "Visit https://github.com/settings/tokens and edit your token to enable `read:org`")
168+
fmt.Fprintln(os.Stderr, "or generate a new token and paste it via `gh config set -h github.com oauth_token MYTOKEN`")
169+
}
170+
return nil
171+
}
172+
139173
opts = append(opts,
140-
api.AddHeader("Authorization", fmt.Sprintf("token %s", token)),
174+
api.CheckScopes("read:org", checkScopesFunc),
175+
api.AddHeaderFunc("Authorization", getAuthValue),
141176
api.AddHeader("User-Agent", fmt.Sprintf("GitHub CLI %s", Version)),
142177
// antiope-preview: Checks
143178
api.AddHeader("Accept", "application/vnd.github.antiope-preview+json"),

internal/config/config_setup.go

Lines changed: 32 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -24,9 +24,14 @@ var (
2424
oauthClientSecret = "34ddeff2b558a23d38fba8a6de74f086ede1cc0b"
2525
)
2626

27-
// TODO: have a conversation about whether this belongs in the "context" package
28-
// FIXME: make testable
29-
func setupConfigFile(filename string) (Config, error) {
27+
// IsGitHubApp reports whether an OAuth app is "GitHub CLI" or "GitHub CLI (dev)"
28+
func IsGitHubApp(id string) bool {
29+
// this intentionally doesn't use `oauthClientID` because that is a variable
30+
// that can potentially be changed at build time via GH_OAUTH_CLIENT_ID
31+
return id == "178c6fc778ccc68e1d6a" || id == "4d747ba5675d5d66553f"
32+
}
33+
34+
func AuthFlow(notice string) (string, string, error) {
3035
var verboseStream io.Writer
3136
if strings.Contains(os.Getenv("DEBUG"), "oauth") {
3237
verboseStream = os.Stderr
@@ -36,21 +41,37 @@ func setupConfigFile(filename string) (Config, error) {
3641
Hostname: oauthHost,
3742
ClientID: oauthClientID,
3843
ClientSecret: oauthClientSecret,
44+
Scopes: []string{"repo", "read:org"},
3945
WriteSuccessHTML: func(w io.Writer) {
4046
fmt.Fprintln(w, oauthSuccessPage)
4147
},
4248
VerboseStream: verboseStream,
4349
}
4450

45-
fmt.Fprintln(os.Stderr, "Notice: authentication required")
51+
fmt.Fprintln(os.Stderr, notice)
4652
fmt.Fprintf(os.Stderr, "Press Enter to open %s in your browser... ", flow.Hostname)
4753
_ = waitForEnter(os.Stdin)
4854
token, err := flow.ObtainAccessToken()
4955
if err != nil {
50-
return nil, err
56+
return "", "", err
5157
}
5258

5359
userLogin, err := getViewer(token)
60+
if err != nil {
61+
return "", "", err
62+
}
63+
64+
return token, userLogin, nil
65+
}
66+
67+
func AuthFlowComplete() {
68+
fmt.Fprintln(os.Stderr, "Authentication complete. Press Enter to continue... ")
69+
_ = waitForEnter(os.Stdin)
70+
}
71+
72+
// FIXME: make testable
73+
func setupConfigFile(filename string) (Config, error) {
74+
token, userLogin, err := AuthFlow("Notice: authentication required")
5475
if err != nil {
5576
return nil, err
5677
}
@@ -61,9 +82,9 @@ func setupConfigFile(filename string) (Config, error) {
6182
}
6283

6384
yamlHosts := map[string]map[string]string{}
64-
yamlHosts[flow.Hostname] = map[string]string{}
65-
yamlHosts[flow.Hostname]["user"] = userLogin
66-
yamlHosts[flow.Hostname]["oauth_token"] = token
85+
yamlHosts[oauthHost] = map[string]string{}
86+
yamlHosts[oauthHost]["user"] = userLogin
87+
yamlHosts[oauthHost]["oauth_token"] = token
6788

6889
defaultConfig := yamlConfig{
6990
Hosts: yamlHosts,
@@ -84,14 +105,9 @@ func setupConfigFile(filename string) (Config, error) {
84105
if err != nil {
85106
return nil, err
86107
}
87-
n, err := cfgFile.Write(yamlData)
88-
if err == nil && n < len(yamlData) {
89-
err = io.ErrShortWrite
90-
}
91-
92-
if err == nil {
93-
fmt.Fprintln(os.Stderr, "Authentication complete. Press Enter to continue... ")
94-
_ = waitForEnter(os.Stdin)
108+
_, err = cfgFile.Write(yamlData)
109+
if err != nil {
110+
return nil, err
95111
}
96112

97113
// TODO cleaner error handling? this "should" always work given that we /just/ wrote the file...

0 commit comments

Comments
 (0)