mirror of
https://github.com/int128/kubelogin.git
synced 2026-02-14 16:39:51 +00:00
Support Client Credentials Flow (#1231)
* Issue 931: Support Client Credentials Flow * Move client-credentials to use --oidc-auth-request-extra-params * Missed a file in moving to --oidc-auth-request-extra-params * Support --oidc-use-access-token * make generate --------- Co-authored-by: Hidetake Iwata <int128@gmail.com>
This commit is contained in:
@@ -5,11 +5,13 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/spf13/pflag"
|
||||
|
||||
"github.com/int128/kubelogin/pkg/oidc/client"
|
||||
"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"
|
||||
)
|
||||
|
||||
type authenticationOptions struct {
|
||||
@@ -34,6 +36,7 @@ var allGrantType = strings.Join([]string{
|
||||
"authcode-keyboard",
|
||||
"password",
|
||||
"device-code",
|
||||
"client-credentials",
|
||||
}, "|")
|
||||
|
||||
func (o *authenticationOptions) addFlags(f *pflag.FlagSet) {
|
||||
@@ -56,6 +59,7 @@ func (o *authenticationOptions) addFlags(f *pflag.FlagSet) {
|
||||
f.StringToStringVar(&o.AuthRequestExtraParams, "oidc-auth-request-extra-params", nil, "[authcode, authcode-keyboard] Extra query parameters to send with an authentication request")
|
||||
f.StringVar(&o.Username, "username", "", "[password] Username for resource owner password credentials grant")
|
||||
f.StringVar(&o.Password, "password", "", "[password] Password for resource owner password credentials grant")
|
||||
|
||||
}
|
||||
|
||||
func (o *authenticationOptions) expandHomedir() {
|
||||
@@ -91,6 +95,12 @@ func (o *authenticationOptions) grantOptionSet() (s authentication.GrantOptionSe
|
||||
SkipOpenBrowser: o.SkipOpenBrowser,
|
||||
BrowserCommand: o.BrowserCommand,
|
||||
}
|
||||
case o.GrantType == "client-credentials":
|
||||
endpointparams := make(map[string][]string, len(o.AuthRequestExtraParams))
|
||||
for k, v := range o.AuthRequestExtraParams {
|
||||
endpointparams[k] = []string{v}
|
||||
}
|
||||
s.ClientCredentialsOption = &client.GetTokenByClientCredentialsInput{EndpointParams: endpointparams}
|
||||
default:
|
||||
err = fmt.Errorf("grant-type must be one of (%s)", allGrantType)
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"time"
|
||||
|
||||
"github.com/google/go-cmp/cmp"
|
||||
"github.com/int128/kubelogin/pkg/oidc/client"
|
||||
"github.com/int128/kubelogin/pkg/usecases/authentication"
|
||||
"github.com/int128/kubelogin/pkg/usecases/authentication/authcode"
|
||||
"github.com/int128/kubelogin/pkg/usecases/authentication/ropc"
|
||||
@@ -91,6 +92,21 @@ func Test_authenticationOptions_grantOptionSet(t *testing.T) {
|
||||
},
|
||||
},
|
||||
},
|
||||
"GrantType=client-credentials": {
|
||||
args: []string{
|
||||
"--grant-type", "client-credentials",
|
||||
"--oidc-auth-request-extra-params", "audience=https://example.com/service1",
|
||||
"--oidc-auth-request-extra-params", "jti=myUUID",
|
||||
},
|
||||
want: authentication.GrantOptionSet{
|
||||
ClientCredentialsOption: &client.GetTokenByClientCredentialsInput{
|
||||
EndpointParams: map[string][]string{
|
||||
"audience": []string{"https://example.com/service1"},
|
||||
"jti": []string{"myUUID"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
"GrantType=auto": {
|
||||
args: []string{
|
||||
"--listen-address", "127.0.0.1:10080",
|
||||
|
||||
@@ -22,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/clientcredentials"
|
||||
"github.com/int128/kubelogin/pkg/usecases/authentication/devicecode"
|
||||
"github.com/int128/kubelogin/pkg/usecases/authentication/ropc"
|
||||
"github.com/int128/kubelogin/pkg/usecases/clean"
|
||||
@@ -76,13 +77,17 @@ func NewCmdForHeadless(clockInterface clock.Interface, stdin stdio.Stdin, stdout
|
||||
Browser: browserInterface,
|
||||
Logger: loggerInterface,
|
||||
}
|
||||
clientCredentials := &clientcredentials.ClientCredentials{
|
||||
Logger: loggerInterface,
|
||||
}
|
||||
authenticationAuthentication := &authentication.Authentication{
|
||||
ClientFactory: factory,
|
||||
Logger: loggerInterface,
|
||||
AuthCodeBrowser: authcodeBrowser,
|
||||
AuthCodeKeyboard: keyboard,
|
||||
ROPC: ropcROPC,
|
||||
DeviceCode: deviceCode,
|
||||
ClientFactory: factory,
|
||||
Logger: loggerInterface,
|
||||
AuthCodeBrowser: authcodeBrowser,
|
||||
AuthCodeKeyboard: keyboard,
|
||||
ROPC: ropcROPC,
|
||||
DeviceCode: deviceCode,
|
||||
ClientCredentials: clientCredentials,
|
||||
}
|
||||
loader3 := &loader2.Loader{}
|
||||
writerWriter := &writer.Writer{}
|
||||
|
||||
@@ -7,13 +7,15 @@ import (
|
||||
"time"
|
||||
|
||||
gooidc "github.com/coreos/go-oidc/v3/oidc"
|
||||
"github.com/int128/oauth2cli"
|
||||
"github.com/int128/oauth2dev"
|
||||
"golang.org/x/oauth2"
|
||||
"golang.org/x/oauth2/clientcredentials"
|
||||
|
||||
"github.com/int128/kubelogin/pkg/infrastructure/clock"
|
||||
"github.com/int128/kubelogin/pkg/infrastructure/logger"
|
||||
"github.com/int128/kubelogin/pkg/oidc"
|
||||
"github.com/int128/kubelogin/pkg/pkce"
|
||||
"github.com/int128/oauth2cli"
|
||||
"github.com/int128/oauth2dev"
|
||||
"golang.org/x/oauth2"
|
||||
)
|
||||
|
||||
type Interface interface {
|
||||
@@ -22,6 +24,7 @@ type Interface interface {
|
||||
GetTokenByAuthCode(ctx context.Context, in GetTokenByAuthCodeInput, localServerReadyChan chan<- string) (*oidc.TokenSet, error)
|
||||
NegotiatedPKCEMethod() pkce.Method
|
||||
GetTokenByROPC(ctx context.Context, username, password string) (*oidc.TokenSet, error)
|
||||
GetTokenByClientCredentials(ctx context.Context, in GetTokenByClientCredentialsInput) (*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)
|
||||
@@ -52,6 +55,10 @@ type GetTokenByAuthCodeInput struct {
|
||||
LocalServerKeyFile string
|
||||
}
|
||||
|
||||
type GetTokenByClientCredentialsInput struct {
|
||||
EndpointParams map[string][]string
|
||||
}
|
||||
|
||||
type client struct {
|
||||
httpClient *http.Client
|
||||
provider *gooidc.Provider
|
||||
@@ -145,6 +152,32 @@ func (c *client) GetTokenByROPC(ctx context.Context, username, password string)
|
||||
return c.verifyToken(ctx, token, "")
|
||||
}
|
||||
|
||||
// GetTokenByClientCredentials performs the client credentials flow.
|
||||
func (c *client) GetTokenByClientCredentials(ctx context.Context, in GetTokenByClientCredentialsInput) (*oidc.TokenSet, error) {
|
||||
ctx = c.wrapContext(ctx)
|
||||
c.logger.V(1).Infof("%s, %s, %v", c.oauth2Config.ClientID, c.oauth2Config.Endpoint.AuthURL, c.oauth2Config.Scopes)
|
||||
|
||||
config := clientcredentials.Config{
|
||||
ClientID: c.oauth2Config.ClientID,
|
||||
ClientSecret: c.oauth2Config.ClientSecret,
|
||||
TokenURL: c.oauth2Config.Endpoint.TokenURL,
|
||||
Scopes: c.oauth2Config.Scopes,
|
||||
EndpointParams: in.EndpointParams,
|
||||
AuthStyle: oauth2.AuthStyleInHeader,
|
||||
}
|
||||
source := config.TokenSource(ctx)
|
||||
token, err := source.Token()
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("could not acquire token: %w", err)
|
||||
}
|
||||
if c.useAccessToken {
|
||||
return &oidc.TokenSet{
|
||||
IDToken: token.AccessToken,
|
||||
RefreshToken: token.RefreshToken}, nil
|
||||
}
|
||||
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)
|
||||
|
||||
@@ -10,6 +10,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/clientcredentials"
|
||||
"github.com/int128/kubelogin/pkg/usecases/authentication/devicecode"
|
||||
"github.com/int128/kubelogin/pkg/usecases/authentication/ropc"
|
||||
)
|
||||
@@ -22,6 +23,7 @@ var Set = wire.NewSet(
|
||||
wire.Struct(new(authcode.Keyboard), "*"),
|
||||
wire.Struct(new(ropc.ROPC), "*"),
|
||||
wire.Struct(new(devicecode.DeviceCode), "*"),
|
||||
wire.Struct(new(clientcredentials.ClientCredentials), "*"),
|
||||
)
|
||||
|
||||
type Interface interface {
|
||||
@@ -37,10 +39,11 @@ type Input struct {
|
||||
}
|
||||
|
||||
type GrantOptionSet struct {
|
||||
AuthCodeBrowserOption *authcode.BrowserOption
|
||||
AuthCodeKeyboardOption *authcode.KeyboardOption
|
||||
ROPCOption *ropc.Option
|
||||
DeviceCodeOption *devicecode.Option
|
||||
AuthCodeBrowserOption *authcode.BrowserOption
|
||||
AuthCodeKeyboardOption *authcode.KeyboardOption
|
||||
ROPCOption *ropc.Option
|
||||
DeviceCodeOption *devicecode.Option
|
||||
ClientCredentialsOption *client.GetTokenByClientCredentialsInput
|
||||
}
|
||||
|
||||
// Output represents an output DTO of the Authentication use-case.
|
||||
@@ -52,7 +55,7 @@ type Output struct {
|
||||
//
|
||||
// If the IDToken is not set, it performs the authentication flow.
|
||||
// If the IDToken is valid, it does nothing.
|
||||
// If the IDtoken has expired and the RefreshToken is set, it refreshes the token.
|
||||
// If the IDToken has expired and the RefreshToken is set, it refreshes the token.
|
||||
// If the RefreshToken has expired, it performs the authentication flow.
|
||||
//
|
||||
// The authentication flow is determined as:
|
||||
@@ -61,12 +64,13 @@ type Output struct {
|
||||
// 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
|
||||
AuthCodeBrowser *authcode.Browser
|
||||
AuthCodeKeyboard *authcode.Keyboard
|
||||
ROPC *ropc.ROPC
|
||||
DeviceCode *devicecode.DeviceCode
|
||||
ClientFactory client.FactoryInterface
|
||||
Logger logger.Interface
|
||||
AuthCodeBrowser *authcode.Browser
|
||||
AuthCodeKeyboard *authcode.Keyboard
|
||||
ROPC *ropc.ROPC
|
||||
DeviceCode *devicecode.DeviceCode
|
||||
ClientCredentials *clientcredentials.ClientCredentials
|
||||
}
|
||||
|
||||
func (u *Authentication) Do(ctx context.Context, in Input) (*Output, error) {
|
||||
@@ -113,5 +117,12 @@ func (u *Authentication) Do(ctx context.Context, in Input) (*Output, error) {
|
||||
}
|
||||
return &Output{TokenSet: *tokenSet}, nil
|
||||
}
|
||||
if in.GrantOptionSet.ClientCredentialsOption != nil {
|
||||
tokenSet, err := u.ClientCredentials.Do(ctx, in.GrantOptionSet.ClientCredentialsOption, oidcClient)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("client-credentials error: %w", err)
|
||||
}
|
||||
return &Output{TokenSet: *tokenSet}, nil
|
||||
}
|
||||
return nil, fmt.Errorf("any authorization grant must be set")
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ import (
|
||||
testingLogger "github.com/int128/kubelogin/pkg/testing/logger"
|
||||
"github.com/int128/kubelogin/pkg/tlsclientconfig"
|
||||
"github.com/int128/kubelogin/pkg/usecases/authentication/authcode"
|
||||
"github.com/int128/kubelogin/pkg/usecases/authentication/clientcredentials"
|
||||
"github.com/int128/kubelogin/pkg/usecases/authentication/ropc"
|
||||
"github.com/stretchr/testify/mock"
|
||||
)
|
||||
@@ -181,4 +182,45 @@ func TestAuthentication_Do(t *testing.T) {
|
||||
t.Errorf("mismatch (-want +got):\n%s", diff)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("NoToken/ClientCredentials", func(t *testing.T) {
|
||||
ctx, cancel := context.WithTimeout(context.TODO(), timeout)
|
||||
defer cancel()
|
||||
ccIn := client.GetTokenByClientCredentialsInput{
|
||||
EndpointParams: map[string][]string{
|
||||
"audience": []string{"gopher://myaud"},
|
||||
},
|
||||
}
|
||||
in := Input{Provider: dummyProvider,
|
||||
TLSClientConfig: dummyTLSClientConfig,
|
||||
GrantOptionSet: GrantOptionSet{ClientCredentialsOption: &ccIn}}
|
||||
testToken := &oidc.TokenSet{IDToken: "TEST_ID_TOKEN"}
|
||||
mockClient := client_mock.NewMockInterface(t)
|
||||
mockClient.EXPECT().
|
||||
GetTokenByClientCredentials(ctx, ccIn).Return(testToken, nil).Once()
|
||||
|
||||
mockClientFactory := client_mock.NewMockFactoryInterface(t)
|
||||
mockClientFactory.EXPECT().
|
||||
New(ctx, dummyProvider, dummyTLSClientConfig).
|
||||
Return(mockClient, nil)
|
||||
u := Authentication{
|
||||
ClientFactory: mockClientFactory,
|
||||
Logger: testingLogger.New(t),
|
||||
ClientCredentials: &clientcredentials.ClientCredentials{
|
||||
Logger: testingLogger.New(t),
|
||||
},
|
||||
}
|
||||
got, err := u.Do(ctx, in)
|
||||
if err != nil {
|
||||
t.Errorf("Do returned error: %+v", err)
|
||||
}
|
||||
want := &Output{
|
||||
TokenSet: oidc.TokenSet{
|
||||
IDToken: "TEST_ID_TOKEN",
|
||||
},
|
||||
}
|
||||
if diff := cmp.Diff(want, got); diff != "" {
|
||||
t.Errorf("mismatch (-want +got):\n%s", diff)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
package clientcredentials
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/int128/kubelogin/pkg/infrastructure/logger"
|
||||
"github.com/int128/kubelogin/pkg/oidc"
|
||||
"github.com/int128/kubelogin/pkg/oidc/client"
|
||||
)
|
||||
|
||||
// DeviceCode provides the oauth2 device code flow.
|
||||
type ClientCredentials struct {
|
||||
Logger logger.Interface
|
||||
}
|
||||
|
||||
func (u *ClientCredentials) Do(ctx context.Context, in *client.GetTokenByClientCredentialsInput, oidcClient client.Interface) (*oidc.TokenSet, error) {
|
||||
u.Logger.V(1).Infof("starting the oauth2 client credentials code flow")
|
||||
if in == nil {
|
||||
return nil, fmt.Errorf("nil input")
|
||||
}
|
||||
tokenSet, err := oidcClient.GetTokenByClientCredentials(ctx, *in)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("authorization error: %w", err)
|
||||
}
|
||||
u.Logger.V(1).Infof("finished the oauth2 client credentials code flow")
|
||||
return tokenSet, nil
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
package clientcredentials
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"testing"
|
||||
|
||||
"github.com/int128/kubelogin/mocks/github.com/int128/kubelogin/pkg/oidc/client_mock"
|
||||
"github.com/int128/kubelogin/pkg/oidc"
|
||||
"github.com/int128/kubelogin/pkg/oidc/client"
|
||||
"github.com/int128/kubelogin/pkg/testing/logger"
|
||||
)
|
||||
|
||||
func TestClientCredentials(t *testing.T) {
|
||||
ctx := context.TODO()
|
||||
|
||||
t.Run("Authorization error", func(t *testing.T) {
|
||||
mockClient := client_mock.NewMockInterface(t)
|
||||
cc := &ClientCredentials{
|
||||
Logger: logger.New(t),
|
||||
}
|
||||
errTest := errors.New("make sure Do() passes err")
|
||||
in := client.GetTokenByClientCredentialsInput{}
|
||||
mockClient.EXPECT().GetTokenByClientCredentials(ctx, in).Return(nil, errTest).Once()
|
||||
opts := client.GetTokenByClientCredentialsInput{}
|
||||
_, err := cc.Do(ctx, &opts, mockClient)
|
||||
if !errors.Is(err, errTest) {
|
||||
t.Errorf("returned error is not the test error: %v", err)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("Server returns access_token", func(t *testing.T) {
|
||||
mockClient := client_mock.NewMockInterface(t)
|
||||
cc := &ClientCredentials{
|
||||
Logger: logger.New(t),
|
||||
}
|
||||
in := client.GetTokenByClientCredentialsInput{}
|
||||
testToken := &oidc.TokenSet{}
|
||||
mockClient.EXPECT().GetTokenByClientCredentials(ctx, in).Return(testToken, nil).Once()
|
||||
opts := client.GetTokenByClientCredentialsInput{}
|
||||
ts, err := cc.Do(ctx, &opts, mockClient)
|
||||
if err != nil {
|
||||
t.Errorf("returned unexpected error: %v", err)
|
||||
}
|
||||
if ts != testToken {
|
||||
t.Errorf("returned unexpected token set: %v", ts)
|
||||
}
|
||||
})
|
||||
|
||||
}
|
||||
Reference in New Issue
Block a user