Repository: digitalocean/doctl
Pull request #1328 (2022-12-14 18:12:11)

auth: Use OAuth token info endpoint to validate token.
Rather than hitting `/v2/account` to validate a token, we should use the OAuth info endpoint. Requests may not be authorized for  `/v2/account` while the OAuth info endpoint only validates the token.


Patch:
diff --git a/args.go b/args.go
index dece1778..471b0c00 100644
--- a/args.go
+++ b/args.go
@@ -436,4 +436,7 @@ const (
 
 	// ArgAlertPolicySlackURLs are the Slack URLs to send alerts to.
 	ArgAlertPolicySlackURLs = "slack-urls"
+
+	// ArgTokenValidationServer is the server used to validate an OAuth token
+	ArgTokenValidationServer = "token-validation-server"
 )
diff --git a/commands/auth.go b/commands/auth.go
index 68e147f7..fdf6cb51 100644
--- a/commands/auth.go
+++ b/commands/auth.go
@@ -32,6 +32,11 @@ import (
 	yaml "gopkg.in/yaml.v2"
 )
 
+const (
+	// TokenValidationServer is the default server used to validate an OAuth token
+	TokenValidationServer = "https://cloud.digitalocean.com"
+)
+
 // ErrUnknownTerminal signifies an unknown terminal. It is returned when doit
 // can't ascertain the current terminal type with requesting an auth token.
 var (
@@ -83,7 +88,7 @@ To remove accounts from the configuration file, you can run ` + "`" + `doctl aut
 		},
 	}
 
