mirror of
https://github.com/int128/kubelogin.git
synced 2026-02-14 16:39:51 +00:00
Added key cache via OS keyring (#973)
* Added key cache via OS keyring * Fix lint issue * Disable keyring in integration tests * Disable keyring in system test --------- Co-authored-by: Hidetake Iwata <int128@gmail.com>
This commit is contained in:
@@ -6,6 +6,7 @@ import (
|
||||
|
||||
"github.com/int128/kubelogin/pkg/infrastructure/logger"
|
||||
"github.com/int128/kubelogin/pkg/oidc"
|
||||
"github.com/int128/kubelogin/pkg/tokencache"
|
||||
"github.com/int128/kubelogin/pkg/usecases/credentialplugin"
|
||||
"github.com/spf13/cobra"
|
||||
"github.com/spf13/pflag"
|
||||
@@ -23,6 +24,8 @@ type getTokenOptions struct {
|
||||
tlsOptions tlsOptions
|
||||
authenticationOptions authenticationOptions
|
||||
ForceRefresh bool
|
||||
ForceKeyring bool
|
||||
NoKeyring bool
|
||||
}
|
||||
|
||||
func (o *getTokenOptions) addFlags(f *pflag.FlagSet) {
|
||||
@@ -34,6 +37,8 @@ func (o *getTokenOptions) addFlags(f *pflag.FlagSet) {
|
||||
f.BoolVar(&o.UseAccessToken, "oidc-use-access-token", false, "Instead of using the id_token, use the access_token to authenticate to Kubernetes")
|
||||
f.StringVar(&o.TokenCacheDir, "token-cache-dir", defaultTokenCacheDir, "Path to a directory for token cache")
|
||||
f.BoolVar(&o.ForceRefresh, "force-refresh", false, "If set, refresh the ID token regardless of its expiration time")
|
||||
f.BoolVar(&o.ForceKeyring, "force-keyring", false, "If set, cached tokens will be stored in the OS keyring")
|
||||
f.BoolVar(&o.NoKeyring, "no-keyring", false, "If set, cached tokens will be stored on disk")
|
||||
o.tlsOptions.addFlags(f)
|
||||
o.authenticationOptions.addFlags(f)
|
||||
}
|
||||
@@ -75,6 +80,13 @@ func (cmd *GetToken) New() *cobra.Command {
|
||||
if err != nil {
|
||||
return fmt.Errorf("get-token: %w", err)
|
||||
}
|
||||
tokenStorage := tokencache.StorageAuto
|
||||
switch {
|
||||
case o.ForceKeyring:
|
||||
tokenStorage = tokencache.StorageKeyring
|
||||
case o.NoKeyring:
|
||||
tokenStorage = tokencache.StorageDisk
|
||||
}
|
||||
in := credentialplugin.Input{
|
||||
Provider: oidc.Provider{
|
||||
IssuerURL: o.IssuerURL,
|
||||
@@ -84,10 +96,11 @@ func (cmd *GetToken) New() *cobra.Command {
|
||||
UseAccessToken: o.UseAccessToken,
|
||||
ExtraScopes: o.ExtraScopes,
|
||||
},
|
||||
TokenCacheDir: o.TokenCacheDir,
|
||||
GrantOptionSet: grantOptionSet,
|
||||
TLSClientConfig: o.tlsOptions.tlsClientConfig(),
|
||||
ForceRefresh: o.ForceRefresh,
|
||||
TokenCacheDir: o.TokenCacheDir,
|
||||
TokenCacheStorage: tokenStorage,
|
||||
GrantOptionSet: grantOptionSet,
|
||||
TLSClientConfig: o.tlsOptions.tlsClientConfig(),
|
||||
ForceRefresh: o.ForceRefresh,
|
||||
}
|
||||
if err := cmd.GetToken.Do(c.Context(), in); err != nil {
|
||||
return fmt.Errorf("get-token: %w", err)
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"encoding/gob"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
@@ -14,6 +15,7 @@ import (
|
||||
"github.com/google/wire"
|
||||
"github.com/int128/kubelogin/pkg/oidc"
|
||||
"github.com/int128/kubelogin/pkg/tokencache"
|
||||
"github.com/zalando/go-keyring"
|
||||
)
|
||||
|
||||
// Set provides an implementation and interface for Kubeconfig.
|
||||
@@ -23,9 +25,9 @@ var Set = wire.NewSet(
|
||||
)
|
||||
|
||||
type Interface interface {
|
||||
FindByKey(dir string, key tokencache.Key) (*oidc.TokenSet, error)
|
||||
Save(dir string, key tokencache.Key, tokenSet oidc.TokenSet) error
|
||||
Lock(dir string, key tokencache.Key) (io.Closer, error)
|
||||
FindByKey(dir string, storage tokencache.Storage, key tokencache.Key) (*oidc.TokenSet, error)
|
||||
Save(dir string, storage tokencache.Storage, key tokencache.Key, tokenSet oidc.TokenSet) error
|
||||
Lock(dir string, storage tokencache.Storage, key tokencache.Key) (io.Closer, error)
|
||||
}
|
||||
|
||||
type entity struct {
|
||||
@@ -37,21 +39,70 @@ type entity struct {
|
||||
// Filename of a token cache is sha256 digest of the issuer, zero-character and client ID.
|
||||
type Repository struct{}
|
||||
|
||||
func (r *Repository) FindByKey(dir string, key tokencache.Key) (*oidc.TokenSet, error) {
|
||||
filename, err := computeFilename(key)
|
||||
// keyringService is used to namespace the keyring access.
|
||||
// Some implementations may also display this string when prompting the user
|
||||
// for allowing access.
|
||||
const keyringService = "kubelogin"
|
||||
|
||||
// keyringItemPrefix is used as the prefix in the keyring items.
|
||||
const keyringItemPrefix = "kubelogin/tokencache/"
|
||||
|
||||
func (r *Repository) FindByKey(dir string, storage tokencache.Storage, key tokencache.Key) (*oidc.TokenSet, error) {
|
||||
checksum, err := computeChecksum(key)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("could not compute the key: %w", err)
|
||||
}
|
||||
p := filepath.Join(dir, filename)
|
||||
f, err := os.Open(p)
|
||||
switch storage {
|
||||
case tokencache.StorageAuto:
|
||||
t, err := readFromKeyring(checksum)
|
||||
if errors.Is(err, keyring.ErrUnsupportedPlatform) ||
|
||||
errors.Is(err, keyring.ErrNotFound) {
|
||||
return readFromFile(dir, checksum)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return t, nil
|
||||
case tokencache.StorageDisk:
|
||||
return readFromFile(dir, checksum)
|
||||
case tokencache.StorageKeyring:
|
||||
return readFromKeyring(checksum)
|
||||
default:
|
||||
return nil, fmt.Errorf("unknown storage mode: %v", storage)
|
||||
}
|
||||
}
|
||||
|
||||
func readFromFile(dir, checksum string) (*oidc.TokenSet, error) {
|
||||
p := filepath.Join(dir, checksum)
|
||||
b, err := os.ReadFile(p)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("could not open file %s: %w", p, err)
|
||||
}
|
||||
defer f.Close()
|
||||
d := json.NewDecoder(f)
|
||||
t, err := decodeKey(b)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("file %s: %w", p, err)
|
||||
}
|
||||
return t, nil
|
||||
}
|
||||
|
||||
func readFromKeyring(checksum string) (*oidc.TokenSet, error) {
|
||||
p := keyringItemPrefix + checksum
|
||||
s, err := keyring.Get(keyringService, p)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("could not get keyring secret %s: %w", p, err)
|
||||
}
|
||||
t, err := decodeKey([]byte(s))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("keyring %s: %w", p, err)
|
||||
}
|
||||
return t, nil
|
||||
}
|
||||
|
||||
func decodeKey(b []byte) (*oidc.TokenSet, error) {
|
||||
var e entity
|
||||
if err := d.Decode(&e); err != nil {
|
||||
return nil, fmt.Errorf("invalid json file %s: %w", p, err)
|
||||
err := json.Unmarshal(b, &e)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid token cache json: %w", err)
|
||||
}
|
||||
return &oidc.TokenSet{
|
||||
IDToken: e.IDToken,
|
||||
@@ -59,41 +110,73 @@ func (r *Repository) FindByKey(dir string, key tokencache.Key) (*oidc.TokenSet,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (r *Repository) Save(dir string, key tokencache.Key, tokenSet oidc.TokenSet) error {
|
||||
if err := os.MkdirAll(dir, 0700); err != nil {
|
||||
return fmt.Errorf("could not create directory %s: %w", dir, err)
|
||||
}
|
||||
filename, err := computeFilename(key)
|
||||
func (r *Repository) Save(dir string, storage tokencache.Storage, key tokencache.Key, tokenSet oidc.TokenSet) error {
|
||||
checksum, err := computeChecksum(key)
|
||||
if err != nil {
|
||||
return fmt.Errorf("could not compute the key: %w", err)
|
||||
}
|
||||
p := filepath.Join(dir, filename)
|
||||
f, err := os.OpenFile(p, os.O_RDWR|os.O_CREATE|os.O_TRUNC, 0600)
|
||||
switch storage {
|
||||
case tokencache.StorageAuto:
|
||||
if err := writeToKeyring(checksum, tokenSet); err != nil {
|
||||
if errors.Is(err, keyring.ErrUnsupportedPlatform) {
|
||||
return writeToFile(dir, checksum, tokenSet)
|
||||
}
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
case tokencache.StorageDisk:
|
||||
return writeToFile(dir, checksum, tokenSet)
|
||||
case tokencache.StorageKeyring:
|
||||
return writeToKeyring(checksum, tokenSet)
|
||||
default:
|
||||
return fmt.Errorf("unknown storage mode: %v", storage)
|
||||
}
|
||||
}
|
||||
|
||||
func writeToFile(dir, checksum string, tokenSet oidc.TokenSet) error {
|
||||
p := filepath.Join(dir, checksum)
|
||||
b, err := encodeKey(tokenSet)
|
||||
if err != nil {
|
||||
return fmt.Errorf("file %s: %w", p, err)
|
||||
}
|
||||
if err := os.MkdirAll(dir, 0700); err != nil {
|
||||
return fmt.Errorf("could not create directory %s: %w", dir, err)
|
||||
}
|
||||
if err := os.WriteFile(p, b, 0600); err != nil {
|
||||
return fmt.Errorf("could not create file %s: %w", p, err)
|
||||
}
|
||||
defer f.Close()
|
||||
e := entity{
|
||||
IDToken: tokenSet.IDToken,
|
||||
RefreshToken: tokenSet.RefreshToken,
|
||||
}
|
||||
if err := json.NewEncoder(f).Encode(&e); err != nil {
|
||||
return fmt.Errorf("json encode error: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *Repository) Lock(tokenCacheDir string, key tokencache.Key) (io.Closer, error) {
|
||||
if err := os.MkdirAll(tokenCacheDir, 0700); err != nil {
|
||||
return nil, fmt.Errorf("could not create directory %s: %w", tokenCacheDir, err)
|
||||
func writeToKeyring(checksum string, tokenSet oidc.TokenSet) error {
|
||||
p := keyringItemPrefix + checksum
|
||||
b, err := encodeKey(tokenSet)
|
||||
if err != nil {
|
||||
return fmt.Errorf("keyring %s: %w", p, err)
|
||||
}
|
||||
keyDigest, err := computeFilename(key)
|
||||
if err := keyring.Set(keyringService, p, string(b)); err != nil {
|
||||
return fmt.Errorf("keyring write %s: %w", p, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *Repository) Lock(tokenCacheDir string, storage tokencache.Storage, key tokencache.Key) (io.Closer, error) {
|
||||
checksum, err := computeChecksum(key)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("could not compute the key: %w", err)
|
||||
}
|
||||
// NOTE: Both keyring and disk storage types use files for locking
|
||||
// No sensitive data is stored in the lock file
|
||||
return lockFile(tokenCacheDir, checksum)
|
||||
}
|
||||
|
||||
func lockFile(tokenCacheDir, checksum string) (io.Closer, error) {
|
||||
if err := os.MkdirAll(tokenCacheDir, 0700); err != nil {
|
||||
return nil, fmt.Errorf("could not create directory %s: %w", tokenCacheDir, err)
|
||||
}
|
||||
// Do not lock the token cache file.
|
||||
// https://github.com/int128/kubelogin/issues/1144
|
||||
lockFilepath := filepath.Join(tokenCacheDir, keyDigest+".lock")
|
||||
lockFilepath := filepath.Join(tokenCacheDir, checksum+".lock")
|
||||
lockFile := flock.New(lockFilepath)
|
||||
if err := lockFile.Lock(); err != nil {
|
||||
return nil, fmt.Errorf("could not lock the cache file %s: %w", lockFilepath, err)
|
||||
@@ -101,7 +184,15 @@ func (r *Repository) Lock(tokenCacheDir string, key tokencache.Key) (io.Closer,
|
||||
return lockFile, nil
|
||||
}
|
||||
|
||||
func computeFilename(key tokencache.Key) (string, error) {
|
||||
func encodeKey(tokenSet oidc.TokenSet) ([]byte, error) {
|
||||
e := entity{
|
||||
IDToken: tokenSet.IDToken,
|
||||
RefreshToken: tokenSet.RefreshToken,
|
||||
}
|
||||
return json.Marshal(&e)
|
||||
}
|
||||
|
||||
func computeChecksum(key tokencache.Key) (string, error) {
|
||||
s := sha256.New()
|
||||
e := gob.NewEncoder(s)
|
||||
if err := e.Encode(&key); err != nil {
|
||||
|
||||
@@ -28,7 +28,7 @@ func TestRepository_FindByKey(t *testing.T) {
|
||||
},
|
||||
}
|
||||
json := `{"id_token":"YOUR_ID_TOKEN","refresh_token":"YOUR_REFRESH_TOKEN"}`
|
||||
filename, err := computeFilename(key)
|
||||
filename, err := computeChecksum(key)
|
||||
if err != nil {
|
||||
t.Errorf("could not compute the key: %s", err)
|
||||
}
|
||||
@@ -37,7 +37,7 @@ func TestRepository_FindByKey(t *testing.T) {
|
||||
t.Fatalf("could not write to the temp file: %s", err)
|
||||
}
|
||||
|
||||
got, err := r.FindByKey(dir, key)
|
||||
got, err := r.FindByKey(dir, tokencache.StorageDisk, key)
|
||||
if err != nil {
|
||||
t.Errorf("err wants nil but %+v", err)
|
||||
}
|
||||
@@ -65,11 +65,11 @@ func TestRepository_Save(t *testing.T) {
|
||||
},
|
||||
}
|
||||
tokenSet := oidc.TokenSet{IDToken: "YOUR_ID_TOKEN", RefreshToken: "YOUR_REFRESH_TOKEN"}
|
||||
if err := r.Save(dir, key, tokenSet); err != nil {
|
||||
if err := r.Save(dir, tokencache.StorageDisk, key, tokenSet); err != nil {
|
||||
t.Errorf("err wants nil but %+v", err)
|
||||
}
|
||||
|
||||
filename, err := computeFilename(key)
|
||||
filename, err := computeChecksum(key)
|
||||
if err != nil {
|
||||
t.Errorf("could not compute the key: %s", err)
|
||||
}
|
||||
@@ -78,8 +78,7 @@ func TestRepository_Save(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("could not read the token cache file: %s", err)
|
||||
}
|
||||
want := `{"id_token":"YOUR_ID_TOKEN","refresh_token":"YOUR_REFRESH_TOKEN"}
|
||||
`
|
||||
want := `{"id_token":"YOUR_ID_TOKEN","refresh_token":"YOUR_REFRESH_TOKEN"}`
|
||||
got := string(b)
|
||||
if diff := cmp.Diff(want, got); diff != "" {
|
||||
t.Errorf("mismatch (-want +got):\n%s", diff)
|
||||
|
||||
@@ -11,3 +11,15 @@ type Key struct {
|
||||
TLSClientConfig tlsclientconfig.Config
|
||||
Username string
|
||||
}
|
||||
|
||||
// Storage is an enum of different storage strategies.
|
||||
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 will only store cached keys in the OS keyring.
|
||||
StorageKeyring
|
||||
)
|
||||
|
||||
@@ -31,11 +31,12 @@ type Interface interface {
|
||||
|
||||
// Input represents an input DTO of the GetToken use-case.
|
||||
type Input struct {
|
||||
Provider oidc.Provider
|
||||
TokenCacheDir string
|
||||
GrantOptionSet authentication.GrantOptionSet
|
||||
TLSClientConfig tlsclientconfig.Config
|
||||
ForceRefresh bool
|
||||
Provider oidc.Provider
|
||||
TokenCacheDir string
|
||||
TokenCacheStorage tokencache.Storage
|
||||
GrantOptionSet authentication.GrantOptionSet
|
||||
TLSClientConfig tlsclientconfig.Config
|
||||
ForceRefresh bool
|
||||
}
|
||||
|
||||
type GetToken struct {
|
||||
@@ -66,7 +67,7 @@ func (u *GetToken) Do(ctx context.Context, in Input) error {
|
||||
}
|
||||
|
||||
u.Logger.V(1).Infof("acquiring the lock of token cache")
|
||||
lock, err := u.TokenCacheRepository.Lock(in.TokenCacheDir, tokenCacheKey)
|
||||
lock, err := u.TokenCacheRepository.Lock(in.TokenCacheDir, in.TokenCacheStorage, tokenCacheKey)
|
||||
if err != nil {
|
||||
return fmt.Errorf("could not lock the token cache: %w", err)
|
||||
}
|
||||
@@ -77,7 +78,7 @@ func (u *GetToken) Do(ctx context.Context, in Input) error {
|
||||
}
|
||||
}()
|
||||
|
||||
cachedTokenSet, err := u.TokenCacheRepository.FindByKey(in.TokenCacheDir, tokenCacheKey)
|
||||
cachedTokenSet, err := u.TokenCacheRepository.FindByKey(in.TokenCacheDir, in.TokenCacheStorage, tokenCacheKey)
|
||||
if err != nil {
|
||||
u.Logger.V(1).Infof("could not find a token cache: %s", err)
|
||||
}
|
||||
@@ -126,7 +127,7 @@ func (u *GetToken) Do(ctx context.Context, in Input) error {
|
||||
}
|
||||
u.Logger.V(1).Infof("you got a token: %s", idTokenClaims.Pretty)
|
||||
u.Logger.V(1).Infof("you got a valid token until %s", idTokenClaims.Expiry)
|
||||
if err := u.TokenCacheRepository.Save(in.TokenCacheDir, tokenCacheKey, authenticationOutput.TokenSet); err != nil {
|
||||
if err := u.TokenCacheRepository.Save(in.TokenCacheDir, in.TokenCacheStorage, tokenCacheKey, authenticationOutput.TokenSet); err != nil {
|
||||
return fmt.Errorf("could not write the token cache: %w", err)
|
||||
}
|
||||
u.Logger.V(1).Infof("writing the token to client-go")
|
||||
|
||||
@@ -81,13 +81,13 @@ func TestGetToken_Do(t *testing.T) {
|
||||
Return(nil)
|
||||
mockRepository := repository_mock.NewMockInterface(t)
|
||||
mockRepository.EXPECT().
|
||||
Lock("/path/to/token-cache", tokenCacheKey).
|
||||
Lock("/path/to/token-cache", tokencache.StorageAuto, tokenCacheKey).
|
||||
Return(mockCloser, nil)
|
||||
mockRepository.EXPECT().
|
||||
FindByKey("/path/to/token-cache", tokenCacheKey).
|
||||
FindByKey("/path/to/token-cache", tokencache.StorageAuto, tokenCacheKey).
|
||||
Return(nil, errors.New("file not found"))
|
||||
mockRepository.EXPECT().
|
||||
Save("/path/to/token-cache", tokenCacheKey, issuedTokenSet).
|
||||
Save("/path/to/token-cache", tokencache.StorageAuto, tokenCacheKey, issuedTokenSet).
|
||||
Return(nil)
|
||||
mockReader := reader_mock.NewMockInterface(t)
|
||||
mockReader.EXPECT().
|
||||
@@ -142,13 +142,13 @@ func TestGetToken_Do(t *testing.T) {
|
||||
Return(nil)
|
||||
mockRepository := repository_mock.NewMockInterface(t)
|
||||
mockRepository.EXPECT().
|
||||
Lock("/path/to/token-cache", tokenCacheKey).
|
||||
Lock("/path/to/token-cache", tokencache.StorageAuto, tokenCacheKey).
|
||||
Return(mockCloser, nil)
|
||||
mockRepository.EXPECT().
|
||||
FindByKey("/path/to/token-cache", tokenCacheKey).
|
||||
FindByKey("/path/to/token-cache", tokencache.StorageAuto, tokenCacheKey).
|
||||
Return(nil, errors.New("file not found"))
|
||||
mockRepository.EXPECT().
|
||||
Save("/path/to/token-cache", tokenCacheKey, issuedTokenSet).
|
||||
Save("/path/to/token-cache", tokencache.StorageAuto, tokenCacheKey, issuedTokenSet).
|
||||
Return(nil)
|
||||
mockReader := reader_mock.NewMockInterface(t)
|
||||
mockReader.EXPECT().
|
||||
@@ -192,10 +192,10 @@ func TestGetToken_Do(t *testing.T) {
|
||||
Return(nil)
|
||||
mockRepository := repository_mock.NewMockInterface(t)
|
||||
mockRepository.EXPECT().
|
||||
Lock("/path/to/token-cache", tokenCacheKey).
|
||||
Lock("/path/to/token-cache", tokencache.StorageAuto, tokenCacheKey).
|
||||
Return(mockCloser, nil)
|
||||
mockRepository.EXPECT().
|
||||
FindByKey("/path/to/token-cache", tokencache.Key{
|
||||
FindByKey("/path/to/token-cache", tokencache.StorageAuto, tokencache.Key{
|
||||
Provider: oidc.Provider{
|
||||
IssuerURL: "https://accounts.google.com",
|
||||
ClientID: "YOUR_CLIENT_ID",
|
||||
@@ -251,10 +251,10 @@ func TestGetToken_Do(t *testing.T) {
|
||||
Return(nil)
|
||||
mockRepository := repository_mock.NewMockInterface(t)
|
||||
mockRepository.EXPECT().
|
||||
Lock("/path/to/token-cache", tokenCacheKey).
|
||||
Lock("/path/to/token-cache", tokencache.StorageAuto, tokenCacheKey).
|
||||
Return(mockCloser, nil)
|
||||
mockRepository.EXPECT().
|
||||
FindByKey("/path/to/token-cache", tokencache.Key{
|
||||
FindByKey("/path/to/token-cache", tokencache.StorageAuto, tokencache.Key{
|
||||
Provider: oidc.Provider{
|
||||
IssuerURL: "https://accounts.google.com",
|
||||
ClientID: "YOUR_CLIENT_ID",
|
||||
|
||||
Reference in New Issue
Block a user