Repository: digitalocean/doctl
Pull request #1327 (2022-12-13 21:18:52)

Implement tokens sub-commands.
Opening as a draft until https://github.com/digitalocean/godo/pull/591 is released.

danaelhe: Looks great! Very natural command line look and feel. It seems that the "token" command might be missing from the "available commands" list? Is that intentional for now?

```
Available Commands:
  1-click         Display commands that pertain to 1-click applications
  account         Display commands that retrieve account details
  apps            Display commands for working with apps
  auth            Display commands for authenticating doctl with an account
  balance         Display commands for retrieving your account balance
  billing-history Display commands for retrieving your billing history
  completion      Generate the autocompletion script for the specified shell
  compute         Display commands that manage infrastructure
  databases       Display commands that manage databases
  help            Help about any command
  invoice         Display commands for retrieving invoices for your account
  kubernetes      Displays commands to manage Kubernetes clusters and configurations
  monitoring      [Beta] Display commands to manage monitoring
  projects        Manage projects and assign resources to them
  registry        Display commands for working with container registries
  serverless      Develop, test, and deploy serverless functions
  version         Show the current version
  vpcs            Display commands that manage VPCs
```
andrewsomething: Yes, that is intentional as this is a very limited release. 

https://github.com/digitalocean/doctl/blob/dcca9fdfbe620a1ac70c945b31f48ceb60ffc2b5/commands/tokens.go#L30

Patch:
diff --git a/args.go b/args.go
index 471b0c00..5d345e3b 100644
--- a/args.go
+++ b/args.go
@@ -439,4 +439,15 @@ const (
 
 	// ArgTokenValidationServer is the server used to validate an OAuth token
 	ArgTokenValidationServer = "token-validation-server"
+
+	// ArgTokenScopeNamespace is the namespace for a scope
+	ArgTokenScopeNamespace = "namespace"
+	// ArgTokenExpirySeconds is the number of seconds from when a token was create that it will expire
+	ArgTokenExpirySeconds = "expiry-seconds"
+	// ArgTokenExpiresIn is the duration until a token expires
+	ArgTokenExpiresIn = "expires-in"
+	// ArgTokenScopes is a list of token scopes
+	ArgTokenScopes = "scopes"
+	// ArgTokenUpdatedName is the name to used to update a token
+	ArgTokenUpdatedName = "updated-name"
 )
diff --git a/commands/command_config.go b/commands/command_config.go
index 811d285d..b4234b41 100644
--- a/commands/command_config.go
+++ b/commands/command_config.go
@@ -71,6 +71,7 @@ type CmdConfig struct {
 	Monitoring        func() do.MonitoringService
 	Serverless        func() do.ServerlessService
 	OAuth             func() do.OAuthService
+	Tokens            func() do.TokensService
 }
 
 // NewCmdConfig creates an instance of a CmdConfig.
@@ -124,6 +125,7 @@ func NewCmdConfig(ns string, dc doctl.Config, out io.Writer, args []string, init
 				return do.NewServerlessService(godoClient, getServerlessDirectory(), accessToken)
 			}
 			c.OAuth = func() do.OAuthService { return do.NewOAuthService(godoClient) }
+			c.Tokens = func() do.TokensService { return do.NewTokensService(godoClient) }
 
 			return nil
 		},
diff --git a/commands/displayers/tokens.go b/commands/displayers/tokens.go
new file mode 100644
index 00000000..e2935f2a
--- /dev/null
+++ b/commands/displayers/tokens.go
@@ -0,0 +1,103 @@
+package displayers
+
+import (
+	"io"
+	"time"
+
+	"github.com/digitalocean/doctl/do"
+)
+
+type Tokens struct {
+	Tokens          []do.Token
+	WithAccessToken bool
+}
+
+var _ Displayable = &Tokens{}
+
+func (t *Tokens) JSON(out io.Writer) error {
+	return writeJSON(t.Tokens, out)
+}
+
+func (t *Tokens) Cols() []string {
+	cols := []string{
+		"ID", "Name", "Scopes", "ExpiresAt",
+	}
+
+	// We only return the the access token in the recreate response.
+	if t.WithAccessToken {
+		cols = append(cols, "AccessToken")
+	} else {
+		cols = append(cols, "LastUsedAt", "CreatedAt")
+	}
+
+	return cols
+}
+
+func (t *Tokens) ColMap() map[string]string {
+	return map[string]string{
+		"ID":          "ID",
+		"Name":        "Name",
+		"Scopes":      "Scopes",
+		"ExpiresAt":   "Expires At",
+		"CreatedAt":   "Created At",
+		"LastUsedAt":  "Last Used At",
+		"AccessToken": "Access Token",
+	}
+}
+
+func (t *Tokens) KV() []map[string]interface{} {
+	out := make([]map[string]interface{}, 0, len(t.Tokens))
+
+	for _, t := range t.Tokens {
+		m := map[string]interface{}{
+			"ID":          t.ID,
+			"Name":        t.Name,
+			"Scopes":      t.Scopes,
+			"ExpiresAt":   "Never",
+			"CreatedAt":   t.CreatedAt.String(),
+			"LastUsedAt":  t.LastUsedAt,
+			"AccessToken": t.AccessToken,
+		}
+
+		if t.ExpirySeconds != nil {
+			m["ExpiresAt"] = t.CreatedAt.Add(time.Duration(*t.ExpirySeconds) * time.Second)
+		}
+		out = append(out, m)
+	}
+
+	return out
+}
+
+type TokenScopes struct {
+	TokenScopes []do.TokenScope
+}
+
+var _ Displayable = &TokenScopes{}
+
+func (s *TokenScopes) JSON(out io.Writer) error {
+	return writeJSON(s.TokenScopes, out)
+}
+
+func (s *TokenScopes) Cols() []string {
+	return []string{"Name"}
+}
+
+func (s *TokenScopes) ColMap() map[string]string {
+	return map[string]string{
+		"Name": "Name",
+	}
+}
+
+func (s *TokenScopes) KV() []map[string]interface{} {
+	out := make([]map[string]interface{}, 0)
+
+	for _, s := range s.TokenScopes {
+		m := map[string]interface{}{
+			"Name": s.Name,
+		}
+
+		out = append(out, m)
+	}
+
+	return out
+}
diff --git a/commands/doit.go b/commands/doit.go
index cfcdce78..42314f90 100644
--- a/commands/doit.go
+++ b/commands/doit.go
@@ -164,6 +164,7 @@ func addCommands() {
 	DoitCmd.AddCommand(OneClicks())
 	DoitCmd.AddCommand(Monitoring())
 	DoitCmd.AddCommand(Serverless())
+	DoitCmd.AddCommand(Tokens())
 }
 
 func computeCmd() *Command {
@@ -217,6 +218,12 @@ func requiredOpt() flagOpt {
 	}
 }
 
