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:
kalle (jag)
2025-01-08 04:32:26 +01:00
committed by GitHub
parent a836ef0e92
commit afb25f511c
11 changed files with 234 additions and 99 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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