From 0c582e97adabac6b616062d0ce5adc6ee933d5a4 Mon Sep 17 00:00:00 2001 From: Hidetake Iwata Date: Thu, 31 Oct 2019 00:36:40 +0900 Subject: [PATCH] Add --grant-type option and username prompt for ROPC (#178) --- README.md | 37 +- docs/standalone-mode.md | 5 +- pkg/adaptors/cmd/cmd_test.go | 352 +++++++++--------- pkg/adaptors/cmd/get_token.go | 5 +- pkg/adaptors/cmd/root.go | 29 +- pkg/adaptors/cmd/setup.go | 5 +- pkg/adaptors/env/env.go | 20 +- pkg/adaptors/env/mock_env/mock_env.go | 19 + pkg/usecases/authentication/authentication.go | 8 + .../authentication/authentication_test.go | 53 +++ 10 files changed, 327 insertions(+), 206 deletions(-) diff --git a/README.md b/README.md index 78301a0..4f5d172 100644 --- a/README.md +++ b/README.md @@ -85,13 +85,14 @@ Flags: --oidc-client-id string Client ID of the provider (mandatory) --oidc-client-secret string Client secret of the provider --oidc-extra-scope strings Scopes to request to the provider + --certificate-authority string Path to a cert file for the certificate authority + --insecure-skip-tls-verify If true, the server's certificate will not be checked for validity. This will make your HTTPS connections insecure + --token-cache-dir string Path to a directory for caching tokens (default "~/.kube/cache/oidc-login") + --grant-type string The authorization grant type to use. One of (auto|authcode|password) (default "auto") --listen-port ints Port to bind to the local server. If multiple ports are given, it will try the ports in order (default [8000,18000]) --skip-open-browser If true, it does not open the browser on authentication --username string If set, perform the resource owner password credentials grant --password string If set, use the password instead of asking it - --certificate-authority string Path to a cert file for the certificate authority - --insecure-skip-tls-verify If true, the server's certificate will not be checked for validity. This will make your HTTPS connections insecure - --token-cache-dir string Path to a directory for caching tokens (default "~/.kube/cache/oidc-login") -h, --help help for get-token Global Flags: @@ -155,21 +156,39 @@ You can change the ports by the option: #### Resource owner password credentials grant flow -As well as you can use the resource owner password credentials grant flow. -Keycloak supports this flow but you need to explicitly enable the "Direct Access Grants" feature in the client settings. -Most OIDC providers do not support this flow. +Kubelogin performs the resource owner password credentials grant flow +when `--grant-type=password` or `--username` is set. -You can pass the username and password: +Note that most OIDC providers do not support this flow. +Keycloak supports this flow but you need to explicitly enable the "Direct Access Grants" feature in the client settings. + +You can set the username and password. ```yaml - --username USERNAME - --password PASSWORD ``` -If the password is not set, kubelogin will show the prompt. +If the password is not set, kubelogin will show the prompt for the password. + +```yaml + - --username USERNAME +``` ``` -% kubelogin --username USER +% kubectl get pods +Password: +``` + +If the username is not set, kubelogin will show the prompt for the username and password. + +```yaml + - --grant-type=password +``` + +``` +% kubectl get pods +Username: foo Password: ``` diff --git a/docs/standalone-mode.md b/docs/standalone-mode.md index 544e9fc..1375fc4 100644 --- a/docs/standalone-mode.md +++ b/docs/standalone-mode.md @@ -102,12 +102,13 @@ Flags: --kubeconfig string Path to the kubeconfig file --context string The name of the kubeconfig context to use --user string The name of the kubeconfig user to use. Prior to --context + --certificate-authority string Path to a cert file for the certificate authority + --insecure-skip-tls-verify If true, the server's certificate will not be checked for validity. This will make your HTTPS connections insecure + --grant-type string The authorization grant type to use. One of (auto|authcode|password) (default "auto") --listen-port ints Port to bind to the local server. If multiple ports are given, it will try the ports in order (default [8000,18000]) --skip-open-browser If true, it does not open the browser on authentication --username string If set, perform the resource owner password credentials grant --password string If set, use the password instead of asking it - --certificate-authority string Path to a cert file for the certificate authority - --insecure-skip-tls-verify If true, the server's certificate will not be checked for validity. This will make your HTTPS connections insecure --add_dir_header If true, adds the file directory to the header --alsologtostderr log to standard error as well as files --log_backtrace_at traceLocation when logging hits line file:N, emit a stack trace (default :0) diff --git a/pkg/adaptors/cmd/cmd_test.go b/pkg/adaptors/cmd/cmd_test.go index 2696a42..473ae88 100644 --- a/pkg/adaptors/cmd/cmd_test.go +++ b/pkg/adaptors/cmd/cmd_test.go @@ -18,100 +18,96 @@ func TestCmd_Run(t *testing.T) { const version = "HEAD" t.Run("root", func(t *testing.T) { - t.Run("Defaults", func(t *testing.T) { - ctrl := gomock.NewController(t) - defer ctrl.Finish() - ctx := context.TODO() - mockStandalone := mock_standalone.NewMockInterface(ctrl) - mockStandalone.EXPECT(). - Do(ctx, standalone.Input{ + tests := map[string]struct { + args []string + in standalone.Input + }{ + "Defaults": { + args: []string{executable}, + in: standalone.Input{ AuthCodeOption: &authentication.AuthCodeOption{ BindAddress: []string{"127.0.0.1:8000", "127.0.0.1:18000"}, }, - }) - cmd := Cmd{ - Root: &Root{ - Standalone: mockStandalone, - Logger: mock_logger.New(t), }, - Logger: mock_logger.New(t), - } - exitCode := cmd.Run(ctx, []string{executable}, version) - if exitCode != 0 { - t.Errorf("exitCode wants 0 but %d", exitCode) - } - }) - - t.Run("AuthCodeOptions", func(t *testing.T) { - ctrl := gomock.NewController(t) - defer ctrl.Finish() - ctx := context.TODO() - mockStandalone := mock_standalone.NewMockInterface(ctrl) - mockStandalone.EXPECT(). - Do(ctx, standalone.Input{ - AuthCodeOption: &authentication.AuthCodeOption{ - BindAddress: []string{"127.0.0.1:10080", "127.0.0.1:20080"}, - SkipOpenBrowser: true, - }, - }) - cmd := Cmd{ - Root: &Root{ - Standalone: mockStandalone, - Logger: mock_logger.New(t), + }, + "FullOptions": { + args: []string{executable, + "--kubeconfig", "/path/to/kubeconfig", + "--context", "hello.k8s.local", + "--user", "google", + "--certificate-authority", "/path/to/cacert", + "--insecure-skip-tls-verify", + "-v1", + "--grant-type", "authcode", + "--listen-port", "10080", + "--listen-port", "20080", + "--skip-open-browser", + "--username", "USER", + "--password", "PASS", }, - Logger: mock_logger.New(t), - } - exitCode := cmd.Run(ctx, []string{executable, - "--listen-port", "10080", - "--listen-port", "20080", - "--skip-open-browser", - }, version) - if exitCode != 0 { - t.Errorf("exitCode wants 0 but %d", exitCode) - } - }) - - t.Run("FullOptions", func(t *testing.T) { - ctrl := gomock.NewController(t) - defer ctrl.Finish() - ctx := context.TODO() - mockStandalone := mock_standalone.NewMockInterface(ctrl) - mockStandalone.EXPECT(). - Do(ctx, standalone.Input{ + in: standalone.Input{ KubeconfigFilename: "/path/to/kubeconfig", KubeconfigContext: "hello.k8s.local", KubeconfigUser: "google", CACertFilename: "/path/to/cacert", SkipTLSVerify: true, + AuthCodeOption: &authentication.AuthCodeOption{ + BindAddress: []string{"127.0.0.1:10080", "127.0.0.1:20080"}, + SkipOpenBrowser: true, + }, + }, + }, + "GrantType=password": { + args: []string{executable, + "--grant-type", "password", + "--listen-port", "10080", + "--listen-port", "20080", + "--username", "USER", + "--password", "PASS", + }, + in: standalone.Input{ ROPCOption: &authentication.ROPCOption{ Username: "USER", Password: "PASS", }, - }) - cmd := Cmd{ - Root: &Root{ - Standalone: mockStandalone, - Logger: mock_logger.New(t), }, - Logger: mock_logger.New(t), - } - exitCode := cmd.Run(ctx, []string{executable, - "--kubeconfig", "/path/to/kubeconfig", - "--context", "hello.k8s.local", - "--user", "google", - "--certificate-authority", "/path/to/cacert", - "--insecure-skip-tls-verify", - "-v1", - "--listen-port", "10080", - "--listen-port", "20080", - "--skip-open-browser", - "--username", "USER", - "--password", "PASS", - }, version) - if exitCode != 0 { - t.Errorf("exitCode wants 0 but %d", exitCode) - } - }) + }, + "GrantType=auto": { + args: []string{executable, + "--listen-port", "10080", + "--listen-port", "20080", + "--username", "USER", + "--password", "PASS", + }, + in: standalone.Input{ + ROPCOption: &authentication.ROPCOption{ + Username: "USER", + Password: "PASS", + }, + }, + }, + } + for name, c := range tests { + t.Run(name, func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + ctx := context.TODO() + mockStandalone := mock_standalone.NewMockInterface(ctrl) + mockStandalone.EXPECT(). + Do(ctx, c.in) + cmd := Cmd{ + Root: &Root{ + Standalone: mockStandalone, + Logger: mock_logger.New(t), + }, + Logger: mock_logger.New(t), + } + exitCode := cmd.Run(ctx, c.args, version) + if exitCode != 0 { + t.Errorf("exitCode wants 0 but %d", exitCode) + } + }) + } t.Run("TooManyArgs", func(t *testing.T) { ctrl := gomock.NewController(t) @@ -131,85 +127,44 @@ func TestCmd_Run(t *testing.T) { }) t.Run("get-token", func(t *testing.T) { - t.Run("Defaults", func(t *testing.T) { - ctrl := gomock.NewController(t) - defer ctrl.Finish() - ctx := context.TODO() - getToken := mock_credentialplugin.NewMockInterface(ctrl) - getToken.EXPECT(). - Do(ctx, credentialplugin.Input{ + tests := map[string]struct { + args []string + in credentialplugin.Input + }{ + "Defaults": { + args: []string{executable, + "get-token", + "--oidc-issuer-url", "https://issuer.example.com", + "--oidc-client-id", "YOUR_CLIENT_ID", + }, + in: credentialplugin.Input{ TokenCacheDir: defaultTokenCacheDir, IssuerURL: "https://issuer.example.com", ClientID: "YOUR_CLIENT_ID", AuthCodeOption: &authentication.AuthCodeOption{ BindAddress: []string{"127.0.0.1:8000", "127.0.0.1:18000"}, }, - }) - cmd := Cmd{ - Root: &Root{ - Logger: mock_logger.New(t), }, - GetToken: &GetToken{ - GetToken: getToken, - Logger: mock_logger.New(t), + }, + "FullOptions": { + args: []string{executable, + "get-token", + "--oidc-issuer-url", "https://issuer.example.com", + "--oidc-client-id", "YOUR_CLIENT_ID", + "--oidc-client-secret", "YOUR_CLIENT_SECRET", + "--oidc-extra-scope", "email", + "--oidc-extra-scope", "profile", + "--certificate-authority", "/path/to/cacert", + "--insecure-skip-tls-verify", + "-v1", + "--grant-type", "authcode", + "--listen-port", "10080", + "--listen-port", "20080", + "--skip-open-browser", + "--username", "USER", + "--password", "PASS", }, - Logger: mock_logger.New(t), - } - exitCode := cmd.Run(ctx, []string{executable, - "get-token", - "--oidc-issuer-url", "https://issuer.example.com", - "--oidc-client-id", "YOUR_CLIENT_ID", - }, version) - if exitCode != 0 { - t.Errorf("exitCode wants 0 but %d", exitCode) - } - }) - - t.Run("AuthCodeOptions", func(t *testing.T) { - ctrl := gomock.NewController(t) - defer ctrl.Finish() - ctx := context.TODO() - getToken := mock_credentialplugin.NewMockInterface(ctrl) - getToken.EXPECT(). - Do(ctx, credentialplugin.Input{ - TokenCacheDir: defaultTokenCacheDir, - IssuerURL: "https://issuer.example.com", - ClientID: "YOUR_CLIENT_ID", - AuthCodeOption: &authentication.AuthCodeOption{ - BindAddress: []string{"127.0.0.1:10080", "127.0.0.1:20080"}, - SkipOpenBrowser: true, - }, - }) - cmd := Cmd{ - Root: &Root{ - Logger: mock_logger.New(t), - }, - GetToken: &GetToken{ - GetToken: getToken, - Logger: mock_logger.New(t), - }, - Logger: mock_logger.New(t), - } - exitCode := cmd.Run(ctx, []string{executable, - "get-token", - "--oidc-issuer-url", "https://issuer.example.com", - "--oidc-client-id", "YOUR_CLIENT_ID", - "--listen-port", "10080", - "--listen-port", "20080", - "--skip-open-browser", - }, version) - if exitCode != 0 { - t.Errorf("exitCode wants 0 but %d", exitCode) - } - }) - - t.Run("FullOptions", func(t *testing.T) { - ctrl := gomock.NewController(t) - defer ctrl.Finish() - ctx := context.TODO() - getToken := mock_credentialplugin.NewMockInterface(ctrl) - getToken.EXPECT(). - Do(ctx, credentialplugin.Input{ + in: credentialplugin.Input{ TokenCacheDir: defaultTokenCacheDir, IssuerURL: "https://issuer.example.com", ClientID: "YOUR_CLIENT_ID", @@ -217,41 +172,78 @@ func TestCmd_Run(t *testing.T) { ExtraScopes: []string{"email", "profile"}, CACertFilename: "/path/to/cacert", SkipTLSVerify: true, + AuthCodeOption: &authentication.AuthCodeOption{ + BindAddress: []string{"127.0.0.1:10080", "127.0.0.1:20080"}, + SkipOpenBrowser: true, + }, + }, + }, + "GrantType=password": { + args: []string{executable, + "get-token", + "--oidc-issuer-url", "https://issuer.example.com", + "--oidc-client-id", "YOUR_CLIENT_ID", + "--grant-type", "password", + "--listen-port", "10080", + "--listen-port", "20080", + "--username", "USER", + "--password", "PASS", + }, + in: credentialplugin.Input{ + TokenCacheDir: defaultTokenCacheDir, + IssuerURL: "https://issuer.example.com", + ClientID: "YOUR_CLIENT_ID", ROPCOption: &authentication.ROPCOption{ Username: "USER", Password: "PASS", }, - }) - cmd := Cmd{ - Root: &Root{ + }, + }, + "GrantType=auto": { + args: []string{executable, + "get-token", + "--oidc-issuer-url", "https://issuer.example.com", + "--oidc-client-id", "YOUR_CLIENT_ID", + "--listen-port", "10080", + "--listen-port", "20080", + "--username", "USER", + "--password", "PASS", + }, + in: credentialplugin.Input{ + TokenCacheDir: defaultTokenCacheDir, + IssuerURL: "https://issuer.example.com", + ClientID: "YOUR_CLIENT_ID", + ROPCOption: &authentication.ROPCOption{ + Username: "USER", + Password: "PASS", + }, + }, + }, + } + for name, c := range tests { + t.Run(name, func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + ctx := context.TODO() + getToken := mock_credentialplugin.NewMockInterface(ctrl) + getToken.EXPECT(). + Do(ctx, c.in) + cmd := Cmd{ + Root: &Root{ + Logger: mock_logger.New(t), + }, + GetToken: &GetToken{ + GetToken: getToken, + Logger: mock_logger.New(t), + }, Logger: mock_logger.New(t), - }, - GetToken: &GetToken{ - GetToken: getToken, - Logger: mock_logger.New(t), - }, - Logger: mock_logger.New(t), - } - exitCode := cmd.Run(ctx, []string{executable, - "get-token", - "--oidc-issuer-url", "https://issuer.example.com", - "--oidc-client-id", "YOUR_CLIENT_ID", - "--oidc-client-secret", "YOUR_CLIENT_SECRET", - "--oidc-extra-scope", "email", - "--oidc-extra-scope", "profile", - "--certificate-authority", "/path/to/cacert", - "--insecure-skip-tls-verify", - "-v1", - "--listen-port", "10080", - "--listen-port", "20080", - "--skip-open-browser", - "--username", "USER", - "--password", "PASS", - }, version) - if exitCode != 0 { - t.Errorf("exitCode wants 0 but %d", exitCode) - } - }) + } + exitCode := cmd.Run(ctx, c.args, version) + if exitCode != 0 { + t.Errorf("exitCode wants 0 but %d", exitCode) + } + }) + } t.Run("MissingMandatoryOptions", func(t *testing.T) { ctrl := gomock.NewController(t) diff --git a/pkg/adaptors/cmd/get_token.go b/pkg/adaptors/cmd/get_token.go index 2098a66..a193842 100644 --- a/pkg/adaptors/cmd/get_token.go +++ b/pkg/adaptors/cmd/get_token.go @@ -57,7 +57,10 @@ func (cmd *GetToken) New(ctx context.Context) *cobra.Command { return nil }, RunE: func(*cobra.Command, []string) error { - authCodeOption, ropcOption := o.authenticationOptions.toUseCaseOptions() + authCodeOption, ropcOption, err := o.authenticationOptions.toUseCaseOptions() + if err != nil { + return xerrors.Errorf("error: %w", err) + } in := credentialplugin.Input{ IssuerURL: o.IssuerURL, ClientID: o.ClientID, diff --git a/pkg/adaptors/cmd/root.go b/pkg/adaptors/cmd/root.go index a7af8cd..a2338fb 100644 --- a/pkg/adaptors/cmd/root.go +++ b/pkg/adaptors/cmd/root.go @@ -43,6 +43,7 @@ func (o *rootOptions) register(f *pflag.FlagSet) { } type authenticationOptions struct { + GrantType string ListenPort []int SkipOpenBrowser bool Username string @@ -50,26 +51,27 @@ type authenticationOptions struct { } func (o *authenticationOptions) register(f *pflag.FlagSet) { + f.StringVar(&o.GrantType, "grant-type", "auto", "The authorization grant type to use. One of (auto|authcode|password)") f.IntSliceVar(&o.ListenPort, "listen-port", defaultListenPort, "Port to bind to the local server. If multiple ports are given, it will try the ports in order") f.BoolVar(&o.SkipOpenBrowser, "skip-open-browser", false, "If true, it does not open the browser on authentication") f.StringVar(&o.Username, "username", "", "If set, perform the resource owner password credentials grant") f.StringVar(&o.Password, "password", "", "If set, use the password instead of asking it") } -func (o *authenticationOptions) toUseCaseOptions() (authCodeOption *authentication.AuthCodeOption, ropcOption *authentication.ROPCOption) { - switch { - case o.Username != "": - ropcOption = &authentication.ROPCOption{ - Username: o.Username, - Password: o.Password, - } - default: - authCodeOption = &authentication.AuthCodeOption{ +func (o *authenticationOptions) toUseCaseOptions() (*authentication.AuthCodeOption, *authentication.ROPCOption, error) { + if o.GrantType == "authcode" || (o.GrantType == "auto" && o.Username == "") { + return &authentication.AuthCodeOption{ BindAddress: translateListenPortToBindAddress(o.ListenPort), SkipOpenBrowser: o.SkipOpenBrowser, - } + }, nil, nil } - return + if o.GrantType == "password" || (o.GrantType == "auto" && o.Username != "") { + return nil, &authentication.ROPCOption{ + Username: o.Username, + Password: o.Password, + }, nil + } + return nil, nil, xerrors.Errorf("grant-type must be one of (auto|authcode|password)") } type Root struct { @@ -85,7 +87,10 @@ func (cmd *Root) New(ctx context.Context, executable string) *cobra.Command { Long: longDescription, Args: cobra.NoArgs, RunE: func(*cobra.Command, []string) error { - authCodeOption, ropcOption := o.authenticationOptions.toUseCaseOptions() + authCodeOption, ropcOption, err := o.authenticationOptions.toUseCaseOptions() + if err != nil { + return xerrors.Errorf("invalid option: %w", err) + } in := standalone.Input{ KubeconfigFilename: o.Kubeconfig, KubeconfigContext: kubeconfig.ContextName(o.Context), diff --git a/pkg/adaptors/cmd/setup.go b/pkg/adaptors/cmd/setup.go index b6cc939..a1799e1 100644 --- a/pkg/adaptors/cmd/setup.go +++ b/pkg/adaptors/cmd/setup.go @@ -42,7 +42,10 @@ func (cmd *Setup) New(ctx context.Context) *cobra.Command { Short: "Show the setup instruction", Args: cobra.NoArgs, RunE: func(c *cobra.Command, _ []string) error { - authCodeOption, ropcOption := o.authenticationOptions.toUseCaseOptions() + authCodeOption, ropcOption, err := o.authenticationOptions.toUseCaseOptions() + if err != nil { + return xerrors.Errorf("error: %w", err) + } in := setup.Stage2Input{ IssuerURL: o.IssuerURL, ClientID: o.ClientID, diff --git a/pkg/adaptors/env/env.go b/pkg/adaptors/env/env.go index 48ed121..9c87f9b 100644 --- a/pkg/adaptors/env/env.go +++ b/pkg/adaptors/env/env.go @@ -1,8 +1,11 @@ +// Package env provides environment dependent facilities. package env import ( + "bufio" "fmt" "os" + "strings" "syscall" "github.com/google/wire" @@ -27,6 +30,7 @@ var Set = wire.NewSet( ) type Interface interface { + ReadString(prompt string) (string, error) ReadPassword(prompt string) (string, error) OpenBrowser(url string) error } @@ -34,6 +38,20 @@ type Interface interface { // Env provides environment specific facilities. type Env struct{} +// ReadString reads a string from the stdin. +func (*Env) ReadString(prompt string) (string, error) { + if _, err := fmt.Fprint(os.Stderr, prompt); err != nil { + return "", xerrors.Errorf("could not write the prompt: %w", err) + } + r := bufio.NewReader(os.Stdin) + s, err := r.ReadString('\n') + if err != nil { + return "", xerrors.Errorf("could not read from stdin: %w", err) + } + s = strings.TrimRight(s, "\r\n") + return s, nil +} + // ReadPassword reads a password from the stdin without echo back. func (*Env) ReadPassword(prompt string) (string, error) { if _, err := fmt.Fprint(os.Stderr, prompt); err != nil { @@ -41,7 +59,7 @@ func (*Env) ReadPassword(prompt string) (string, error) { } b, err := terminal.ReadPassword(int(syscall.Stdin)) if err != nil { - return "", xerrors.Errorf("could not read: %w", err) + return "", xerrors.Errorf("could not read from stdin: %w", err) } if _, err := fmt.Fprintln(os.Stderr); err != nil { return "", xerrors.Errorf("could not write a new line: %w", err) diff --git a/pkg/adaptors/env/mock_env/mock_env.go b/pkg/adaptors/env/mock_env/mock_env.go index d775ded..7ad05d6 100644 --- a/pkg/adaptors/env/mock_env/mock_env.go +++ b/pkg/adaptors/env/mock_env/mock_env.go @@ -34,6 +34,7 @@ func (m *MockInterface) EXPECT() *MockInterfaceMockRecorder { // OpenBrowser mocks base method func (m *MockInterface) OpenBrowser(arg0 string) error { + m.ctrl.T.Helper() ret := m.ctrl.Call(m, "OpenBrowser", arg0) ret0, _ := ret[0].(error) return ret0 @@ -41,11 +42,13 @@ func (m *MockInterface) OpenBrowser(arg0 string) error { // OpenBrowser indicates an expected call of OpenBrowser func (mr *MockInterfaceMockRecorder) OpenBrowser(arg0 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "OpenBrowser", reflect.TypeOf((*MockInterface)(nil).OpenBrowser), arg0) } // ReadPassword mocks base method func (m *MockInterface) ReadPassword(arg0 string) (string, error) { + m.ctrl.T.Helper() ret := m.ctrl.Call(m, "ReadPassword", arg0) ret0, _ := ret[0].(string) ret1, _ := ret[1].(error) @@ -54,5 +57,21 @@ func (m *MockInterface) ReadPassword(arg0 string) (string, error) { // ReadPassword indicates an expected call of ReadPassword func (mr *MockInterfaceMockRecorder) ReadPassword(arg0 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ReadPassword", reflect.TypeOf((*MockInterface)(nil).ReadPassword), arg0) } + +// ReadString mocks base method +func (m *MockInterface) ReadString(arg0 string) (string, error) { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "ReadString", arg0) + ret0, _ := ret[0].(string) + ret1, _ := ret[1].(error) + return ret0, ret1 +} + +// ReadString indicates an expected call of ReadString +func (mr *MockInterfaceMockRecorder) ReadString(arg0 interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ReadString", reflect.TypeOf((*MockInterface)(nil).ReadString), arg0) +} diff --git a/pkg/usecases/authentication/authentication.go b/pkg/usecases/authentication/authentication.go index 880eb8c..34fc2f2 100644 --- a/pkg/usecases/authentication/authentication.go +++ b/pkg/usecases/authentication/authentication.go @@ -67,6 +67,7 @@ type Output struct { RefreshToken string } +const usernamePrompt = "Username: " const passwordPrompt = "Password: " // Authentication provides the internal use-case of authentication. @@ -200,6 +201,13 @@ func (u *Authentication) doAuthCodeFlow(ctx context.Context, in *AuthCodeOption, func (u *Authentication) doPasswordCredentialsFlow(ctx context.Context, in *ROPCOption, client oidcclient.Interface) (*Output, error) { u.Logger.V(1).Infof("performing the resource owner password credentials flow") + if in.Username == "" { + var err error + in.Username, err = u.Env.ReadString(usernamePrompt) + if err != nil { + return nil, xerrors.Errorf("could not get the username: %w", err) + } + } if in.Password == "" { var err error in.Password, err = u.Env.ReadPassword(passwordPrompt) diff --git a/pkg/usecases/authentication/authentication_test.go b/pkg/usecases/authentication/authentication_test.go index 44683c6..e886d34 100644 --- a/pkg/usecases/authentication/authentication_test.go +++ b/pkg/usecases/authentication/authentication_test.go @@ -141,6 +141,59 @@ func TestAuthentication_Do(t *testing.T) { } }) + t.Run("ResourceOwnerPasswordCredentialsFlow/AskUsernameAndPassword", func(t *testing.T) { + ctrl := gomock.NewController(t) + defer ctrl.Finish() + ctx, cancel := context.WithTimeout(context.TODO(), timeout) + defer cancel() + in := Input{ + ROPCOption: &ROPCOption{}, + IssuerURL: "https://issuer.example.com", + ClientID: "YOUR_CLIENT_ID", + ClientSecret: "YOUR_CLIENT_SECRET", + } + mockOIDCClient := mock_oidcclient.NewMockInterface(ctrl) + mockOIDCClient.EXPECT(). + AuthenticateByPassword(gomock.Any(), "USER", "PASS"). + Return(&oidcclient.TokenSet{ + IDToken: "YOUR_ID_TOKEN", + RefreshToken: "YOUR_REFRESH_TOKEN", + IDTokenSubject: "YOUR_SUBJECT", + IDTokenExpiry: futureTime, + IDTokenClaims: dummyTokenClaims, + }, nil) + mockOIDCClientFactory := mock_oidcclient.NewMockFactoryInterface(ctrl) + mockOIDCClientFactory.EXPECT(). + New(ctx, oidcclient.Config{ + IssuerURL: "https://issuer.example.com", + ClientID: "YOUR_CLIENT_ID", + ClientSecret: "YOUR_CLIENT_SECRET", + }). + Return(mockOIDCClient, nil) + mockEnv := mock_env.NewMockInterface(ctrl) + mockEnv.EXPECT().ReadString(usernamePrompt).Return("USER", nil) + mockEnv.EXPECT().ReadPassword(passwordPrompt).Return("PASS", nil) + u := Authentication{ + OIDCClientFactory: mockOIDCClientFactory, + Env: mockEnv, + Logger: mock_logger.New(t), + } + out, err := u.Do(ctx, in) + if err != nil { + t.Errorf("Do returned error: %+v", err) + } + want := &Output{ + IDToken: "YOUR_ID_TOKEN", + RefreshToken: "YOUR_REFRESH_TOKEN", + IDTokenSubject: "YOUR_SUBJECT", + IDTokenExpiry: futureTime, + IDTokenClaims: dummyTokenClaims, + } + if diff := deep.Equal(want, out); diff != nil { + t.Error(diff) + } + }) + t.Run("ResourceOwnerPasswordCredentialsFlow/UsePassword", func(t *testing.T) { ctrl := gomock.NewController(t) defer ctrl.Finish()