+func hiddenFlag() flagOpt {
+	return func(c *Command, name, key string) {
+		c.Flags().MarkHidden(name)
+	}
+}
+
 // AddStringFlag adds a string flag to a command.
 func AddStringFlag(cmd *Command, name, shorthand, dflt, desc string, opts ...flagOpt) {
 	fn := flagName(cmd, name)
diff --git a/commands/tokens.go b/commands/tokens.go
new file mode 100644
index 00000000..5fca76e3
--- /dev/null
+++ b/commands/tokens.go
@@ -0,0 +1,334 @@
+package commands
+
+import (
+	"errors"
+	"fmt"
+	"strconv"
+	"time"
+
+	"github.com/MakeNowJust/heredoc"
+	"github.com/digitalocean/doctl"
+	"github.com/digitalocean/doctl/commands/displayers"
+	"github.com/digitalocean/doctl/do"
+	"github.com/digitalocean/godo"
+	"github.com/spf13/cobra"
+)
+
+// Tokens creates the tokens command
+func Tokens() *Command {
+	cmd := &Command{
+		Command: &cobra.Command{
+			Use:     "token",
+			Aliases: []string{"tokens"},
+			Short:   "[BETA] Display commands for managing DigitalOcean API tokens",
+			Long: heredoc.Docf(`
+			[BETA] Display commands for managing DigitalOcean API tokens.
+
+			Use the sub-commands of %s to create, manage, or revoke DigitalOcean API tokens.`,
+				"`doctl token`",
+			),
+			Hidden: true,
+		},
+	}
+
+	tokenDetails := heredoc.Docf(`
+	- The ID of the token
+	- The name of the token
+	- The scopes applied to the token
+	- The timestamp for when the token will expire
+	- The date that it was last used
+	- The timestamp for when it was created
+	`)
+
+	CmdBuilder(cmd, RunTokenGet, "get <id|name>",
+		"Retrieve information about a DigitalOcean API token",
+		heredoc.Docf(`Display the details for a specific DigitalOcean API token on your account. This includes:
+
+		%s`, tokenDetails),
+		Writer, aliasOpt("g"), displayerType(&displayers.Tokens{}))
+
+	CmdBuilder(cmd, RunTokenList, "list",
+		"List your DigitalOcean API tokens",
+		heredoc.Docf(`Lists the following details for DigitalOcean API tokens on your account:
+
+		%s`, tokenDetails),
+		Writer, aliasOpt("ls"), displayerType(&displayers.Tokens{}))
+
+	tokensCreateDesc := heredoc.Docf(`Create a new DigitalOcean API token with granular scopes.
+
+		You can find all of the scopes that are available to be applied to a token using the
+		%s sub-command. For example, to create a token that can be used to create and manage
+		Droplets, use:
+
+		    doctl token create droplet-service-token --scopes droplet:create,droplet:read,droplet:delete,droplet:operate
+
+		By default, tokens will expire in 30 days (2592000 seconds). The maximum
+		expiration for a token is 90 days (7776000 seconds). Use the %s flag to customize the
+		expiration time.`,
+		"`doctl token list-scopes`", "`--expires-in`")
+	createTokenCmd := CmdBuilder(cmd, RunTokenCreate, "create <name>",
+		"Create a new DigitalOcean API token", tokensCreateDesc,
+		Writer, aliasOpt("c", "issue"), displayerType(&displayers.Tokens{}))
+	// We hide this by default to prefer --expires-in which accepts a duration,
+	// but we still offer --expiry-seconds for compatibility with other commands.
+	AddIntFlag(createTokenCmd, doctl.ArgTokenExpirySeconds, "", 0,
+		"Number of seconds from created at in which the token will expire", hiddenFlag())
+	AddStringFlag(createTokenCmd, doctl.ArgTokenExpiresIn, "", "",
+		`Duration (e.g. 24h, 60m, or 3600s) from now until the token will expire. Valid units are: "h", "m", "s".`)
+	AddStringSliceFlag(createTokenCmd, doctl.ArgTokenScopes, "", nil,
+		`Scopes to assign to the to token (e.g. droplet:read)`, requiredOpt())
+
+	updateTokenCmd := CmdBuilder(cmd, RunTokenUpdate, "update <id|name>",
+		"Update an existing DigitalOcean API token",
+		heredoc.Docf(`Update the name or scopes for an existing DigitalOcean API token.
+
+		You can find all of the scopes that are available to be applied to a token using the
+		%s sub-command.`, "`doctl token list-scopes`"),
+		Writer, aliasOpt("u"), displayerType(&displayers.Tokens{}))
+	AddStringSliceFlag(updateTokenCmd, doctl.ArgTokenScopes, "", nil,
+		`Scopes to assign to the to token (e.g. droplet:read)`)
+	AddStringFlag(updateTokenCmd, doctl.ArgTokenUpdatedName, "", "", "The new name for the token")
+
+	revokeTokenCmd := CmdBuilder(cmd, RunTokenRevoke, "revoke <id|name>...",
+		"Revoke an existing DigitalOcean API token",
+		heredoc.Docf(`Revoke an existing DigitalOcean API token. Once revoked, the token can no longer
+		be updated or used to authenticate with the DigitalOcean API.`),
+		Writer, aliasOpt("rm", "delete"))
+	AddBoolFlag(revokeTokenCmd, doctl.ArgForce, doctl.ArgShortForce, false, "Revoke the token without a confirmation prompt")
+
+	listScopesCmd := CmdBuilder(cmd, RunTokenListScopes, "list-scopes",
+		"List available scopes for DigitalOcean API tokens",
+		heredoc.Docf(`Use this command to list all of the available scopes for DigitalOcean API tokens.
+
+		A scope consists of a namespace and an action (<namespace>:<action>). The namespace
+		describes the resource type the scope authorizes the token to interact with. The action
+		describes what the token will be authorized to do with that resource. For example, a
+		droplet:read scoped token can list or retrieve information about Droplets but not create
+		or destroy one.
+
+		The list of scopes can be filtered by namespace using the %s flag.`, "`--namespace`"),
+		Writer, aliasOpt("ls"), displayerType(&displayers.Tokens{}))
+	AddStringFlag(listScopesCmd, doctl.ArgTokenScopeNamespace, "", "", "Filter scopes by their namespace")
+
+	return cmd
+}
+
+// RunTokenGet returns a single API token.
+func RunTokenGet(c *CmdConfig) error {
+	err := ensureOneArg(c)
+	if err != nil {
+		return err
+	}
+
+	id, err := strconv.Atoi(c.Args[0])
+	if err != nil {
+		// The argument is not an integer. Look for a name.
+		found, err := tokenFromName(c, c.Args[0])
+		if err != nil {
+			return err
+		}
+
+		return c.Display(&displayers.Tokens{Tokens: found})
+	}
+
+	token, err := c.Tokens().Get(id)
+	if err != nil {
+		return err
+	}
+
+	return c.Display(&displayers.Tokens{Tokens: []do.Token{*token}})
+}
+
+// RunTokenList returns a list of Tokens.
+func RunTokenList(c *CmdConfig) error {
+	tokens, err := c.Tokens().List()
+	if err != nil {
+		return err
+	}
+
+	return c.Display(&displayers.Tokens{Tokens: tokens})
+}
+
+// RunTokenCreate creates a DigitalOcean API token
+func RunTokenCreate(c *CmdConfig) error {
+	err := ensureOneArg(c)
+	if err != nil {
+		return err
+	}
+
+	scopes, err := c.Doit.GetStringSlice(c.NS, doctl.ArgTokenScopes)
+	if err != nil {
+		return err
+	}
+
+	expiry, err := c.Doit.GetIntPtr(c.NS, doctl.ArgTokenExpirySeconds)
+	if err != nil {
+		return err
+	}
+
+	expiration, err := c.Doit.GetString(c.NS, doctl.ArgTokenExpiresIn)
+	if err != nil {
+		return err
+	}
+
+	if expiry != nil && expiration != "" {
+		return errors.New("the `--expiration` and `--expiry-seconds` flags are mutually exclusive")
+	}
+
+	if expiration != "" {
+		expirationDur, err := time.ParseDuration(expiration)
+		if err != nil {
+			return err
+		}
+		expiry = godo.PtrTo(int(expirationDur.Seconds()))
+	}
+
+	createRequest := &godo.TokenCreateRequest{
+		Name:          c.Args[0],
+		Scopes:        scopes,
+		ExpirySeconds: expiry,
+	}
+
+	token, err := c.Tokens().Create(createRequest)
+	if err != nil {
+		return err
+	}
+
+	return c.Display(&displayers.Tokens{
+		Tokens:          []do.Token{*token},
+		WithAccessToken: true,
+	})
+}
+
+// RunTokenUpdate updates a DigitalOcean API token
+func RunTokenUpdate(c *CmdConfig) error {
+	err := ensureOneArg(c)
+	if err != nil {
+		return err
+	}
+
+	var id int
+	id, err = strconv.Atoi(c.Args[0])
+	if err != nil {
+		// The argument is not an integer. Look for a name.
+		found, err := tokenFromName(c, c.Args[0])
+		if err != nil {
+			return err
+		}
+
+		id = found[0].ID
+	}
+
+	updateRequest := &godo.TokenUpdateRequest{}
+	scopes, err := c.Doit.GetStringSlice(c.NS, doctl.ArgTokenScopes)
+	if err != nil {
+		return err
+	}
+	if len(scopes) > 0 {
+		updateRequest.Scopes = scopes
+	}
+
+	updateName, err := c.Doit.GetString(c.NS, doctl.ArgTokenUpdatedName)
+	if err != nil {
+		return err
+	}
+	if updateName != "" {
+		updateRequest.Name = updateName
+	}
+
+	if len(scopes) < 1 && updateName == "" {
+		return errors.New("must supply at least one of --scopes or --updated-name")
+	}
+
+	token, err := c.Tokens().Update(id, updateRequest)
+	if err != nil {
+		return err
+	}
+
+	return c.Display(&displayers.Tokens{Tokens: []do.Token{*token}})
+}
+
+// RunTokenRevoke revokes a DigitalOcean API token
+func RunTokenRevoke(c *CmdConfig) error {
+	err := ensureOneArg(c)
+	if err != nil {
+		return err
+	}
+
+	force, err := c.Doit.GetBool(c.NS, doctl.ArgForce)
+	if err != nil {
+		return err
+	}
+
+	if force || AskForConfirmDelete("API token", len(c.Args)) == nil {
+		var idList []int
+		for _, in := range c.Args {
+			var id int
+			id, err = strconv.Atoi(in)
+			if err != nil {
+				// The argument is not an integer. Look for a name.
+				found, err := tokenFromName(c, in)
+				if err != nil {
+					return err
+				}
+
+				id = found[0].ID
+			}
+			idList = append(idList, id)
+		}
+
+		for _, id := range idList {
+			err = c.Tokens().Revoke(id)
+			if err != nil {
+				return err
+			}
+		}
+	} else {
+		return errOperationAborted
+	}
+
+	return nil
+}
+
+// RunTokenListScopes returns a list of token scopes.
+func RunTokenListScopes(c *CmdConfig) error {
+	namespace, err := c.Doit.GetString(c.NS, doctl.ArgTokenScopeNamespace)
+	if err != nil {
+		return err
+	}
+
+	scopes, err := c.Tokens().ListScopes(namespace)
+	if err != nil {
+		return err
+	}
+
+	return c.Display(&displayers.TokenScopes{TokenScopes: scopes})
+}
+
+// tokenFromName attempts to find a token by its name. An error is returned
+// if more than one token is found with the same name as there is no
+// uniqueness constraint on token names. An error is also returned if
+// no token is found with the given name.
+func tokenFromName(c *CmdConfig, name string) ([]do.Token, error) {
+	var found []do.Token
+	tokens, err := c.Tokens().List()
+	if err != nil {
+		return nil, err
+	}
+
+	for _, t := range tokens {
+		if t.Name == name {
+			found = append(found, t)
+		}
+	}
+
+	if len(found) > 1 {
+		return nil, fmt.Errorf("%d tokens named %s found", len(found), name)
+	}
+
+	if len(found) < 1 {
+		return nil, fmt.Errorf("no tokens named %s found", name)
+	}
+
+	return found, nil
+}
diff --git a/do/mocks/TokensService.go b/do/mocks/TokensService.go
new file mode 100644
index 00000000..827a2f6c
--- /dev/null
+++ b/do/mocks/TokensService.go
@@ -0,0 +1,125 @@
+// Code generated by MockGen. DO NOT EDIT.
+// Source: tokens.go
+
+// Package mocks is a generated GoMock package.
+package mocks
+
+import (
+	reflect "reflect"
+
+	do "github.com/digitalocean/doctl/do"
+	godo "github.com/digitalocean/godo"
+	gomock "github.com/golang/mock/gomock"
+)
+
+// MockTokensService is a mock of TokensService interface.
+type MockTokensService struct {
+	ctrl     *gomock.Controller
+	recorder *MockTokensServiceMockRecorder
+}
+
+// MockTokensServiceMockRecorder is the mock recorder for MockTokensService.
+type MockTokensServiceMockRecorder struct {
+	mock *MockTokensService
+}
+
+// NewMockTokensService creates a new mock instance.
+func NewMockTokensService(ctrl *gomock.Controller) *MockTokensService {
+	mock := &MockTokensService{ctrl: ctrl}
+	mock.recorder = &MockTokensServiceMockRecorder{mock}
+	return mock
+}
+
+// EXPECT returns an object that allows the caller to indicate expected use.
+func (m *MockTokensService) EXPECT() *MockTokensServiceMockRecorder {
+	return m.recorder
+}
+
+// Create mocks base method.
+func (m *MockTokensService) Create(arg0 *godo.TokenCreateRequest) (*do.Token, error) {
+	m.ctrl.T.Helper()
+	ret := m.ctrl.Call(m, "Create", arg0)
+	ret0, _ := ret[0].(*do.Token)
+	ret1, _ := ret[1].(error)
+	return ret0, ret1
+}
+
+// Create indicates an expected call of Create.
+func (mr *MockTokensServiceMockRecorder) Create(arg0 interface{}) *gomock.Call {
+	mr.mock.ctrl.T.Helper()
+	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Create", reflect.TypeOf((*MockTokensService)(nil).Create), arg0)
+}
+
+// Get mocks base method.
+func (m *MockTokensService) Get(arg0 int) (*do.Token, error) {
+	m.ctrl.T.Helper()
+	ret := m.ctrl.Call(m, "Get", arg0)
+	ret0, _ := ret[0].(*do.Token)
+	ret1, _ := ret[1].(error)
+	return ret0, ret1
+}
+
+// Get indicates an expected call of Get.
+func (mr *MockTokensServiceMockRecorder) Get(arg0 interface{}) *gomock.Call {
+	mr.mock.ctrl.T.Helper()
+	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Get", reflect.TypeOf((*MockTokensService)(nil).Get), arg0)
+}
+
+// List mocks base method.
+func (m *MockTokensService) List() ([]do.Token, error) {
+	m.ctrl.T.Helper()
+	ret := m.ctrl.Call(m, "List")
+	ret0, _ := ret[0].([]do.Token)
+	ret1, _ := ret[1].(error)
+	return ret0, ret1
+}
+
+// List indicates an expected call of List.
+func (mr *MockTokensServiceMockRecorder) List() *gomock.Call {
+	mr.mock.ctrl.T.Helper()
+	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "List", reflect.TypeOf((*MockTokensService)(nil).List))
+}
+
+// ListScopes mocks base method.
+func (m *MockTokensService) ListScopes(arg0 string) ([]do.TokenScope, error) {
+	m.ctrl.T.Helper()
+	ret := m.ctrl.Call(m, "ListScopes", arg0)
+	ret0, _ := ret[0].([]do.TokenScope)
+	ret1, _ := ret[1].(error)
+	return ret0, ret1
+}
+
+// ListScopes indicates an expected call of ListScopes.
+func (mr *MockTokensServiceMockRecorder) ListScopes(arg0 interface{}) *gomock.Call {
+	mr.mock.ctrl.T.Helper()
+	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ListScopes", reflect.TypeOf((*MockTokensService)(nil).ListScopes), arg0)
+}
+
+// Revoke mocks base method.
+func (m *MockTokensService) Revoke(arg0 int) error {
+	m.ctrl.T.Helper()
+	ret := m.ctrl.Call(m, "Revoke", arg0)
+	ret0, _ := ret[0].(error)
+	return ret0
+}
+
+// Revoke indicates an expected call of Revoke.
+func (mr *MockTokensServiceMockRecorder) Revoke(arg0 interface{}) *gomock.Call {
+	mr.mock.ctrl.T.Helper()
+	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Revoke", reflect.TypeOf((*MockTokensService)(nil).Revoke), arg0)
+}
+
+// Update mocks base method.
+func (m *MockTokensService) Update(arg0 int, arg1 *godo.TokenUpdateRequest) (*do.Token, error) {
+	m.ctrl.T.Helper()
+	ret := m.ctrl.Call(m, "Update", arg0, arg1)
+	ret0, _ := ret[0].(*do.Token)
+	ret1, _ := ret[1].(error)
+	return ret0, ret1
+}
+
+// Update indicates an expected call of Update.
+func (mr *MockTokensServiceMockRecorder) Update(arg0, arg1 interface{}) *gomock.Call {
+	mr.mock.ctrl.T.Helper()
+	return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "Update", reflect.TypeOf((*MockTokensService)(nil).Update), arg0, arg1)
+}
diff --git a/do/tokens.go b/do/tokens.go
new file mode 100644
index 00000000..4f9cd922
--- /dev/null
+++ b/do/tokens.go
@@ -0,0 +1,141 @@
+package do
+
+import (
+	"context"
+
+	"github.com/digitalocean/godo"
+)
+
+// TokensService is an interface for managing DigitalOcean's API tokens.
+type TokensService interface {
+	List() ([]Token, error)
+	Get(int) (*Token, error)
+	Create(*godo.TokenCreateRequest) (*Token, error)
+	Update(int, *godo.TokenUpdateRequest) (*Token, error)
+	Revoke(int) error
+	ListScopes(string) ([]TokenScope, error)
+}
+
+// Token is a wrapper for godo.Token.
+type Token struct {
+	*godo.Token
+}
+
+// TokenScope is a wrapper for godo.TokenScope.
+type TokenScope struct {
+	*godo.TokenScope
+}
+
+type tokensService struct {
+	client *godo.Client
+}
+
+// NewTokensService builds a new TokensService instance.
+func NewTokensService(godoClient *godo.Client) TokensService {
+	return &tokensService{
+		client: godoClient,
+	}
+}
+
+func (t *tokensService) List() ([]Token, error) {
+	f := func(opt *godo.ListOptions) ([]interface{}, *godo.Response, error) {
+		list, resp, err := t.client.Tokens.List(context.TODO(), opt)
+		if err != nil {
+			return nil, nil, err
+		}
+
+		si := make([]interface{}, len(list))
+		for i := range list {
+			si[i] = list[i]
+		}
+
+		return si, resp, err
+	}
+
+	si, err := PaginateResp(f)
+	if err != nil {
+		return nil, err
+	}
+
+	list := make([]Token, len(si))
+	for i := range si {
+		token := si[i].(godo.Token)
+		list[i] = Token{Token: &token}
+	}
+	return list, nil
+}
+
+func (t *tokensService) Get(id int) (*Token, error) {
+	token, _, err := t.client.Tokens.Get(context.TODO(), id)
+	if err != nil {
+		return nil, err
+	}
+
+	return &Token{Token: token}, nil
+}
+
+func (t *tokensService) Create(req *godo.TokenCreateRequest) (*Token, error) {
+	token, _, err := t.client.Tokens.Create(context.TODO(), req)
+	if err != nil {
+		return nil, err
+	}
+
+	return &Token{Token: token}, nil
+}
+
+func (t *tokensService) Update(id int, req *godo.TokenUpdateRequest) (*Token, error) {
+	token, _, err := t.client.Tokens.Update(context.TODO(), id, req)
+	if err != nil {
+		return nil, err
+	}
+
+	return &Token{Token: token}, nil
+}
+
+func (t *tokensService) Revoke(id int) error {
+	_, err := t.client.Tokens.Revoke(context.TODO(), id)
+
+	return err
+}
+
+func (t *tokensService) ListScopes(namespace string) ([]TokenScope, error) {
+	f := func(opt *godo.ListOptions) ([]interface{}, *godo.Response, error) {
+		var (
+			list []godo.TokenScope
+			resp *godo.Response
+			err  error
+		)
+
+		if namespace != "" {
+			list, resp, err = t.client.Tokens.ListScopesByNamespace(context.TODO(), namespace, opt)
+			if err != nil {
+				return nil, nil, err
+			}
+		} else {
+			list, resp, err = t.client.Tokens.ListScopes(context.TODO(), opt)
+			if err != nil {
+				return nil, nil, err
+			}
+		}
+
+		si := make([]interface{}, len(list))
+		for i := range list {
+			si[i] = list[i]
+		}
+
+		return si, resp, err
+	}
+
+	si, err := PaginateResp(f)
+	if err != nil {
+		return nil, err
+	}
+
+	list := make([]TokenScope, len(si))
+	for i := range si {
+		scope := si[i].(godo.TokenScope)
+		list[i] = TokenScope{TokenScope: &scope}
+	}
+
+	return list, nil
+}
diff --git a/doit.go b/doit.go
index 75bc10fa..71091743 100644
--- a/doit.go
+++ b/doit.go
@@ -533,7 +533,7 @@ func (c *TestConfig) GetDuration(ns, key string) (time.Duration, error) {
 
 // This is needed because an empty StringSlice flag returns `"[]"`
 func emptyStringSlice(s []string) bool {
-	return len(s) == 1 && s[0] == "[]"
+	return (len(s) == 1 && s[0] == "[]") || len(s) == 0
 }
 
 // CommandName returns the name by which doctl was invoked
diff --git a/go.mod b/go.mod
index 39669bc9..ec575e88 100644
--- a/go.mod
+++ b/go.mod
@@ -7,7 +7,7 @@ require (
 	github.com/blang/semver v3.5.1+incompatible
 	github.com/containerd/continuity v0.3.0 // indirect
 	github.com/creack/pty v1.1.11
-	github.com/digitalocean/godo v1.92.0
+	github.com/digitalocean/godo v1.93.0
 	github.com/docker/cli v20.10.17+incompatible
 	github.com/docker/docker v20.10.17+incompatible
 	github.com/docker/docker-credential-helpers v0.6.4 // indirect
diff --git a/go.sum b/go.sum
index 194007f6..4f6167f1 100644
--- a/go.sum
+++ b/go.sum
@@ -280,8 +280,8 @@ github.com/denverdino/aliyungo v0.0.0-20190125010748-a747050bb1ba/go.mod h1:dV8l
 github.com/dgrijalva/jwt-go v0.0.0-20170104182250-a601269ab70c/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ=
 github.com/dgrijalva/jwt-go v3.2.0+incompatible/go.mod h1:E3ru+11k8xSBh+hMPgOLZmtrrCbhqsmaPHjLKYnJCaQ=
 github.com/dgryski/go-sip13 v0.0.0-20181026042036-e10d5fee7954/go.mod h1:vAd38F8PWV+bWy6jNmig1y/TA+kYO4g3RSRF0IAv0no=
-github.com/digitalocean/godo v1.92.0 h1:eK9DgdLcjozZbywjh/9k2NF++ttbcidASOoCGRu48yQ=
-github.com/digitalocean/godo v1.92.0/go.mod h1:NRpFznZFvhHjBoqZAaOD3khVzsJ3EibzKqFL4R60dmA=
+github.com/digitalocean/godo v1.93.0 h1:N0K9z2yssZVP7nBHQ32P1Wemd5yeiJdH4ROg+7ySRxY=
+github.com/digitalocean/godo v1.93.0/go.mod h1:NRpFznZFvhHjBoqZAaOD3khVzsJ3EibzKqFL4R60dmA=
 github.com/dnaeon/go-vcr v1.0.1/go.mod h1:aBB1+wY4s93YsC3HHjMBMrwTj2R9FHDzUr9KyGc8n1E=
 github.com/docker/cli v0.0.0-20191017083524-a8ff7f821017/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8=
 github.com/docker/cli v20.10.17+incompatible h1:eO2KS7ZFeov5UJeaDmIs1NFEDRf32PaqRpvoEkKBy5M=
diff --git a/scripts/regenmocks.sh b/scripts/regenmocks.sh
index 9a34ee15..a0fba315 100755
--- a/scripts/regenmocks.sh
+++ b/scripts/regenmocks.sh
@@ -43,3 +43,4 @@ mockgen -source monitoring.go -package=mocks MonitoringService > mocks/Monitorin
 mockgen -source reserved_ip_actions.go -package=mocks ReservedIPActionsService > mocks/ReservedIPActionsService.go
 mockgen -source reserved_ips.go -package=mocks ReservedIPsService > mocks/ReservedIPsService.go
 mockgen -source serverless.go -package=mocks ServerlessService > mocks/ServerlessService.go
+mockgen -source tokens.go -package=mocks TokensService > mocks/TokensService.go
diff --git a/vendor/github.com/digitalocean/godo/CHANGELOG.md b/vendor/github.com/digitalocean/godo/CHANGELOG.md
index 04da396c..be918ea5 100644
--- a/vendor/github.com/digitalocean/godo/CHANGELOG.md
+++ b/vendor/github.com/digitalocean/godo/CHANGELOG.md
@@ -1,6 +1,10 @@
 # Change Log
 
-## [v1.92.0] - 2022-12-08
+## [v1.93.0] - 2022-12-15
+
+- #591 - @andrewsomething - tokens: Add initial support for new API.
+
+## [v1.92.0] - 2022-12-14
 
 - #589 - @wez470 - load-balancers: Minor doc fixup
 - #585 - @StephenVarela - Add firewall support for load balancers
diff --git a/vendor/github.com/digitalocean/godo/godo.go b/vendor/github.com/digitalocean/godo/godo.go
index 354a1d51..7970b0ca 100644
--- a/vendor/github.com/digitalocean/godo/godo.go
+++ b/vendor/github.com/digitalocean/godo/godo.go
@@ -81,6 +81,7 @@ type Client struct {
 	Storage           StorageService
 	StorageActions    StorageActionsService
 	Tags              TagsService
+	Tokens            TokensService
 	VPCs              VPCsService
 
 	// Optional function called after every successful request made to the DO APIs
@@ -250,6 +251,7 @@ func NewClient(httpClient *http.Client) *Client {
 	c.Storage = &StorageServiceOp{client: c}
 	c.StorageActions = &StorageActionsServiceOp{client: c}
 	c.Tags = &TagsServiceOp{client: c}
+	c.Tokens = &TokensServiceOp{client: c}
 	c.VPCs = &VPCsServiceOp{client: c}
 
 	c.headers = make(map[string]string)
diff --git a/vendor/github.com/digitalocean/godo/tokens.go b/vendor/github.com/digitalocean/godo/tokens.go
new file mode 100644
index 00000000..13aa418d
--- /dev/null
+++ b/vendor/github.com/digitalocean/godo/tokens.go
@@ -0,0 +1,228 @@
+package godo
+
+import (
+	"context"
+	"fmt"
+	"net/http"
+	"time"
+)
+
+const (
+	accessTokensBasePath = "v2/tokens"
+	tokenScopesBasePath  = accessTokensBasePath + "/scopes"
+)
+
+// TokensService is an interface for managing DigitalOcean API access tokens.
+// It is not currently generally available. Follow the release notes for
+// updates: https://docs.digitalocean.com/release-notes/api/
+type TokensService interface {
+	List(context.Context, *ListOptions) ([]Token, *Response, error)
+	Get(context.Context, int) (*Token, *Response, error)
+	Create(context.Context, *TokenCreateRequest) (*Token, *Response, error)
+	Update(context.Context, int, *TokenUpdateRequest) (*Token, *Response, error)
+	Revoke(context.Context, int) (*Response, error)
+	ListScopes(context.Context, *ListOptions) ([]TokenScope, *Response, error)
+	ListScopesByNamespace(context.Context, string, *ListOptions) ([]TokenScope, *Response, error)
+}
+
+// TokensServiceOp handles communication with the tokens related methods of the
+// DigitalOcean API.
+type TokensServiceOp struct {
+	client *Client
+}
+
+var _ TokensService = &TokensServiceOp{}
+
+// Token represents a DigitalOcean API token.
+type Token struct {
+	ID            int       `json:"id"`
+	Name          string    `json:"name"`
+	Scopes        []string  `json:"scopes"`
+	ExpirySeconds *int      `json:"expiry_seconds"`
+	CreatedAt     time.Time `json:"created_at"`
+	LastUsedAt    string    `json:"last_used_at"`
+
+	// AccessToken contains the actual Oauth token string. It is only included
+	// in the create response.
+	AccessToken string `json:"access_token,omitempty"`
+}
+
+// tokenRoot represents a response from the DigitalOcean API
+type tokenRoot struct {
+	Token *Token `json:"token"`
+}
+
+type tokensRoot struct {
+	Tokens []Token `json:"tokens"`
+	Links  *Links  `json:"links"`
+	Meta   *Meta   `json:"meta"`
+}
+
+// TokenCreateRequest represents a request to create a token.
+type TokenCreateRequest struct {
+	Name          string   `json:"name"`
+	Scopes        []string `json:"scopes"`
+	ExpirySeconds *int     `json:"expiry_seconds,omitempty"`
+}
+
+// TokenUpdateRequest represents a request to update a token.
+type TokenUpdateRequest struct {
+	Name   string   `json:"name,omitempty"`
+	Scopes []string `json:"scopes,omitempty"`
+}
+
+// TokenScope is a representation of a scope for the public API.
+type TokenScope struct {
+	Name string `json:"name"`
+}
+
+type tokenScopesRoot struct {
+	TokenScopes []TokenScope `json:"scopes"`
+	Links       *Links       `json:"links"`
+	Meta        *Meta        `json:"meta"`
+}
+
+type tokenScopeNamespaceParam struct {
+	Namespace string `url:"namespace,omitempty"`
+}
+
+// List all DigitalOcean API access tokens.
+func (c TokensServiceOp) List(ctx context.Context, opt *ListOptions) ([]Token, *Response, error) {
+	path, err := addOptions(accessTokensBasePath, opt)
+	if err != nil {
+		return nil, nil, err
+	}
+
+	req, err := c.client.NewRequest(ctx, http.MethodGet, path, nil)
+	if err != nil {
+		return nil, nil, err
+	}
+
+	root := new(tokensRoot)
+	resp, err := c.client.Do(ctx, req, root)
+	if err != nil {
+		return nil, resp, err
+	}
+	if l := root.Links; l != nil {
+		resp.Links = l
+	}
+	if m := root.Meta; m != nil {
+		resp.Meta = m
+	}
+
+	return root.Tokens, resp, err
+}
+
+// Get a specific DigitalOcean API access token.
+func (c TokensServiceOp) Get(ctx context.Context, tokenID int) (*Token, *Response, error) {
+	path := fmt.Sprintf("%s/%d", accessTokensBasePath, tokenID)
+	req, err := c.client.NewRequest(ctx, http.MethodGet, path, nil)
+	if err != nil {
+		return nil, nil, err
+	}
+
+	root := new(tokenRoot)
+	resp, err := c.client.Do(ctx, req, root)
+	if err != nil {
+		return nil, resp, err
+	}
+
+	return root.Token, resp, err
+}
+
+// Create a new DigitalOcean API access token.
+func (c TokensServiceOp) Create(ctx context.Context, createRequest *TokenCreateRequest) (*Token, *Response, error) {
+	req, err := c.client.NewRequest(ctx, http.MethodPost, accessTokensBasePath, createRequest)
+	if err != nil {
+		return nil, nil, err
+	}
+
+	root := new(tokenRoot)
+	resp, err := c.client.Do(ctx, req, root)
+	if err != nil {
+		return nil, resp, err
+	}
+
+	return root.Token, resp, err
+}
+
+// Update the name or scopes of a specific DigitalOcean API access token.
+func (c TokensServiceOp) Update(ctx context.Context, tokenID int, updateRequest *TokenUpdateRequest) (*Token, *Response, error) {
+	path := fmt.Sprintf("%s/%d", accessTokensBasePath, tokenID)
+	req, err := c.client.NewRequest(ctx, http.MethodPatch, path, updateRequest)
+	if err != nil {
+		return nil, nil, err
+	}
+
+	root := new(tokenRoot)
+	resp, err := c.client.Do(ctx, req, root)
+	if err != nil {
+		return nil, resp, err
+	}
+
+	return root.Token, resp, err
+}
+
+// Revoke a specific DigitalOcean API access token.
+func (c TokensServiceOp) Revoke(ctx context.Context, tokenID int) (*Response, error) {
+	path := fmt.Sprintf("%s/%d", accessTokensBasePath, tokenID)
+	req, err := c.client.NewRequest(ctx, http.MethodDelete, path, nil)
+	if err != nil {
+		return nil, err
+	}
+
+	resp, err := c.client.Do(ctx, req, nil)
+
+	return resp, err
+}
+
+// ListScopes lists all available scopes that can be granted to a token.
+func (c TokensServiceOp) ListScopes(ctx context.Context, opt *ListOptions) ([]TokenScope, *Response, error) {
+	path, err := addOptions(tokenScopesBasePath, opt)
+	if err != nil {
+		return nil, nil, err
+	}
+
+	return listTokenScopes(ctx, c, path)
+}
+
+// ListScopesByNamespace lists available scopes in a namespace that can be granted
+// to a token (e.g. the namespace for the `droplet:read“ scope is `droplet`).
+func (c TokensServiceOp) ListScopesByNamespace(ctx context.Context, namespace string, opt *ListOptions) ([]TokenScope, *Response, error) {
+	path, err := addOptions(tokenScopesBasePath, opt)
+	if err != nil {
+		return nil, nil, err
+	}
+
+	namespaceOpt := tokenScopeNamespaceParam{
+		Namespace: namespace,
+	}
+
+	path, err = addOptions(path, namespaceOpt)
+	if err != nil {
+		return nil, nil, err
+	}
+
+	return listTokenScopes(ctx, c, path)
+}
+
+func listTokenScopes(ctx context.Context, c TokensServiceOp, path string) ([]TokenScope, *Response, error) {
+	req, err := c.client.NewRequest(ctx, http.MethodGet, path, nil)
+	if err != nil {
+		return nil, nil, err
+	}
+
+	root := new(tokenScopesRoot)
+	resp, err := c.client.Do(ctx, req, root)
+	if err != nil {
+		return nil, resp, err
+	}
+	if l := root.Links; l != nil {
+		resp.Links = l
+	}
+	if m := root.Meta; m != nil {
+		resp.Meta = m
+	}
+
+	return root.TokenScopes, resp, err
+}
diff --git a/vendor/modules.txt b/vendor/modules.txt
index ad879520..40fe1560 100644
--- a/vendor/modules.txt
+++ b/vendor/modules.txt
@@ -85,7 +85,7 @@ github.com/creack/pty
 # github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc
 ## explicit
 github.com/davecgh/go-spew/spew
-# github.com/digitalocean/godo v1.92.0
+# github.com/digitalocean/godo v1.93.0
 ## explicit; go 1.18
 github.com/digitalocean/godo
 github.com/digitalocean/godo/metrics


Test patch:
diff --git a/commands/commands_test.go b/commands/commands_test.go
index 7c983fa9..4daa6c49 100644
--- a/commands/commands_test.go
+++ b/commands/commands_test.go
@@ -18,14 +18,13 @@ import (
 	"sort"
 	"testing"
 
-	"github.com/spf13/cobra"
-
 	"github.com/digitalocean/doctl"
 	"github.com/digitalocean/doctl/do"
 	domocks "github.com/digitalocean/doctl/do/mocks"
 	"github.com/digitalocean/doctl/internal/apps/builder"
 	"github.com/digitalocean/godo"
 	"github.com/golang/mock/gomock"
+	"github.com/spf13/cobra"
 	"github.com/spf13/viper"
 	"github.com/stretchr/testify/assert"
 )
@@ -187,6 +186,7 @@ type tcMocks struct {
 	appBuilder            *builder.MockComponentBuilder
 	appDockerEngineClient *builder.MockDockerEngineClient
 	oauth                 *domocks.MockOAuthService
+	tokens                *domocks.MockTokensService
 }
 
 func withTestClient(t *testing.T, tFn testFn) {
@@ -232,6 +232,7 @@ func withTestClient(t *testing.T, tFn testFn) {
 		appBuilder:            builder.NewMockComponentBuilder(ctrl),
 		appDockerEngineClient: builder.NewMockDockerEngineClient(ctrl),
 		oauth:                 domocks.NewMockOAuthService(ctrl),
+		tokens:                domocks.NewMockTokensService(ctrl),
 	}
 
 	testConfig := doctl.NewTestConfig()
@@ -286,6 +287,7 @@ func withTestClient(t *testing.T, tFn testFn) {
 		Monitoring:        func() do.MonitoringService { return tm.monitoring },
 		Serverless:        func() do.ServerlessService { return tm.serverless },
 		OAuth:             func() do.OAuthService { return tm.oauth },
+		Tokens:            func() do.TokensService { return tm.tokens },
 	}
 
 	tFn(config, tm)
diff --git a/commands/tokens_test.go b/commands/tokens_test.go
new file mode 100644
index 00000000..ba32a15e
--- /dev/null
+++ b/commands/tokens_test.go
@@ -0,0 +1,327 @@
+package commands
+
+import (
+	"bytes"
+	"testing"
+	"time"
+
+	"github.com/digitalocean/doctl"
+	"github.com/digitalocean/doctl/do"
+	"github.com/digitalocean/godo"
+	"github.com/stretchr/testify/assert"
+)
+
+var (
+	testTokenID = "123"
+
+	testToken = do.Token{
+		Token: &godo.Token{
+			ID:            123,
+			Name:          "droplets-read-token",
+			Scopes:        []string{"droplet:read"},
+			CreatedAt:     time.Date(2022, time.December, 5, 20, 32, 15, 0, time.UTC),
+			LastUsedAt:    time.Date(2022, time.December, 5, 20, 32, 15, 0, time.UTC).Format("2006-01-02"),
+			ExpirySeconds: godo.PtrTo(604800),
+		},
+	}
+
+	testTokenList = []do.Token{
+		{Token: testToken.Token},
+	}
+
+	testTokenCreateResp = do.Token{
+		Token: &godo.Token{
+			ID:            123,
+			Name:          "droplets-read-token",
+			Scopes:        []string{"droplet:read"},
+			CreatedAt:     time.Date(2022, time.December, 5, 20, 32, 15, 0, time.UTC),
+			LastUsedAt:    time.Date(2022, time.December, 5, 20, 32, 15, 0, time.UTC).Format("2006-01-02"),
+			ExpirySeconds: godo.PtrTo(3600),
+			AccessToken:   "shhhhhhhimsecret",
+		},
+	}
+
+	testTokenScopesList = []do.TokenScope{
+		{
+			TokenScope: &godo.TokenScope{Name: "account:read"},
+		},
+		{
+			TokenScope: &godo.TokenScope{Name: "droplet:read"},
+		},
+	}
+)
+
+func TestTokensCommand(t *testing.T) {
+	cmd := Tokens()
+	assert.NotNil(t, cmd)
+	assertCommandNames(t, cmd, "create", "revoke", "get", "list", "update", "list-scopes")
+}
+
+func TestTokensGet(t *testing.T) {
+	withTestClient(t, func(config *CmdConfig, tm *tcMocks) {
+		tm.tokens.EXPECT().Get(testToken.ID).Return(&testToken, nil)
+
+		config.Args = append(config.Args, testTokenID)
+
+		err := RunTokenGet(config)
+		assert.NoError(t, err)
+	})
+}
+
+func TestTokensGetByName(t *testing.T) {
+	withTestClient(t, func(config *CmdConfig, tm *tcMocks) {
+		tm.tokens.EXPECT().List().Return(testTokenList, nil)
+
+		config.Args = append(config.Args, testToken.Name)
+
+		err := RunTokenGet(config)
+		assert.NoError(t, err)
+	})
+}
+
+func TestTokensGetFormatted(t *testing.T) {
+	withTestClient(t, func(config *CmdConfig, tm *tcMocks) {
+		buf := &bytes.Buffer{}
+		config.Out = buf
+
+		tm.tokens.EXPECT().Get(testToken.ID).Return(&testToken, nil)
+
+		config.Args = append(config.Args, testTokenID)
+		config.Doit.Set(config.NS, doctl.ArgNoHeader, true)
+		config.Doit.Set(config.NS, doctl.ArgFormat, "Name")
+
+		err := RunTokenGet(config)
+		assert.NoError(t, err)
+		assert.Equal(t, testToken.Name+"\n", buf.String())
+	})
+}
+
+func TestTokensGetMissingRequiredArguments(t *testing.T) {
+	withTestClient(t, func(config *CmdConfig, tm *tcMocks) {
+		err := RunTokenGet(config)
+		assert.Error(t, err)
+	})
+}
+
+func TestTokensList(t *testing.T) {
+	withTestClient(t, func(config *CmdConfig, tm *tcMocks) {
+		tm.tokens.EXPECT().List().Return(testTokenList, nil)
+
+		err := RunTokenList(config)
+		assert.NoError(t, err)
+	})
+}
+
+func TestTokensListFormatted(t *testing.T) {
+	withTestClient(t, func(config *CmdConfig, tm *tcMocks) {
+		buf := &bytes.Buffer{}
+		config.Out = buf
+
+		tm.tokens.EXPECT().List().Return(testTokenList, nil)
+
+		config.Args = append(config.Args, testTokenID)
+		config.Doit.Set(config.NS, doctl.ArgNoHeader, true)
+		config.Doit.Set(config.NS, doctl.ArgFormat, "Name")
+
+		err := RunTokenList(config)
+		assert.NoError(t, err)
+		assert.Equal(t, testToken.Name+"\n", buf.String())
+	})
+}
+
+func TestTokensCreate(t *testing.T) {
+	withTestClient(t, func(config *CmdConfig, tm *tcMocks) {
+		createReq := &godo.TokenCreateRequest{
+			Name:          testToken.Name,
+			ExpirySeconds: godo.PtrTo(3600),
+			Scopes:        testToken.Scopes,
+		}
+		tm.tokens.EXPECT().Create(createReq).Return(&testTokenCreateResp, nil)
+
+		config.Args = append(config.Args, testToken.Name)
+		config.Doit.Set(config.NS, doctl.ArgTokenExpirySeconds, "3600")
+		config.Doit.Set(config.NS, doctl.ArgTokenScopes, "droplet:read")
+
+		err := RunTokenCreate(config)
+		assert.NoError(t, err)
+	})
+}
+
+func TestTokensCreateWithDuration(t *testing.T) {
+	withTestClient(t, func(config *CmdConfig, tm *tcMocks) {
+		createReq := &godo.TokenCreateRequest{
+			Name:          testToken.Name,
+			ExpirySeconds: godo.PtrTo(86400),
+			Scopes:        testToken.Scopes,
+		}
+		tm.tokens.EXPECT().Create(createReq).Return(&testTokenCreateResp, nil)
+
+		config.Args = append(config.Args, testToken.Name)
+		config.Doit.Set(config.NS, doctl.ArgTokenExpiresIn, "24h")
+		config.Doit.Set(config.NS, doctl.ArgTokenScopes, "droplet:read")
+
+		err := RunTokenCreate(config)
+		assert.NoError(t, err)
+	})
+}
+
+func TestTokensCreateNoExpiration(t *testing.T) {
+	withTestClient(t, func(config *CmdConfig, tm *tcMocks) {
+		createReq := &godo.TokenCreateRequest{
+			Name:   testToken.Name,
+			Scopes: testToken.Scopes,
+		}
+		tm.tokens.EXPECT().Create(createReq).Return(&testTokenCreateResp, nil)
+
+		config.Args = append(config.Args, testToken.Name)
+		config.Doit.Set(config.NS, doctl.ArgTokenScopes, "droplet:read")
+
+		err := RunTokenCreate(config)
+		assert.NoError(t, err)
+	})
+}
+
+func TestTokensCreateHasAccessToken(t *testing.T) {
+	withTestClient(t, func(config *CmdConfig, tm *tcMocks) {
+		buf := &bytes.Buffer{}
+		config.Out = buf
+
+		createReq := &godo.TokenCreateRequest{
+			Name:          testToken.Name,
+			ExpirySeconds: godo.PtrTo(3600),
+			Scopes:        testToken.Scopes,
+		}
+		tm.tokens.EXPECT().Create(createReq).Return(&testTokenCreateResp, nil)
+
+		config.Args = append(config.Args, testToken.Name)
+		config.Doit.Set(config.NS, doctl.ArgTokenExpirySeconds, "3600")
+		config.Doit.Set(config.NS, doctl.ArgTokenScopes, "droplet:read")
+		config.Doit.Set(config.NS, doctl.ArgNoHeader, true)
+		config.Doit.Set(config.NS, doctl.ArgFormat, "AccessToken")
+
+		err := RunTokenCreate(config)
+		assert.NoError(t, err)
+		assert.Equal(t, testTokenCreateResp.AccessToken+"\n", buf.String())
+	})
+}
+
+func TestTokensCreateMissingRequiredArg(t *testing.T) {
+	withTestClient(t, func(config *CmdConfig, tm *tcMocks) {
+		config.Doit.Set(config.NS, doctl.ArgTokenExpirySeconds, "3600")
+		config.Doit.Set(config.NS, doctl.ArgTokenScopes, "droplet:read")
+
+		err := RunTokenCreate(config)
+		assert.Error(t, err)
+	})
+}
+
+func TestTokensCreateMutuallyExclusiveArg(t *testing.T) {
+	withTestClient(t, func(config *CmdConfig, tm *tcMocks) {
+		config.Doit.Set(config.NS, doctl.ArgTokenExpirySeconds, "3600")
+		config.Doit.Set(config.NS, doctl.ArgTokenExpiresIn, "3600s")
+		config.Doit.Set(config.NS, doctl.ArgTokenScopes, "droplet:read")
+
+		config.Args = append(config.Args, testToken.Name)
+
+		err := RunTokenCreate(config)
+		assert.Error(t, err)
+	})
+}
+
+func TestTokensRevoke(t *testing.T) {
+	withTestClient(t, func(config *CmdConfig, tm *tcMocks) {
+		tm.tokens.EXPECT().Revoke(testToken.ID).Return(nil)
+
+		config.Args = append(config.Args, testTokenID)
+		config.Doit.Set(config.NS, doctl.ArgForce, true)
+
+		err := RunTokenRevoke(config)
+		assert.NoError(t, err)
+	})
+}
+
+func TestTokensRevokeByName(t *testing.T) {
+	withTestClient(t, func(config *CmdConfig, tm *tcMocks) {
+		tm.tokens.EXPECT().List().Return(testTokenList, nil)
+		tm.tokens.EXPECT().Revoke(testToken.ID).Return(nil)
+
+		config.Args = append(config.Args, testToken.Name)
+		config.Doit.Set(config.NS, doctl.ArgForce, true)
+
+		err := RunTokenRevoke(config)
+		assert.NoError(t, err)
+	})
+}
+
+func TestTokensRevokeMissingRequiredArguments(t *testing.T) {
+	withTestClient(t, func(config *CmdConfig, tm *tcMocks) {
+		config.Doit.Set(config.NS, doctl.ArgForce, true)
+
+		err := RunTokenRevoke(config)
+		assert.Error(t, err)
+	})
+}
+
+func TestTokensUpdate(t *testing.T) {
+	withTestClient(t, func(config *CmdConfig, tm *tcMocks) {
+		updateReq := &godo.TokenUpdateRequest{
+			Name:   "new-name",
+			Scopes: []string{"droplet:delete"},
+		}
+		tm.tokens.EXPECT().Update(testToken.ID, updateReq).Return(&testToken, nil)
+
+		config.Args = append(config.Args, testTokenID)
+		config.Doit.Set(config.NS, doctl.ArgTokenUpdatedName, "new-name")
+		config.Doit.Set(config.NS, doctl.ArgTokenScopes, "droplet:delete")
+
+		err := RunTokenUpdate(config)
+		assert.NoError(t, err)
+	})
+}
+
+func TestTokensUpdateByName(t *testing.T) {
+	withTestClient(t, func(config *CmdConfig, tm *tcMocks) {
+		updateReq := &godo.TokenUpdateRequest{
+			Name:   "new-name",
+			Scopes: []string{"droplet:delete"},
+		}
+		tm.tokens.EXPECT().List().Return(testTokenList, nil)
+		tm.tokens.EXPECT().Update(testToken.ID, updateReq).Return(&testToken, nil)
+
+		config.Args = append(config.Args, testToken.Name)
+		config.Doit.Set(config.NS, doctl.ArgTokenUpdatedName, "new-name")
+		config.Doit.Set(config.NS, doctl.ArgTokenScopes, "droplet:delete")
+
+		err := RunTokenUpdate(config)
+		assert.NoError(t, err)
+	})
+}
+
+func TestTokensUpdateMissingArgs(t *testing.T) {
+	withTestClient(t, func(config *CmdConfig, tm *tcMocks) {
+		config.Args = append(config.Args, testTokenID)
+
+		err := RunTokenUpdate(config)
+		assert.Error(t, err)
+	})
+}
+
+func TestTokensListScopes(t *testing.T) {
+	withTestClient(t, func(config *CmdConfig, tm *tcMocks) {
+		tm.tokens.EXPECT().ListScopes("").Return(testTokenScopesList, nil)
+
+		err := RunTokenListScopes(config)
+		assert.NoError(t, err)
+	})
+}
+
+func TestTokensListScopesByNamespace(t *testing.T) {
+	withTestClient(t, func(config *CmdConfig, tm *tcMocks) {
+		tm.tokens.EXPECT().ListScopes("droplet").Return(testTokenScopesList, nil)
+
+		config.Doit.Set(config.NS, doctl.ArgTokenScopeNamespace, "droplet")
+
+		err := RunTokenListScopes(config)
+		assert.NoError(t, err)
+	})
+}
diff --git a/integration/tokens_create_test.go b/integration/tokens_create_test.go
new file mode 100644
index 00000000..2643ed20
--- /dev/null
+++ b/integration/tokens_create_test.go
@@ -0,0 +1,144 @@
+package integration
+
+import (
+	"encoding/json"
+	"fmt"
+	"io/ioutil"
+	"net/http"
+	"net/http/httptest"
+	"net/http/httputil"
+	"os/exec"
+	"strings"
+	"testing"
+
+	"github.com/digitalocean/godo"
+	"github.com/sclevine/spec"
+	"github.com/stretchr/testify/require"
+)
+
+var _ = suite("tokens/create", func(t *testing.T, when spec.G, it spec.S) {
+	var (
+		expect *require.Assertions
+		server *httptest.Server
+	)
+
+	it.Before(func() {
+		expect = require.New(t)
+
+		server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
+			switch req.URL.Path {
+			case "/v2/tokens":
+				auth := req.Header.Get("Authorization")
+				if auth != "Bearer some-magic-token" {
+					w.WriteHeader(http.StatusUnauthorized)
+					return
+				}
+
+				if req.Method != http.MethodPost {
+					w.WriteHeader(http.StatusMethodNotAllowed)
+					return
+				}
+
+				reqBody, err := ioutil.ReadAll(req.Body)
+				expect.NoError(err)
+
+				request := godo.TokenCreateRequest{}
+				err = json.Unmarshal(reqBody, &request)
+				expect.NoError(err)
+				expect.Equal([]string{"account:read", "droplet:read"}, request.Scopes)
+
+				w.Write([]byte(tokensCreateResponse))
+			default:
+				dump, err := httputil.DumpRequest(req, true)
+				if err != nil {
+					t.Fatal("failed to dump request")
+				}
+
+				t.Fatalf("received unknown request: %s", dump)
+			}
+		}))
+	})
+
+	when("all required flags are passed", func() {
+		it("creates the token", func() {
+			cmd := exec.Command(builtBinaryPath,
+				"-t", "some-magic-token",
+				"-u", server.URL,
+				"tokens",
+				"create",
+				"droplets-reader",
+				"--scopes", "account:read",
+				"--scopes", "droplet:read",
+				"--expiry-seconds", "2592000",
+			)
+
+			output, err := cmd.CombinedOutput()
+			expect.NoError(err, fmt.Sprintf("received error output: %s", output))
+			expect.Equal(strings.TrimSpace(tokensCreateOutput), strings.TrimSpace(string(output)))
+		})
+	})
+
+	when("passing the format flag", func() {
+		it("changes the output", func() {
+			cmd := exec.Command(builtBinaryPath,
+				"-t", "some-magic-token",
+				"-u", server.URL,
+				"tokens",
+				"create",
+				"--format", "Name,AccessToken",
+				"droplets-reader",
+				"--scopes", "account:read,droplet:read",
+				"--expiry-seconds", "2592000",
+			)
+
+			output, err := cmd.CombinedOutput()
+			expect.NoError(err, fmt.Sprintf("received error output: %s", output))
+			expect.Equal(strings.TrimSpace(tokensCreateFormattedOutput), strings.TrimSpace(string(output)))
+		})
+	})
+
+	when("missing the scopes flag", func() {
+		it("it returns an error", func() {
+			cmd := exec.Command(builtBinaryPath,
+				"-t", "some-magic-token",
+				"-u", server.URL,
+				"tokens",
+				"create",
+				"droplets-reader",
+			)
+
+			output, err := cmd.CombinedOutput()
+			expect.Error(err, fmt.Sprintf("received error output: %s", output))
+			expect.Equal(strings.TrimSpace(tokensCreateFlagOutputError), strings.TrimSpace(string(output)))
+		})
+	})
+})
+
+const (
+	tokensCreateOutput = `
+ID     Name               Scopes                          Expires At                       Access Token
+123    droplets-reader    [account:read  droplet:read]    2023-01-11 19:21:53 +0000 UTC    dop_v1_shhhhhh
+`
+
+	tokensCreateFormattedOutput = `
+Name               Access Token
+droplets-reader    dop_v1_shhhhhh
+`
+
+	tokensCreateResponse = `
+{
+	"token":
+		{
+			"id":123,
+			"name":"droplets-reader",
+			"scopes":["account:read ","droplet:read"],
+			"created_at":"2022-12-12T19:21:53Z",
+			"last_used_at":"2022-12-12",
+			"expiry_seconds":2592000,
+			"access_token":"dop_v1_shhhhhh"
+		}
+}
+`
+
+	tokensCreateFlagOutputError = `Error: (token.create.scopes) command is missing required arguments`
+)
diff --git a/integration/tokens_get_test.go b/integration/tokens_get_test.go
new file mode 100644
index 00000000..8dd111d5
--- /dev/null
+++ b/integration/tokens_get_test.go
@@ -0,0 +1,149 @@
+package integration
+
+import (
+	"fmt"
+	"net/http"
+	"net/http/httptest"
+	"net/http/httputil"
+	"os/exec"
+	"strings"
+	"testing"
+
+	"github.com/sclevine/spec"
+	"github.com/stretchr/testify/require"
+)
+
+var _ = suite("tokens/get", func(t *testing.T, when spec.G, it spec.S) {
+	var (
+		expect *require.Assertions
+		server *httptest.Server
+	)
+
+	it.Before(func() {
+		expect = require.New(t)
+
+		server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
+			switch req.URL.Path {
+			case "/v2/tokens/123":
+				auth := req.Header.Get("Authorization")
+				if auth != "Bearer some-magic-token" {
+					w.WriteHeader(http.StatusUnauthorized)
+					return
+				}
+
+				if req.Method != http.MethodGet {
+					w.WriteHeader(http.StatusMethodNotAllowed)
+					return
+				}
+
+				w.Write([]byte(tokensGetResponse))
+			case "/v2/tokens":
+				auth := req.Header.Get("Authorization")
+				if auth != "Bearer some-magic-token" {
+					w.WriteHeader(http.StatusUnauthorized)
+					return
+				}
+
+				if req.Method != http.MethodGet {
+					w.WriteHeader(http.StatusMethodNotAllowed)
+					return
+				}
+
+				w.Write([]byte(tokensGetLookupResponse))
+			default:
+				dump, err := httputil.DumpRequest(req, true)
+				if err != nil {
+					t.Fatal("failed to dump request")
+				}
+
+				t.Fatalf("received unknown request: %s", dump)
+			}
+		}))
+	})
+
+	when("all required flags are passed", func() {
+		it("gets the specified token", func() {
+			cmd := exec.Command(builtBinaryPath,
+				"-t", "some-magic-token",
+				"-u", server.URL,
+				"tokens",
+				"get",
+				"123",
+			)
+
+			output, err := cmd.CombinedOutput()
+			expect.NoError(err, fmt.Sprintf("received error output: %s", output))
+			expect.Equal(strings.TrimSpace(tokensGetOutput), strings.TrimSpace(string(output)))
+		})
+	})
+
+	when("the name is passed", func() {
+		it("looks up the specified token", func() {
+			cmd := exec.Command(builtBinaryPath,
+				"-t", "some-magic-token",
+				"-u", server.URL,
+				"tokens",
+				"get",
+				"droplets-reader",
+			)
+
+			output, err := cmd.CombinedOutput()
+			expect.NoError(err, fmt.Sprintf("received error output: %s", output))
+			expect.Equal(strings.TrimSpace(tokensGetOutput), strings.TrimSpace(string(output)))
+		})
+	})
+
+	when("passing the format flag", func() {
+		it("changes the output", func() {
+			cmd := exec.Command(builtBinaryPath,
+				"-t", "some-magic-token",
+				"-u", server.URL,
+				"tokens",
+				"get",
+				"123",
+				"--format", "Name,LastUsedAt",
+			)
+
+			output, err := cmd.CombinedOutput()
+			expect.NoError(err, fmt.Sprintf("received error output: %s", output))
+			expect.Equal(strings.TrimSpace(tokensGetFormattedOutput), strings.TrimSpace(string(output)))
+		})
+	})
+})
+
+const (
+	tokensGetOutput = `
+ID     Name               Scopes                          Expires At                       Last Used At    Created At
+123    droplets-reader    [account:read  droplet:read]    2023-01-11 19:21:53 +0000 UTC    2022-12-12      2022-12-12 19:21:53 +0000 UTC
+`
+
+	tokensGetFormattedOutput = `
+Name               Last Used At
+droplets-reader    2022-12-12
+`
+	tokensGetResponse = `
+{
+	"token": {
+	"id":123,
+	"name":"droplets-reader",
+	"scopes":["account:read ","droplet:read"],
+	"created_at":"2022-12-12T19:21:53Z",
+	"last_used_at":"2022-12-12",
+	"expiry_seconds":2592000
+	}
+}
+`
+
+	tokensGetLookupResponse = `
+{
+	"tokens": [{
+	"id":123,
+	"name":"droplets-reader",
+	"scopes":["account:read ","droplet:read"],
+	"created_at":"2022-12-12T19:21:53Z",
+	"last_used_at":"2022-12-12",
+	"expiry_seconds":2592000
+	}]
+}
+`
+)
diff --git a/integration/tokens_list_scopes_test.go b/integration/tokens_list_scopes_test.go
new file mode 100644
index 00000000..ba61cf59
--- /dev/null
+++ b/integration/tokens_list_scopes_test.go
@@ -0,0 +1,92 @@
+package integration
+
+import (
+	"fmt"
+	"net/http"
+	"net/http/httptest"
+	"net/http/httputil"
+	"os/exec"
+	"strings"
+	"testing"
+
+	"github.com/sclevine/spec"
+	"github.com/stretchr/testify/require"
+)
+
+var _ = suite("tokens/list-scopes", func(t *testing.T, when spec.G, it spec.S) {
+	var (
+		expect *require.Assertions
+		server *httptest.Server
+	)
+
+	it.Before(func() {
+		expect = require.New(t)
+
+		server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
+			switch req.URL.Path {
+			case "/v2/tokens/scopes":
+				auth := req.Header.Get("Authorization")
+				if auth != "Bearer some-magic-token" {
+					w.WriteHeader(http.StatusUnauthorized)
+					return
+				}
+
+				if req.Method != http.MethodGet {
+					w.WriteHeader(http.StatusMethodNotAllowed)
+					return
+				}
+
+				w.Write([]byte(tokensListScopesResponse))
+			default:
+				dump, err := httputil.DumpRequest(req, true)
+				if err != nil {
+					t.Fatal("failed to dump request")
+				}
+
+				t.Fatalf("received unknown request: %s", dump)
+			}
+		}))
+	})
+
+	when("all required flags are passed", func() {
+		it("lists the tokens", func() {
+			cmd := exec.Command(builtBinaryPath,
+				"-t", "some-magic-token",
+				"-u", server.URL,
+				"tokens",
+				"list-scopes",
+			)
+
+			output, err := cmd.CombinedOutput()
+			expect.NoError(err, fmt.Sprintf("received error output: %s", output))
+			expect.Equal(strings.TrimSpace(tokensListScopesOutput), strings.TrimSpace(string(output)))
+		})
+	})
+})
+
+const (
+	tokensListScopesOutput = `
+Name
+droplet:create
+droplet:delete
+`
+
+	tokensListScopesResponse = `
+{
+	"scopes": [
+	{
+		"name":"droplet:create"
+	},
+	{
+		"name":"droplet:delete"
+	}
+	],
+	"links": {
+	"pages": {}
+	},
+	"meta": {
+	"total": 2
+	}
+}
+`
+)
diff --git a/integration/tokens_list_test.go b/integration/tokens_list_test.go
new file mode 100644
index 00000000..9eaddd2a
--- /dev/null
+++ b/integration/tokens_list_test.go
@@ -0,0 +1,132 @@
+package integration
+
+import (
+	"fmt"
+	"net/http"
+	"net/http/httptest"
+	"net/http/httputil"
+	"os/exec"
+	"strings"
+	"testing"
+
+	"github.com/sclevine/spec"
+	"github.com/stretchr/testify/require"
+)
+
+var _ = suite("tokens/list", func(t *testing.T, when spec.G, it spec.S) {
+	var (
+		expect *require.Assertions
+		server *httptest.Server
+	)
+
+	it.Before(func() {
+		expect = require.New(t)
+
+		server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
+			switch req.URL.Path {
+			case "/v2/tokens":
+				auth := req.Header.Get("Authorization")
+				if auth != "Bearer some-magic-token" {
+					w.WriteHeader(http.StatusUnauthorized)
+					return
+				}
+
+				if req.Method != http.MethodGet {
+					w.WriteHeader(http.StatusMethodNotAllowed)
+					return
+				}
+
+				w.Write([]byte(tokensListResponse))
+			default:
+				dump, err := httputil.DumpRequest(req, true)
+				if err != nil {
+					t.Fatal("failed to dump request")
+				}
+
+				t.Fatalf("received unknown request: %s", dump)
+			}
+		}))
+	})
+
+	when("all required flags are passed", func() {
+		it("lists the tokens", func() {
+			cmd := exec.Command(builtBinaryPath,
+				"-t", "some-magic-token",
+				"-u", server.URL,
+				"tokens",
+				"list",
+			)
+
+			output, err := cmd.CombinedOutput()
+			expect.NoError(err, fmt.Sprintf("received error output: %s", output))
+			expect.Equal(strings.TrimSpace(tokensListOutput), strings.TrimSpace(string(output)))
+		})
+	})
+
+	when("passing the format flag", func() {
+		it("changes the output", func() {
+			cmd := exec.Command(builtBinaryPath,
+				"-t", "some-magic-token",
+				"-u", server.URL,
+				"tokens",
+				"list",
+				"--format", "Name,LastUsedAt",
+			)
+
+			output, err := cmd.CombinedOutput()
+			expect.NoError(err, fmt.Sprintf("received error output: %s", output))
+			expect.Equal(strings.TrimSpace(tokensListFormattedOutput), strings.TrimSpace(string(output)))
+		})
+	})
+})
+
+const (
+	tokensListOutput = `
+ID     Name                Scopes                           Expires At                       Last Used At    Created At
+123    droplets-reader     [account:read droplet:read]      2023-01-11 19:21:53 +0000 UTC    2022-12-12      2022-12-12 19:21:53 +0000 UTC
+456    droplets-creator    [droplet:create droplet:read]    2023-01-11 19:21:53 +0000 UTC    2022-12-12      2022-12-12 19:21:53 +0000 UTC
+789    legacy              [read write]                     Never                            2022-12-12      2022-12-12 19:21:53 +0000 UTC`
+
+	tokensListFormattedOutput = `
+Name                Last Used At
+droplets-reader     2022-12-12
+droplets-creator    2022-12-12
+legacy              2022-12-12
+`
+
+	tokensListResponse = `
+{
+	"tokens": [
+		{
+			"id":123,
+			"name":"droplets-reader",
+			"scopes":["account:read","droplet:read"],
+			"created_at":"2022-12-12T19:21:53Z",
+			"last_used_at":"2022-12-12",
+			"expiry_seconds":2592000
+		},
+		{
+			"id":456,
+			"name":"droplets-creator",
+			"scopes":["droplet:create","droplet:read"],
+			"created_at":"2022-12-12T19:21:53Z",
+			"last_used_at":"2022-12-12",
+			"expiry_seconds":2592000
+		},
+		{
+			"id":789,
+			"name":"legacy",
+			"scopes":["read","write"],
+			"created_at":"2022-12-12T19:21:53Z",
+			"last_used_at":"2022-12-12"
+		}
+	],
+	"links": {
+	"pages": {}
+	},
+	"meta": {
+	"total": 2
+	}
+}
+`
+)
diff --git a/integration/tokens_revoke_test.go b/integration/tokens_revoke_test.go
new file mode 100644
index 00000000..fc8f15f4
--- /dev/null
+++ b/integration/tokens_revoke_test.go
@@ -0,0 +1,133 @@
+package integration
+
+import (
+	"fmt"
+	"net/http"
+	"net/http/httptest"
+	"net/http/httputil"
+	"os/exec"
+	"strings"
+	"testing"
+
+	"github.com/sclevine/spec"
+	"github.com/stretchr/testify/require"
+)
+
+var _ = suite("tokens/revoke", func(t *testing.T, when spec.G, it spec.S) {
+	var (
+		expect *require.Assertions
+		server *httptest.Server
+	)
+
+	it.Before(func() {
+		expect = require.New(t)
+
+		server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
+			switch req.URL.Path {
+			case "/v2/tokens/123":
+				auth := req.Header.Get("Authorization")
+				if auth != "Bearer some-magic-token" {
+					w.WriteHeader(http.StatusUnauthorized)
+					return
+				}
+
+				if req.Method != http.MethodDelete {
+					w.WriteHeader(http.StatusMethodNotAllowed)
+					return
+				}
+
+				w.Write([]byte(tokensGetResponse))
+			case "/v2/tokens":
+				auth := req.Header.Get("Authorization")
+				if auth != "Bearer some-magic-token" {
+					w.WriteHeader(http.StatusUnauthorized)
+					return
+				}
+
+				if req.Method != http.MethodGet {
+					w.WriteHeader(http.StatusMethodNotAllowed)
+					return
+				}
+
+				w.Write([]byte(tokensGetLookupResponse))
+			default:
+				dump, err := httputil.DumpRequest(req, true)
+				if err != nil {
+					t.Fatal("failed to dump request")
+				}
+
+				t.Fatalf("received unknown request: %s", dump)
+			}
+		}))
+	})
+
+	when("all required flags are passed", func() {
+		it("revokes the specified token", func() {
+			cmd := exec.Command(builtBinaryPath,
+				"-t", "some-magic-token",
+				"-u", server.URL,
+				"tokens",
+				"revoke",
+				"123",
+				"--force",
+			)
+
+			output, err := cmd.CombinedOutput()
+			expect.NoError(err, fmt.Sprintf("received error output: %s", output))
+			expect.Empty(strings.TrimSpace(string(output)))
+		})
+	})
+
+	when("delete alias is used", func() {
+		it("revokes the specified token", func() {
+			cmd := exec.Command(builtBinaryPath,
+				"-t", "some-magic-token",
+				"-u", server.URL,
+				"tokens",
+				"delete",
+				"123",
+				"--force",
+			)
+
+			output, err := cmd.CombinedOutput()
+			expect.NoError(err, fmt.Sprintf("received error output: %s", output))
+			expect.Empty(strings.TrimSpace(string(output)))
+		})
+	})
+
+	when("the name is passed", func() {
+		it("looks up the specified token and revokes it", func() {
+			cmd := exec.Command(builtBinaryPath,
+				"-t", "some-magic-token",
+				"-u", server.URL,
+				"tokens",
+				"revoke",
+				"droplets-reader",
+				"--force",
+			)
+
+			output, err := cmd.CombinedOutput()
+			expect.NoError(err, fmt.Sprintf("received error output: %s", output))
+			expect.Empty(strings.TrimSpace(string(output)))
+		})
+	})
+
+	when("the force flag is not passed", func() {
+		it("it returns an error", func() {
+			cmd := exec.Command(builtBinaryPath,
+				"-t", "some-magic-token",
+				"-u", server.URL,
+				"tokens",
+				"revoke",
+				"droplets-reader",
+			)
+
+			output, err := cmd.CombinedOutput()
+			expect.Error(err, fmt.Sprintf("received error output: %s", output))
+			expect.Equal(
+				"Warning: Requires confirmation. Use the `--force` flag to continue without confirmation.\nError: Operation aborted.",
+				strings.TrimSpace(string(output)),
+			)
+		})
+	})
+})
diff --git a/integration/tokens_update_test.go b/integration/tokens_update_test.go
new file mode 100644
index 00000000..50ca95d7
--- /dev/null
+++ b/integration/tokens_update_test.go
@@ -0,0 +1,177 @@
+package integration
+
+import (
+	"encoding/json"
+	"fmt"
+	"io/ioutil"
+	"net/http"
+	"net/http/httptest"
+	"net/http/httputil"
+	"os/exec"
+	"strings"
+	"testing"
+
+	"github.com/digitalocean/godo"
+	"github.com/sclevine/spec"
+	"github.com/stretchr/testify/require"
+)
+
+var _ = suite("tokens/update", func(t *testing.T, when spec.G, it spec.S) {
+	var (
+		expect *require.Assertions
+		server *httptest.Server
+	)
+
+	it.Before(func() {
+		expect = require.New(t)
+
+		server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) {
+			switch req.URL.Path {
+			case "/v2/tokens/123":
+				auth := req.Header.Get("Authorization")
+				if auth != "Bearer some-magic-token" {
+					w.WriteHeader(http.StatusUnauthorized)
+					return
+				}
+
+				if req.Method != http.MethodPatch {
+					w.WriteHeader(http.StatusMethodNotAllowed)
+					return
+				}
+
+				reqBody, err := ioutil.ReadAll(req.Body)
+				expect.NoError(err)
+
+				request := godo.TokenUpdateRequest{}
+				err = json.Unmarshal(reqBody, &request)
+				expect.NoError(err)
+				expect.Equal([]string{"account:read", "droplet:read"}, request.Scopes)
+
+				w.Write([]byte(tokensUpdateResponse))
+
+			case "/v2/tokens":
+				auth := req.Header.Get("Authorization")
+				if auth != "Bearer some-magic-token" {
+					w.WriteHeader(http.StatusUnauthorized)
+					return
+				}
+
+				if req.Method != http.MethodGet {
+					w.WriteHeader(http.StatusMethodNotAllowed)
+					return
+				}
+
+				w.Write([]byte(tokensGetLookupResponse))
+
+			default:
+				dump, err := httputil.DumpRequest(req, true)
+				if err != nil {
+					t.Fatal("failed to dump request")
+				}
+
+				t.Fatalf("received unknown request: %s", dump)
+			}
+		}))
+	})
+
+	when("all required flags are passed", func() {
+		it("updates the token", func() {
+			cmd := exec.Command(builtBinaryPath,
+				"-t", "some-magic-token",
+				"-u", server.URL,
+				"tokens",
+				"update",
+				"--scopes", "account:read",
+				"--scopes", "droplet:read",
+				"--updated-name", "droplets-reader",
+				"123",
+			)
+
+			output, err := cmd.CombinedOutput()
+			expect.NoError(err, fmt.Sprintf("received error output: %s", output))
+			expect.Equal(strings.TrimSpace(tokensUpdateOutput), strings.TrimSpace(string(output)))
+		})
+	})
+
+	when("the name is passed", func() {
+		it("looks up and updates the token", func() {
+			cmd := exec.Command(builtBinaryPath,
+				"-t", "some-magic-token",
+				"-u", server.URL,
+				"tokens",
+				"update",
+				"--scopes", "account:read",
+				"--scopes", "droplet:read",
+				"--updated-name", "droplets-reader",
+				"droplets-reader",
+			)
+
+			output, err := cmd.CombinedOutput()
+			expect.NoError(err, fmt.Sprintf("received error output: %s", output))
+			expect.Equal(strings.TrimSpace(tokensUpdateOutput), strings.TrimSpace(string(output)))
+		})
+	})
+
+	when("passing the format flag", func() {
+		it("changes the output", func() {
+			cmd := exec.Command(builtBinaryPath,
+				"-t", "some-magic-token",
+				"-u", server.URL,
+				"tokens",
+				"update",
+				"--format", "Name,Scopes",
+				"--updated-name", "droplets-reader",
+				"--scopes", "account:read,droplet:read",
+				"123",
+			)
+
+			output, err := cmd.CombinedOutput()
+			expect.NoError(err, fmt.Sprintf("received error output: %s", output))
+			expect.Equal(strings.TrimSpace(tokensUpdateFormattedOutput), strings.TrimSpace(string(output)))
+		})
+	})
+
+	when("missing the scopes flag", func() {
+		it("it returns an error", func() {
+			cmd := exec.Command(builtBinaryPath,
+				"-t", "some-magic-token",
+				"-u", server.URL,
+				"tokens",
+				"update",
+				"123",
+			)
+
+			output, err := cmd.CombinedOutput()
+			expect.Error(err, fmt.Sprintf("received error output: %s", output))
+			expect.Equal(strings.TrimSpace(tokensUpdateFlagOutputError), strings.TrimSpace(string(output)))
+		})
+	})
+})
+
+const (
+	tokensUpdateOutput = `
+ID     Name               Scopes                          Expires At                       Last Used At    Created At
+123    droplets-reader    [account:read  droplet:read]    2023-01-11 19:21:53 +0000 UTC    2022-12-12      2022-12-12 19:21:53 +0000 UTC
+`
+
+	tokensUpdateFormattedOutput = `
+Name               Scopes
+droplets-reader    [account:read  droplet:read]
+`
+
+	tokensUpdateResponse = `
+{
+	"token":
+		{
+			"id":123,
+			"name":"droplets-reader",
+			"scopes":["account:read ","droplet:read"],
+			"created_at":"2022-12-12T19:21:53Z",
+			"last_used_at":"2022-12-12",
+			"expiry_seconds":2592000
+		}
+}
+`
+
+	tokensUpdateFlagOutputError = `Error: must supply at least one of --scopes or --updated-name`
+)