diff --git a/mocks/github.com/int128/kubelogin/pkg/credentialplugin/reader_mock/mock_Interface.go b/mocks/github.com/int128/kubelogin/pkg/credentialplugin/reader_mock/mock_Interface.go new file mode 100644 index 00000000..208ac397 --- /dev/null +++ b/mocks/github.com/int128/kubelogin/pkg/credentialplugin/reader_mock/mock_Interface.go @@ -0,0 +1,90 @@ +// Code generated by mockery v2.46.3. DO NOT EDIT. + +package reader_mock + +import ( + credentialplugin "github.com/int128/kubelogin/pkg/credentialplugin" + mock "github.com/stretchr/testify/mock" +) + +// MockInterface is an autogenerated mock type for the Interface type +type MockInterface struct { + mock.Mock +} + +type MockInterface_Expecter struct { + mock *mock.Mock +} + +func (_m *MockInterface) EXPECT() *MockInterface_Expecter { + return &MockInterface_Expecter{mock: &_m.Mock} +} + +// Read provides a mock function with given fields: +func (_m *MockInterface) Read() (credentialplugin.Input, error) { + ret := _m.Called() + + if len(ret) == 0 { + panic("no return value specified for Read") + } + + var r0 credentialplugin.Input + var r1 error + if rf, ok := ret.Get(0).(func() (credentialplugin.Input, error)); ok { + return rf() + } + if rf, ok := ret.Get(0).(func() credentialplugin.Input); ok { + r0 = rf() + } else { + r0 = ret.Get(0).(credentialplugin.Input) + } + + if rf, ok := ret.Get(1).(func() error); ok { + r1 = rf() + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// MockInterface_Read_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Read' +type MockInterface_Read_Call struct { + *mock.Call +} + +// Read is a helper method to define mock.On call +func (_e *MockInterface_Expecter) Read() *MockInterface_Read_Call { + return &MockInterface_Read_Call{Call: _e.mock.On("Read")} +} + +func (_c *MockInterface_Read_Call) Run(run func()) *MockInterface_Read_Call { + _c.Call.Run(func(args mock.Arguments) { + run() + }) + return _c +} + +func (_c *MockInterface_Read_Call) Return(_a0 credentialplugin.Input, _a1 error) *MockInterface_Read_Call { + _c.Call.Return(_a0, _a1) + return _c +} + +func (_c *MockInterface_Read_Call) RunAndReturn(run func() (credentialplugin.Input, error)) *MockInterface_Read_Call { + _c.Call.Return(run) + return _c +} + +// NewMockInterface creates a new instance of MockInterface. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewMockInterface(t interface { + mock.TestingT + Cleanup(func()) +}) *MockInterface { + mock := &MockInterface{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/pkg/credentialplugin/reader/reader.go b/pkg/credentialplugin/reader/reader.go new file mode 100644 index 00000000..9ba94f5d --- /dev/null +++ b/pkg/credentialplugin/reader/reader.go @@ -0,0 +1,39 @@ +// Package reader provides a loader for the credential plugin. +package reader + +import ( + "encoding/json" + "fmt" + "os" + + "github.com/google/wire" + "github.com/int128/kubelogin/pkg/credentialplugin" + "k8s.io/client-go/pkg/apis/clientauthentication" +) + +var Set = wire.NewSet( + wire.Struct(new(Reader), "*"), + wire.Bind(new(Interface), new(*Reader)), +) + +type Interface interface { + Read() (credentialplugin.Input, error) +} + +type Reader struct{} + +// Read parses the environment variable KUBERNETES_EXEC_INFO. +// If the environment variable is not given by kubectl, Read returns a zero value. +func (r Reader) Read() (credentialplugin.Input, error) { + execInfo := os.Getenv("KUBERNETES_EXEC_INFO") + if execInfo == "" { + return credentialplugin.Input{}, nil + } + var execCredential clientauthentication.ExecCredential + if err := json.Unmarshal([]byte(execInfo), &execCredential); err != nil { + return credentialplugin.Input{}, fmt.Errorf("invalid KUBERNETES_EXEC_INFO: %w", err) + } + return credentialplugin.Input{ + ClientAuthenticationAPIVersion: execCredential.APIVersion, + }, nil +} diff --git a/pkg/credentialplugin/reader/reader_test.go b/pkg/credentialplugin/reader/reader_test.go new file mode 100644 index 00000000..8726ff81 --- /dev/null +++ b/pkg/credentialplugin/reader/reader_test.go @@ -0,0 +1,44 @@ +package reader + +import ( + "testing" + + "github.com/google/go-cmp/cmp" + "github.com/int128/kubelogin/pkg/credentialplugin" +) + +func TestReader_Read(t *testing.T) { + var reader Reader + + t.Run("KUBERNETES_EXEC_INFO is empty", func(t *testing.T) { + input, err := reader.Read() + if err != nil { + t.Errorf("Read returned error: %v", err) + } + want := credentialplugin.Input{} + if diff := cmp.Diff(want, input); diff != "" { + t.Errorf("input mismatch (-want +got):\n%s", diff) + } + }) + t.Run("KUBERNETES_EXEC_INFO is invalid JSON", func(t *testing.T) { + t.Setenv("KUBERNETES_EXEC_INFO", "invalid") + _, err := reader.Read() + if err == nil { + t.Errorf("Read wants error but no error") + } + }) + t.Run("KUBERNETES_EXEC_INFO is v1", func(t *testing.T) { + t.Setenv( + "KUBERNETES_EXEC_INFO", + `{"kind":"ExecCredential","apiVersion":"client.authentication.k8s.io/v1","spec":{"interactive":true}}`, + ) + input, err := reader.Read() + if err != nil { + t.Errorf("Read returned error: %v", err) + } + want := credentialplugin.Input{ClientAuthenticationAPIVersion: "client.authentication.k8s.io/v1"} + if diff := cmp.Diff(want, input); diff != "" { + t.Errorf("input mismatch (-want +got):\n%s", diff) + } + }) +} diff --git a/pkg/credentialplugin/types.go b/pkg/credentialplugin/types.go index 27546e09..0b2c7710 100644 --- a/pkg/credentialplugin/types.go +++ b/pkg/credentialplugin/types.go @@ -3,8 +3,15 @@ package credentialplugin import "time" +// Input represents an input object of the credential plugin. +// This may be a zero value if the input is not available. +type Input struct { + ClientAuthenticationAPIVersion string +} + // Output represents an output object of the credential plugin. type Output struct { - Token string - Expiry time.Time + Token string + Expiry time.Time + ClientAuthenticationAPIVersion string } diff --git a/pkg/credentialplugin/writer/credential_plugin.go b/pkg/credentialplugin/writer/credential_plugin.go index cd6dbb75..5dd32483 100644 --- a/pkg/credentialplugin/writer/credential_plugin.go +++ b/pkg/credentialplugin/writer/credential_plugin.go @@ -1,4 +1,4 @@ -// Package writer provides a writer for a credential plugin. +// Package writer provides a writer for the credential plugin. package writer import ( @@ -9,6 +9,7 @@ import ( "github.com/int128/kubelogin/pkg/credentialplugin" "github.com/int128/kubelogin/pkg/infrastructure/stdio" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + clientauthenticationv1 "k8s.io/client-go/pkg/apis/clientauthentication/v1" clientauthenticationv1beta1 "k8s.io/client-go/pkg/apis/clientauthentication/v1beta1" ) @@ -27,19 +28,44 @@ type Writer struct { // Write writes the ExecCredential to standard output for kubectl. func (w *Writer) Write(out credentialplugin.Output) error { - ec := &clientauthenticationv1beta1.ExecCredential{ - TypeMeta: metav1.TypeMeta{ - APIVersion: "client.authentication.k8s.io/v1beta1", - Kind: "ExecCredential", - }, - Status: &clientauthenticationv1beta1.ExecCredentialStatus{ - Token: out.Token, - ExpirationTimestamp: &metav1.Time{Time: out.Expiry}, - }, + execCredential, err := generateExecCredential(out) + if err != nil { + return fmt.Errorf("generate ExecCredential: %w", err) } - e := json.NewEncoder(w.Stdout) - if err := e.Encode(ec); err != nil { - return fmt.Errorf("could not write the ExecCredential: %w", err) + if err := json.NewEncoder(w.Stdout).Encode(execCredential); err != nil { + return fmt.Errorf("write ExecCredential: %w", err) } return nil } + +func generateExecCredential(out credentialplugin.Output) (any, error) { + switch out.ClientAuthenticationAPIVersion { + // If the API version is not available, fall back to v1beta1. + case clientauthenticationv1beta1.SchemeGroupVersion.String(), "": + return &clientauthenticationv1beta1.ExecCredential{ + TypeMeta: metav1.TypeMeta{ + APIVersion: clientauthenticationv1beta1.SchemeGroupVersion.String(), + Kind: "ExecCredential", + }, + Status: &clientauthenticationv1beta1.ExecCredentialStatus{ + Token: out.Token, + ExpirationTimestamp: &metav1.Time{Time: out.Expiry}, + }, + }, nil + + case clientauthenticationv1.SchemeGroupVersion.String(): + return &clientauthenticationv1.ExecCredential{ + TypeMeta: metav1.TypeMeta{ + APIVersion: clientauthenticationv1.SchemeGroupVersion.String(), + Kind: "ExecCredential", + }, + Status: &clientauthenticationv1.ExecCredentialStatus{ + Token: out.Token, + ExpirationTimestamp: &metav1.Time{Time: out.Expiry}, + }, + }, nil + + default: + return nil, fmt.Errorf("unknown apiVersion: %s", out.ClientAuthenticationAPIVersion) + } +} diff --git a/pkg/di/di.go b/pkg/di/di.go index e928a7ca..aba61227 100644 --- a/pkg/di/di.go +++ b/pkg/di/di.go @@ -7,7 +7,8 @@ package di import ( "github.com/google/wire" "github.com/int128/kubelogin/pkg/cmd" - "github.com/int128/kubelogin/pkg/credentialplugin/writer" + credentialpluginreader "github.com/int128/kubelogin/pkg/credentialplugin/reader" + credentialpluginwriter "github.com/int128/kubelogin/pkg/credentialplugin/writer" "github.com/int128/kubelogin/pkg/infrastructure/browser" "github.com/int128/kubelogin/pkg/infrastructure/clock" "github.com/int128/kubelogin/pkg/infrastructure/logger" @@ -55,7 +56,8 @@ func NewCmdForHeadless(clock.Interface, stdio.Stdin, stdio.Stdout, logger.Interf repository.Set, client.Set, loader.Set, - writer.Set, + credentialpluginreader.Set, + credentialpluginwriter.Set, ) return nil } diff --git a/pkg/di/wire_gen.go b/pkg/di/wire_gen.go index f78caeeb..5527edf7 100644 --- a/pkg/di/wire_gen.go +++ b/pkg/di/wire_gen.go @@ -8,6 +8,7 @@ package di import ( "github.com/int128/kubelogin/pkg/cmd" + reader2 "github.com/int128/kubelogin/pkg/credentialplugin/reader" writer2 "github.com/int128/kubelogin/pkg/credentialplugin/writer" "github.com/int128/kubelogin/pkg/infrastructure/browser" "github.com/int128/kubelogin/pkg/infrastructure/clock" @@ -96,15 +97,17 @@ func NewCmdForHeadless(clockInterface clock.Interface, stdin stdio.Stdin, stdout Logger: loggerInterface, } repositoryRepository := &repository.Repository{} + reader3 := &reader2.Reader{} writer3 := &writer2.Writer{ Stdout: stdout, } getToken := &credentialplugin.GetToken{ - Authentication: authenticationAuthentication, - TokenCacheRepository: repositoryRepository, - Writer: writer3, - Logger: loggerInterface, - Clock: clockInterface, + Authentication: authenticationAuthentication, + TokenCacheRepository: repositoryRepository, + CredentialPluginReader: reader3, + CredentialPluginWriter: writer3, + Logger: loggerInterface, + Clock: clockInterface, } cmdGetToken := &cmd.GetToken{ GetToken: getToken, diff --git a/pkg/usecases/credentialplugin/get_token.go b/pkg/usecases/credentialplugin/get_token.go index 77fcf657..e14b9dbc 100644 --- a/pkg/usecases/credentialplugin/get_token.go +++ b/pkg/usecases/credentialplugin/get_token.go @@ -9,7 +9,8 @@ import ( "github.com/google/wire" "github.com/int128/kubelogin/pkg/credentialplugin" - "github.com/int128/kubelogin/pkg/credentialplugin/writer" + credentialpluginreader "github.com/int128/kubelogin/pkg/credentialplugin/reader" + credentialpluginwriter "github.com/int128/kubelogin/pkg/credentialplugin/writer" "github.com/int128/kubelogin/pkg/infrastructure/clock" "github.com/int128/kubelogin/pkg/infrastructure/logger" "github.com/int128/kubelogin/pkg/oidc" @@ -38,16 +39,23 @@ type Input struct { } type GetToken struct { - Authentication authentication.Interface - TokenCacheRepository repository.Interface - Writer writer.Interface - Logger logger.Interface - Clock clock.Interface + Authentication authentication.Interface + TokenCacheRepository repository.Interface + CredentialPluginReader credentialpluginreader.Interface + CredentialPluginWriter credentialpluginwriter.Interface + Logger logger.Interface + Clock clock.Interface } func (u *GetToken) Do(ctx context.Context, in Input) error { u.Logger.V(1).Infof("WARNING: log may contain your secrets such as token or password") + credentialPluginInput, err := u.CredentialPluginReader.Read() + if err != nil { + return fmt.Errorf("could not read the input of credential plugin: %w", err) + } + u.Logger.V(1).Infof("credential plugin is called with apiVersion: %s", credentialPluginInput.ClientAuthenticationAPIVersion) + u.Logger.V(1).Infof("finding a token from cache directory %s", in.TokenCacheDir) tokenCacheKey := tokencache.Key{ Provider: in.Provider, @@ -88,10 +96,11 @@ func (u *GetToken) Do(ctx context.Context, in Input) error { if !claims.IsExpired(u.Clock) { u.Logger.V(1).Infof("you already have a valid token until %s", claims.Expiry) out := credentialplugin.Output{ - Token: cachedTokenSet.IDToken, - Expiry: claims.Expiry, + Token: cachedTokenSet.IDToken, + Expiry: claims.Expiry, + ClientAuthenticationAPIVersion: credentialPluginInput.ClientAuthenticationAPIVersion, } - if err := u.Writer.Write(out); err != nil { + if err := u.CredentialPluginWriter.Write(out); err != nil { return fmt.Errorf("could not write the token to client-go: %w", err) } return nil @@ -122,10 +131,11 @@ func (u *GetToken) Do(ctx context.Context, in Input) error { } u.Logger.V(1).Infof("writing the token to client-go") out := credentialplugin.Output{ - Token: authenticationOutput.TokenSet.IDToken, - Expiry: idTokenClaims.Expiry, + Token: authenticationOutput.TokenSet.IDToken, + Expiry: idTokenClaims.Expiry, + ClientAuthenticationAPIVersion: credentialPluginInput.ClientAuthenticationAPIVersion, } - if err := u.Writer.Write(out); err != nil { + if err := u.CredentialPluginWriter.Write(out); err != nil { return fmt.Errorf("could not write the token to client-go: %w", err) } return nil diff --git a/pkg/usecases/credentialplugin/get_token_test.go b/pkg/usecases/credentialplugin/get_token_test.go index 0aa8285b..9ce6ad5e 100644 --- a/pkg/usecases/credentialplugin/get_token_test.go +++ b/pkg/usecases/credentialplugin/get_token_test.go @@ -7,6 +7,7 @@ import ( "time" "github.com/golang-jwt/jwt/v5" + "github.com/int128/kubelogin/mocks/github.com/int128/kubelogin/pkg/credentialplugin/reader_mock" "github.com/int128/kubelogin/mocks/github.com/int128/kubelogin/pkg/credentialplugin/writer_mock" "github.com/int128/kubelogin/mocks/github.com/int128/kubelogin/pkg/tokencache/repository_mock" "github.com/int128/kubelogin/mocks/github.com/int128/kubelogin/pkg/usecases/authentication_mock" @@ -40,8 +41,12 @@ func TestGetToken_Do(t *testing.T) { RefreshToken: "YOUR_REFRESH_TOKEN", } issuedOutput := credentialplugin.Output{ - Token: issuedIDToken, - Expiry: expiryTime, + Token: issuedIDToken, + Expiry: expiryTime, + ClientAuthenticationAPIVersion: "client.authentication.k8s.io/v1", + } + credentialpluginInput := credentialplugin.Input{ + ClientAuthenticationAPIVersion: "client.authentication.k8s.io/v1", } grantOptionSet := authentication.GrantOptionSet{ AuthCodeBrowserOption: &authcode.BrowserOption{ @@ -84,16 +89,21 @@ func TestGetToken_Do(t *testing.T) { mockRepository.EXPECT(). Save("/path/to/token-cache", tokenCacheKey, issuedTokenSet). Return(nil) + mockReader := reader_mock.NewMockInterface(t) + mockReader.EXPECT(). + Read(). + Return(credentialpluginInput, nil) mockWriter := writer_mock.NewMockInterface(t) mockWriter.EXPECT(). Write(issuedOutput). Return(nil) u := GetToken{ - Authentication: mockAuthentication, - TokenCacheRepository: mockRepository, - Writer: mockWriter, - Logger: logger.New(t), - Clock: clock.Fake(expiryTime.Add(-time.Hour)), + Authentication: mockAuthentication, + TokenCacheRepository: mockRepository, + CredentialPluginReader: mockReader, + CredentialPluginWriter: mockWriter, + Logger: logger.New(t), + Clock: clock.Fake(expiryTime.Add(-time.Hour)), } if err := u.Do(ctx, in); err != nil { t.Errorf("Do returned error: %+v", err) @@ -140,16 +150,21 @@ func TestGetToken_Do(t *testing.T) { mockRepository.EXPECT(). Save("/path/to/token-cache", tokenCacheKey, issuedTokenSet). Return(nil) + mockReader := reader_mock.NewMockInterface(t) + mockReader.EXPECT(). + Read(). + Return(credentialplugin.Input{ClientAuthenticationAPIVersion: "client.authentication.k8s.io/v1"}, nil) mockWriter := writer_mock.NewMockInterface(t) mockWriter.EXPECT(). Write(issuedOutput). Return(nil) u := GetToken{ - Authentication: mockAuthentication, - TokenCacheRepository: mockRepository, - Writer: mockWriter, - Logger: logger.New(t), - Clock: clock.Fake(expiryTime.Add(-time.Hour)), + Authentication: mockAuthentication, + TokenCacheRepository: mockRepository, + CredentialPluginReader: mockReader, + CredentialPluginWriter: mockWriter, + Logger: logger.New(t), + Clock: clock.Fake(expiryTime.Add(-time.Hour)), } if err := u.Do(ctx, in); err != nil { t.Errorf("Do returned error: %+v", err) @@ -188,16 +203,21 @@ func TestGetToken_Do(t *testing.T) { }, }). Return(&issuedTokenSet, nil) + mockReader := reader_mock.NewMockInterface(t) + mockReader.EXPECT(). + Read(). + Return(credentialpluginInput, nil) mockWriter := writer_mock.NewMockInterface(t) mockWriter.EXPECT(). Write(issuedOutput). Return(nil) u := GetToken{ - Authentication: authentication_mock.NewMockInterface(t), - TokenCacheRepository: mockRepository, - Writer: mockWriter, - Logger: logger.New(t), - Clock: clock.Fake(expiryTime.Add(-time.Hour)), + Authentication: authentication_mock.NewMockInterface(t), + TokenCacheRepository: mockRepository, + CredentialPluginReader: mockReader, + CredentialPluginWriter: mockWriter, + Logger: logger.New(t), + Clock: clock.Fake(expiryTime.Add(-time.Hour)), } if err := u.Do(ctx, in); err != nil { t.Errorf("Do returned error: %+v", err) @@ -242,12 +262,17 @@ func TestGetToken_Do(t *testing.T) { }, }). Return(nil, errors.New("file not found")) + mockReader := reader_mock.NewMockInterface(t) + mockReader.EXPECT(). + Read(). + Return(credentialpluginInput, nil) u := GetToken{ - Authentication: mockAuthentication, - TokenCacheRepository: mockRepository, - Writer: writer_mock.NewMockInterface(t), - Logger: logger.New(t), - Clock: clock.Fake(expiryTime.Add(-time.Hour)), + Authentication: mockAuthentication, + TokenCacheRepository: mockRepository, + CredentialPluginReader: mockReader, + CredentialPluginWriter: writer_mock.NewMockInterface(t), + Logger: logger.New(t), + Clock: clock.Fake(expiryTime.Add(-time.Hour)), } if err := u.Do(ctx, in); err == nil { t.Errorf("err wants non-nil but nil") diff --git a/system_test/login/Makefile b/system_test/login/Makefile index 6ceacc8e..7a7c8c86 100644 --- a/system_test/login/Makefile +++ b/system_test/login/Makefile @@ -19,7 +19,8 @@ test: build --browser-command=$(BIN_DIR)/chromelogin # set up the kubeconfig kubectl config set-credentials oidc \ - --exec-api-version=client.authentication.k8s.io/v1beta1 \ + --exec-api-version=client.authentication.k8s.io/v1 \ + --exec-interactive-mode=Never \ --exec-command=kubectl \ --exec-arg=oidc-login \ --exec-arg=get-token \