feat(authentication): add oauth2 device grant (#837)

This commit is contained in:
Bastian
2022-12-22 00:03:10 +01:00
committed by GitHub
parent 3aab0a575d
commit cda2eccaac
11 changed files with 375 additions and 39 deletions

View File

@@ -7,6 +7,7 @@ import (
"github.com/int128/kubelogin/pkg/usecases/authentication"
"github.com/int128/kubelogin/pkg/usecases/authentication/authcode"
"github.com/int128/kubelogin/pkg/usecases/authentication/devicecode"
"github.com/int128/kubelogin/pkg/usecases/authentication/ropc"
"github.com/spf13/pflag"
)
@@ -47,6 +48,7 @@ var allGrantType = strings.Join([]string{
"authcode",
"authcode-keyboard",
"password",
"device-code",
}, "|")
func (o *authenticationOptions) addFlags(f *pflag.FlagSet) {
@@ -97,6 +99,11 @@ func (o *authenticationOptions) grantOptionSet() (s authentication.GrantOptionSe
Username: o.Username,
Password: o.Password,
}
case o.GrantType == "device-code":
s.DeviceCodeOption = &devicecode.Option{
SkipOpenBrowser: o.SkipOpenBrowser,
BrowserCommand: o.BrowserCommand,
}
default:
err = fmt.Errorf("grant-type must be one of (%s)", allGrantType)
}

View File

@@ -2,6 +2,7 @@ package cmd
import (
"fmt"
"github.com/int128/kubelogin/pkg/infrastructure/logger"
"github.com/int128/kubelogin/pkg/kubeconfig"
"github.com/int128/kubelogin/pkg/usecases/standalone"

View File

@@ -1,7 +1,8 @@
// Code generated by Wire. DO NOT EDIT.
//go:generate wire
//+build !wireinject
//go:generate go run github.com/google/wire/cmd/wire
//go:build !wireinject
// +build !wireinject
package di
@@ -21,6 +22,7 @@ import (
"github.com/int128/kubelogin/pkg/tokencache/repository"
"github.com/int128/kubelogin/pkg/usecases/authentication"
"github.com/int128/kubelogin/pkg/usecases/authentication/authcode"
"github.com/int128/kubelogin/pkg/usecases/authentication/devicecode"
"github.com/int128/kubelogin/pkg/usecases/authentication/ropc"
"github.com/int128/kubelogin/pkg/usecases/credentialplugin"
"github.com/int128/kubelogin/pkg/usecases/setup"
@@ -30,6 +32,7 @@ import (
// Injectors from di.go:
// NewCmd returns an instance of infrastructure.Cmd.
func NewCmd() cmd.Interface {
clockReal := &clock.Real{}
stdin := _wireFileValue
@@ -45,6 +48,7 @@ var (
_wireOsFileValue = os.Stdout
)
// NewCmdForHeadless returns an instance of infrastructure.Cmd for headless testing.
func NewCmdForHeadless(clockInterface clock.Interface, stdin stdio.Stdin, stdout stdio.Stdout, loggerInterface logger.Interface, browserInterface browser.Interface) cmd.Interface {
loaderLoader := loader.Loader{}
factory := &client.Factory{
@@ -67,6 +71,10 @@ func NewCmdForHeadless(clockInterface clock.Interface, stdin stdio.Stdin, stdout
Reader: readerReader,
Logger: loggerInterface,
}
deviceCode := &devicecode.DeviceCode{
Browser: browserInterface,
Logger: loggerInterface,
}
authenticationAuthentication := &authentication.Authentication{
ClientFactory: factory,
Logger: loggerInterface,
@@ -74,6 +82,7 @@ func NewCmdForHeadless(clockInterface clock.Interface, stdin stdio.Stdin, stdout
AuthCodeBrowser: authcodeBrowser,
AuthCodeKeyboard: keyboard,
ROPC: ropcROPC,
DeviceCode: deviceCode,
}
loader3 := &loader2.Loader{}
writerWriter := &writer.Writer{}

View File

@@ -12,6 +12,7 @@ import (
"github.com/int128/kubelogin/pkg/oidc"
"github.com/int128/kubelogin/pkg/pkce"
"github.com/int128/oauth2cli"
"github.com/int128/oauth2dev"
"golang.org/x/oauth2"
)
@@ -20,6 +21,8 @@ type Interface interface {
ExchangeAuthCode(ctx context.Context, in ExchangeAuthCodeInput) (*oidc.TokenSet, error)
GetTokenByAuthCode(ctx context.Context, in GetTokenByAuthCodeInput, localServerReadyChan chan<- string) (*oidc.TokenSet, error)
GetTokenByROPC(ctx context.Context, username, password string) (*oidc.TokenSet, error)
GetDeviceAuthorization(ctx context.Context) (*oauth2dev.AuthorizationResponse, error)
ExchangeDeviceCode(ctx context.Context, authResponse *oauth2dev.AuthorizationResponse) (*oidc.TokenSet, error)
Refresh(ctx context.Context, refreshToken string) (*oidc.TokenSet, error)
SupportedPKCEMethods() []string
}
@@ -52,12 +55,13 @@ type GetTokenByAuthCodeInput struct {
}
type client struct {
httpClient *http.Client
provider *gooidc.Provider
oauth2Config oauth2.Config
clock clock.Interface
logger logger.Interface
supportedPKCEMethods []string
httpClient *http.Client
provider *gooidc.Provider
oauth2Config oauth2.Config
clock clock.Interface
logger logger.Interface
supportedPKCEMethods []string
deviceAuthorizationEndpoint string
}
func (c *client) wrapContext(ctx context.Context) context.Context {
@@ -151,6 +155,26 @@ func (c *client) GetTokenByROPC(ctx context.Context, username, password string)
return c.verifyToken(ctx, token, "")
}
// GetDeviceAuthorization initializes the device authorization code challenge
func (c *client) GetDeviceAuthorization(ctx context.Context) (*oauth2dev.AuthorizationResponse, error) {
ctx = c.wrapContext(ctx)
config := c.oauth2Config
config.Endpoint = oauth2.Endpoint{
AuthURL: c.deviceAuthorizationEndpoint,
}
return oauth2dev.RetrieveCode(ctx, config)
}
// ExchangeDeviceCode exchanges the device to an oidc.TokenSet
func (c *client) ExchangeDeviceCode(ctx context.Context, authResponse *oauth2dev.AuthorizationResponse) (*oidc.TokenSet, error) {
ctx = c.wrapContext(ctx)
tokenResponse, err := oauth2dev.PollToken(ctx, c.oauth2Config, *authResponse)
if err != nil {
return nil, fmt.Errorf("device-code: exchange failed: %w", err)
}
return c.verifyToken(ctx, tokenResponse, "")
}
// Refresh sends a refresh token request and returns a token set.
func (c *client) Refresh(ctx context.Context, refreshToken string) (*oidc.TokenSet, error) {
ctx = c.wrapContext(ctx)

View File

@@ -63,6 +63,10 @@ func (f *Factory) New(ctx context.Context, p oidc.Provider, tlsClientConfig tlsc
if len(supportedPKCEMethods) == 0 && p.UsePKCE {
supportedPKCEMethods = []string{pkce.MethodS256}
}
deviceAuthorizationEndpoint, err := extractDeviceAuthorizationEndpoint(provider)
if err != nil {
return nil, fmt.Errorf("could not determine device authorization endpoint: %w", err)
}
return &client{
httpClient: httpClient,
provider: provider,
@@ -72,9 +76,10 @@ func (f *Factory) New(ctx context.Context, p oidc.Provider, tlsClientConfig tlsc
ClientSecret: p.ClientSecret,
Scopes: append(p.ExtraScopes, gooidc.ScopeOpenID),
},
clock: f.Clock,
logger: f.Logger,
supportedPKCEMethods: supportedPKCEMethods,
clock: f.Clock,
logger: f.Logger,
supportedPKCEMethods: supportedPKCEMethods,
deviceAuthorizationEndpoint: deviceAuthorizationEndpoint,
}, nil
}
@@ -87,3 +92,13 @@ func extractSupportedPKCEMethods(provider *gooidc.Provider) ([]string, error) {
}
return d.CodeChallengeMethodsSupported, nil
}
func extractDeviceAuthorizationEndpoint(provider *gooidc.Provider) (string, error) {
var d struct {
DeviceAuthorizationEndpoint string `json:"device_authorization_endpoint"`
}
if err := provider.Claims(&d); err != nil {
return "", fmt.Errorf("invalid discovery document: %w", err)
}
return d.DeviceAuthorizationEndpoint, nil
}

View File

@@ -5,8 +5,10 @@ package client
import (
context "context"
oidc "github.com/int128/kubelogin/pkg/oidc"
oauth2dev "github.com/int128/oauth2dev"
mock "github.com/stretchr/testify/mock"
oidc "github.com/int128/kubelogin/pkg/oidc"
)
// MockInterface is an autogenerated mock type for the Interface type
@@ -51,8 +53,8 @@ type MockInterface_ExchangeAuthCode_Call struct {
}
// ExchangeAuthCode is a helper method to define mock.On call
// - ctx context.Context
// - in ExchangeAuthCodeInput
// - ctx context.Context
// - in ExchangeAuthCodeInput
func (_e *MockInterface_Expecter) ExchangeAuthCode(ctx interface{}, in interface{}) *MockInterface_ExchangeAuthCode_Call {
return &MockInterface_ExchangeAuthCode_Call{Call: _e.mock.On("ExchangeAuthCode", ctx, in)}
}
@@ -69,6 +71,53 @@ func (_c *MockInterface_ExchangeAuthCode_Call) Return(_a0 *oidc.TokenSet, _a1 er
return _c
}
// ExchangeDeviceCode provides a mock function with given fields: ctx, authResponse
func (_m *MockInterface) ExchangeDeviceCode(ctx context.Context, authResponse *oauth2dev.AuthorizationResponse) (*oidc.TokenSet, error) {
ret := _m.Called(ctx, authResponse)
var r0 *oidc.TokenSet
if rf, ok := ret.Get(0).(func(context.Context, *oauth2dev.AuthorizationResponse) *oidc.TokenSet); ok {
r0 = rf(ctx, authResponse)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(*oidc.TokenSet)
}
}
var r1 error
if rf, ok := ret.Get(1).(func(context.Context, *oauth2dev.AuthorizationResponse) error); ok {
r1 = rf(ctx, authResponse)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// MockInterface_ExchangeDeviceCode_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'ExchangeDeviceCode'
type MockInterface_ExchangeDeviceCode_Call struct {
*mock.Call
}
// ExchangeDeviceCode is a helper method to define mock.On call
// - ctx context.Context
// - authResponse *oauth2dev.AuthorizationResponse
func (_e *MockInterface_Expecter) ExchangeDeviceCode(ctx interface{}, authResponse interface{}) *MockInterface_ExchangeDeviceCode_Call {
return &MockInterface_ExchangeDeviceCode_Call{Call: _e.mock.On("ExchangeDeviceCode", ctx, authResponse)}
}
func (_c *MockInterface_ExchangeDeviceCode_Call) Run(run func(ctx context.Context, authResponse *oauth2dev.AuthorizationResponse)) *MockInterface_ExchangeDeviceCode_Call {
_c.Call.Run(func(args mock.Arguments) {
run(args[0].(context.Context), args[1].(*oauth2dev.AuthorizationResponse))
})
return _c
}
func (_c *MockInterface_ExchangeDeviceCode_Call) Return(_a0 *oidc.TokenSet, _a1 error) *MockInterface_ExchangeDeviceCode_Call {
_c.Call.Return(_a0, _a1)
return _c
}
// GetAuthCodeURL provides a mock function with given fields: in
func (_m *MockInterface) GetAuthCodeURL(in AuthCodeURLInput) string {
ret := _m.Called(in)
@@ -89,7 +138,7 @@ type MockInterface_GetAuthCodeURL_Call struct {
}
// GetAuthCodeURL is a helper method to define mock.On call
// - in AuthCodeURLInput
// - in AuthCodeURLInput
func (_e *MockInterface_Expecter) GetAuthCodeURL(in interface{}) *MockInterface_GetAuthCodeURL_Call {
return &MockInterface_GetAuthCodeURL_Call{Call: _e.mock.On("GetAuthCodeURL", in)}
}
@@ -106,6 +155,52 @@ func (_c *MockInterface_GetAuthCodeURL_Call) Return(_a0 string) *MockInterface_G
return _c
}
// GetDeviceAuthorization provides a mock function with given fields: ctx
func (_m *MockInterface) GetDeviceAuthorization(ctx context.Context) (*oauth2dev.AuthorizationResponse, error) {
ret := _m.Called(ctx)
var r0 *oauth2dev.AuthorizationResponse
if rf, ok := ret.Get(0).(func(context.Context) *oauth2dev.AuthorizationResponse); ok {
r0 = rf(ctx)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(*oauth2dev.AuthorizationResponse)
}
}
var r1 error
if rf, ok := ret.Get(1).(func(context.Context) error); ok {
r1 = rf(ctx)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// MockInterface_GetDeviceAuthorization_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetDeviceAuthorization'
type MockInterface_GetDeviceAuthorization_Call struct {
*mock.Call
}
// GetDeviceAuthorization is a helper method to define mock.On call
// - ctx context.Context
func (_e *MockInterface_Expecter) GetDeviceAuthorization(ctx interface{}) *MockInterface_GetDeviceAuthorization_Call {
return &MockInterface_GetDeviceAuthorization_Call{Call: _e.mock.On("GetDeviceAuthorization", ctx)}
}
func (_c *MockInterface_GetDeviceAuthorization_Call) Run(run func(ctx context.Context)) *MockInterface_GetDeviceAuthorization_Call {
_c.Call.Run(func(args mock.Arguments) {
run(args[0].(context.Context))
})
return _c
}
func (_c *MockInterface_GetDeviceAuthorization_Call) Return(_a0 *oauth2dev.AuthorizationResponse, _a1 error) *MockInterface_GetDeviceAuthorization_Call {
_c.Call.Return(_a0, _a1)
return _c
}
// GetTokenByAuthCode provides a mock function with given fields: ctx, in, localServerReadyChan
func (_m *MockInterface) GetTokenByAuthCode(ctx context.Context, in GetTokenByAuthCodeInput, localServerReadyChan chan<- string) (*oidc.TokenSet, error) {
ret := _m.Called(ctx, in, localServerReadyChan)
@@ -135,9 +230,9 @@ type MockInterface_GetTokenByAuthCode_Call struct {
}
// GetTokenByAuthCode is a helper method to define mock.On call
// - ctx context.Context
// - in GetTokenByAuthCodeInput
// - localServerReadyChan chan<- string
// - ctx context.Context
// - in GetTokenByAuthCodeInput
// - localServerReadyChan chan<- string
func (_e *MockInterface_Expecter) GetTokenByAuthCode(ctx interface{}, in interface{}, localServerReadyChan interface{}) *MockInterface_GetTokenByAuthCode_Call {
return &MockInterface_GetTokenByAuthCode_Call{Call: _e.mock.On("GetTokenByAuthCode", ctx, in, localServerReadyChan)}
}
@@ -183,9 +278,9 @@ type MockInterface_GetTokenByROPC_Call struct {
}
// GetTokenByROPC is a helper method to define mock.On call
// - ctx context.Context
// - username string
// - password string
// - ctx context.Context
// - username string
// - password string
func (_e *MockInterface_Expecter) GetTokenByROPC(ctx interface{}, username interface{}, password interface{}) *MockInterface_GetTokenByROPC_Call {
return &MockInterface_GetTokenByROPC_Call{Call: _e.mock.On("GetTokenByROPC", ctx, username, password)}
}
@@ -231,8 +326,8 @@ type MockInterface_Refresh_Call struct {
}
// Refresh is a helper method to define mock.On call
// - ctx context.Context
// - refreshToken string
// - ctx context.Context
// - refreshToken string
func (_e *MockInterface_Expecter) Refresh(ctx interface{}, refreshToken interface{}) *MockInterface_Refresh_Call {
return &MockInterface_Refresh_Call{Call: _e.mock.On("Refresh", ctx, refreshToken)}
}

View File

@@ -11,6 +11,7 @@ import (
"github.com/int128/kubelogin/pkg/oidc/client"
"github.com/int128/kubelogin/pkg/tlsclientconfig"
"github.com/int128/kubelogin/pkg/usecases/authentication/authcode"
"github.com/int128/kubelogin/pkg/usecases/authentication/devicecode"
"github.com/int128/kubelogin/pkg/usecases/authentication/ropc"
)
@@ -21,6 +22,7 @@ var Set = wire.NewSet(
wire.Struct(new(authcode.Browser), "*"),
wire.Struct(new(authcode.Keyboard), "*"),
wire.Struct(new(ropc.ROPC), "*"),
wire.Struct(new(devicecode.DeviceCode), "*"),
)
type Interface interface {
@@ -39,6 +41,7 @@ type GrantOptionSet struct {
AuthCodeBrowserOption *authcode.BrowserOption
AuthCodeKeyboardOption *authcode.KeyboardOption
ROPCOption *ropc.Option
DeviceCodeOption *devicecode.Option
}
// Output represents an output DTO of the Authentication use-case.
@@ -59,7 +62,6 @@ type Output struct {
// If the Username is not set, it performs the authorization code flow.
// Otherwise, it performs the resource owner password credentials flow.
// If the Password is not set, it asks a password by the prompt.
//
type Authentication struct {
ClientFactory client.FactoryInterface
Logger logger.Interface
@@ -67,6 +69,7 @@ type Authentication struct {
AuthCodeBrowser *authcode.Browser
AuthCodeKeyboard *authcode.Keyboard
ROPC *ropc.ROPC
DeviceCode *devicecode.DeviceCode
}
func (u *Authentication) Do(ctx context.Context, in Input) (*Output, error) {
@@ -125,5 +128,12 @@ func (u *Authentication) Do(ctx context.Context, in Input) (*Output, error) {
}
return &Output{TokenSet: *tokenSet}, nil
}
if in.GrantOptionSet.DeviceCodeOption != nil {
tokenSet, err := u.DeviceCode.Do(ctx, in.GrantOptionSet.DeviceCodeOption, oidcClient)
if err != nil {
return nil, fmt.Errorf("device-code error: %w", err)
}
return &Output{TokenSet: *tokenSet}, nil
}
return nil, fmt.Errorf("any authorization grant must be set")
}

View File

@@ -0,0 +1,67 @@
package devicecode
import (
"context"
"fmt"
"github.com/int128/kubelogin/pkg/infrastructure/browser"
"github.com/int128/kubelogin/pkg/infrastructure/logger"
"github.com/int128/kubelogin/pkg/oidc"
"github.com/int128/kubelogin/pkg/oidc/client"
)
type Option struct {
SkipOpenBrowser bool
BrowserCommand string
}
// DeviceCode provides the oauth2 device code flow.
type DeviceCode struct {
Browser browser.Interface
Logger logger.Interface
}
func (u *DeviceCode) Do(ctx context.Context, in *Option, oidcClient client.Interface) (*oidc.TokenSet, error) {
u.Logger.V(1).Infof("starting the oauth2 device code flow")
authResponse, err := oidcClient.GetDeviceAuthorization(ctx)
if err != nil {
return nil, err
}
if authResponse.VerificationURIComplete == "" {
u.Logger.Printf("Please enter the following code when asked in your browser: %s", authResponse.UserCode)
u.openURL(ctx, in, authResponse.VerificationURI)
} else {
u.openURL(ctx, in, authResponse.VerificationURIComplete)
}
tokenSet, err := oidcClient.ExchangeDeviceCode(ctx, authResponse)
u.Logger.V(1).Infof("finished the oauth2 device code flow")
if err != nil {
return nil, fmt.Errorf("unable to exchange device code: %w", err)
}
return tokenSet, nil
}
func (u *DeviceCode) openURL(ctx context.Context, o *Option, url string) {
if o != nil && o.SkipOpenBrowser {
u.Logger.Printf("Please visit the following URL in your browser: %s", url)
return
}
u.Logger.V(1).Infof("opening %s in the browser", url)
if o != nil && o.BrowserCommand != "" {
if err := u.Browser.OpenCommand(ctx, url, o.BrowserCommand); err != nil {
u.Logger.Printf(`error: could not open the browser: %s
Please visit the following URL in your browser manually: %s`, err, url)
}
return
}
if err := u.Browser.Open(url); err != nil {
u.Logger.Printf(`error: could not open the browser: %s
Please visit the following URL in your browser manually: %s`, err, url)
}
}

View File

@@ -0,0 +1,105 @@
package devicecode
import (
"context"
"errors"
"testing"
"github.com/int128/kubelogin/pkg/infrastructure/browser"
"github.com/int128/kubelogin/pkg/oidc"
"github.com/int128/kubelogin/pkg/oidc/client"
"github.com/int128/kubelogin/pkg/testing/logger"
"github.com/int128/oauth2dev"
"github.com/stretchr/testify/mock"
)
func TestDeviceCode(t *testing.T) {
mockBrowser := browser.NewMockInterface(t)
logger := logger.New(t)
mockClient := client.NewMockInterface(t)
dc := &DeviceCode{
Browser: mockBrowser,
Logger: logger,
}
ctx := context.Background()
errTest := errors.New("test error")
mockClient.EXPECT().GetDeviceAuthorization(ctx).Return(nil, errTest).Once()
_, err := dc.Do(ctx, &Option{}, mockClient)
if !errors.Is(err, errTest) {
t.Errorf("returned error is not the test error: %v", err)
}
mockResponse := &oauth2dev.AuthorizationResponse{DeviceCode: "device-code-1", UserCode: "", VerificationURI: "", VerificationURIComplete: "https://example.com/verificationComplete?code=code123", VerificationURL: "", ExpiresIn: 2, Interval: 1}
mockClient.EXPECT().GetDeviceAuthorization(ctx).Return(&oauth2dev.AuthorizationResponse{
Interval: 1,
ExpiresIn: 2,
VerificationURIComplete: "https://example.com/verificationComplete?code=code123",
DeviceCode: "device-code-1",
}, nil).Once()
mockBrowser.EXPECT().Open("https://example.com/verificationComplete?code=code123").Return(nil).Once()
mockClient.EXPECT().ExchangeDeviceCode(mock.Anything, mockResponse).Return(&oidc.TokenSet{
IDToken: "test-id-token",
}, nil).Once()
ts, err := dc.Do(ctx, &Option{}, mockClient)
if err != nil {
t.Errorf("returned unexpected error: %v", err)
}
if ts.IDToken != "test-id-token" {
t.Errorf("wrong returned tokenset: %v", err)
}
mockResponseWithoutComplete := &oauth2dev.AuthorizationResponse{DeviceCode: "device-code-1", UserCode: "", VerificationURI: "https://example.com/verificationComplete", VerificationURIComplete: "", VerificationURL: "", ExpiresIn: 2, Interval: 1}
mockClient.EXPECT().GetDeviceAuthorization(ctx).Return(&oauth2dev.AuthorizationResponse{
Interval: 1,
ExpiresIn: 2,
VerificationURI: "https://example.com/verificationComplete",
DeviceCode: "device-code-1",
}, nil).Once()
mockBrowser.EXPECT().Open("https://example.com/verificationComplete").Return(nil).Once()
mockClient.EXPECT().ExchangeDeviceCode(mock.Anything, mockResponseWithoutComplete).Return(&oidc.TokenSet{
IDToken: "test-id-token",
}, nil).Once()
ts, err = dc.Do(ctx, &Option{}, mockClient)
if err != nil {
t.Errorf("returned unexpected error: %v", err)
}
if ts.IDToken != "test-id-token" {
t.Errorf("wrong returned tokenset: %v", err)
}
mockClient.EXPECT().GetDeviceAuthorization(ctx).Return(&oauth2dev.AuthorizationResponse{
Interval: 1,
ExpiresIn: 2,
VerificationURIComplete: "https://example.com/verificationComplete?code=code123",
DeviceCode: "device-code-1",
}, nil).Once()
mockBrowser.EXPECT().Open("https://example.com/verificationComplete?code=code123").Return(nil).Once()
mockClient.EXPECT().ExchangeDeviceCode(mock.Anything, mockResponse).Return(nil, errTest).Once()
_, err = dc.Do(ctx, &Option{}, mockClient)
if err == nil {
t.Errorf("did not return error: %v", err)
}
}
func TestOpenUrl(t *testing.T) {
ctx := context.Background()
browserMock := browser.NewMockInterface(t)
deviceCode := &DeviceCode{
Browser: browserMock,
Logger: logger.New(t),
}
const url = "https://example.com"
var testError = errors.New("test error")
browserMock.EXPECT().Open(url).Return(testError).Once()
deviceCode.openURL(ctx, nil, url)
deviceCode.openURL(ctx, &Option{SkipOpenBrowser: true}, url)
browserMock.EXPECT().OpenCommand(ctx, url, "test-command").Return(testError).Once()
deviceCode.openURL(ctx, &Option{BrowserCommand: "test-command"}, url)
}