Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
25 changes: 22 additions & 3 deletions cli/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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
Expand All @@ -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 {
Expand Down
60 changes: 60 additions & 0 deletions cli/serviceaccount_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
)
Expand Down Expand Up @@ -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")
}
1 change: 1 addition & 0 deletions main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
Loading
Loading