-	cmdBuilderWithInit(cmd, RunAuthInit(retrieveUserTokenFromCommandLine), "init", "Initialize doctl to use a specific account", `This command allows you to initialize doctl with a token that allows it to query and manage your account details and resources.
+	cmdAuthInit := cmdBuilderWithInit(cmd, RunAuthInit(retrieveUserTokenFromCommandLine), "init", "Initialize doctl to use a specific account", `This command allows you to initialize doctl with a token that allows it to query and manage your account details and resources.
 
 You will need an API token, which you can generate in the control panel at https://cloud.digitalocean.com/account/api/tokens.
 
@@ -92,6 +97,8 @@ You can provide a name to this initialization via the `+"`"+`--context`+"`"+` fl
 If the `+"`"+`--context`+"`"+` flag is not specified, a default authentication context will be created during initialization.
 
 If doctl is never initialized, you will need to specify an API token whenever you use a `+"`"+`doctl`+"`"+` command via the `+"`"+`--access-token`+"`"+` flag.`, Writer, false)
+	AddStringFlag(cmdAuthInit, doctl.ArgTokenValidationServer, "", TokenValidationServer, "The server used to validate a token")
+
 	cmdAuthSwitch := cmdBuilderWithInit(cmd, RunAuthSwitch, "switch", "Switches between authentication contexts", `This command allows you to switch between accounts with authentication contexts you've already created.
 
 To see a list of available authentication contexts, call `+"`"+`doctl auth list`+"`"+`.
@@ -146,7 +153,12 @@ func RunAuthInit(retrieveUserTokenFunc func() (string, error)) func(c *CmdConfig
 			return fmt.Errorf("Unable to initialize DigitalOcean API client with new token: %s", err)
 		}
 
-		if _, err := c.Account().Get(); err != nil {
+		server, err := c.Doit.GetString(c.NS, doctl.ArgTokenValidationServer)
+		if err != nil {
+			return err
+		}
+
+		if _, err := c.OAuth().TokenInfo(server); err != nil {
 			fmt.Fprintln(c.Out, "invalid token")
 			fmt.Fprintln(c.Out)
 			return fmt.Errorf("Unable to use supplied token to access API: %s", err)
diff --git a/commands/command_config.go b/commands/command_config.go
index ae93807f..811d285d 100644
--- a/commands/command_config.go
+++ b/commands/command_config.go
@@ -70,6 +70,7 @@ type CmdConfig struct {
 	Apps              func() do.AppsService
 	Monitoring        func() do.MonitoringService
 	Serverless        func() do.ServerlessService
+	OAuth             func() do.OAuthService
 }
 
 // NewCmdConfig creates an instance of a CmdConfig.
@@ -122,6 +123,7 @@ func NewCmdConfig(ns string, dc doctl.Config, out io.Writer, args []string, init
 			c.Serverless = func() do.ServerlessService {
 				return do.NewServerlessService(godoClient, getServerlessDirectory(), accessToken)
 			}
+			c.OAuth = func() do.OAuthService { return do.NewOAuthService(godoClient) }
 
 			return nil
 		},
diff --git a/do/mocks/OAuthService.go b/do/mocks/OAuthService.go
new file mode 100644
index 00000000..1e1e6d7a
--- /dev/null
+++ b/do/mocks/OAuthService.go
@@ -0,0 +1,50 @@
+// Code generated by MockGen. DO NOT EDIT.
+// Source: oauth.go
+
+// Package mocks is a generated GoMock package.
+package mocks
+
+import (
+	reflect "reflect"
+
+	do "github.com/digitalocean/doctl/do"
+	gomock "github.com/golang/mock/gomock"
+)
+
+// MockOAuthService is a mock of OAuthService interface.
+type MockOAuthService struct {
+	ctrl     *gomock.Controller
+	recorder *MockOAuthServiceMockRecorder
+}
+
+// MockOAuthServiceMockRecorder is the mock recorder for MockOAuthService.
+type MockOAuthServiceMockRecorder struct {
+	mock *MockOAuthService
+}
+
+// NewMockOAuthService creates a new mock instance.
+func NewMockOAuthService(ctrl *gomock.Controller) *MockOAuthService {
+	mock := &MockOAuthService{ctrl: ctrl}
+	mock.recorder = &MockOAuthServiceMockRecorder{mock}
+	return mock
+}
+
+// EXPECT returns an object that allows the caller to indicate expected use.
+func (m *MockOAuthService) EXPECT() *MockOAuthServiceMockRecorder {
+	return m.recorder
+}
+
+// TokenInfo mocks base method.
+func (m *MockOAuthService) TokenInfo(arg0 string) (*do.OAuthTokenInfo, error) {
+	m.ctrl.T.Helper()
+	ret := m.ctrl.Call(m, "TokenInfo", arg0)
+	ret0, _ := ret[0].(*do.OAuthTokenInfo)
+	ret1, _ := ret[1].(error)
+	return ret0, ret1
+}
+
+// TokenInfo indicates an expected call of TokenInfo.
+func (mr *MockOAuthServiceMockRecorder) TokenInfo(arg0 interface{}) *gomock.Call {
+	mr.mock.ctrl.T.Helper()
+	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "TokenInfo", reflect.TypeOf((*MockOAuthService)(nil).TokenInfo), arg0)
+}
diff --git a/do/oauth.go b/do/oauth.go
new file mode 100644
index 00000000..002a1305
--- /dev/null
+++ b/do/oauth.go
@@ -0,0 +1,80 @@
+/*
+Copyright 2018 The Doctl Authors All rights reserved.
+Licensed under the Apache License, Version 2.0 (the "License");
+you may not use this file except in compliance with the License.
+You may obtain a copy of the License at
+    http://www.apache.org/licenses/LICENSE-2.0
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package do
+
+import (
+	"context"
+	"net/http"
+
+	"github.com/digitalocean/godo"
+)
+
+const (
+	oauthBaseURL  = "https://cloud.digitalocean.com"
+	tokenInfoPath = "/v1/oauth/token/info"
+)
+
+// OAuthTokenInfo contains information about an OAuth token
+type OAuthTokenInfo struct {
+	ResourceOwnerID  int              `json:"resource_owner_id"`
+	Scopes           []string         `json:"scopes"`
+	ExpiresInSeconds int              `json:"expires_in_seconds"`
+	Application      OAuthApplication `json:"application"`
+	CreatedAt        int              `json:"created_at"`
+}
+
+// OAuthApplication contains info about an OAuth application
+type OAuthApplication struct {
+	UID string `json:"uid"`
+}
+
+// OAuthService is an interface for interacting with DigitalOcean's account api.
+type OAuthService interface {
+	TokenInfo(string) (*OAuthTokenInfo, error)
+}
+
+type oauthService struct {
+	client *godo.Client
+	server string
+}
+
+var _ OAuthService = &oauthService{}
+
+// NewOAuthService builds an OAuthService instance.
+func NewOAuthService(godoClient *godo.Client) OAuthService {
+	return &oauthService{
+		client: godoClient,
+	}
+}
+
+func (oa *oauthService) TokenInfo(server string) (*OAuthTokenInfo, error) {
+	tokenInfoURI := oauthBaseURL + tokenInfoPath
+	if server != "" {
+		tokenInfoURI = server + tokenInfoPath
+	}
+
+	ctx := context.TODO()
+	req, err := oa.client.NewRequest(ctx, http.MethodGet, tokenInfoURI, nil)
+	if err != nil {
+		return nil, err
+	}
+
+	info := new(OAuthTokenInfo)
+	_, err = oa.client.Do(ctx, req, info)
+	if err != nil {
+		return nil, err
+	}
+
+	return info, nil
+}
diff --git a/scripts/regenmocks.sh b/scripts/regenmocks.sh
index b52b80f5..9a34ee15 100755
--- a/scripts/regenmocks.sh
+++ b/scripts/regenmocks.sh
@@ -25,6 +25,7 @@ mockgen -source images.go -package=mocks ImageService > mocks/ImageService.go
 mockgen -source invoices.go -package=mocks InvoicesService > mocks/InvoicesService.go
 mockgen -source kubernetes.go -package=mocks KubernetesService > mocks/KubernetesService.go
 mockgen -source load_balancers.go -package=mocks LoadBalancersService > mocks/LoadBalancersService.go
+mockgen -source oauth.go -package=mocks OAuthService > mocks/OAuthService.go
 mockgen -source projects.go -package=mocks ProjectsService > mocks/ProjectsService.go
 mockgen -source regions.go -package=mocks RegionsService > mocks/RegionsService.go
 mockgen -source registry.go -package=mocks RegistryService > mocks/RegistryService.go


Test patch:
diff --git a/commands/auth_test.go b/commands/auth_test.go
index f093fe9a..b8745401 100644
--- a/commands/auth_test.go
+++ b/commands/auth_test.go
@@ -25,6 +25,7 @@ import (
 
 	"github.com/digitalocean/doctl"
 	"github.com/digitalocean/doctl/do"
+	"github.com/golang/mock/gomock"
 	"github.com/spf13/viper"
 	"github.com/stretchr/testify/assert"
 	yaml "gopkg.in/yaml.v2"
@@ -50,7 +51,7 @@ func TestAuthInit(t *testing.T) {
 	cfgFileWriter = func() (io.WriteCloser, error) { return &nopWriteCloser{Writer: ioutil.Discard}, nil }
 
 	withTestClient(t, func(config *CmdConfig, tm *tcMocks) {
-		tm.account.EXPECT().Get().Return(&do.Account{}, nil)
+		tm.oauth.EXPECT().TokenInfo(gomock.Any()).Return(&do.OAuthTokenInfo{}, nil)
 
 		err := RunAuthInit(retrieveUserTokenFunc)(config)
 		assert.NoError(t, err)
@@ -76,7 +77,7 @@ func TestAuthInitConfig(t *testing.T) {
 	}
 
 	withTestClient(t, func(config *CmdConfig, tm *tcMocks) {
-		tm.account.EXPECT().Get().Return(&do.Account{}, nil)
+		tm.oauth.EXPECT().TokenInfo(gomock.Any()).Return(&do.OAuthTokenInfo{}, nil)
 
 		err := RunAuthInit(retrieveUserTokenFunc)(config)
 		assert.NoError(t, err)
@@ -116,7 +117,7 @@ func TestAuthInitWithProvidedToken(t *testing.T) {
 	cfgFileWriter = func() (io.WriteCloser, error) { return &nopWriteCloser{Writer: ioutil.Discard}, nil }
 
 	withTestClient(t, func(config *CmdConfig, tm *tcMocks) {
-		tm.account.EXPECT().Get().Return(&do.Account{}, nil)
+		tm.oauth.EXPECT().TokenInfo(gomock.Any()).Return(&do.OAuthTokenInfo{}, nil)
 
 		err := RunAuthInit(retrieveUserTokenFunc)(config)
 		assert.NoError(t, err)
diff --git a/commands/commands_test.go b/commands/commands_test.go
index 561fef6b..7c983fa9 100644
--- a/commands/commands_test.go
+++ b/commands/commands_test.go
@@ -14,11 +14,12 @@ limitations under the License.
 package commands
 
 import (
-	"github.com/spf13/cobra"
 	"io/ioutil"
 	"sort"
 	"testing"
 
+	"github.com/spf13/cobra"
+
 	"github.com/digitalocean/doctl"
 	"github.com/digitalocean/doctl/do"
 	domocks "github.com/digitalocean/doctl/do/mocks"
@@ -185,6 +186,7 @@ type tcMocks struct {
 	appBuilderFactory     *builder.MockComponentBuilderFactory
 	appBuilder            *builder.MockComponentBuilder
 	appDockerEngineClient *builder.MockDockerEngineClient
+	oauth                 *domocks.MockOAuthService
 }
 
 func withTestClient(t *testing.T, tFn testFn) {
@@ -229,6 +231,7 @@ func withTestClient(t *testing.T, tFn testFn) {
 		appBuilderFactory:     builder.NewMockComponentBuilderFactory(ctrl),
 		appBuilder:            builder.NewMockComponentBuilder(ctrl),
 		appDockerEngineClient: builder.NewMockDockerEngineClient(ctrl),
+		oauth:                 domocks.NewMockOAuthService(ctrl),
 	}
 
 	testConfig := doctl.NewTestConfig()
@@ -282,6 +285,7 @@ func withTestClient(t *testing.T, tFn testFn) {
 		Apps:              func() do.AppsService { return tm.apps },
 		Monitoring:        func() do.MonitoringService { return tm.monitoring },
 		Serverless:        func() do.ServerlessService { return tm.serverless },
+		OAuth:             func() do.OAuthService { return tm.oauth },
 	}
 
 	tFn(config, tm)
diff --git a/integration/auth_test.go b/integration/auth_test.go
index 7fee4bc0..e3bcb9a2 100644
--- a/integration/auth_test.go
+++ b/integration/auth_test.go
@@ -34,11 +34,11 @@ var _ = suite("auth/init", func(t *testing.T, when spec.G, it spec.S) {
 			w.Header().Add("content-type", "application/json")
 
 			switch req.URL.Path {
-			case "/v2/account":
+			case "/v1/oauth/token/info":
 				auth := req.Header.Get("Authorization")
 
 				if auth == "Bearer first-token" || auth == "Bearer second-token" || auth == "Bearer some-magic-token" {
-					w.Write([]byte(`{ "account":{}}`))
+					w.Write([]byte(`{"resource_owner_id":123}`))
 					return
 				}
 
@@ -73,6 +73,7 @@ var _ = suite("auth/init", func(t *testing.T, when spec.G, it spec.S) {
 				"--config", testConfig,
 				"auth",
 				"init",
+				"--token-validation-server", server.URL,
 			)
 
 			ptmx, err := pty.Start(cmd)
@@ -109,6 +110,7 @@ var _ = suite("auth/init", func(t *testing.T, when spec.G, it spec.S) {
 				"auth",
 				"init",
 				"--access-token", "some-magic-token",
+				"--token-validation-server", server.URL,
 			)
 
 			_, err := cmd.CombinedOutput()
@@ -135,6 +137,7 @@ context: default
 				"auth",
 				"init",
 				"--access-token", "some-magic-token",
+				"--token-validation-server", server.URL,
 			)
 
 			_, err := cmd.CombinedOutput()
@@ -153,6 +156,7 @@ context: default
 				"-u", server.URL,
 				"auth",
 				"init",
+				"--token-validation-server", server.URL,
 			)
 
 			ptmx, err := pty.Start(cmd)
@@ -194,6 +198,7 @@ context: default
 				"--config", testConfig,
 				"auth",
 				"init",
+				"--token-validation-server", server.URL,
 			)
 
 			ptmx, err := pty.Start(cmd)
@@ -210,7 +215,7 @@ context: default
 			ptmx.Close()
 
 			expect.Contains(buf.String(), "Validating token... invalid token")
-			expect.Contains(buf.String(), fmt.Sprintf("Unable to use supplied token to access API: GET %s/v2/account: 401", server.URL))
+			expect.Contains(buf.String(), fmt.Sprintf("Unable to use supplied token to access API: GET %s/v1/oauth/token/info: 401", server.URL))
 		})
 	})