diff --git a/README.md b/README.md index 011e903d..8ca1cb1e 100644 --- a/README.md +++ b/README.md @@ -50,6 +50,14 @@ apimetrics login This opens your browser for OAuth2 authentication. Your credentials are cached locally and refreshed automatically. +On a machine whose browser can't reach it — over SSH, in a container, or on a remote desktop — use the device flow instead: + +```bash +apimetrics login --device +``` + +The CLI prints a URL and a short code. Open the URL in a browser on any device, check the code matches, and approve; the CLI finishes logging in once you do. + ### 2. Select a project All commands require an active project. Run the following to choose one: diff --git a/cli/config.go b/cli/config.go index 08ebf818..1ac1507d 100644 --- a/cli/config.go +++ b/cli/config.go @@ -351,11 +351,17 @@ func ensureProject(cmd *cobra.Command) error { } func loginCmd() *cobra.Command { - return &cobra.Command{ + var device bool + + cmd := &cobra.Command{ Use: "login", Short: "Log in to APImetrics", Long: `Log in to APImetrics. Commands will automatically trigger login if needed, so this is rarely required. +With --device, you approve the login in a browser on any device by confirming a +short code, instead of being redirected back to this machine. Use it over SSH or +anywhere the browser can't reach localhost. + With --service-account, this fetches and caches an access token for the service account, which is a convenient way to check that its credentials work.`, Args: cobra.NoArgs, @@ -368,9 +374,18 @@ account, which is a convenient way to check that its credentials work.`, if profile == nil || profile.Auth == nil || profile.Auth.Name == "" { return fmt.Errorf("no auth configured for profile %q", viper.GetString("rsh-profile")) } - handler, ok := authHandlers[profile.Auth.Name] + handlerName := profile.Auth.Name + if device { + if activeServiceAccount != nil { + return fmt.Errorf("--device can't be used with --service-account") + } + // Same parameters and cache key as the browser login, so the + // token is picked up by every later command. + handlerName = "oauth-device-code" + } + handler, ok := authHandlers[handlerName] if !ok { - return fmt.Errorf("unknown auth handler %q", profile.Auth.Name) + return fmt.Errorf("unknown auth handler %q", handlerName) } // Use a dummy request — we only want the side effect of fetching and @@ -394,6 +409,10 @@ account, which is a convenient way to check that its credentials work.`, return selectProjectIfUnset() }, } + + cmd.Flags().BoolVar(&device, "device", false, "Log in by confirming a code in a browser on any device, e.g. over SSH") + + return cmd } func logoutCmd() *cobra.Command { diff --git a/cli/serviceaccount_test.go b/cli/serviceaccount_test.go index fe75e826..f7caca7f 100644 --- a/cli/serviceaccount_test.go +++ b/cli/serviceaccount_test.go @@ -2,12 +2,14 @@ package cli import ( "io" + "net/http" "os" "path/filepath" "strings" "testing" "time" + "github.com/spf13/viper" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -178,3 +180,61 @@ func TestLogoutClearsOnlyServiceAccountToken(t *testing.T) { } assert.Equal(t, "browser-token", Cache.GetString("apimetrics:default.token")) } + +// recordingAuth records which handler a login used and with what. +type recordingAuth struct { + key string + params map[string]string +} + +func (a *recordingAuth) Parameters() []AuthParam { return nil } + +func (a *recordingAuth) OnRequest(req *http.Request, key string, params map[string]string) error { + a.key, a.params = key, params + return nil +} + +func TestLoginDeviceUsesDeviceCodeHandler(t *testing.T) { + defer func(sa *ServiceAccount, project string, stdout io.Writer) { + activeServiceAccount, projectIDOverride, Stdout = sa, project, stdout + }(activeServiceAccount, projectIDOverride, Stdout) + activeServiceAccount, projectIDOverride, Stdout = nil, "project-42", io.Discard + + viper.Set("rsh-profile", "default") + saved := authHandlers + authHandlers = map[string]AuthHandler{} + t.Cleanup(func() { + viper.Set("rsh-profile", "") + authHandlers = saved + }) + + browser, device := &recordingAuth{}, &recordingAuth{} + authHandlers["oauth-authorization-code"] = browser + authHandlers["oauth-device-code"] = device + initAPIConfig() + + cmd := loginCmd() + require.NoError(t, cmd.Flags().Set("device", "true")) + require.NoError(t, cmd.RunE(cmd, nil)) + + // Same cache key and parameters as the browser login, so later commands + // pick up the token. + assert.Empty(t, browser.key) + assert.Equal(t, "apimetrics:default", device.key) + assert.Equal(t, configs["apimetrics"].Profiles["default"].Auth.Params, device.params) +} + +func TestLoginDeviceRejectsServiceAccount(t *testing.T) { + defer func(sa *ServiceAccount) { activeServiceAccount = sa }(activeServiceAccount) + activeServiceAccount = &ServiceAccount{ClientID: "abc123", ClientSecret: "s3cret"} + + viper.Set("rsh-profile", "default") + t.Cleanup(func() { viper.Set("rsh-profile", "") }) + + initAPIConfig() + applyCredentialOverrides() + + cmd := loginCmd() + require.NoError(t, cmd.Flags().Set("device", "true")) + assert.ErrorContains(t, cmd.RunE(cmd, nil), "--device can't be used with --service-account") +} diff --git a/main.go b/main.go index a94fadd6..0e91178f 100644 --- a/main.go +++ b/main.go @@ -51,6 +51,7 @@ func main() { cli.AddAuth("oauth-client-credentials", &oauth.ClientCredentialsHandler{}) cli.AddAuth("oauth-authorization-code", &oauth.AuthorizationCodeHandler{}) cli.AddAuth("oauth-service-account", &oauth.ServiceAccountHandler{}) + cli.AddAuth("oauth-device-code", &oauth.DeviceCodeHandler{}) // Run the CLI, parsing arguments, making requests, and printing responses. if err := cli.Run(); err != nil { diff --git a/oauth/devicecode.go b/oauth/devicecode.go new file mode 100644 index 00000000..a61574f5 --- /dev/null +++ b/oauth/devicecode.go @@ -0,0 +1,284 @@ +package oauth + +import ( + "encoding/json" + "errors" + "fmt" + "net/http" + "net/url" + "os" + "strings" + "time" + + "apicontext.com/apimetrics/cli" + "golang.org/x/oauth2" +) + +// deviceGrantType is the token request grant type for the device flow. +const deviceGrantType = "urn:ietf:params:oauth:grant-type:device_code" + +// deviceAuthResponse is the device authorization endpoint's reply +// (RFC 8628 §3.2). +type deviceAuthResponse struct { + DeviceCode string `json:"device_code"` + UserCode string `json:"user_code"` + VerificationURI string `json:"verification_uri"` + VerificationURIComplete string `json:"verification_uri_complete"` + ExpiresIn int `json:"expires_in"` + Interval int `json:"interval"` +} + +// DeviceCodeTokenSource implements the OAuth 2.0 device authorization grant +// (RFC 8628). The user confirms a short code in a browser on any device while +// the CLI polls the token endpoint, so no local redirect server is needed. +// This suits remote shells, where the browser can't reach localhost. +type DeviceCodeTokenSource struct { + ClientID string + + // DeviceURL is the device authorization endpoint. + DeviceURL string + + // TokenURL is polled until the user has approved the request. + TokenURL string + + // EndpointParams are extra parameters, such as the audience, to send to + // the device authorization endpoint. + EndpointParams *url.Values + + Scopes []string + + // sleep waits between polls; tests replace it to run without delay. + sleep func(time.Duration) +} + +// Token starts a device authorization and waits for the user to approve it. +func (ds *DeviceCodeTokenSource) Token() (*oauth2.Token, error) { + params := url.Values{} + if ds.EndpointParams != nil { + for k, v := range *ds.EndpointParams { + params[k] = v + } + } + params.Set("client_id", ds.ClientID) + params.Set("scope", strings.Join(ds.Scopes, " ")) + + auth, err := requestDeviceCode(ds.DeviceURL, params.Encode()) + if err != nil { + return nil, err + } + + fmt.Fprintln(os.Stderr, "") + fmt.Fprintln(os.Stderr, "To log in, open the following URL in a browser on any device:") + fmt.Fprintln(os.Stderr, "") + if auth.VerificationURIComplete != "" { + fmt.Fprintln(os.Stderr, " "+auth.VerificationURIComplete) + } else { + fmt.Fprintln(os.Stderr, " "+auth.VerificationURI) + } + fmt.Fprintln(os.Stderr, "") + fmt.Fprintln(os.Stderr, "and confirm that it shows this code: "+auth.UserCode) + fmt.Fprintln(os.Stderr, "") + fmt.Fprintln(os.Stderr, "Waiting for approval...") + + return ds.poll(auth) +} + +// poll requests a token at the server's interval until the user approves or +// denies the request, or the device code expires (RFC 8628 §3.4, §3.5). +func (ds *DeviceCodeTokenSource) poll(auth *deviceAuthResponse) (*oauth2.Token, error) { + sleep := ds.sleep + if sleep == nil { + sleep = time.Sleep + } + + interval := time.Duration(auth.Interval) * time.Second + if interval <= 0 { + // RFC 8628 §3.2: clients must use 5 seconds if no interval is given. + interval = 5 * time.Second + } + + payload := url.Values{ + "grant_type": []string{deviceGrantType}, + "device_code": []string{auth.DeviceCode}, + "client_id": []string{ds.ClientID}, + }.Encode() + + // Stop after the code's lifetime rather than relying on the server to say + // expired_token. The wait is summed from the intervals rather than read + // from the clock, so tests that skip the sleep still terminate. + lifetime := time.Duration(auth.ExpiresIn) * time.Second + if lifetime <= 0 { + lifetime = 15 * time.Minute + } + for waited := time.Duration(0); waited < lifetime; waited += interval { + sleep(interval) + + token, code, err := requestDeviceToken(ds.TokenURL, payload) + switch code { + case "": + if err != nil { + return nil, err + } + return token, nil + case "authorization_pending": + continue + case "slow_down": + // RFC 8628 §3.5: increase the interval by 5 seconds. + interval += 5 * time.Second + continue + case "access_denied": + return nil, errors.New("login was denied in the browser") + case "expired_token": + return nil, errors.New("the login code expired before it was approved, please try again") + default: + return nil, err + } + } + + return nil, errors.New("the login code expired before it was approved, please try again") +} + +// requestDeviceCode asks the device authorization endpoint for a device code +// and the user code to show. +func requestDeviceCode(deviceURL, payload string) (*deviceAuthResponse, error) { + body, status, err := postForm(deviceURL, payload) + if err != nil { + return nil, err + } + + if status != http.StatusOK { + return nil, fmt.Errorf("bad response from device authorization endpoint:\n%s", body) + } + + auth := &deviceAuthResponse{} + if err := json.Unmarshal(body, auth); err != nil { + return nil, err + } + + if auth.DeviceCode == "" || auth.UserCode == "" { + return nil, fmt.Errorf("device authorization endpoint returned no code:\n%s", body) + } + + return auth, nil +} + +// requestDeviceToken polls the token endpoint once. On an OAuth error it +// returns the error code, e.g. authorization_pending, so the caller can decide +// whether to keep polling. +func requestDeviceToken(tokenURL, payload string) (*oauth2.Token, string, error) { + body, status, err := postForm(tokenURL, payload) + if err != nil { + return nil, "", err + } + + if status != http.StatusOK { + var oauthErr struct { + Error string `json:"error"` + } + if json.Unmarshal(body, &oauthErr) == nil && oauthErr.Error != "" { + return nil, oauthErr.Error, fmt.Errorf("bad response from token endpoint:\n%s", body) + } + return nil, "", fmt.Errorf("bad response from token endpoint:\n%s", body) + } + + token, err := parseToken(body) + return token, "", err +} + +// deviceURLFor derives the device authorization endpoint from the token URL, +// following Auth0's layout: https:///oauth/token becomes +// https:///oauth/device/code. +func deviceURLFor(tokenURL string) (string, error) { + u, err := url.Parse(tokenURL) + if err != nil { + return "", err + } + u.Path = "/oauth/device/code" + u.RawQuery = "" + return u.String(), nil +} + +// withAudience returns a copy of params with the audience added from the +// authorize URL's query string, which is where the browser login carries it, +// unless params already has one. +func withAudience(params url.Values, authorizeURL string) url.Values { + out := url.Values{} + for k, v := range params { + out[k] = v + } + + if out.Get("audience") == "" && authorizeURL != "" { + if u, err := url.Parse(authorizeURL); err == nil { + if audience := u.Query().Get("audience"); audience != "" { + out.Set("audience", audience) + } + } + } + + return out +} + +// DeviceCodeHandler sets up the OAuth 2.0 device authorization grant. It takes +// the same parameters as the authorization code handler so the CLI can switch +// between them for one login and share the cached token. +type DeviceCodeHandler struct{} + +// Parameters returns a list of OAuth2 device authorization inputs. +func (h *DeviceCodeHandler) Parameters() []cli.AuthParam { + return []cli.AuthParam{ + {Name: "client_id", Required: true, Help: "OAuth 2.0 Client ID"}, + {Name: "token_url", Required: true, Help: "OAuth 2.0 token URL, e.g. https://api.example.com/oauth/token"}, + {Name: "device_url", Help: "Optional device authorization URL, defaults to /oauth/device/code on the token URL's host"}, + {Name: "authorize_url", Help: "Optional authorization URL; an audience in its query string is sent with the device request"}, + {Name: "scopes", Help: "Optional scopes to request in the token"}, + } +} + +// OnRequest gets run before the request goes out on the wire. +func (h *DeviceCodeHandler) OnRequest(request *http.Request, key string, params map[string]string) error { + if request.Header.Get("Authorization") != "" { + return nil + } + + deviceURL := params["device_url"] + if deviceURL == "" { + var err error + if deviceURL, err = deviceURLFor(params["token_url"]); err != nil { + return err + } + } + + endpointParams := url.Values{} + for k, v := range params { + switch k { + case "client_id", "client_secret", "scopes", "authorize_url", "token_url", "redirect_url", "device_url": + // Not a custom param... + continue + } + endpointParams.Add(k, v) + } + + // Only the device request gets the audience: the refresh request below + // matches the browser login's, which never sends one. + deviceParams := withAudience(endpointParams, params["authorize_url"]) + + source := &DeviceCodeTokenSource{ + ClientID: params["client_id"], + DeviceURL: deviceURL, + TokenURL: params["token_url"], + EndpointParams: &deviceParams, + Scopes: strings.Split(params["scopes"], ","), + } + + // As with the browser login, try a cached refresh token first. + refreshSource := RefreshTokenSource{ + ClientID: params["client_id"], + TokenURL: params["token_url"], + Scopes: strings.Split(params["scopes"], ","), + EndpointParams: &endpointParams, + RefreshToken: cli.Cache.GetString(key + ".refresh"), + TokenSource: source, + } + + return TokenHandler(&refreshSource, key, request) +} diff --git a/oauth/devicecode_test.go b/oauth/devicecode_test.go new file mode 100644 index 00000000..d39b7cc7 --- /dev/null +++ b/oauth/devicecode_test.go @@ -0,0 +1,140 @@ +package oauth + +import ( + "net/http" + "net/http/httptest" + "net/url" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// deviceServer stands in for Auth0's device flow: /oauth/device/code hands out +// a code, and /oauth/token replies with each of the given responses in turn. +func deviceServer(t *testing.T, tokenReplies ...string) (*httptest.Server, *url.Values, *[]url.Values) { + t.Helper() + deviceForm := &url.Values{} + tokenForms := &[]url.Values{} + + s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + require.NoError(t, r.ParseForm()) + w.Header().Set("Content-Type", "application/json") + + switch r.URL.Path { + case "/oauth/device/code": + *deviceForm = r.PostForm + w.Write([]byte(`{"device_code":"dev-123","user_code":"ABCD-EFGH","verification_uri":"https://auth.example.com/activate","verification_uri_complete":"https://auth.example.com/activate?user_code=ABCD-EFGH","expires_in":900,"interval":5}`)) + case "/oauth/token": + *tokenForms = append(*tokenForms, r.PostForm) + reply := tokenReplies[len(*tokenForms)-1] + if reply[0] == '!' { + w.WriteHeader(http.StatusForbidden) + reply = reply[1:] + } + w.Write([]byte(reply)) + default: + http.NotFound(w, r) + } + })) + t.Cleanup(s.Close) + + return s, deviceForm, tokenForms +} + +func newDeviceSource(server *httptest.Server, slept *[]time.Duration) *DeviceCodeTokenSource { + return &DeviceCodeTokenSource{ + ClientID: "client-1", + DeviceURL: server.URL + "/oauth/device/code", + TokenURL: server.URL + "/oauth/token", + EndpointParams: &url.Values{"audience": []string{"https://client.apimetrics.io"}}, + Scopes: []string{"openid profile email"}, + sleep: func(d time.Duration) { *slept = append(*slept, d) }, + } +} + +func TestDeviceCodeTokenSourcePollsUntilApproved(t *testing.T) { + server, deviceForm, tokenForms := deviceServer(t, + `!{"error":"authorization_pending"}`, + `!{"error":"slow_down"}`, + `{"access_token":"an-access-token","token_type":"Bearer","expires_in":3600,"refresh_token":"a-refresh-token"}`, + ) + + var slept []time.Duration + token, err := newDeviceSource(server, &slept).Token() + require.NoError(t, err) + + assert.Equal(t, "an-access-token", token.AccessToken) + assert.Equal(t, "a-refresh-token", token.RefreshToken) + assert.True(t, token.Valid()) + + assert.Equal(t, "client-1", deviceForm.Get("client_id")) + assert.Equal(t, "openid profile email", deviceForm.Get("scope")) + assert.Equal(t, "https://client.apimetrics.io", deviceForm.Get("audience")) + + require.Len(t, *tokenForms, 3) + for _, form := range *tokenForms { + assert.Equal(t, deviceGrantType, form.Get("grant_type")) + assert.Equal(t, "dev-123", form.Get("device_code")) + assert.Equal(t, "client-1", form.Get("client_id")) + } + + // The server's interval, then 5 seconds more after slow_down. + assert.Equal(t, []time.Duration{5 * time.Second, 5 * time.Second, 10 * time.Second}, slept) +} + +func TestDeviceCodeTokenSourceStopsOnTerminalErrors(t *testing.T) { + for _, tc := range []struct{ name, reply, message string }{ + {"access denied", `!{"error":"access_denied"}`, "denied"}, + {"code expired", `!{"error":"expired_token"}`, "expired"}, + {"other oauth error", `!{"error":"invalid_grant"}`, "bad response from token endpoint"}, + {"not json", `!not json`, "bad response from token endpoint"}, + } { + t.Run(tc.name, func(t *testing.T) { + server, _, tokenForms := deviceServer(t, tc.reply) + + var slept []time.Duration + _, err := newDeviceSource(server, &slept).Token() + + assert.ErrorContains(t, err, tc.message) + assert.Len(t, *tokenForms, 1) + }) + } +} + +func TestDeviceCodeTokenSourceGivesUpWhenCodeExpires(t *testing.T) { + pending := make([]string, 200) + for i := range pending { + pending[i] = `!{"error":"authorization_pending"}` + } + server, _, tokenForms := deviceServer(t, pending...) + + var slept []time.Duration + _, err := newDeviceSource(server, &slept).Token() + + assert.ErrorContains(t, err, "expired") + // 900 second lifetime at a 5 second interval. + assert.Len(t, *tokenForms, 180) +} + +func TestDeviceURLFor(t *testing.T) { + u, err := deviceURLFor("https://auth.apimetrics.io/oauth/token") + require.NoError(t, err) + assert.Equal(t, "https://auth.apimetrics.io/oauth/device/code", u) +} + +func TestWithAudience(t *testing.T) { + authorize := "https://auth.apimetrics.io/authorize?audience=https://client.apimetrics.io" + + params := withAudience(url.Values{}, authorize) + assert.Equal(t, "https://client.apimetrics.io", params.Get("audience")) + + // An explicit audience wins, and the input is left untouched. + explicit := url.Values{"audience": []string{"https://other.example.com"}} + assert.Equal(t, "https://other.example.com", withAudience(explicit, authorize).Get("audience")) + + empty := url.Values{} + withAudience(empty, authorize) + assert.Empty(t, empty) +} diff --git a/oauth/request.go b/oauth/request.go index d44fc053..8c6f2b0c 100644 --- a/oauth/request.go +++ b/oauth/request.go @@ -26,28 +26,20 @@ type tokenResponse struct { // requestToken from the given URL with the given payload. This can be used // for many different grant types and will return a parsed token. func requestToken(tokenURL, payload string) (*oauth2.Token, error) { - req, err := http.NewRequest("POST", tokenURL, strings.NewReader(payload)) + body, status, err := postForm(tokenURL, payload) if err != nil { return nil, err } - req.Header.Add("content-type", "application/x-www-form-urlencoded") - - cli.LogDebugRequest(req) - - start := time.Now() - res, err := http.DefaultClient.Do(req) - if err != nil { - return nil, err - } - cli.LogDebugResponse(start, res) - defer res.Body.Close() - body, _ := io.ReadAll(res.Body) - - if res.StatusCode > 200 { + if status > 200 { return nil, fmt.Errorf("bad response from token endpoint:\n%s", body) } + return parseToken(body) +} + +// parseToken decodes a successful token endpoint response. +func parseToken(body []byte) (*oauth2.Token, error) { decoded := tokenResponse{} if err := json.Unmarshal(body, &decoded); err != nil { return nil, err @@ -67,3 +59,29 @@ func requestToken(tokenURL, payload string) (*oauth2.Token, error) { return token, nil } + +// postForm sends a form-encoded POST and returns the body and status code. +func postForm(target, payload string) ([]byte, int, error) { + req, err := http.NewRequest(http.MethodPost, target, strings.NewReader(payload)) + if err != nil { + return nil, 0, err + } + req.Header.Add("content-type", "application/x-www-form-urlencoded") + + cli.LogDebugRequest(req) + + start := time.Now() + res, err := http.DefaultClient.Do(req) + if err != nil { + return nil, 0, err + } + cli.LogDebugResponse(start, res) + defer res.Body.Close() + + body, err := io.ReadAll(res.Body) + if err != nil { + return nil, 0, err + } + + return body, res.StatusCode, nil +}