Skip to content

Commit 394b2e7

Browse files
authored
Clear all cached registry tokens on logout (#5382)
thv registry logout only deleted the refresh-token key and its derived fallback. The shared OAuthTokenSource also persists an access-token cache under "<key>_AT", and its tier-2 lookup reads that key directly. The cached access token survived logout, so the next thv registry login short-circuited through the cache instead of triggering a fresh browser flow. Replace the targeted deletes with a list-and-bulk-delete over every secret visible to the registry-scoped provider, mirroring pkg/llm.DeleteCachedTokens. This also catches stale entries left behind under derived keys after a registry URL or issuer change. Closes #5373
1 parent a7aaf31 commit 394b2e7

2 files changed

Lines changed: 119 additions & 41 deletions

File tree

pkg/registry/auth/login.go

Lines changed: 34 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -97,6 +97,14 @@ func Login(
9797
}
9898

9999
// Logout clears cached OAuth credentials for the configured registry.
100+
//
101+
// Every secret under the registry scope is deleted, not just the refresh-token
102+
// key the current config points at. This is intentional: the token source also
103+
// stores a cached access token under "<key>_AT" (see pkg/auth/tokensource),
104+
// and a registry/issuer change can leave stale entries under derived keys the
105+
// current config no longer points at. A targeted delete would miss both and
106+
// allow the next login to short-circuit through tier 2/3 of the token source
107+
// instead of triggering a fresh browser flow.
100108
func Logout(ctx context.Context, configProvider config.Provider, secretsProvider secrets.Provider) error {
101109
cfg, err := configProvider.LoadOrCreateConfig()
102110
if err != nil {
@@ -106,30 +114,13 @@ func Logout(ctx context.Context, configProvider config.Provider, secretsProvider
106114
return err
107115
}
108116

109-
registryURL := registryURLFromConfig(cfg)
110-
111-
if ref := cfg.RegistryAuth.OAuth.CachedRefreshTokenRef; ref != "" {
112-
if err := secretsProvider.DeleteSecret(ctx, ref); err != nil && !secrets.IsNotFoundError(err) {
113-
return fmt.Errorf("deleting cached token: %w", err)
114-
}
115-
}
116-
117-
// Also attempt cleanup using the derived key as a fallback. If
118-
// updateConfigTokenRef failed previously (it only logs a warning),
119-
// the secret may exist under this key even when CachedRefreshTokenRef
120-
// is empty or points to a different reference.
121-
if cfg.RegistryAuth.OAuth.Issuer != "" {
122-
derivedKey := DeriveSecretKey(registryURL, cfg.RegistryAuth.OAuth.Issuer)
123-
if derivedKey != cfg.RegistryAuth.OAuth.CachedRefreshTokenRef {
124-
if err := secretsProvider.DeleteSecret(ctx, derivedKey); err != nil && !secrets.IsNotFoundError(err) {
125-
slog.Debug("failed to delete derived secret key", "error", err)
126-
}
127-
}
117+
if err := deleteAllScopedSecrets(ctx, secretsProvider); err != nil {
118+
return fmt.Errorf("deleting cached tokens: %w", err)
128119
}
129120

130121
// Clear the persistent registry cache so authenticated data doesn't
131122
// remain on disk after logout.
132-
if err := clearRegistryCache(registryURL); err != nil {
123+
if err := clearRegistryCache(registryURLFromConfig(cfg)); err != nil {
133124
slog.Debug("failed to clear registry cache", "error", err)
134125
}
135126

@@ -142,6 +133,29 @@ func Logout(ctx context.Context, configProvider config.Provider, secretsProvider
142133
})
143134
}
144135

136+
// deleteAllScopedSecrets removes every secret visible to the given (already
137+
// scope-restricted) provider. Mirrors pkg/llm.DeleteCachedTokens — providers
138+
// that cannot list or delete (e.g. the environment provider) cannot hold cached
139+
// tokens, so the operation is a no-op there.
140+
func deleteAllScopedSecrets(ctx context.Context, provider secrets.Provider) error {
141+
caps := provider.Capabilities()
142+
if !caps.CanList || !caps.CanDelete {
143+
return nil
144+
}
145+
descs, err := provider.ListSecrets(ctx)
146+
if err != nil {
147+
return fmt.Errorf("listing cached tokens: %w", err)
148+
}
149+
if len(descs) == 0 {
150+
return nil
151+
}
152+
names := make([]string, len(descs))
153+
for i, d := range descs {
154+
names[i] = d.Key
155+
}
156+
return provider.DeleteSecrets(ctx, names)
157+
}
158+
145159
// validateOAuthConfig checks that registry OAuth authentication is configured.
146160
func validateOAuthConfig(cfg *config.Config) error {
147161
if cfg.RegistryAuth.Type != config.RegistryAuthTypeOAuth || cfg.RegistryAuth.OAuth == nil {

pkg/registry/auth/login_test.go

Lines changed: 85 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import (
1616
"github.com/stacklok/toolhive/pkg/auth/remote"
1717
"github.com/stacklok/toolhive/pkg/config"
1818
configmocks "github.com/stacklok/toolhive/pkg/config/mocks"
19+
"github.com/stacklok/toolhive/pkg/secrets"
1920
secretsmocks "github.com/stacklok/toolhive/pkg/secrets/mocks"
2021
)
2122

@@ -647,24 +648,43 @@ func TestLogout(t *testing.T) {
647648
})
648649
}
649650

650-
// TestLogout_DeletesCachedToken cannot be parallel because it uses t.Setenv.
651-
func TestLogout_DeletesCachedToken(t *testing.T) {
651+
// fullCaps is the set of capabilities a real keyring/encrypted provider exposes:
652+
// every operation is supported, so Logout takes the list-and-delete fast path.
653+
var fullCaps = secrets.ProviderCapabilities{CanRead: true, CanWrite: true, CanDelete: true, CanList: true}
654+
655+
// TestLogout_DeletesAllScopedSecrets cannot be parallel because it uses t.Setenv.
656+
//
657+
// Verifies that Logout wipes every secret the (already scope-restricted)
658+
// provider can see — both the refresh token and the access-token cache entry
659+
// stored under "<key>_AT" by the token source. The original bug (#5373) was
660+
// that the "_AT" entry survived logout and let the next login short-circuit
661+
// through the access-token cache without a browser flow.
662+
func TestLogout_DeletesAllScopedSecrets(t *testing.T) {
652663
tmpDir := resolvedTempDir(t)
653664
t.Setenv("XDG_CACHE_HOME", tmpDir)
654665

655666
cfg := configWithOAuth()
656-
cfg.RegistryAuth.OAuth.CachedRefreshTokenRef = "my-token-ref"
667+
cfg.RegistryAuth.OAuth.CachedRefreshTokenRef = "REGISTRY_OAUTH_abc"
657668
cfg.RegistryAuth.OAuth.CachedTokenExpiry = time.Now().Add(time.Hour)
658669

659670
ctrl := gomock.NewController(t)
660671
mockCfg := configmocks.NewMockProvider(ctrl)
661672
mockCfg.EXPECT().LoadOrCreateConfig().Return(cfg, nil)
662673

663674
mockSecrets := secretsmocks.NewMockProvider(ctrl)
664-
mockSecrets.EXPECT().DeleteSecret(gomock.Any(), "my-token-ref").Return(nil)
665-
// Derived key fallback: DeriveSecretKey(registryURL, issuer) differs from "my-token-ref".
666-
derivedKey := DeriveSecretKey(cfg.RegistryApiUrl, cfg.RegistryAuth.OAuth.Issuer)
667-
mockSecrets.EXPECT().DeleteSecret(gomock.Any(), derivedKey).Return(nil)
675+
mockSecrets.EXPECT().Capabilities().Return(fullCaps)
676+
// A scoped provider strips the "__thv_registry_" prefix from listed keys;
677+
// the bug surfaces when the "_AT" entry is present alongside the refresh token.
678+
mockSecrets.EXPECT().ListSecrets(gomock.Any()).Return([]secrets.SecretDescription{
679+
{Key: "REGISTRY_OAUTH_abc"},
680+
{Key: "REGISTRY_OAUTH_abc_AT"},
681+
{Key: "REGISTRY_OAUTH_stale"}, // left over from an earlier issuer/registry config
682+
}, nil)
683+
mockSecrets.EXPECT().DeleteSecrets(gomock.Any(), gomock.InAnyOrder([]string{
684+
"REGISTRY_OAUTH_abc",
685+
"REGISTRY_OAUTH_abc_AT",
686+
"REGISTRY_OAUTH_stale",
687+
})).Return(nil)
668688

669689
mockCfg.EXPECT().UpdateConfig(gomock.Any()).DoAndReturn(func(fn func(*config.Config) error) error {
670690
require.NoError(t, fn(cfg))
@@ -676,46 +696,91 @@ func TestLogout_DeletesCachedToken(t *testing.T) {
676696
require.NoError(t, Logout(context.Background(), mockCfg, mockSecrets))
677697
}
678698

679-
// TestLogout_NoCachedRefSkipsDelete cannot be parallel because it uses t.Setenv.
680-
func TestLogout_NoCachedRefSkipsDelete(t *testing.T) {
699+
// TestLogout_NoSecretsIsNoop cannot be parallel because it uses t.Setenv.
700+
func TestLogout_NoSecretsIsNoop(t *testing.T) {
701+
tmpDir := resolvedTempDir(t)
702+
t.Setenv("XDG_CACHE_HOME", tmpDir)
703+
704+
cfg := configWithOAuth()
705+
706+
ctrl := gomock.NewController(t)
707+
mockCfg := configmocks.NewMockProvider(ctrl)
708+
mockCfg.EXPECT().LoadOrCreateConfig().Return(cfg, nil)
709+
710+
mockSecrets := secretsmocks.NewMockProvider(ctrl)
711+
mockSecrets.EXPECT().Capabilities().Return(fullCaps)
712+
mockSecrets.EXPECT().ListSecrets(gomock.Any()).Return(nil, nil)
713+
// DeleteSecrets is not expected — the list is empty, nothing to delete.
714+
715+
mockCfg.EXPECT().UpdateConfig(gomock.Any()).Return(nil)
716+
717+
require.NoError(t, Logout(context.Background(), mockCfg, mockSecrets))
718+
}
719+
720+
// TestLogout_ReadOnlyProviderSkipsCleanup cannot be parallel because it uses t.Setenv.
721+
//
722+
// Providers like EnvironmentProvider cannot list or delete; they also cannot
723+
// hold cached tokens, so cleanup is a no-op rather than an error.
724+
func TestLogout_ReadOnlyProviderSkipsCleanup(t *testing.T) {
681725
tmpDir := resolvedTempDir(t)
682726
t.Setenv("XDG_CACHE_HOME", tmpDir)
683727

684728
cfg := configWithOAuth()
685-
cfg.RegistryAuth.OAuth.CachedRefreshTokenRef = ""
686729

687730
ctrl := gomock.NewController(t)
688731
mockCfg := configmocks.NewMockProvider(ctrl)
689732
mockCfg.EXPECT().LoadOrCreateConfig().Return(cfg, nil)
690733

691734
mockSecrets := secretsmocks.NewMockProvider(ctrl)
692-
// No CachedRefreshTokenRef, but derived key fallback fires.
693-
derivedKey := DeriveSecretKey(cfg.RegistryApiUrl, cfg.RegistryAuth.OAuth.Issuer)
694-
mockSecrets.EXPECT().DeleteSecret(gomock.Any(), derivedKey).Return(nil)
735+
mockSecrets.EXPECT().Capabilities().Return(secrets.ProviderCapabilities{CanRead: true})
736+
// ListSecrets / DeleteSecrets are not expected.
695737

696738
mockCfg.EXPECT().UpdateConfig(gomock.Any()).Return(nil)
697739

698740
require.NoError(t, Logout(context.Background(), mockCfg, mockSecrets))
699741
}
700742

701-
// TestLogout_DeleteSecretError cannot be parallel because it uses t.Setenv.
702-
func TestLogout_DeleteSecretError(t *testing.T) {
743+
// TestLogout_ListSecretsError cannot be parallel because it uses t.Setenv.
744+
func TestLogout_ListSecretsError(t *testing.T) {
703745
tmpDir := resolvedTempDir(t)
704746
t.Setenv("XDG_CACHE_HOME", tmpDir)
705747

706748
cfg := configWithOAuth()
707-
cfg.RegistryAuth.OAuth.CachedRefreshTokenRef = "token-ref"
708749

709750
ctrl := gomock.NewController(t)
710751
mockCfg := configmocks.NewMockProvider(ctrl)
711752
mockCfg.EXPECT().LoadOrCreateConfig().Return(cfg, nil)
712753

713754
mockSecrets := secretsmocks.NewMockProvider(ctrl)
714-
mockSecrets.EXPECT().DeleteSecret(gomock.Any(), "token-ref").Return(errors.New("vault locked"))
755+
mockSecrets.EXPECT().Capabilities().Return(fullCaps)
756+
mockSecrets.EXPECT().ListSecrets(gomock.Any()).Return(nil, errors.New("vault locked"))
757+
758+
err := Logout(context.Background(), mockCfg, mockSecrets)
759+
require.Error(t, err)
760+
require.Contains(t, err.Error(), "deleting cached tokens")
761+
}
762+
763+
// TestLogout_DeleteSecretsError cannot be parallel because it uses t.Setenv.
764+
func TestLogout_DeleteSecretsError(t *testing.T) {
765+
tmpDir := resolvedTempDir(t)
766+
t.Setenv("XDG_CACHE_HOME", tmpDir)
767+
768+
cfg := configWithOAuth()
769+
770+
ctrl := gomock.NewController(t)
771+
mockCfg := configmocks.NewMockProvider(ctrl)
772+
mockCfg.EXPECT().LoadOrCreateConfig().Return(cfg, nil)
773+
774+
mockSecrets := secretsmocks.NewMockProvider(ctrl)
775+
mockSecrets.EXPECT().Capabilities().Return(fullCaps)
776+
mockSecrets.EXPECT().ListSecrets(gomock.Any()).Return([]secrets.SecretDescription{
777+
{Key: "REGISTRY_OAUTH_abc"},
778+
}, nil)
779+
mockSecrets.EXPECT().DeleteSecrets(gomock.Any(), gomock.Any()).Return(errors.New("vault locked"))
715780

716781
err := Logout(context.Background(), mockCfg, mockSecrets)
717782
require.Error(t, err)
718-
require.Contains(t, err.Error(), "deleting cached token")
783+
require.Contains(t, err.Error(), "deleting cached tokens")
719784
}
720785

721786
// TestLogout_UpdateConfigError cannot be parallel because it uses t.Setenv.
@@ -730,9 +795,8 @@ func TestLogout_UpdateConfigError(t *testing.T) {
730795
mockCfg.EXPECT().LoadOrCreateConfig().Return(cfg, nil)
731796

732797
mockSecrets := secretsmocks.NewMockProvider(ctrl)
733-
// Derived key fallback fires since CachedRefreshTokenRef is empty.
734-
derivedKey := DeriveSecretKey(cfg.RegistryApiUrl, cfg.RegistryAuth.OAuth.Issuer)
735-
mockSecrets.EXPECT().DeleteSecret(gomock.Any(), derivedKey).Return(nil)
798+
mockSecrets.EXPECT().Capabilities().Return(fullCaps)
799+
mockSecrets.EXPECT().ListSecrets(gomock.Any()).Return(nil, nil)
736800

737801
mockCfg.EXPECT().UpdateConfig(gomock.Any()).Return(errors.New("write failed"))
738802

0 commit comments

Comments
 (0)