Compare commits

..

6 Commits

Author SHA1 Message Date
renovate[bot]
a3013a12b9 fix(deps): update module github.com/spf13/pflag to v1.0.6 (#1266)
Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com>
2025-01-30 12:22:57 +00:00
Hidetake Iwata
bc7e71f586 Change default token cache storage to disk (#1264)
* Change default token cache storage to disk

* Fix

* Fix

* Clean up both storages
2025-01-30 18:47:07 +09:00
Hidetake Iwata
19d61e70a9 Fix client secret description (#1265) 2025-01-26 16:04:14 +09:00
Hidetake Iwata
3a38753ee7 Refactor setup command and docs (#1253)
* Refactor setup command and docs

* Fix slice flags

* Fix
2025-01-25 16:08:28 +09:00
Johannes Edelstam
56e09ad65e Update instructions for Google OAuth (#1260) 2025-01-25 10:04:12 +09:00
Johannes Edelstam
58a4b1399f Use homebrew main tap (#1259)
Since kubelogin exists in the homebrew main tap use that instead.
2025-01-25 10:02:43 +09:00
24 changed files with 347 additions and 528 deletions

View File

@@ -21,7 +21,7 @@ Install the latest release from [Homebrew](https://brew.sh/), [Krew](https://git
```sh
# Homebrew (macOS and Linux)
brew install int128/kubelogin/kubelogin
brew install kubelogin
# Krew (macOS, Linux, Windows and ARM)
kubectl krew install oidc-login
@@ -83,8 +83,8 @@ If the refresh token has expired, it will perform re-authentication.
### Token cache
If the OS keyring is available, kubelogin stores the token cache to the OS keyring.
Otherwise, kubelogin stores the token cache to the file system.
Kubelogin stores the token cache to the file system by default.
For enhanced security, it is recommended to store it to the keyring.
See the [token cache](docs/usage.md#token-cache) for details.
You can log out by deleting the token cache.
@@ -92,7 +92,7 @@ You can log out by deleting the token cache.
```console
% kubectl oidc-login clean
Deleted the token cache at /home/user/.kube/cache/oidc-login
Deleted the token cache in the keyring
Deleted the token cache from the keyring
```
Kubelogin will ask you to log in via the browser again.

View File

@@ -24,17 +24,18 @@ See the [usage](usage.md) for the details.
You can log in with a Google account.
Open [Google APIs Console](https://console.developers.google.com/apis/credentials) and create an OAuth client with the following setting:
Open [Google APIs Console](https://console.cloud.google.com/apis/credentials) and create an OAuth client with the following setting:
- Application Type: Other
- Application Type: Desktop app
Check the client ID and secret.
Replace the following variables in the later sections.
| Variable | Value |
| ---------------- | -------------------------------- |
| `ISSUER_URL` | `https://accounts.google.com` |
| `YOUR_CLIENT_ID` | `xxx.apps.googleusercontent.com` |
| Variable | Value |
| -------------------- | -------------------------------- |
| `ISSUER_URL` | `https://accounts.google.com` |
| `YOUR_CLIENT_ID` | `xxx.apps.googleusercontent.com` |
| `YOUR_CLIENT_SECRET` | `XXXXXXXXXXXXXXXXXXXXXXXXXXXXXX` |
### Keycloak
@@ -67,6 +68,8 @@ Replace the following variables in the later sections.
| `ISSUER_URL` | `https://keycloak.example.com/auth/realms/YOUR_REALM` |
| `YOUR_CLIENT_ID` | `YOUR_CLIENT_ID` |
`YOUR_CLIENT_SECRET` is not required for this configuration.
### Dex with GitHub
You can log in with a GitHub account.
@@ -129,7 +132,7 @@ Replace the following variables in the later sections.
| `ISSUER_URL` | `https://YOUR_ORGANIZATION.okta.com` |
| `YOUR_CLIENT_ID` | random string |
You do not need to set `YOUR_CLIENT_SECRET`.
`YOUR_CLIENT_SECRET` is not required for this configuration.
If you need `groups` claim for access control,
see [jetstack/okta-kubectl-auth](https://github.com/jetstack/okta-kubectl-auth/blob/master/docs/okta-setup.md) and [#250](https://github.com/int128/kubelogin/issues/250).
@@ -154,7 +157,7 @@ Leverage the following variables in the next steps.
`YOUR_CLIENT_SECRET` is not required for this configuration.
## 2. Verify authentication
## 2. Authenticate with the OpenID Connect Provider
Run the following command:
@@ -164,11 +167,12 @@ kubectl oidc-login setup \
--oidc-client-id=YOUR_CLIENT_ID
```
If your provider requires a client secret, add `--oidc-client-secret=YOUR_CLIENT_SECRET`.
It launches the browser and navigates to `http://localhost:8000`.
Please log in to the provider.
You can set extra options, for example, extra scope or CA certificate.
See also the full options.
For the full options,
```sh
kubectl oidc-login setup --help
@@ -176,14 +180,12 @@ kubectl oidc-login setup --help
## 3. Bind a cluster role
Here bind `cluster-admin` role to you.
You can run the following command to bind `cluster-admin` role to you:
```sh
kubectl create clusterrolebinding oidc-cluster-admin --clusterrole=cluster-admin --user='ISSUER_URL#YOUR_SUBJECT'
```
As well as you can create a custom cluster role and bind it.
## 4. Set up the Kubernetes API server
Add the following flags to kube-apiserver:
@@ -201,6 +203,7 @@ Add `oidc` user to the kubeconfig.
```sh
kubectl config set-credentials oidc \
--exec-interactive-mode=Never \
--exec-api-version=client.authentication.k8s.io/v1 \
--exec-command=kubectl \
--exec-arg=oidc-login \
@@ -209,6 +212,11 @@ kubectl config set-credentials oidc \
--exec-arg=--oidc-client-id=YOUR_CLIENT_ID
```
If your provider requires a client secret, add `--oidc-client-secret=YOUR_CLIENT_SECRET`.
For security, it is recommended to add `--token-cache-storage=keyring` to store the token cache to the keyring instead of the file system.
If you encounter an error, see the [token cache](usage.md#token-cache) for details.
## 6. Verify cluster access
Make sure you can access the Kubernetes cluster.

View File

@@ -14,7 +14,7 @@ Flags:
--oidc-use-access-token Instead of using the id_token, use the access_token to authenticate to Kubernetes
--force-refresh If set, refresh the ID token regardless of its expiration time
--token-cache-dir string Path to a directory of the token cache (default "~/.kube/cache/oidc-login")
--token-cache-storage string Storage for the token cache. One of (auto|keyring|disk) (default "auto")
--token-cache-storage string Storage for the token cache. One of (disk|keyring) (default "disk")
--certificate-authority stringArray Path to a cert file for the certificate authority
--certificate-authority-data stringArray Base64 encoded cert for the certificate authority
--insecure-skip-tls-verify [SECURITY RISK] If set, the server's certificate will not be checked for validity
@@ -105,16 +105,21 @@ See also [net/http#ProxyFromEnvironment](https://golang.org/pkg/net/http/#ProxyF
### Token cache
Kubelogin stores the token cache to the OS keyring if available.
It depends on [zalando/go-keyring](https://github.com/zalando/go-keyring) for the keyring storage.
Kubelogin stores the token cache to the file system by default.
If you encounter a problem, try `--token-cache-storage` to set the storage.
You can store the token cache to the OS keyring for enhanced security.
It depends on [zalando/go-keyring](https://github.com/zalando/go-keyring).
```yaml
# Force to use the OS keyring
- --token-cache-storage=keyring
# Force to use the file system
- --token-cache-storage=disk
```
You can delete the token cache by the clean command.
```console
% kubectl oidc-login clean
Deleted the token cache at /home/user/.kube/cache/oidc-login
Deleted the token cache from the keyring
```
### Home directory expansion

2
go.mod
View File

@@ -13,7 +13,7 @@ require (
github.com/int128/oauth2dev v1.0.1
github.com/pkg/browser v0.0.0-20240102092130-5ac0b6a4141c
github.com/spf13/cobra v1.8.1
github.com/spf13/pflag v1.0.5
github.com/spf13/pflag v1.0.6
github.com/stretchr/testify v1.10.0
github.com/zalando/go-keyring v0.2.6
golang.org/x/oauth2 v0.25.0

3
go.sum
View File

@@ -106,8 +106,9 @@ github.com/rogpeppe/go-internal v1.13.1/go.mod h1:uMEvuHeurkdAXX61udpOXGD/AzZDWN
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/spf13/cobra v1.8.1 h1:e5/vxKd/rZsfSJMUX1agtjeTDf+qv1/JdBF8gg5k9ZM=
github.com/spf13/cobra v1.8.1/go.mod h1:wHxEcudfqmLYa8iTfL+OuZPbBZkmvliBWKIezN3kD9Y=
github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA=
github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o=
github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.5.2 h1:xuMeJ0Sdp5ZMRXx/aWO6RZxdr3beISkG5/G/aIRr3pY=
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=

View File

@@ -20,7 +20,6 @@ func TestClean(t *testing.T) {
"kubelogin",
"clean",
"--token-cache-dir", tokenCacheDir,
"--token-cache-storage", "disk",
}, "HEAD")
if exitCode != 0 {
t.Errorf("exit status wants 0 but %d", exitCode)

View File

@@ -443,7 +443,6 @@ func runGetToken(t *testing.T, ctx context.Context, cfg getTokenConfig) {
"kubelogin",
"get-token",
"--token-cache-dir", cfg.tokenCacheDir,
"--token-cache-storage", "disk",
"--oidc-issuer-url", cfg.issuerURL,
"--oidc-client-id", "kubernetes",
"--listen-address", "127.0.0.1:0",

View File

@@ -22,48 +22,16 @@ func (_m *MockInterface) EXPECT() *MockInterface_Expecter {
return &MockInterface_Expecter{mock: &_m.Mock}
}
// DoStage1 provides a mock function with no fields
func (_m *MockInterface) DoStage1() {
_m.Called()
}
// MockInterface_DoStage1_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'DoStage1'
type MockInterface_DoStage1_Call struct {
*mock.Call
}
// DoStage1 is a helper method to define mock.On call
func (_e *MockInterface_Expecter) DoStage1() *MockInterface_DoStage1_Call {
return &MockInterface_DoStage1_Call{Call: _e.mock.On("DoStage1")}
}
func (_c *MockInterface_DoStage1_Call) Run(run func()) *MockInterface_DoStage1_Call {
_c.Call.Run(func(args mock.Arguments) {
run()
})
return _c
}
func (_c *MockInterface_DoStage1_Call) Return() *MockInterface_DoStage1_Call {
_c.Call.Return()
return _c
}
func (_c *MockInterface_DoStage1_Call) RunAndReturn(run func()) *MockInterface_DoStage1_Call {
_c.Run(run)
return _c
}
// DoStage2 provides a mock function with given fields: ctx, in
func (_m *MockInterface) DoStage2(ctx context.Context, in setup.Stage2Input) error {
// Do provides a mock function with given fields: ctx, in
func (_m *MockInterface) Do(ctx context.Context, in setup.Input) error {
ret := _m.Called(ctx, in)
if len(ret) == 0 {
panic("no return value specified for DoStage2")
panic("no return value specified for Do")
}
var r0 error
if rf, ok := ret.Get(0).(func(context.Context, setup.Stage2Input) error); ok {
if rf, ok := ret.Get(0).(func(context.Context, setup.Input) error); ok {
r0 = rf(ctx, in)
} else {
r0 = ret.Error(0)
@@ -72,31 +40,31 @@ func (_m *MockInterface) DoStage2(ctx context.Context, in setup.Stage2Input) err
return r0
}
// MockInterface_DoStage2_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'DoStage2'
type MockInterface_DoStage2_Call struct {
// MockInterface_Do_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Do'
type MockInterface_Do_Call struct {
*mock.Call
}
// DoStage2 is a helper method to define mock.On call
// Do is a helper method to define mock.On call
// - ctx context.Context
// - in setup.Stage2Input
func (_e *MockInterface_Expecter) DoStage2(ctx interface{}, in interface{}) *MockInterface_DoStage2_Call {
return &MockInterface_DoStage2_Call{Call: _e.mock.On("DoStage2", ctx, in)}
// - in setup.Input
func (_e *MockInterface_Expecter) Do(ctx interface{}, in interface{}) *MockInterface_Do_Call {
return &MockInterface_Do_Call{Call: _e.mock.On("Do", ctx, in)}
}
func (_c *MockInterface_DoStage2_Call) Run(run func(ctx context.Context, in setup.Stage2Input)) *MockInterface_DoStage2_Call {
func (_c *MockInterface_Do_Call) Run(run func(ctx context.Context, in setup.Input)) *MockInterface_Do_Call {
_c.Call.Run(func(args mock.Arguments) {
run(args[0].(context.Context), args[1].(setup.Stage2Input))
run(args[0].(context.Context), args[1].(setup.Input))
})
return _c
}
func (_c *MockInterface_DoStage2_Call) Return(_a0 error) *MockInterface_DoStage2_Call {
func (_c *MockInterface_Do_Call) Return(_a0 error) *MockInterface_Do_Call {
_c.Call.Return(_a0)
return _c
}
func (_c *MockInterface_DoStage2_Call) RunAndReturn(run func(context.Context, setup.Stage2Input) error) *MockInterface_DoStage2_Call {
func (_c *MockInterface_Do_Call) RunAndReturn(run func(context.Context, setup.Input) error) *MockInterface_Do_Call {
_c.Call.Return(run)
return _c
}

View File

@@ -9,15 +9,11 @@ import (
)
type cleanOptions struct {
tokenCacheOptions tokenCacheOptions
TokenCacheDir string
}
func (o *cleanOptions) addFlags(f *pflag.FlagSet) {
o.tokenCacheOptions.addFlags(f)
}
func (o *cleanOptions) expandHomedir() {
o.tokenCacheOptions.expandHomedir()
f.StringVar(&o.TokenCacheDir, "token-cache-dir", getDefaultTokenCacheDir(), "Path to a directory of the token cache")
}
type Clean struct {
@@ -31,18 +27,13 @@ func (cmd *Clean) New() *cobra.Command {
Short: "Delete the token cache",
Long: `Delete the token cache.
This deletes both the OS keyring and the directory by default.
If you encounter an error of keyring, try --token-cache-storage=disk.
This deletes the token cache directory from both the file system and the keyring.
`,
Args: cobra.NoArgs,
RunE: func(c *cobra.Command, _ []string) error {
o.expandHomedir()
tokenCacheConfig, err := o.tokenCacheOptions.tokenCacheConfig()
if err != nil {
return fmt.Errorf("clean: %w", err)
}
o.TokenCacheDir = expandHomedir(o.TokenCacheDir)
in := clean.Input{
TokenCacheConfig: tokenCacheConfig,
TokenCacheDir: o.TokenCacheDir,
}
if err := cmd.Clean.Do(c.Context(), in); err != nil {
return fmt.Errorf("clean: %w", err)

View File

@@ -8,6 +8,7 @@ import (
"time"
"github.com/int128/kubelogin/mocks/github.com/int128/kubelogin/pkg/usecases/credentialplugin_mock"
"github.com/int128/kubelogin/mocks/github.com/int128/kubelogin/pkg/usecases/setup_mock"
"github.com/int128/kubelogin/mocks/github.com/int128/kubelogin/pkg/usecases/standalone_mock"
"github.com/int128/kubelogin/pkg/oidc"
"github.com/int128/kubelogin/pkg/testing/logger"
@@ -16,6 +17,7 @@ import (
"github.com/int128/kubelogin/pkg/usecases/authentication"
"github.com/int128/kubelogin/pkg/usecases/authentication/authcode"
"github.com/int128/kubelogin/pkg/usecases/credentialplugin"
"github.com/int128/kubelogin/pkg/usecases/setup"
"github.com/int128/kubelogin/pkg/usecases/standalone"
)
@@ -23,6 +25,14 @@ func TestCmd_Run(t *testing.T) {
const executable = "kubelogin"
const version = "HEAD"
defaultGrantOptionSet := authentication.GrantOptionSet{
AuthCodeBrowserOption: &authcode.BrowserOption{
BindAddress: defaultListenAddress,
AuthenticationTimeout: defaultAuthenticationTimeoutSec * time.Second,
RedirectURLHostname: "localhost",
},
}
t.Run("root", func(t *testing.T) {
tests := map[string]struct {
args []string
@@ -31,13 +41,7 @@ func TestCmd_Run(t *testing.T) {
"Defaults": {
args: []string{executable},
in: standalone.Input{
GrantOptionSet: authentication.GrantOptionSet{
AuthCodeBrowserOption: &authcode.BrowserOption{
BindAddress: defaultListenAddress,
AuthenticationTimeout: defaultAuthenticationTimeoutSec * time.Second,
RedirectURLHostname: "localhost",
},
},
GrantOptionSet: defaultGrantOptionSet,
},
},
"FullOptions": {
@@ -51,13 +55,7 @@ func TestCmd_Run(t *testing.T) {
KubeconfigFilename: "/path/to/kubeconfig",
KubeconfigContext: "hello.k8s.local",
KubeconfigUser: "google",
GrantOptionSet: authentication.GrantOptionSet{
AuthCodeBrowserOption: &authcode.BrowserOption{
BindAddress: defaultListenAddress,
AuthenticationTimeout: defaultAuthenticationTimeoutSec * time.Second,
RedirectURLHostname: "localhost",
},
},
GrantOptionSet: defaultGrantOptionSet,
},
},
}
@@ -120,15 +118,8 @@ func TestCmd_Run(t *testing.T) {
},
TokenCacheConfig: tokencache.Config{
Directory: filepath.Join(userHomeDir, ".kube/cache/oidc-login"),
Storage: tokencache.StorageAuto,
},
GrantOptionSet: authentication.GrantOptionSet{
AuthCodeBrowserOption: &authcode.BrowserOption{
BindAddress: defaultListenAddress,
AuthenticationTimeout: defaultAuthenticationTimeoutSec * time.Second,
RedirectURLHostname: "localhost",
},
},
GrantOptionSet: defaultGrantOptionSet,
},
},
"FullOptions": {
@@ -139,7 +130,7 @@ func TestCmd_Run(t *testing.T) {
"--oidc-client-secret", "YOUR_CLIENT_SECRET",
"--oidc-extra-scope", "email",
"--oidc-extra-scope", "profile",
"--token-cache-storage", "disk",
"--token-cache-storage", "keyring",
"-v1",
},
in: credentialplugin.Input{
@@ -151,15 +142,9 @@ func TestCmd_Run(t *testing.T) {
},
TokenCacheConfig: tokencache.Config{
Directory: filepath.Join(userHomeDir, ".kube/cache/oidc-login"),
Storage: tokencache.StorageDisk,
},
GrantOptionSet: authentication.GrantOptionSet{
AuthCodeBrowserOption: &authcode.BrowserOption{
BindAddress: defaultListenAddress,
AuthenticationTimeout: defaultAuthenticationTimeoutSec * time.Second,
RedirectURLHostname: "localhost",
},
Storage: tokencache.StorageKeyring,
},
GrantOptionSet: defaultGrantOptionSet,
},
},
"AccessToken": {
@@ -177,15 +162,8 @@ func TestCmd_Run(t *testing.T) {
},
TokenCacheConfig: tokencache.Config{
Directory: filepath.Join(userHomeDir, ".kube/cache/oidc-login"),
Storage: tokencache.StorageAuto,
},
GrantOptionSet: authentication.GrantOptionSet{
AuthCodeBrowserOption: &authcode.BrowserOption{
BindAddress: defaultListenAddress,
AuthenticationTimeout: defaultAuthenticationTimeoutSec * time.Second,
RedirectURLHostname: "localhost",
},
},
GrantOptionSet: defaultGrantOptionSet,
},
},
"HomedirExpansion": {
@@ -205,7 +183,6 @@ func TestCmd_Run(t *testing.T) {
},
TokenCacheConfig: tokencache.Config{
Directory: filepath.Join(userHomeDir, ".kube/oidc-cache"),
Storage: tokencache.StorageAuto,
},
GrantOptionSet: authentication.GrantOptionSet{
AuthCodeBrowserOption: &authcode.BrowserOption{
@@ -282,4 +259,54 @@ func TestCmd_Run(t *testing.T) {
}
})
})
t.Run("setup", func(t *testing.T) {
t.Run("NoOption", func(t *testing.T) {
ctx := context.TODO()
cmd := Cmd{
Logger: logger.New(t),
Root: &Root{
Logger: logger.New(t),
},
}
exitCode := cmd.Run(ctx, []string{executable, "setup"}, version)
if exitCode != 0 {
t.Errorf("exitCode wants 0 but %d", exitCode)
}
})
t.Run("WithOptions", func(t *testing.T) {
ctx := context.TODO()
setupMock := setup_mock.NewMockInterface(t)
setupMock.EXPECT().Do(ctx, setup.Input{
IssuerURL: "https://issuer.example.com",
ClientID: "YOUR_CLIENT",
ExtraScopes: []string{"email", "profile"},
GrantOptionSet: defaultGrantOptionSet,
ChangedFlags: []string{
"--oidc-issuer-url=https://issuer.example.com",
"--oidc-client-id=YOUR_CLIENT",
"--oidc-extra-scope=email",
"--oidc-extra-scope=profile",
},
}).Return(nil)
cmd := Cmd{
Logger: logger.New(t),
Root: &Root{
Logger: logger.New(t),
},
Setup: &Setup{
Setup: setupMock,
},
}
exitCode := cmd.Run(ctx, []string{executable, "setup",
"--oidc-issuer-url", "https://issuer.example.com",
"--oidc-client-id", "YOUR_CLIENT",
"--oidc-extra-scope", "email,profile",
}, version)
if exitCode != 0 {
t.Errorf("exitCode wants 0 but %d", exitCode)
}
})
})
}

View File

@@ -3,6 +3,8 @@ package cmd
import (
"fmt"
_ "embed"
"github.com/int128/kubelogin/pkg/usecases/setup"
"github.com/spf13/cobra"
"github.com/spf13/pflag"
@@ -35,13 +37,31 @@ type Setup struct {
Setup setup.Interface
}
//go:embed setup.md
var setupLongDescription string
func (cmd *Setup) New() *cobra.Command {
var o setupOptions
c := &cobra.Command{
Use: "setup",
Short: "Show the setup instruction",
Long: setupLongDescription,
Args: cobra.NoArgs,
RunE: func(c *cobra.Command, _ []string) error {
var changedFlags []string
c.Flags().VisitAll(func(f *pflag.Flag) {
if !f.Changed {
return
}
if sliceValue, ok := f.Value.(pflag.SliceValue); ok {
for _, v := range sliceValue.GetSlice() {
changedFlags = append(changedFlags, fmt.Sprintf("--%s=%s", f.Name, v))
}
return
}
changedFlags = append(changedFlags, fmt.Sprintf("--%s=%s", f.Name, f.Value))
})
grantOptionSet, err := o.authenticationOptions.grantOptionSet()
if err != nil {
return fmt.Errorf("setup: %w", err)
@@ -50,7 +70,7 @@ func (cmd *Setup) New() *cobra.Command {
if err != nil {
return fmt.Errorf("setup: %w", err)
}
in := setup.Stage2Input{
in := setup.Input{
IssuerURL: o.IssuerURL,
ClientID: o.ClientID,
ClientSecret: o.ClientSecret,
@@ -59,18 +79,12 @@ func (cmd *Setup) New() *cobra.Command {
PKCEMethod: pkceMethod,
GrantOptionSet: grantOptionSet,
TLSClientConfig: o.tlsOptions.tlsClientConfig(),
}
if c.Flags().Lookup("listen-address").Changed {
in.ListenAddressArgs = o.authenticationOptions.ListenAddress
}
if c.Flags().Lookup("oidc-pkce-method").Changed {
in.PKCEMethodArg = o.pkceOptions.PKCEMethod
ChangedFlags: changedFlags,
}
if in.IssuerURL == "" || in.ClientID == "" {
cmd.Setup.DoStage1()
return nil
return c.Help()
}
if err := cmd.Setup.DoStage2(c.Context(), in); err != nil {
if err := cmd.Setup.Do(c.Context(), in); err != nil {
return fmt.Errorf("setup: %w", err)
}
return nil

12
pkg/cmd/setup.md Normal file
View File

@@ -0,0 +1,12 @@
This setup shows the instruction of Kubernetes OpenID Connect authentication.
You need to set up the OpenID Connect Provider.
Run the following command to authenticate with the OpenID Connect Provider:
```
kubectl oidc-login setup \
--oidc-issuer-url=ISSUER_URL \
--oidc-client-id=YOUR_CLIENT_ID
```
See https://github.com/int128/kubelogin for the details.

View File

@@ -18,7 +18,7 @@ func getDefaultTokenCacheDir() string {
return filepath.Join("~", ".kube", "cache", "oidc-login")
}
var allTokenCacheStorage = strings.Join([]string{"auto", "keyring", "disk"}, "|")
var allTokenCacheStorage = strings.Join([]string{"disk", "keyring"}, "|")
type tokenCacheOptions struct {
TokenCacheDir string
@@ -27,7 +27,7 @@ type tokenCacheOptions struct {
func (o *tokenCacheOptions) addFlags(f *pflag.FlagSet) {
f.StringVar(&o.TokenCacheDir, "token-cache-dir", getDefaultTokenCacheDir(), "Path to a directory of the token cache")
f.StringVar(&o.TokenCacheStorage, "token-cache-storage", "auto", fmt.Sprintf("Storage for the token cache. One of (%s)", allTokenCacheStorage))
f.StringVar(&o.TokenCacheStorage, "token-cache-storage", "disk", fmt.Sprintf("Storage for the token cache. One of (%s)", allTokenCacheStorage))
}
func (o *tokenCacheOptions) expandHomedir() {
@@ -39,12 +39,10 @@ func (o *tokenCacheOptions) tokenCacheConfig() (tokencache.Config, error) {
Directory: o.TokenCacheDir,
}
switch o.TokenCacheStorage {
case "auto":
config.Storage = tokencache.StorageAuto
case "keyring":
config.Storage = tokencache.StorageKeyring
case "disk":
config.Storage = tokencache.StorageDisk
case "keyring":
config.Storage = tokencache.StorageKeyring
default:
return tokencache.Config{}, fmt.Errorf("token-cache-storage must be one of (%s)", allTokenCacheStorage)
}

View File

@@ -97,9 +97,7 @@ func NewCmdForHeadless(clockInterface clock.Interface, stdin stdio.Stdin, stdout
Standalone: standaloneStandalone,
Logger: loggerInterface,
}
repositoryRepository := &repository.Repository{
Logger: loggerInterface,
}
repositoryRepository := &repository.Repository{}
reader3 := &reader2.Reader{}
writer3 := &writer2.Writer{
Stdout: stdout,

View File

@@ -5,7 +5,6 @@ import (
"encoding/gob"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"io"
"os"
@@ -13,7 +12,6 @@ import (
"github.com/gofrs/flock"
"github.com/google/wire"
"github.com/int128/kubelogin/pkg/infrastructure/logger"
"github.com/int128/kubelogin/pkg/oidc"
"github.com/int128/kubelogin/pkg/tokencache"
"github.com/zalando/go-keyring"
@@ -39,9 +37,7 @@ type entity struct {
// Repository provides access to the token cache on the local filesystem.
// Filename of a token cache is sha256 digest of the issuer, zero-character and client ID.
type Repository struct {
Logger logger.Interface
}
type Repository struct{}
// keyringService is used to namespace the keyring access.
// Some implementations may also display this string when prompting the user
@@ -57,16 +53,6 @@ func (r *Repository) FindByKey(config tokencache.Config, key tokencache.Key) (*o
return nil, fmt.Errorf("could not compute the key: %w", err)
}
switch config.Storage {
case tokencache.StorageAuto:
t, err := readFromKeyring(checksum)
if errors.Is(err, keyring.ErrUnsupportedPlatform) ||
errors.Is(err, keyring.ErrNotFound) {
return readFromFile(config, checksum)
}
if err != nil {
return nil, err
}
return t, nil
case tokencache.StorageDisk:
return readFromFile(config, checksum)
case tokencache.StorageKeyring:
@@ -120,17 +106,6 @@ func (r *Repository) Save(config tokencache.Config, key tokencache.Key, tokenSet
return fmt.Errorf("could not compute the key: %w", err)
}
switch config.Storage {
case tokencache.StorageAuto:
if err := writeToKeyring(checksum, tokenSet); err != nil {
if errors.Is(err, keyring.ErrUnsupportedPlatform) {
return writeToFile(config, checksum, tokenSet)
}
if errors.Is(err, keyring.ErrSetDataTooBig) {
return writeToFile(config, checksum, tokenSet)
}
return err
}
return nil
case tokencache.StorageDisk:
return writeToFile(config, checksum, tokenSet)
case tokencache.StorageKeyring:
@@ -188,36 +163,20 @@ func (r *Repository) Lock(config tokencache.Config, key tokencache.Key) (io.Clos
}
func (r *Repository) DeleteAll(config tokencache.Config) error {
return errors.Join(
func() error {
if err := os.RemoveAll(config.Directory); err != nil {
return fmt.Errorf("remove the directory %s: %w", config.Directory, err)
}
r.Logger.Printf("Deleted the token cache at %s", config.Directory)
return nil
}(),
func() error {
switch config.Storage {
case tokencache.StorageAuto:
if err := keyring.DeleteAll(keyringService); err != nil {
if errors.Is(err, keyring.ErrUnsupportedPlatform) {
return nil
}
return fmt.Errorf("keyring delete: %w", err)
}
r.Logger.Printf("Deleted the token cache in the keyring")
return nil
case tokencache.StorageKeyring:
if err := keyring.DeleteAll(keyringService); err != nil {
return fmt.Errorf("keyring delete: %w", err)
}
r.Logger.Printf("Deleted the token cache in the keyring")
return nil
default:
return nil
}
}(),
)
switch config.Storage {
case tokencache.StorageDisk:
if err := os.RemoveAll(config.Directory); err != nil {
return fmt.Errorf("remove the directory %s: %w", config.Directory, err)
}
return nil
case tokencache.StorageKeyring:
if err := keyring.DeleteAll(keyringService); err != nil {
return fmt.Errorf("keyring delete: %w", err)
}
return nil
default:
return fmt.Errorf("unknown storage mode: %v", config.Storage)
}
}
func encodeKey(tokenSet oidc.TokenSet) ([]byte, error) {

View File

@@ -25,10 +25,8 @@ type Config struct {
type Storage byte
const (
// StorageAuto will prefer keyring when available, and fallback to disk when not.
StorageAuto Storage = iota
// StorageDisk will only store cached keys on disk.
StorageDisk
StorageDisk Storage = iota
// StorageDisk will only store cached keys in the OS keyring.
StorageKeyring
)

View File

@@ -21,7 +21,7 @@ type Interface interface {
// Input represents an input of the Clean use-case.
type Input struct {
TokenCacheConfig tokencache.Config
TokenCacheDir string
}
type Clean struct {
@@ -31,8 +31,17 @@ type Clean struct {
func (u *Clean) Do(ctx context.Context, in Input) error {
u.Logger.V(1).Infof("Deleting the token cache")
if err := u.TokenCacheRepository.DeleteAll(in.TokenCacheConfig); err != nil {
return fmt.Errorf("delete the token cache: %w", err)
if err := u.TokenCacheRepository.DeleteAll(tokencache.Config{Directory: in.TokenCacheDir, Storage: tokencache.StorageDisk}); err != nil {
return fmt.Errorf("delete the token cache from %s: %w", in.TokenCacheDir, err)
}
u.Logger.Printf("Deleted the token cache from %s", in.TokenCacheDir)
if err := u.TokenCacheRepository.DeleteAll(tokencache.Config{Directory: in.TokenCacheDir, Storage: tokencache.StorageKeyring}); err != nil {
// Do not return an error because the keyring may not be available.
u.Logger.Printf("Could not delete the token cache from the keyring: %s", err)
} else {
u.Logger.Printf("Deleted the token cache from the keyring")
}
return nil
}

View File

@@ -3,9 +3,17 @@ package setup
import (
"context"
"fmt"
"strconv"
"strings"
"text/template"
_ "embed"
"github.com/google/wire"
"github.com/int128/kubelogin/pkg/infrastructure/logger"
"github.com/int128/kubelogin/pkg/oidc"
"github.com/int128/kubelogin/pkg/tlsclientconfig"
"github.com/int128/kubelogin/pkg/usecases/authentication"
)
@@ -15,11 +23,62 @@ var Set = wire.NewSet(
)
type Interface interface {
DoStage1()
DoStage2(ctx context.Context, in Stage2Input) error
Do(ctx context.Context, in Input) error
}
type Setup struct {
Authentication authentication.Interface
Logger logger.Interface
}
//go:embed setup.md
var setupMarkdown string
var setupTemplate = template.Must(template.New("setup.md").Funcs(template.FuncMap{
"quote": strconv.Quote,
}).Parse(setupMarkdown))
type Input struct {
IssuerURL string
ClientID string
ClientSecret string
ExtraScopes []string
UseAccessToken bool
PKCEMethod oidc.PKCEMethod
GrantOptionSet authentication.GrantOptionSet
TLSClientConfig tlsclientconfig.Config
ChangedFlags []string
}
func (u Setup) Do(ctx context.Context, in Input) error {
u.Logger.Printf("Authentication in progress...")
out, err := u.Authentication.Do(ctx, authentication.Input{
Provider: oidc.Provider{
IssuerURL: in.IssuerURL,
ClientID: in.ClientID,
ClientSecret: in.ClientSecret,
ExtraScopes: in.ExtraScopes,
PKCEMethod: in.PKCEMethod,
UseAccessToken: in.UseAccessToken,
},
GrantOptionSet: in.GrantOptionSet,
TLSClientConfig: in.TLSClientConfig,
})
if err != nil {
return fmt.Errorf("authentication error: %w", err)
}
idTokenClaims, err := out.TokenSet.DecodeWithoutVerify()
if err != nil {
return fmt.Errorf("you got an invalid token: %w", err)
}
var b strings.Builder
if err := setupTemplate.Execute(&b, map[string]any{
"IDTokenPrettyJSON": idTokenClaims.Pretty,
"Flags": in.ChangedFlags,
}); err != nil {
return fmt.Errorf("render the template: %w", err)
}
u.Logger.Printf(b.String())
return nil
}

View File

@@ -0,0 +1,24 @@
## Authenticated with the OpenID Connect Provider
You got the token with the following claims:
```
{{ .IDTokenPrettyJSON }}
```
## Set up the kubeconfig
You can run the following command to set up the kubeconfig:
```
kubectl config set-credentials oidc \
--exec-api-version=client.authentication.k8s.io/v1 \
--exec-interactive-mode=Never \
--exec-command=kubectl \
--exec-arg=oidc-login \
--exec-arg=get-token \
{{- range $index, $flag := .Flags }}
{{- if $index}} \{{end}}
--exec-arg={{ $flag | quote }}
{{- end }}
```

View File

@@ -0,0 +1,66 @@
package setup
import (
"context"
"testing"
"time"
"github.com/golang-jwt/jwt/v5"
"github.com/int128/kubelogin/mocks/github.com/int128/kubelogin/pkg/usecases/authentication_mock"
"github.com/int128/kubelogin/pkg/oidc"
testingJWT "github.com/int128/kubelogin/pkg/testing/jwt"
"github.com/int128/kubelogin/pkg/testing/logger"
"github.com/int128/kubelogin/pkg/tlsclientconfig"
"github.com/int128/kubelogin/pkg/usecases/authentication"
)
func TestSetup_Do(t *testing.T) {
issuedIDToken := testingJWT.EncodeF(t, func(claims *testingJWT.Claims) {
claims.Issuer = "https://issuer.example.com"
claims.Subject = "YOUR_SUBJECT"
claims.ExpiresAt = jwt.NewNumericDate(time.Now().Add(1 * time.Hour))
})
dummyTLSClientConfig := tlsclientconfig.Config{
CACertFilename: []string{"/path/to/cert"},
}
var grantOptionSet authentication.GrantOptionSet
ctx := context.Background()
in := Input{
IssuerURL: "https://accounts.google.com",
ClientID: "YOUR_CLIENT_ID",
ClientSecret: "YOUR_CLIENT_SECRET",
ExtraScopes: []string{"email"},
GrantOptionSet: grantOptionSet,
TLSClientConfig: dummyTLSClientConfig,
ChangedFlags: []string{
"--oidc-issuer-url=https://accounts.google.com",
"--oidc-client-id=YOUR_CLIENT_ID",
},
}
mockAuthentication := authentication_mock.NewMockInterface(t)
mockAuthentication.EXPECT().
Do(ctx, authentication.Input{
Provider: oidc.Provider{
IssuerURL: "https://accounts.google.com",
ClientID: "YOUR_CLIENT_ID",
ClientSecret: "YOUR_CLIENT_SECRET",
ExtraScopes: []string{"email"},
},
GrantOptionSet: grantOptionSet,
TLSClientConfig: dummyTLSClientConfig,
}).
Return(&authentication.Output{
TokenSet: oidc.TokenSet{
IDToken: issuedIDToken,
RefreshToken: "YOUR_REFRESH_TOKEN",
},
}, nil)
u := Setup{
Authentication: mockAuthentication,
Logger: logger.New(t),
}
if err := u.Do(ctx, in); err != nil {
t.Errorf("Do returned error: %+v", err)
}
}

View File

@@ -1,28 +0,0 @@
package setup
const stage1 = `This setup shows the instruction of Kubernetes OpenID Connect authentication.
See also https://github.com/int128/kubelogin.
## 1. Set up the OpenID Connect Provider
Open the OpenID Connect Provider and create a client.
For example, Google Identity Platform:
Open https://console.developers.google.com/apis/credentials and create an OAuth client of "Other" type.
ISSUER is https://accounts.google.com
## 2. Verify authentication
Run the following command to proceed.
kubectl oidc-login setup \
--oidc-issuer-url=ISSUER \
--oidc-client-id=YOUR_CLIENT_ID \
--oidc-client-secret=YOUR_CLIENT_SECRET
You can set your CA certificate. See also the options by --help.
`
func (u *Setup) DoStage1() {
u.Logger.Printf(stage1)
}

View File

@@ -1,178 +0,0 @@
package setup
import (
"context"
"fmt"
"path/filepath"
"strings"
"text/template"
"github.com/int128/kubelogin/pkg/oidc"
"github.com/int128/kubelogin/pkg/tlsclientconfig"
"github.com/int128/kubelogin/pkg/usecases/authentication"
)
var stage2Tpl = template.Must(template.New("").Parse(`
## 2. Verify authentication
You got a token with the following claims:
{{ .IDTokenPrettyJSON }}
## 3. Bind a cluster role
Run the following command:
kubectl create clusterrolebinding oidc-cluster-admin --clusterrole=cluster-admin --user='{{ .IssuerURL }}#{{ .Subject }}'
## 4. Set up the Kubernetes API server
Add the following options to the kube-apiserver:
--oidc-issuer-url={{ .IssuerURL }}
--oidc-client-id={{ .ClientID }}
## 5. Set up the kubeconfig
Run the following command:
kubectl config set-credentials oidc \
--exec-api-version=client.authentication.k8s.io/v1 \
--exec-command=kubectl \
--exec-arg=oidc-login \
--exec-arg=get-token \
{{- range $index, $arg := .Args }}
{{- if $index}} \{{end}}
--exec-arg={{ $arg }}
{{- end }}
## 6. Verify cluster access
Make sure you can access the Kubernetes cluster.
kubectl --user=oidc get nodes
You can switch the default context to oidc.
kubectl config set-context --current --user=oidc
You can share the kubeconfig to your team members for on-boarding.
`))
type stage2Vars struct {
IDTokenPrettyJSON string
IssuerURL string
ClientID string
Args []string
Subject string
}
// Stage2Input represents an input DTO of the stage2.
type Stage2Input struct {
IssuerURL string
ClientID string
ClientSecret string
ExtraScopes []string // optional
UseAccessToken bool // optional
ListenAddressArgs []string // non-nil if set by the command arg
PKCEMethod oidc.PKCEMethod
PKCEMethodArg string
GrantOptionSet authentication.GrantOptionSet
TLSClientConfig tlsclientconfig.Config
}
func (u *Setup) DoStage2(ctx context.Context, in Stage2Input) error {
u.Logger.Printf("authentication in progress...")
out, err := u.Authentication.Do(ctx, authentication.Input{
Provider: oidc.Provider{
IssuerURL: in.IssuerURL,
ClientID: in.ClientID,
ClientSecret: in.ClientSecret,
ExtraScopes: in.ExtraScopes,
PKCEMethod: in.PKCEMethod,
UseAccessToken: in.UseAccessToken,
},
GrantOptionSet: in.GrantOptionSet,
TLSClientConfig: in.TLSClientConfig,
})
if err != nil {
return fmt.Errorf("authentication error: %w", err)
}
idTokenClaims, err := out.TokenSet.DecodeWithoutVerify()
if err != nil {
return fmt.Errorf("you got an invalid token: %w", err)
}
v := stage2Vars{
IDTokenPrettyJSON: idTokenClaims.Pretty,
IssuerURL: in.IssuerURL,
ClientID: in.ClientID,
Args: makeCredentialPluginArgs(in),
Subject: idTokenClaims.Subject,
}
var b strings.Builder
if err := stage2Tpl.Execute(&b, &v); err != nil {
return fmt.Errorf("could not render the template: %w", err)
}
u.Logger.Printf(b.String())
return nil
}
func makeCredentialPluginArgs(in Stage2Input) []string {
var args []string
args = append(args, "--oidc-issuer-url="+in.IssuerURL)
args = append(args, "--oidc-client-id="+in.ClientID)
if in.ClientSecret != "" {
args = append(args, "--oidc-client-secret="+in.ClientSecret)
}
for _, extraScope := range in.ExtraScopes {
args = append(args, "--oidc-extra-scope="+extraScope)
}
if in.PKCEMethodArg != "" {
args = append(args, "--oidc-pkce-method="+in.PKCEMethodArg)
}
if in.UseAccessToken {
args = append(args, "--oidc-use-access-token")
}
for _, f := range in.TLSClientConfig.CACertFilename {
args = append(args, "--certificate-authority="+f)
}
for _, d := range in.TLSClientConfig.CACertData {
args = append(args, "--certificate-authority-data="+d)
}
if in.TLSClientConfig.SkipTLSVerify {
args = append(args, "--insecure-skip-tls-verify")
}
if in.GrantOptionSet.AuthCodeBrowserOption != nil {
if in.GrantOptionSet.AuthCodeBrowserOption.SkipOpenBrowser {
args = append(args, "--skip-open-browser")
}
if in.GrantOptionSet.AuthCodeBrowserOption.BrowserCommand != "" {
args = append(args, "--browser-command="+in.GrantOptionSet.AuthCodeBrowserOption.BrowserCommand)
}
if in.GrantOptionSet.AuthCodeBrowserOption.LocalServerCertFile != "" {
// Resolve the absolute path for the cert files so the user doesn't have to know
// to use one when running setup.
certpath, err := filepath.Abs(in.GrantOptionSet.AuthCodeBrowserOption.LocalServerCertFile)
if err != nil {
panic(err)
}
keypath, err := filepath.Abs(in.GrantOptionSet.AuthCodeBrowserOption.LocalServerKeyFile)
if err != nil {
panic(err)
}
args = append(args, "--local-server-cert="+certpath)
args = append(args, "--local-server-key="+keypath)
}
}
for _, l := range in.ListenAddressArgs {
args = append(args, "--listen-address="+l)
}
if in.GrantOptionSet.ROPCOption != nil {
if in.GrantOptionSet.ROPCOption.Username != "" {
args = append(args, "--username="+in.GrantOptionSet.ROPCOption.Username)
}
}
return args
}

View File

@@ -1,111 +0,0 @@
package setup
import (
"context"
"testing"
"time"
"github.com/golang-jwt/jwt/v5"
"github.com/int128/kubelogin/mocks/github.com/int128/kubelogin/pkg/usecases/authentication_mock"
"github.com/int128/kubelogin/pkg/oidc"
testingJWT "github.com/int128/kubelogin/pkg/testing/jwt"
"github.com/int128/kubelogin/pkg/testing/logger"
"github.com/int128/kubelogin/pkg/tlsclientconfig"
"github.com/int128/kubelogin/pkg/usecases/authentication"
"github.com/int128/kubelogin/pkg/usecases/authentication/authcode"
"github.com/int128/kubelogin/pkg/usecases/authentication/ropc"
"github.com/stretchr/testify/assert"
)
func TestSetup_DoStage2(t *testing.T) {
issuedIDToken := testingJWT.EncodeF(t, func(claims *testingJWT.Claims) {
claims.Issuer = "https://issuer.example.com"
claims.Subject = "YOUR_SUBJECT"
claims.ExpiresAt = jwt.NewNumericDate(time.Now().Add(1 * time.Hour))
})
dummyTLSClientConfig := tlsclientconfig.Config{
CACertFilename: []string{"/path/to/cert"},
}
var grantOptionSet authentication.GrantOptionSet
ctx := context.Background()
in := Stage2Input{
IssuerURL: "https://accounts.google.com",
ClientID: "YOUR_CLIENT_ID",
ClientSecret: "YOUR_CLIENT_SECRET",
ExtraScopes: []string{"email"},
GrantOptionSet: grantOptionSet,
TLSClientConfig: dummyTLSClientConfig,
}
mockAuthentication := authentication_mock.NewMockInterface(t)
mockAuthentication.EXPECT().
Do(ctx, authentication.Input{
Provider: oidc.Provider{
IssuerURL: "https://accounts.google.com",
ClientID: "YOUR_CLIENT_ID",
ClientSecret: "YOUR_CLIENT_SECRET",
ExtraScopes: []string{"email"},
},
GrantOptionSet: grantOptionSet,
TLSClientConfig: dummyTLSClientConfig,
}).
Return(&authentication.Output{
TokenSet: oidc.TokenSet{
IDToken: issuedIDToken,
RefreshToken: "YOUR_REFRESH_TOKEN",
},
}, nil)
u := Setup{
Authentication: mockAuthentication,
Logger: logger.New(t),
}
if err := u.DoStage2(ctx, in); err != nil {
t.Errorf("DoStage2 returned error: %+v", err)
}
}
func Test_makeCredentialPluginArgs(t *testing.T) {
in := Stage2Input{
IssuerURL: "https://oidc.example.com",
ClientID: "test_kid",
ClientSecret: "test_ksecret",
ExtraScopes: []string{"groups"},
PKCEMethodArg: "S256",
ListenAddressArgs: []string{"127.0.0.1:8080", "127.0.0.1:8888"},
GrantOptionSet: authentication.GrantOptionSet{
AuthCodeBrowserOption: &authcode.BrowserOption{
SkipOpenBrowser: true,
BrowserCommand: "firefox",
LocalServerCertFile: "/path/to/cert.crt",
LocalServerKeyFile: "/path/to/cert.key",
},
ROPCOption: &ropc.Option{
Username: "user1",
},
},
TLSClientConfig: tlsclientconfig.Config{
CACertFilename: []string{"/path/to/ca.crt"},
CACertData: []string{"base64encoded1"},
SkipTLSVerify: true,
},
}
expet := []string{
"--oidc-issuer-url=https://oidc.example.com",
"--oidc-client-id=test_kid",
"--oidc-client-secret=test_ksecret",
"--oidc-extra-scope=groups",
"--oidc-pkce-method=S256",
"--certificate-authority=/path/to/ca.crt",
"--certificate-authority-data=base64encoded1",
"--insecure-skip-tls-verify",
"--skip-open-browser",
"--browser-command=firefox",
"--local-server-cert=/path/to/cert.crt",
"--local-server-key=/path/to/cert.key",
"--listen-address=127.0.0.1:8080",
"--listen-address=127.0.0.1:8888",
"--username=user1",
}
got := makeCredentialPluginArgs(in)
assert.Equal(t, expet, got)
}

View File

@@ -29,6 +29,7 @@ test: build
--exec-arg=--oidc-client-id=YOUR_CLIENT_ID \
--exec-arg=--oidc-client-secret=YOUR_CLIENT_SECRET \
--exec-arg=--oidc-extra-scope=email \
--exec-arg=--token-cache-storage=keyring \
--exec-arg=--certificate-authority=$(CERT_DIR)/ca.crt \
--exec-arg=--browser-command=$(BIN_DIR)/chromelogin