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:
Clay B.
2025-06-15 23:16:58 -06:00
committed by GitHub
parent 0f2f54d4bf
commit cefacba2d2
10 changed files with 296 additions and 22 deletions

View File

@@ -30,7 +30,7 @@ Flags:
--local-server-cert string [authcode] Certificate path for the local server
--local-server-key string [authcode] Certificate key path for the local server
--open-url-after-authentication string [authcode] If set, open the URL in the browser after authentication
--oidc-auth-request-extra-params stringToString [authcode, authcode-keyboard] Extra query parameters to send with an authentication request (default [])
--oidc-auth-request-extra-params stringToString [authcode, authcode-keyboard, client-credentials] Extra query parameters to send with an authentication request (default [])
--username string [password] Username for resource owner password credentials grant
--password string [password] Password for resource owner password credentials grant
-h, --help help for get-token
@@ -140,6 +140,7 @@ Kubelogin support the following flows:
- [Authorization code flow with a keyboard](#authorization-code-flow-with-a-keyboard)
- [Device authorization grant](#device-authorization-grant)
- [Resource owner password credentials grant](#resource-owner-password-credentials-grant)
- [Client Credentials flow](#client-credentials-flow)
### Authorization code flow
@@ -284,6 +285,16 @@ Username: foo
Password:
```
### Client Credentials Flow
Kubelogin performs the [OAuth 2.0 client credentials flow](https://datatracker.ietf.org/doc/html/rfc6749#section-1.3.4) when `--grant-type=client-credentials` is set.
```yaml
- --grant-type=client-credentials
```
Per specification, this flow only returns authorization tokens.
## Run in Docker
You can run [the Docker image](https://ghcr.io/int128/kubelogin) instead of the binary.

View File

@@ -365,6 +365,74 @@ func (_c *MockInterface_GetTokenByAuthCode_Call) RunAndReturn(run func(ctx conte
return _c
}
// GetTokenByClientCredentials provides a mock function for the type MockInterface
func (_mock *MockInterface) GetTokenByClientCredentials(ctx context.Context, in client.GetTokenByClientCredentialsInput) (*oidc.TokenSet, error) {
ret := _mock.Called(ctx, in)
if len(ret) == 0 {
panic("no return value specified for GetTokenByClientCredentials")
}
var r0 *oidc.TokenSet
var r1 error
if returnFunc, ok := ret.Get(0).(func(context.Context, client.GetTokenByClientCredentialsInput) (*oidc.TokenSet, error)); ok {
return returnFunc(ctx, in)
}
if returnFunc, ok := ret.Get(0).(func(context.Context, client.GetTokenByClientCredentialsInput) *oidc.TokenSet); ok {
r0 = returnFunc(ctx, in)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(*oidc.TokenSet)
}
}
if returnFunc, ok := ret.Get(1).(func(context.Context, client.GetTokenByClientCredentialsInput) error); ok {
r1 = returnFunc(ctx, in)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// MockInterface_GetTokenByClientCredentials_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetTokenByClientCredentials'
type MockInterface_GetTokenByClientCredentials_Call struct {
*mock.Call
}
// GetTokenByClientCredentials is a helper method to define mock.On call
// - ctx context.Context
// - in client.GetTokenByClientCredentialsInput
func (_e *MockInterface_Expecter) GetTokenByClientCredentials(ctx interface{}, in interface{}) *MockInterface_GetTokenByClientCredentials_Call {
return &MockInterface_GetTokenByClientCredentials_Call{Call: _e.mock.On("GetTokenByClientCredentials", ctx, in)}
}
func (_c *MockInterface_GetTokenByClientCredentials_Call) Run(run func(ctx context.Context, in client.GetTokenByClientCredentialsInput)) *MockInterface_GetTokenByClientCredentials_Call {
_c.Call.Run(func(args mock.Arguments) {
var arg0 context.Context
if args[0] != nil {
arg0 = args[0].(context.Context)
}
var arg1 client.GetTokenByClientCredentialsInput
if args[1] != nil {
arg1 = args[1].(client.GetTokenByClientCredentialsInput)
}
run(
arg0,
arg1,
)
})
return _c
}
func (_c *MockInterface_GetTokenByClientCredentials_Call) Return(tokenSet *oidc.TokenSet, err error) *MockInterface_GetTokenByClientCredentials_Call {
_c.Call.Return(tokenSet, err)
return _c
}
func (_c *MockInterface_GetTokenByClientCredentials_Call) RunAndReturn(run func(ctx context.Context, in client.GetTokenByClientCredentialsInput) (*oidc.TokenSet, error)) *MockInterface_GetTokenByClientCredentials_Call {
_c.Call.Return(run)
return _c
}
// GetTokenByROPC provides a mock function for the type MockInterface
func (_mock *MockInterface) GetTokenByROPC(ctx context.Context, username string, password string) (*oidc.TokenSet, error) {
ret := _mock.Called(ctx, username, password)

View File

@@ -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)
}

View File

@@ -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",

View File

@@ -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{}

View File

@@ -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)

View File

@@ -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")
}

View File

@@ -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)
}
})
}

View File

@@ -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
}

View File

@@ -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)
}
})
}