Repository: akuity/kargo

Issue:
GHE base url not being used
# Checklist

* [x] I've searched the issue queue to verify this is not a duplicate bug report.
* [x] I've included steps to reproduce the bug.
* [x] I've pasted the output of `kargo version`.
* [x] I've pasted logs, if applicable.

# Description

I'm trying to hook up Kargo with a Github Enterprise repo. However, after adding the app token, I'm seeing this error on the warehouse page: 
```
Unable to discover artifacts: error discovering commits: 
error obtaining credentials for git repo "https://github.rbx.com/org/deployment": 
error getting installation access token: 
error getting installation access token:
 POST https://api.github.com/app/installations/515/access_tokens: 
401 A JSON web token could not be decoded []
```

Supposedly, this should've been fixed by https://github.com/akuity/kargo/issues/1355, however, in the message above, it's clear that it's trying to post to api.github.com when my repo is at github.rbx.com. The correct url is api.github.rbx.com. 

# Version

This is the Kargo image I'm running: 
```
ghcr.io/akuity/kargo:v1.3.0 
```

# Logs

```
level=error msg="Reconciler error" Warehouse="{ray-infra ray-infra}" controller=wa
rehouse controllerGroup=kargo.akuity.io controllerKind=Warehouse error="error discovering artifacts: error dis
covering commits: error obtaining credentials for git repo \"https://github.rbx.com/Roblox/ml-platform-deploym
ent\": error getting installation access token: error getting installation access token: POST https://api.gith
ub.com/app/installations/515/access_tokens: 401 A JSON web token could not be decoded []" name=ray-infra names
pace=ray-infra reconcileID="\"5c208fc9-1007-42c2-85a1-90178c05b092\""
```


Discussion:
@hiddeco: I believe this _somehow_ may have been solved for actions on pull requests, but not for clone operations. As we do not appear to do anything here to detect or configure enterprise URLs: https://github.com/akuity/kargo/blob/413063fe13d3fc1199bb4a34df9b3ee40a9a536a/internal/credentials/kubernetes/github/app.go#L147-L155@han-steve: Thanks that's a good catch! 

Solution patch:
diff --git a/internal/controller/warehouses/git.go b/internal/controller/warehouses/git.go
index 52f9396c..9fe20eeb 100644
--- a/internal/controller/warehouses/git.go
+++ b/internal/controller/warehouses/git.go
@@ -65,7 +65,7 @@ func (r *reconciler) discoverCommits(
 		repoLogger := logger.WithValues("repo", sub.RepoURL)
 
 		// Obtain credentials for the Git repository.
-		creds, ok, err := r.credentialsDB.Get(ctx, namespace, credentials.TypeGit, sub.RepoURL)
+		creds, err := r.credentialsDB.Get(ctx, namespace, credentials.TypeGit, sub.RepoURL)
 		if err != nil {
 			return nil, fmt.Errorf(
 				"error obtaining credentials for git repo %q: %w",
@@ -74,7 +74,7 @@ func (r *reconciler) discoverCommits(
 			)
 		}
 		var repoCreds *git.RepoCredentials
-		if ok {
+		if creds != nil {
 			repoCreds = &git.RepoCredentials{
 				Username:      creds.Username,
 				Password:      creds.Password,
diff --git a/internal/controller/warehouses/helm.go b/internal/controller/warehouses/helm.go
index f420b243..6d2639f1 100644
--- a/internal/controller/warehouses/helm.go
+++ b/internal/controller/warehouses/helm.go
@@ -29,7 +29,7 @@ func (r *reconciler) discoverCharts(
 			logger = logger.WithValues("chart", sub.Name)
 		}
 
-		creds, ok, err := r.credentialsDB.Get(ctx, namespace, credentials.TypeHelm, sub.RepoURL)
+		creds, err := r.credentialsDB.Get(ctx, namespace, credentials.TypeHelm, sub.RepoURL)
 		if err != nil {
 			return nil, fmt.Errorf(
 				"error obtaining credentials for chart repository %q: %w",
@@ -38,7 +38,7 @@ func (r *reconciler) discoverCharts(
 			)
 		}
 		var helmCreds *helm.Credentials
-		if ok {
+		if creds != nil {
 			helmCreds = &helm.Credentials{
 				Username: creds.Username,
 				Password: creds.Password,
diff --git a/internal/controller/warehouses/images.go b/internal/controller/warehouses/images.go
index e42bacad..78394dec 100644
--- a/internal/controller/warehouses/images.go
+++ b/internal/controller/warehouses/images.go
@@ -33,7 +33,7 @@ func (r *reconciler) discoverImages(
 		logger := logging.LoggerFromContext(ctx).WithValues("repo", sub.RepoURL)
 
 		// Obtain credentials for the image repository.
-		creds, ok, err := r.credentialsDB.Get(ctx, namespace, credentials.TypeImage, sub.RepoURL)
+		creds, err := r.credentialsDB.Get(ctx, namespace, credentials.TypeImage, sub.RepoURL)
 		if err != nil {
 			return nil, fmt.Errorf(
 				"error obtaining credentials for image repo %q: %w",
@@ -42,7 +42,7 @@ func (r *reconciler) discoverImages(
 			)
 		}
 		var regCreds *image.Credentials
-		if ok {
+		if creds != nil {
 			regCreds = &image.Credentials{
 				Username: creds.Username,
 				Password: creds.Password,
diff --git a/internal/credentials/credentials.go b/internal/credentials/credentials.go
index ee0cfd7b..90c81bda 100644
--- a/internal/credentials/credentials.go
+++ b/internal/credentials/credentials.go
@@ -2,8 +2,6 @@ package credentials
 
 import (
 	"context"
-
-	corev1 "k8s.io/api/core/v1"
 )
 
 const (
@@ -43,10 +41,22 @@ type Credentials struct {
 	SSHPrivateKey string
 }
 
-type Helper func(
-	ctx context.Context,
-	project string,
-	credType Type,
-	repoURL string,
-	secret *corev1.Secret,
-) (*Credentials, error)
+// Provider is an interface for providing credentials for a given type,
+// repository URL and data values.
+type Provider interface {
+	// Supports returns true if the provider can provide credentials for the
+	// given type, repository URL and data values. Otherwise, it should return
+	// false.
+	Supports(credType Type, repoURL string, data map[string][]byte) bool
+
+	// GetCredentials returns the credentials for the given type, repository URL
+	// and data values. If the provider cannot provide credentials for the given
+	// type, repository URL and data, it should return nil.
+	GetCredentials(
+		ctx context.Context,
+		project string,
+		credType Type,
+		repoURL string,
+		data map[string][]byte,
+	) (*Credentials, error)
+}
diff --git a/internal/credentials/database.go b/internal/credentials/database.go
index f46461e5..0e52e5ee 100644
--- a/internal/credentials/database.go
+++ b/internal/credentials/database.go
@@ -9,7 +9,7 @@ type Database interface {
 		namespace string,
 		credType Type,
 		repo string,
-	) (Credentials, bool, error)
+	) (*Credentials, error)
 }
 
 // FakeDB is a mock implementation of the Database interface that is used to
@@ -20,7 +20,7 @@ type FakeDB struct {
 		namespace string,
 		credType Type,
 		repo string,
-	) (Credentials, bool, error)
+	) (*Credentials, error)
 }
 
 func (f *FakeDB) Get(
@@ -28,9 +28,9 @@ func (f *FakeDB) Get(
 	namespace string,
 	credType Type,
 	repo string,
-) (Credentials, bool, error) {
+) (*Credentials, error) {
 	if f.GetFn == nil {
-		return Credentials{}, false, nil
+		return nil, nil
 	}
 	return f.GetFn(ctx, namespace, credType, repo)
 }
diff --git a/internal/credentials/kubernetes/basic/basic.go b/internal/credentials/kubernetes/basic/basic.go
index d8b7e3cb..8cea568e 100644
--- a/internal/credentials/kubernetes/basic/basic.go
+++ b/internal/credentials/kubernetes/basic/basic.go
@@ -3,29 +3,40 @@ package basic
 import (
 	"context"
 
-	corev1 "k8s.io/api/core/v1"
-
 	"github.com/akuity/kargo/internal/credentials"
 )
 
-// SecretToCreds is an implementation of credentials.Helper that simply extracts
-// a username, password, and SSH private key from a secret.
-func SecretToCreds(
+const (
+	usernameKey   = "username"
+	passwordKey   = "password"
+	sshPrivateKey = "sshPrivateKey"
+)
+
+type CredentialProvider struct{}
+
+func (*CredentialProvider) Supports(
+	_ credentials.Type, _ string, data map[string][]byte,
+) bool {
+	return len(data) > 0 &&
+		(data[usernameKey] != nil && data[passwordKey] != nil) ||
+		data[sshPrivateKey] != nil
+}
+
+func (p *CredentialProvider) GetCredentials(
 	_ context.Context,
 	_ string,
-	_ credentials.Type,
-	_ string,
-	secret *corev1.Secret,
+	credType credentials.Type,
+	repoURL string,
+	data map[string][]byte,
 ) (*credentials.Credentials, error) {
-	if secret == nil {
-		// This helper can't handle this
+	if !p.Supports(credType, repoURL, data) {
 		return nil, nil
 	}
 
 	creds := &credentials.Credentials{
-		Username:      string(secret.Data["username"]),
-		Password:      string(secret.Data["password"]),
-		SSHPrivateKey: string(secret.Data["sshPrivateKey"]),
+		Username:      string(data[usernameKey]),
+		Password:      string(data[passwordKey]),
+		SSHPrivateKey: string(data[sshPrivateKey]),
 	}
 	if (creds.Username != "" && creds.Password != "") ||
 		creds.SSHPrivateKey != "" {
diff --git a/internal/credentials/kubernetes/database.go b/internal/credentials/kubernetes/database.go
index 436ef844..0dace3fc 100644
--- a/internal/credentials/kubernetes/database.go
+++ b/internal/credentials/kubernetes/database.go
@@ -26,9 +26,9 @@ import (
 // utilizes a Kubernetes controller runtime client to retrieve credentials
 // stored in Kubernetes Secrets.
 type database struct {
-	kargoClient       client.Client
-	credentialHelpers []credentials.Helper
-	cfg               DatabaseConfig
+	kargoClient         client.Client
+	credentialProviders []credentials.Provider
+	cfg                 DatabaseConfig
 }
 
 // DatabaseConfig represents configuration for a Kubernetes based implementation
@@ -53,25 +53,27 @@ func NewDatabase(
 	kargoClient client.Client,
 	cfg DatabaseConfig,
 ) credentials.Database {
-	credentialHelpers := []credentials.Helper{
-		basic.SecretToCreds,
-		ecr.NewAccessKeyCredentialHelper(),
-		ecr.NewManagedIdentityCredentialHelper(ctx),
-		gar.NewServiceAccountKeyCredentialHelper(),
-		gar.NewWorkloadIdentityFederationCredentialHelper(ctx),
-		github.NewAppCredentialHelper(),
+	var credentialProviders = []credentials.Provider{
+		&basic.CredentialProvider{},
+		ecr.NewAccessKeyProvider(),
+		ecr.NewManagedIdentityProvider(ctx),
+		gar.NewServiceAccountKeyProvider(),
+		gar.NewWorkloadIdentityFederationProvider(ctx),
+		github.NewAppCredentialProvider(),
 	}
-	finalCredentialHelpers := make([]credentials.Helper, 0, len(credentialHelpers))
-	for _, helper := range credentialHelpers {
-		if helper != nil {
-			finalCredentialHelpers = append(finalCredentialHelpers, helper)
-		}
+
+	db := &database{
+		kargoClient: kargoClient,
+		cfg:         cfg,
 	}
-	return &database{
-		kargoClient:       kargoClient,
-		credentialHelpers: finalCredentialHelpers,
-		cfg:               cfg,
+
+	for _, p := range credentialProviders {
+		if p != nil {
+			db.credentialProviders = append(db.credentialProviders, p)
+		}
 	}
+
+	return db
 }
 
 func (k *database) Get(
@@ -79,7 +81,7 @@ func (k *database) Get(
 	namespace string,
 	credType credentials.Type,
 	repoURL string,
-) (credentials.Credentials, bool, error) {
+) (*credentials.Credentials, error) {
 	// If we are dealing with an insecure HTTP endpoint (of any type),
 	// refuse to return any credentials
 	if !k.cfg.AllowCredentialsOverHTTP && strings.HasPrefix(repoURL, "http://") {
@@ -87,7 +89,7 @@ func (k *database) Get(
 			"refused to get credentials for insecure HTTP endpoint",
 			"repoURL", repoURL,
 		)
-		return credentials.Credentials{}, false, nil
+		return nil, nil
 	}
 
 	var secret *corev1.Secret
@@ -100,7 +102,7 @@ func (k *database) Get(
 		credType,
 		repoURL,
 	); err != nil {
-		return credentials.Credentials{}, false, err
+		return nil, err
 	}
 
 	if secret == nil {
@@ -112,7 +114,7 @@ func (k *database) Get(
 				credType,
 				repoURL,
 			); err != nil {
-				return credentials.Credentials{}, false, err
+				return nil, err
 			}
 			if secret != nil {
 				break
@@ -120,17 +122,22 @@ func (k *database) Get(
 		}
 	}
 
-	for _, helper := range k.credentialHelpers {
-		creds, err := helper(ctx, namespace, credType, repoURL, secret)
+	var data map[string][]byte
+	if secret != nil {
+		data = secret.Data
+	}
+
+	for _, p := range k.credentialProviders {
+		creds, err := p.GetCredentials(ctx, namespace, credType, repoURL, data)
 		if err != nil {
-			return credentials.Credentials{}, false, err
+			return nil, err
 		}
 		if creds != nil {
-			return *creds, true, nil
+			return creds, nil
 		}
 	}
 
-	return credentials.Credentials{}, false, nil
+	return nil, nil
 }
 
 func (k *database) getCredentialsSecret(
diff --git a/internal/credentials/kubernetes/ecr/access_key.go b/internal/credentials/kubernetes/ecr/access_key.go
index d2edd008..5fca2851 100644
--- a/internal/credentials/kubernetes/ecr/access_key.go
+++ b/internal/credentials/kubernetes/ecr/access_key.go
@@ -2,7 +2,6 @@ package ecr
 
 import (
 	"context"
-	"crypto/sha256"
 	"encoding/base64"
 	"fmt"
 	"strings"
@@ -12,7 +11,6 @@ import (
 	awscreds "github.com/aws/aws-sdk-go-v2/credentials"
 	"github.com/aws/aws-sdk-go-v2/service/ecr"
 	"github.com/patrickmn/go-cache"
-	corev1 "k8s.io/api/core/v1"
 
 	"github.com/akuity/kargo/internal/credentials"
 )
@@ -23,118 +21,101 @@ const (
 	secretKey = "awsSecretAccessKey"
 )
 
-type accessKeyCredentialHelper struct {
+type AccessKeyProvider struct {
 	tokenCache *cache.Cache
 
-	// The following behaviors are overridable for testing purposes:
-
-	getAuthTokenFn func(
-		ctx context.Context,
-		region string,
-		accessKeyID string,
-		secretAccessKey string,
-	) (string, error)
+	getAuthTokenFn func(ctx context.Context, region, accessKeyID, secretAccessKey string) (string, error)
 }
 
-// NewAccessKeyCredentialHelper returns an implementation of credentials.Helper
-// that utilizes a cache to avoid unnecessary calls to AWS.
-func NewAccessKeyCredentialHelper() credentials.Helper {
-	a := &accessKeyCredentialHelper{
+func NewAccessKeyProvider() *AccessKeyProvider {
+	p := &AccessKeyProvider{
 		tokenCache: cache.New(
 			// Tokens live for 12 hours. We'll hang on to them for 10.
 			10*time.Hour, // Default ttl for each entry
 			time.Hour,    // Cleanup interval
 		),
 	}
-	a.getAuthTokenFn = a.getAuthToken
-	return a.getCredentials
+	p.getAuthTokenFn = p.getAuthToken
+	return p
 }
 
-func (a *accessKeyCredentialHelper) getCredentials(
-	ctx context.Context,
-	_ string,
-	credType credentials.Type,
-	repoURL string,
-	secret *corev1.Secret,
-) (*credentials.Credentials, error) {
-	if (credType != credentials.TypeImage && credType != credentials.TypeHelm) || secret == nil {
-		// This helper can't handle this
-		return nil, nil
+func (p *AccessKeyProvider) Supports(credType credentials.Type, repoURL string, data map[string][]byte) bool {
+	if (credType != credentials.TypeImage && credType != credentials.TypeHelm) || len(data) == 0 {
+		return false
 	}
 
 	if credType == credentials.TypeHelm && !strings.HasPrefix(repoURL, "oci://") {
-		// Only OCI Helm repos are supported in ECR
-		return nil, nil
+		return false
 	}
 
-	matches := ecrURLRegex.FindStringSubmatch(repoURL)
-	if len(matches) != 2 { // This doesn't look like an ECR URL
-		return nil, nil
+	if matches := ecrURLRegex.FindStringSubmatch(repoURL); len(matches) != 2 {
+		return false
 	}
 
-	region := string(secret.Data[regionKey])
-	accessKeyID := string(secret.Data[idKey])
-	secretAccessKey := string(secret.Data[secretKey])
-	if region == "" && accessKeyID == "" && secretAccessKey == "" {
-		// None of these fields are set, so there's nothing to do here.
+	return data[regionKey] != nil && data[idKey] != nil && data[secretKey] != nil
+}
+
+func (p *AccessKeyProvider) GetCredentials(
+	ctx context.Context,
+	_ string,
+	credType credentials.Type,
+	repoURL string,
+	data map[string][]byte,
+) (*credentials.Credentials, error) {
+	if !p.Supports(credType, repoURL, data) {
 		return nil, nil
 	}
-	// If we get to here, at least one of the fields is set. Now if they aren't
-	// all set, we should return an error.
-	if region == "" || accessKeyID == "" || secretAccessKey == "" {
-		return nil, fmt.Errorf(
-			"%s, %s, and %s must all be set or all be unset",
-			regionKey, idKey, secretKey,
-		)
-	}
 
-	cacheKey := a.tokenCacheKey(region, accessKeyID, secretAccessKey)
+	var (
+		region, accessKeyID, secretAccessKey = string(data[regionKey]), string(data[idKey]), string(data[secretKey])
+		cacheKey                             = tokenCacheKey(region, accessKeyID, secretAccessKey)
+	)
 
-	if entry, exists := a.tokenCache.Get(cacheKey); exists {
+	// Check the cache for the token
+	if entry, exists := p.tokenCache.Get(cacheKey); exists {
 		return decodeAuthToken(entry.(string)) // nolint: forcetypeassert
 	}
 
-	encodedToken, err := a.getAuthTokenFn(ctx, region, accessKeyID, secretAccessKey)
-	if err != nil {
-		return nil, fmt.Errorf("error getting ECR auth token: %w", err)
-	}
-
-	if encodedToken == "" {
-		return nil, nil
+	// Cache miss, get a new token
+	encodedToken, err := p.getAuthTokenFn(ctx, region, accessKeyID, secretAccessKey)
+	if err != nil || encodedToken == "" {
+		if err != nil {
+			err = fmt.Errorf("error getting ECR auth token: %w", err)
+		}
+		return nil, err
 	}
 
 	// Cache the encoded token
-	a.tokenCache.Set(cacheKey, encodedToken, cache.DefaultExpiration)
+	p.tokenCache.Set(cacheKey, encodedToken, cache.DefaultExpiration)
 
 	return decodeAuthToken(encodedToken)
 }
 
-// tokenCacheKey returns a cache key for an ECR authorization token. The key is
-// a hash of the region, access key ID, and secret access key. Using a hash
-// ensures that the secret access key is not stored in plaintext in the cache.
-func (a *accessKeyCredentialHelper) tokenCacheKey(region, accessKeyID, secretAccessKey string) string {
-	return fmt.Sprintf(
-		"%x",
-		sha256.Sum256([]byte(
-			fmt.Sprintf("%s:%s:%s", region, accessKeyID, secretAccessKey),
-		)),
-	)
-}
-
-// getAuthToken returns an ECR authorization token by calling out to AWS with
-// the provided credentials.
-func (a *accessKeyCredentialHelper) getAuthToken(
+// getAuthToken gets an ECR authorization token using the provided access key ID
+// and secret access key. It returns the encoded token, which is a base64 string
+// containing a username and password separated by a colon.
+func (p *AccessKeyProvider) getAuthToken(
 	ctx context.Context, region, accessKeyID, secretAccessKey string,
 ) (string, error) {
 	svc := ecr.NewFromConfig(aws.Config{
 		Region:      region,
 		Credentials: awscreds.NewStaticCredentialsProvider(accessKeyID, secretAccessKey, ""),
 	})
+
 	output, err := svc.GetAuthorizationToken(ctx, &ecr.GetAuthorizationTokenInput{})
 	if err != nil {
 		return "", fmt.Errorf("error getting ECR authorization token: %w", err)
 	}
-	return *output.AuthorizationData[0].AuthorizationToken, nil
+
+	if output == nil || len(output.AuthorizationData) == 0 {
+		return "", fmt.Errorf("no authorization data returned")
+	}
+
+	if token := output.AuthorizationData[0].AuthorizationToken; token != nil {
+		return *token, nil
+	}
+
+	return "", fmt.Errorf("no authorization token returned")
 }
 
 // decodeAuthToken decodes an ECR authorization token by base64 decoding it and
diff --git a/internal/credentials/kubernetes/ecr/common.go b/internal/credentials/kubernetes/ecr/common.go
index b55ba8cc..64404f47 100644
--- a/internal/credentials/kubernetes/ecr/common.go
+++ b/internal/credentials/kubernetes/ecr/common.go
@@ -1,5 +1,25 @@
 package ecr
 
-import "regexp"
+import (
+	"crypto/sha256"
+	"fmt"
+	"regexp"
+)
 
+// ecrURLRegex is a regex that matches ECR URLs.
 var ecrURLRegex = regexp.MustCompile(`^(?:oci://)?[0-9]{12}\.dkr\.ecr\.(.+)\.amazonaws\.com/`)
+
+// tokenCacheKey returns a cache key in the form of a hash for the given parts.
+// Using a hash ensures that any sensitive data is not stored in a decodable
+// form.
+func tokenCacheKey(parts ...string) string {
+	const separator = ":"
+	h := sha256.New()
+	for i := range parts {
+		if i > 0 {
+			_, _ = h.Write([]byte(separator))
+		}
+		_, _ = h.Write([]byte(parts[i]))
+	}
+	return fmt.Sprintf("%x", h.Sum(nil))
+}
diff --git a/internal/credentials/kubernetes/ecr/managed_identity.go b/internal/credentials/kubernetes/ecr/managed_identity.go
index 1f8a63a5..07daca6c 100644
--- a/internal/credentials/kubernetes/ecr/managed_identity.go
+++ b/internal/credentials/kubernetes/ecr/managed_identity.go
@@ -2,7 +2,6 @@ package ecr
 
 import (
 	"context"
-	"crypto/sha256"
 	"errors"
 	"fmt"
 	"net/http"
@@ -16,103 +15,115 @@ import (
 	"github.com/aws/aws-sdk-go-v2/credentials/stscreds"
 	"github.com/aws/aws-sdk-go-v2/service/ecr"
 	"github.com/aws/aws-sdk-go-v2/service/sts"
+	"github.com/hashicorp/go-cleanhttp"
 	"github.com/patrickmn/go-cache"
-	corev1 "k8s.io/api/core/v1"
 
 	"github.com/akuity/kargo/internal/credentials"
 	"github.com/akuity/kargo/internal/logging"
 )
 
-type managedIdentityCredentialHelper struct {
-	awsAccountID string
+const roleARNFormat = "arn:aws:iam::%s:role/kargo-project-%s"
 
+type ManagedIdentityProvider struct {
 	tokenCache *cache.Cache
 
-	// The following behaviors are overridable for testing purposes:
+	accountID string
 
-	getAuthTokenFn func(
-		ctx context.Context,
-		region string,
-		project string,
-	) (string, error)
+	getAuthTokenFn func(ctx context.Context, region, project string) (string, error)
 }
 
-// NewManagedIdentityCredentialHelper returns an implementation of
-// credentials.Helper that utilizes a cache to avoid unnecessary calls to AWS.
-func NewManagedIdentityCredentialHelper(ctx context.Context) credentials.Helper {
+func NewManagedIdentityProvider(ctx context.Context) *ManagedIdentityProvider {
 	logger := logging.LoggerFromContext(ctx)
-	var awsAccountID string
-	if os.Getenv("AWS_CONTAINER_CREDENTIALS_FULL_URI") != "" {
+
+	switch {
+	case os.Getenv("AWS_CONTAINER_CREDENTIALS_FULL_URI") != "":
 		logger.Info("EKS Pod Identity appears to be in use")
-	} else if os.Getenv("AWS_ROLE_ARN") != "" && os.Getenv("AWS_WEB_IDENTITY_TOKEN_FILE") != "" {
+	case os.Getenv("AWS_ROLE_ARN") != "" && os.Getenv("AWS_WEB_IDENTITY_TOKEN_FILE") != "":
 		logger.Info("AWS_WEB_IDENTITY_TOKEN_FILE and AWS_ROLE_ARN set; assuming IRSA is being used")
-	} else {
+	default:
 		logger.Info("Neither AWS_CONTAINER_CREDENTIALS_FULL_URI nor AWS_WEB_IDENTITY_TOKEN_FILE " +
 			"and AWS_ROLE_ARN are set; assuming neither EKS Pod Identity nor IRSA are in use")
 		return nil
 	}
+
+	var awsAccountID string
 	cfg, err := config.LoadDefaultConfig(ctx)
 	if err != nil {
 		logger.Error(
 			err, "error loading AWS config; AWS credentials integration will be disabled",
 		)
-	} else {
-		stsSvc := sts.NewFromConfig(cfg)
-		res, err := stsSvc.GetCallerIdentity(ctx, &sts.GetCallerIdentityInput{})
-		if err != nil {
-			logger.Error(
-				err, "error getting caller identity; AWS credentials integration will be disabled",
-			)
-		} else {
-			logger.Debug(
-				"got AWS account ID",
-				"account", *res.Account,
-			)
-			awsAccountID = *res.Account
-		}
+		return nil
 	}
-	p := &managedIdentityCredentialHelper{
-		awsAccountID: awsAccountID,
+	cfg.HTTPClient = cleanhttp.DefaultClient()
+
+	stsSvc := sts.NewFromConfig(cfg)
+	res, err := stsSvc.GetCallerIdentity(ctx, &sts.GetCallerIdentityInput{})
+	if err != nil {
+		logger.Error(
+			err, "error getting caller identity; AWS credentials integration will be disabled",
+		)
+		return nil
+	}
+
+	logger.Debug("got AWS account ID", "account", *res.Account)
+	awsAccountID = *res.Account
+
+	p := &ManagedIdentityProvider{
 		tokenCache: cache.New(
 			// Tokens live for 12 hours. We'll hang on to them for 10.
 			10*time.Hour, // Default ttl for each entry
 			time.Hour,    // Cleanup interval
 		),
+		accountID: awsAccountID,
 	}
 	p.getAuthTokenFn = p.getAuthToken
-	return p.getCredentials
+	return p
+}
+
+func (p *ManagedIdentityProvider) Supports(credType credentials.Type, repoURL string, _ map[string][]byte) bool {
+	if p.accountID == "" {
+		return false
+	}
+
+	if credType != credentials.TypeImage && credType != credentials.TypeHelm {
+		return false
+	}
+
+	if credType == credentials.TypeHelm && !strings.HasPrefix(repoURL, "oci://") {
+		return false
+	}
+
+	return true
 }
 
-func (p *managedIdentityCredentialHelper) getCredentials(
+func (p *ManagedIdentityProvider) GetCredentials(
 	ctx context.Context,
 	project string,
 	credType credentials.Type,
 	repoURL string,
-	_ *corev1.Secret,
+	_ map[string][]byte,
 ) (*credentials.Credentials, error) {
-	if (credType != credentials.TypeImage && credType != credentials.TypeHelm) ||
-		p.awsAccountID == "" { // Pod Identity isn't set up for this controller
-		// This helper can't handle this
-		return nil, nil
-	}
-
-	if credType == credentials.TypeHelm && !strings.HasPrefix(repoURL, "oci://") {
-		// Only OCI Helm repos are supported in ECR
+	if !p.Supports(credType, repoURL, nil) {
 		return nil, nil
 	}
 
+	// Extract the region from the ECR URL
 	matches := ecrURLRegex.FindStringSubmatch(repoURL)
 	if len(matches) != 2 { // This doesn't look like an ECR URL
 		return nil, nil
 	}
-	region := matches[1]
 
-	cacheKey := p.tokenCacheKey(region, project)
+	var (
+		region   = matches[1]
+		cacheKey = tokenCacheKey(region, project)
+	)
 
+	// Check the cache for the token
 	if entry, exists := p.tokenCache.Get(cacheKey); exists {
 		return decodeAuthToken(entry.(string)) // nolint: forcetypeassert
 	}
 
+	// Cache miss, get a new token
 	encodedToken, err := p.getAuthTokenFn(ctx, region, project)
 	if err != nil {
 		// This might mean the controller's IAM role isn't authorized to assume the
@@ -123,6 +134,7 @@ func (p *managedIdentityCredentialHelper) getCredentials(
 		return nil, fmt.Errorf("error getting ECR auth token: %w", err)
 	}
 
+	// If we didn't get a token, we'll treat this as no credentials found
 	if encodedToken == "" {
 		return nil, nil
 	}
@@ -133,41 +145,34 @@ func (p *managedIdentityCredentialHelper) getCredentials(
 	return decodeAuthToken(encodedToken)
 }
 
-func (p *managedIdentityCredentialHelper) tokenCacheKey(region, project string) string {
-	return fmt.Sprintf(
-		"%x",
-		sha256.Sum256([]byte(
-			fmt.Sprintf("%s:%s", region, project),
-		)),
-	)
-}
-
 // getAuthToken returns an ECR authorization token obtained by assuming a
 // project-specific IAM role and using that to obtain a short-lived ECR access
 // token.
-func (p *managedIdentityCredentialHelper) getAuthToken(
-	ctx context.Context,
-	region string,
-	project string,
-) (string, error) {
+func (p *ManagedIdentityProvider) getAuthToken(ctx context.Context, region, project string) (string, error) {
 	logger := logging.LoggerFromContext(ctx)
+
 	cfg, err := config.LoadDefaultConfig(ctx)
 	if err != nil {
 		logger.Error(err, "error loading AWS config")
 		return "", nil
 	}
+	cfg.HTTPClient = cleanhttp.DefaultClient()
+
 	ecrSvc := ecr.NewFromConfig(aws.Config{
-		Region: region,
+		HTTPClient: cleanhttp.DefaultClient(),
+		Region:     region,
 		Credentials: stscreds.NewAssumeRoleProvider(
 			sts.NewFromConfig(cfg),
-			fmt.Sprintf("arn:aws:iam::%s:role/kargo-project-%s", p.awsAccountID, project),
+			fmt.Sprintf(roleARNFormat, p.accountID, project),
 		),
 	})
+
 	logger = logger.WithValues(
-		"awsAccountID", p.awsAccountID,
+		"awsAccountID", p.accountID,
 		"awsRegion", region,
 		"project", project,
 	)
+
 	output, err := ecrSvc.GetAuthorizationToken(ctx, &ecr.GetAuthorizationTokenInput{})
 	if err != nil {
 		var re *awshttp.ResponseError
@@ -179,6 +184,7 @@ func (p *managedIdentityCredentialHelper) getAuthToken(
 				"or project-specific role is not authorized to obtain an ECR auth token. " +
 				"Falling back to using controller's IAM role directly.",
 		)
+
 		cfg.Region = region
 		ecrSvc = ecr.NewFromConfig(cfg)
 		output, err = ecrSvc.GetAuthorizationToken(ctx, &ecr.GetAuthorizationTokenInput{})
diff --git a/internal/credentials/kubernetes/gar/common.go b/internal/credentials/kubernetes/gar/common.go
index 5b4a6169..4de54d21 100644
--- a/internal/credentials/kubernetes/gar/common.go
+++ b/internal/credentials/kubernetes/gar/common.go
@@ -1,6 +1,10 @@
 package gar
 
-import "regexp"
+import (
+	"crypto/sha256"
+	"fmt"
+	"regexp"
+)
 
 const accessTokenUsername = "oauth2accesstoken"
 
@@ -8,3 +12,7 @@ var (
 	gcrURLRegex = regexp.MustCompile(`^(?:.+\.)?gcr\.io/`) // Legacy
 	garURLRegex = regexp.MustCompile(`^.+-docker\.pkg\.dev/`)
 )
+
+func tokenCacheKey(key string) string {
+	return fmt.Sprintf("%x", sha256.Sum256([]byte(key)))
+}
diff --git a/internal/credentials/kubernetes/gar/service_account_key.go b/internal/credentials/kubernetes/gar/service_account_key.go
index 47eef042..a8210e22 100644
--- a/internal/credentials/kubernetes/gar/service_account_key.go
+++ b/internal/credentials/kubernetes/gar/service_account_key.go
@@ -2,85 +2,89 @@ package gar
 
 import (
 	"context"
-	"crypto/sha256"
 	"encoding/base64"
 	"fmt"
 	"time"
 
 	"github.com/patrickmn/go-cache"
 	"golang.org/x/oauth2/google"
-	corev1 "k8s.io/api/core/v1"
 
 	"github.com/akuity/kargo/internal/credentials"
 )
 
-const serviceAccountKeyKey = "gcpServiceAccountKey"
+const (
+	serviceAccountKeyKey = "gcpServiceAccountKey"
 
-type serviceAccountKeyCredentialHelper struct {
-	tokenCache *cache.Cache
+	scopeStorageRead = "https://www.googleapis.com/auth/devstorage.read_only"
+)
 
-	// The following behaviors are overridable for testing purposes:
+type ServiceAccountKeyProvider struct {
+	tokenCache *cache.Cache
 
-	getAccessTokenFn func(context.Context, string) (string, error)
+	getAccessTokenFn func(ctx context.Context, encodedServiceAccountKey string) (string, error)
 }
 
-// NewServiceAccountKeyCredentialHelper returns an implementation
-// credentials.Helper that utilizes a cache to avoid unnecessary calls to GCP.
-func NewServiceAccountKeyCredentialHelper() credentials.Helper {
-	s := &serviceAccountKeyCredentialHelper{
+func NewServiceAccountKeyProvider() *ServiceAccountKeyProvider {
+	p := &ServiceAccountKeyProvider{
 		tokenCache: cache.New(
 			// Access tokens live for one hour. We'll hang on to them for 40 minutes.
 			40*time.Minute, // Default ttl for each entry
 			time.Hour,      // Cleanup interval
 		),
 	}
-	s.getAccessTokenFn = s.getAccessToken
-	return s.getCredentials
+	p.getAccessTokenFn = p.getAccessToken
+	return p
 }
 
-func (s *serviceAccountKeyCredentialHelper) getCredentials(
-	ctx context.Context,
-	_ string,
-	credType credentials.Type,
-	repoURL string,
-	secret *corev1.Secret,
-) (*credentials.Credentials, error) {
-	if credType != credentials.TypeImage || secret == nil {
-		// This helper can't handle this
-		return nil, nil
+func (p *ServiceAccountKeyProvider) Supports(credType credentials.Type, repoURL string, data map[string][]byte) bool {
+	if credType != credentials.TypeImage || data == nil || data[serviceAccountKeyKey] == nil {
+		return false
 	}
 
 	if !garURLRegex.MatchString(repoURL) && !gcrURLRegex.MatchString(repoURL) {
-		// This doesn't look like a Google Artifact Registry URL
-		return nil, nil
+		return false
 	}
 
-	// This should be base64 encoded
-	encodedServiceAccountKey := string(secret.Data[serviceAccountKeyKey])
-	if encodedServiceAccountKey == "" {
+	return true
+}
+
+func (p *ServiceAccountKeyProvider) GetCredentials(
+	ctx context.Context,
+	_ string,
+	credType credentials.Type,
+	repoURL string,
+	data map[string][]byte,
+) (*credentials.Credentials, error) {
+	if !p.Supports(credType, repoURL, data) {
 		return nil, nil
 	}
 
-	cacheKey := s.tokenCacheKey(encodedServiceAccountKey)
+	var (
+		encodedServiceAccountKey = string(data[serviceAccountKeyKey])
+		cacheKey                 = tokenCacheKey(encodedServiceAccountKey)
+	)
 
-	if entry, exists := s.tokenCache.Get(cacheKey); exists {
+	// Check the cache for the token
+	if entry, exists := p.tokenCache.Get(cacheKey); exists {
 		return &credentials.Credentials{
 			Username: accessTokenUsername,
 			Password: entry.(string), // nolint: forcetypeassert
 		}, nil
 	}
 
-	accessToken, err := s.getAccessTokenFn(ctx, encodedServiceAccountKey)
+	// Cache miss, get a new token
+	accessToken, err := p.getAccessTokenFn(ctx, encodedServiceAccountKey)
 	if err != nil {
 		return nil, fmt.Errorf("error getting GCP access token: %w", err)
 	}
 
+	// If we didn't get a token, we'll treat this as no credentials found
 	if accessToken == "" {
 		return nil, nil
 	}
 
-	// Cache the access token
-	s.tokenCache.Set(cacheKey, accessToken, cache.DefaultExpiration)
+	// Cache the token
+	p.tokenCache.Set(cacheKey, accessToken, cache.DefaultExpiration)
 
 	return &credentials.Credentials{
 		Username: accessTokenUsername,
@@ -88,19 +92,9 @@ func (s *serviceAccountKeyCredentialHelper) getCredentials(
 	}, nil
 }
 
-// tokenCacheKey returns a cache key for a GCP access token. The key is a hash
-// of the provided (encoded) service account key. Using a hash ensures that a
-// decodable service account key is not stored in the cache.
-func (s *serviceAccountKeyCredentialHelper) tokenCacheKey(encodedServiceAccountKey string) string {
-	return fmt.Sprintf(
-		"%x",
-		sha256.Sum256([]byte(encodedServiceAccountKey)),
-	)
-}
-
 // getAccessToken returns a GCP access token retrieved using the provided base64
 // encoded service account key. The access token is valid for one hour.
-func (s *serviceAccountKeyCredentialHelper) getAccessToken(
+func (p *ServiceAccountKeyProvider) getAccessToken(
 	ctx context.Context,
 	encodedServiceAccountKey string,
 ) (string, error) {
@@ -108,10 +102,12 @@ func (s *serviceAccountKeyCredentialHelper) getAccessToken(
 	if err != nil {
 		return "", fmt.Errorf("error decoding service account key: %w", err)
 	}
-	config, err := google.JWTConfigFromJSON(decodedKey, "https://www.googleapis.com/auth/devstorage.read_only")
+
+	config, err := google.JWTConfigFromJSON(decodedKey, scopeStorageRead)
 	if err != nil {
 		return "", fmt.Errorf("error parsing service account key: %w", err)
 	}
+
 	tokenSource := config.TokenSource(ctx)
 	token, err := tokenSource.Token()
 	if err != nil {
diff --git a/internal/credentials/kubernetes/gar/workload_identity_federation.go b/internal/credentials/kubernetes/gar/workload_identity_federation.go
index d2444f52..16d839d4 100644
--- a/internal/credentials/kubernetes/gar/workload_identity_federation.go
+++ b/internal/credentials/kubernetes/gar/workload_identity_federation.go
@@ -8,91 +8,101 @@ import (
 	"cloud.google.com/go/compute/metadata"
 	"github.com/patrickmn/go-cache"
 	"google.golang.org/api/iamcredentials/v1"
-	corev1 "k8s.io/api/core/v1"
 
 	"github.com/akuity/kargo/internal/credentials"
 	"github.com/akuity/kargo/internal/logging"
 )
 
-type workloadIdentityFederationCredentialHelper struct {
-	gcpProjectID string
+const (
+	gcpResourceNameFormat = "projects/-/serviceAccounts/kargo-project-%s@%s.iam.gserviceaccount.com"
+)
 
+type WorkloadIdentityFederationProvider struct {
 	tokenCache *cache.Cache
 
-	// The following behaviors are overridable for testing purposes:
+	projectID string
 
-	getAccessTokenFn func(ctx context.Context, kargoProject string) (string, error)
+	getAccessTokenFn func(ctx context.Context, project string) (string, error)
 }
 
-// NewWorkloadIdentityFederationCredentialHelper returns an implementation of
-// credentials.Helper that utilizes a cache to avoid unnecessary calls to GCP.
-func NewWorkloadIdentityFederationCredentialHelper(
-	ctx context.Context,
-) credentials.Helper {
+func NewWorkloadIdentityFederationProvider(ctx context.Context) *WorkloadIdentityFederationProvider {
 	logger := logging.LoggerFromContext(ctx)
-	var gcpProjectID string
+
 	if !metadata.OnGCE() {
 		logger.Info("not running within GCE; assuming GCP Workload Identity Federation is not in use")
 		return nil
 	}
 	logger.Info("controller appears to be running within GCE")
-	var err error
-	if gcpProjectID, err = metadata.ProjectIDWithContext(ctx); err != nil {
+
+	projectID, err := metadata.ProjectIDWithContext(ctx)
+	if err != nil {
 		logger.Error(err, "error getting GCP project ID")
-	} else {
-		logger.Debug(
-			"got GCP project ID",
-			"project", gcpProjectID,
-		)
+		return nil
 	}
-	w := &workloadIdentityFederationCredentialHelper{
-		gcpProjectID: gcpProjectID,
+	logger.Debug("got GCP project ID", "project", projectID)
+
+	p := &WorkloadIdentityFederationProvider{
 		tokenCache: cache.New(
 			// Access tokens live for one hour. We'll hang on to them for 40 minutes.
 			40*time.Minute, // Default ttl for each entry
 			time.Hour,      // Cleanup interval
 		),
+		projectID: projectID,
 	}
-	w.getAccessTokenFn = w.getAccessToken
-	return w.getCredentials
+	p.getAccessTokenFn = p.getAccessToken
+	return p
 }
 
-func (w *workloadIdentityFederationCredentialHelper) getCredentials(
-	ctx context.Context,
-	kargoProject string,
+func (p *WorkloadIdentityFederationProvider) Supports(
 	credType credentials.Type,
 	repoURL string,
-	_ *corev1.Secret,
-) (*credentials.Credentials, error) {
-	if credType != credentials.TypeImage ||
-		w.gcpProjectID == "" { // Controller isn't running within GCE
-		// This helper can't handle this
-		return nil, nil
+	_ map[string][]byte,
+) bool {
+	if p.projectID == "" || credType != credentials.TypeImage {
+		return false
 	}
 
 	if !garURLRegex.MatchString(repoURL) && !gcrURLRegex.MatchString(repoURL) {
-		// This doesn't look like a Google Artifact Registry URL
+		return false
+	}
+
+	return true
+}
+
+func (p *WorkloadIdentityFederationProvider) GetCredentials(
+	ctx context.Context,
+	project string,
+	credType credentials.Type,
+	repoURL string,
+	_ map[string][]byte,
+) (*credentials.Credentials, error) {
+	if !p.Supports(credType, repoURL, nil) {
 		return nil, nil
 	}
 
-	if entry, exists := w.tokenCache.Get(kargoProject); exists {
+	var cacheKey = tokenCacheKey(project)
+
+	// Check the cache for the token
+	if entry, exists := p.tokenCache.Get(cacheKey); exists {
 		return &credentials.Credentials{
 			Username: accessTokenUsername,
 			Password: entry.(string), // nolint: forcetypeassert
 		}, nil
 	}
 
-	accessToken, err := w.getAccessTokenFn(ctx, kargoProject)
+	// Cache miss, get a new token
+	accessToken, err := p.getAccessTokenFn(ctx, project)
 	if err != nil {
 		return nil, fmt.Errorf("error getting GCP access token: %w", err)
 	}
 
+	// If we didn't get a token, we'll treat this as no credentials found
 	if accessToken == "" {
 		return nil, nil
 	}
 
-	// Cache the access token
-	w.tokenCache.Set(kargoProject, accessToken, cache.DefaultExpiration)
+	// Cache the token
+	p.tokenCache.Set(cacheKey, accessToken, cache.DefaultExpiration)
 
 	return &credentials.Credentials{
 		Username: accessTokenUsername,
@@ -102,28 +112,25 @@ func (w *workloadIdentityFederationCredentialHelper) getCredentials(
 
 // getAccessToken returns a GCP access token retrieved using the provided base64
 // encoded service account key. The access token is valid for one hour.
-func (w *workloadIdentityFederationCredentialHelper) getAccessToken(
+func (p *WorkloadIdentityFederationProvider) getAccessToken(
 	ctx context.Context,
 	kargoProject string,
 ) (string, error) {
 	logger := logging.LoggerFromContext(ctx)
+
 	iamSvc, err := iamcredentials.NewService(ctx)
 	if err != nil {
 		logger.Error(err, "error creating IAM Credentials service client")
 		return "", nil
 	}
-	logger = logger.WithValues(
-		"gcpProjectID", w.gcpProjectID,
-		"kargoProject", kargoProject,
-	)
+
+	logger = logger.WithValues("gcpProjectID", p.projectID, "kargoProject", kargoProject)
+
 	resp, err := iamSvc.Projects.ServiceAccounts.GenerateAccessToken(
-		fmt.Sprintf(
-			"projects/-/serviceAccounts/kargo-project-%s@%s.iam.gserviceaccount.com",
-			kargoProject, w.gcpProjectID,
-		),
+		fmt.Sprintf(gcpResourceNameFormat, kargoProject, p.projectID),
 		&iamcredentials.GenerateAccessTokenRequest{
 			Scope: []string{
-				"https://www.googleapis.com/auth/cloud-platform",
+				iamcredentials.CloudPlatformScope,
 			},
 		},
 	).Do()
@@ -131,6 +138,7 @@ func (w *workloadIdentityFederationCredentialHelper) getAccessToken(
 		logger.Error(err, "error generating access token")
 		return "", nil
 	}
+
 	logger.Debug("generated Artifact Registry access token")
 	return resp.AccessToken, nil
 }
diff --git a/internal/credentials/kubernetes/github/app.go b/internal/credentials/kubernetes/github/app.go
index 5c41c0f5..fdd2a91f 100644
--- a/internal/credentials/kubernetes/github/app.go
+++ b/internal/credentials/kubernetes/github/app.go
@@ -6,13 +6,15 @@ import (
 	"encoding/base64"
 	"errors"
 	"fmt"
+	"net/url"
 	"regexp"
 	"strconv"
+	"strings"
 	"time"
 
+	"github.com/hashicorp/go-cleanhttp"
 	"github.com/jferrl/go-githubauth"
 	"github.com/patrickmn/go-cache"
-	corev1 "k8s.io/api/core/v1"
 
 	"github.com/akuity/kargo/internal/credentials"
 )
@@ -22,96 +24,99 @@ const (
 	installationIDKey = "githubAppInstallationID"
 	privateKeyKey     = "githubAppPrivateKey"
 
+	githubHost = "github.com"
+
 	accessTokenUsername = "kargo"
 )
 
 var base64Regex = regexp.MustCompile(`^[a-zA-Z0-9+/]*={0,2}$`)
 
-type appCredentialHelper struct {
+type AppCredentialProvider struct {
 	tokenCache *cache.Cache
 
-	// The following behaviors are overridable for testing purposes:
-
-	getAccessTokenFn func(
-		appID int64,
-		installationID int64,
-		encodedKey string,
-	) (string, error)
+	getAccessTokenFn func(appID, installationID int64, encodedPrivateKey, baseURL string) (string, error)
 }
 
-// NewAppCredentialHelper returns an implementation of credentials.Helper that
-// utilizes a cache to avoid unnecessary calls to GitHub.
-func NewAppCredentialHelper() credentials.Helper {
-	a := &appCredentialHelper{
+// NewAppCredentialProvider returns an implementation of credentials.Provider.
+func NewAppCredentialProvider() *AppCredentialProvider {
+	p := &AppCredentialProvider{
 		tokenCache: cache.New(
 			// Access tokens live for one hour. We'll hang on to them for 40 minutes.
 			40*time.Minute, // Default ttl for each entry
 			time.Hour,      // Cleanup interval
 		),
 	}
-	a.getAccessTokenFn = a.getAccessToken
-	return a.getCredentials
+	p.getAccessTokenFn = p.getAccessToken
+	return p
+}
+
+func (p *AppCredentialProvider) Supports(credType credentials.Type, _ string, data map[string][]byte) bool {
+	if credType != credentials.TypeGit || len(data) == 0 {
+		return false
+	}
+
+	return data[appIDKey] != nil && data[installationIDKey] != nil && data[privateKeyKey] != nil
 }
 
-func (a *appCredentialHelper) getCredentials(
+func (p *AppCredentialProvider) GetCredentials(
 	_ context.Context,
 	_ string,
 	credType credentials.Type,
-	_ string,
-	secret *corev1.Secret,
+	repoURL string,
+	data map[string][]byte,
 ) (*credentials.Credentials, error) {
-	if credType != credentials.TypeGit || secret == nil {
-		// This helper can't handle this
+	if !p.Supports(credType, repoURL, data) {
 		return nil, nil
 	}
 
-	appIDStr := string(secret.Data[appIDKey])
-	installationIDStr := string(secret.Data[installationIDKey])
-	encodedPrivateKey := string(secret.Data[privateKeyKey])
-	if appIDStr == "" && installationIDStr == "" && encodedPrivateKey == "" {
-		// None of these fields are set, so there's nothing to do here.
-		return nil, nil
-	}
-	// If we get to here, at least one of the fields is set. Now if they aren't
-	// all set, we should return an error.
-	if appIDStr == "" || installationIDStr == "" || encodedPrivateKey == "" {
-		return nil, fmt.Errorf(
-			"%s, %s, and %s must all be set or all be unset",
-			appIDKey, installationIDKey, privateKeyKey,
-		)
-	}
-	appID, err := strconv.ParseInt(appIDStr, 10, 64)
+	appID, err := strconv.ParseInt(string(data[appIDKey]), 10, 64)
 	if err != nil {
 		return nil, fmt.Errorf("error parsing app ID: %w", err)
 	}
-	installationID, err := strconv.ParseInt(installationIDStr, 10, 64)
+
+	installID, err := strconv.ParseInt(string(data[installationIDKey]), 10, 64)
 	if err != nil {
 		return nil, fmt.Errorf("error parsing installation ID: %w", err)
 	}
-	return a.getUsernameAndPassword(appID, installationID, encodedPrivateKey)
+
+	baseURL, err := extractBaseURL(repoURL)
+	if err != nil {
+		return nil, fmt.Errorf("error extracting base URL from : %w", err)
+	}
+
+	return p.getUsernameAndPassword(appID, installID, string(data[privateKeyKey]), baseURL)
 }
 
-func (a *appCredentialHelper) getUsernameAndPassword(
+// getUsernameAndPassword gets a username and password for the given app and
+// installation IDs. The private key is the PEM-encoded private key for the
+// GitHub App. The base URL is the scheme and host of the repository URL, which
+// is used to determine whether the repository is hosted on GitHub Enterprise.
+func (p *AppCredentialProvider) getUsernameAndPassword(
 	appID int64,
 	installationID int64,
-	encodedPrivateKey string,
+	encodedPrivateKey, baseURL string,
 ) (*credentials.Credentials, error) {
-	cacheKey := a.tokenCacheKey(appID, installationID, encodedPrivateKey)
+	cacheKey := tokenCacheKey(baseURL, appID, installationID, encodedPrivateKey)
 
-	if entry, exists := a.tokenCache.Get(cacheKey); exists {
+	// Check the cache for the token
+	if entry, exists := p.tokenCache.Get(cacheKey); exists {
 		return &credentials.Credentials{
 			Username: accessTokenUsername,
 			Password: entry.(string), // nolint: forcetypeassert
 		}, nil
 	}
 
-	accessToken, err := a.getAccessTokenFn(appID, installationID, encodedPrivateKey)
+	// Cache miss, get a new token
+	accessToken, err := p.getAccessTokenFn(
+		appID, installationID,
+		encodedPrivateKey, baseURL,
+	)
 	if err != nil {
 		return nil, fmt.Errorf("error getting installation access token: %w", err)
 	}
 
-	// Cache the access token
-	a.tokenCache.Set(cacheKey, accessToken, cache.DefaultExpiration)
+	// Cache the new token
+	p.tokenCache.Set(cacheKey, accessToken, cache.DefaultExpiration)
 
 	return &credentials.Credentials{
 		Username: accessTokenUsername,
@@ -119,36 +124,37 @@ func (a *appCredentialHelper) getUsernameAndPassword(
 	}, nil
 }
 
-// tokenCacheKey returns a cache key for an installation access token. The key is
-// a hash of the app ID, installation ID, and encoded private key. Using a
-// hash ensures that a decodable key is not stored in the cache.
-func (a *appCredentialHelper) tokenCacheKey(
-	appID int64,
-	installationID int64,
-	encodedPrivateKey string,
-) string {
-	return fmt.Sprintf(
-		"%x",
-		sha256.Sum256([]byte(
-			fmt.Sprintf("%d:%d:%s", appID, installationID, encodedPrivateKey),
-		)),
-	)
-}
-
-func (a *appCredentialHelper) getAccessToken(
-	appID int64,
-	installationID int64,
-	encodedPrivateKey string,
+// getAccessToken gets an installation access token for the given app and
+// installation IDs. The private key is the PEM-encoded private key for the
+// GitHub App. The base URL is the scheme and host of the repository URL, which
+// is used to determine whether the repository is hosted on GitHub Enterprise.
+func (p *AppCredentialProvider) getAccessToken(
+	appID, installationID int64,
+	encodedPrivateKey, baseURL string,
 ) (string, error) {
 	decodedKey, err := decodeKey(encodedPrivateKey)
 	if err != nil {
 		return "", err
 	}
+
 	appTokenSource, err := githubauth.NewApplicationTokenSource(appID, decodedKey)
 	if err != nil {
 		return "", fmt.Errorf("error creating application token source: %w", err)
 	}
-	installationTokenSource := githubauth.NewInstallationTokenSource(installationID, appTokenSource)
+
+	installationOpts := []githubauth.InstallationTokenSourceOpt{
+		githubauth.WithHTTPClient(cleanhttp.DefaultClient()),
+	}
+	if baseURL != "" {
+		if !strings.HasPrefix(baseURL, "http://") && !strings.HasPrefix(baseURL, "https://") {
+			return "", fmt.Errorf("can only request access tokens for HTTP or HTTPS URLs")
+		}
+		if !strings.HasSuffix(baseURL, "://"+githubHost) {
+			installationOpts = append(installationOpts, githubauth.WithEnterpriseURLs(baseURL, baseURL))
+		}
+	}
+	installationTokenSource := githubauth.NewInstallationTokenSource(installationID, appTokenSource, installationOpts...)
+
 	token, err := installationTokenSource.Token()
 	if err != nil {
 		return "", fmt.Errorf("error getting installation access token: %w", err)
@@ -156,6 +162,21 @@ func (a *appCredentialHelper) getAccessToken(
 	return token.AccessToken, nil
 }
 
+// tokenCacheKey returns a cache key for an installation access token. The key
+// is a hash of the hostname, app ID, installation ID, and encoded private key.
+// Using a hash ensures that a decodable key is not stored in the cache.
+func tokenCacheKey(baseURL string, appID, installationID int64, encodedPrivateKey string) string {
+	return fmt.Sprintf(
+		"%x",
+		sha256.Sum256([]byte(
+			fmt.Sprintf(
+				"%s:%d:%d:%s",
+				baseURL, appID, installationID, encodedPrivateKey,
+			),
+		)),
+	)
+}
+
 // decodeKey attempts to base64 decode a key. If successful, it returns the
 // result. If it fails, it attempts to infer whether the input was simply NOT
 // base64 encoded or whether it appears to have been base64 encoded but
@@ -180,3 +201,13 @@ func decodeKey(key string) ([]byte, error) {
 	}
 	return decodedKey, nil
 }
+
+// extractBaseURL extracts the base URL from a full repository URL. The base
+// URL is the scheme and host of the repository URL.
+func extractBaseURL(fullURL string) (string, error) {
+	u, err := url.Parse(fullURL)
+	if err != nil {
+		return "", fmt.Errorf("error parsing URL: %w", err)
+	}
+	return u.Scheme + "://" + u.Host, nil
+}
diff --git a/internal/directives/git_cloner.go b/internal/directives/git_cloner.go
index 8bb6d97e..7b3161e5 100644
--- a/internal/directives/git_cloner.go
+++ b/internal/directives/git_cloner.go
@@ -93,7 +93,7 @@ func (g *gitCloner) runPromotionStep(
 	cfg builtin.GitCloneConfig,
 ) (PromotionStepResult, error) {
 	var repoCreds *git.RepoCredentials
-	creds, found, err := stepCtx.CredentialsDB.Get(
+	creds, err := stepCtx.CredentialsDB.Get(
 		ctx,
 		stepCtx.Project,
 		credentials.TypeGit,
@@ -103,7 +103,7 @@ func (g *gitCloner) runPromotionStep(
 		return PromotionStepResult{Status: kargoapi.PromotionPhaseErrored},
 			fmt.Errorf("error getting credentials for %s: %w", cfg.RepoURL, err)
 	}
-	if found {
+	if creds != nil {
 		repoCreds = &git.RepoCredentials{
 			Username:      creds.Username,
 			Password:      creds.Password,
diff --git a/internal/directives/git_pr_opener.go b/internal/directives/git_pr_opener.go
index 156c754e..5063ec62 100644
--- a/internal/directives/git_pr_opener.go
+++ b/internal/directives/git_pr_opener.go
@@ -95,7 +95,7 @@ func (g *gitPROpener) runPromotionStep(
 	sourceBranch := cfg.SourceBranch
 
 	var repoCreds *git.RepoCredentials
-	creds, found, err := stepCtx.CredentialsDB.Get(
+	creds, err := stepCtx.CredentialsDB.Get(
 		ctx,
 		stepCtx.Project,
 		credentials.TypeGit,
@@ -105,7 +105,7 @@ func (g *gitPROpener) runPromotionStep(
 		return PromotionStepResult{Status: kargoapi.PromotionPhaseErrored},
 			fmt.Errorf("error getting credentials for %s: %w", cfg.RepoURL, err)
 	}
-	if found {
+	if creds != nil {
 		repoCreds = &git.RepoCredentials{
 			Username:      creds.Username,
 			Password:      creds.Password,
diff --git a/internal/directives/git_pr_waiter.go b/internal/directives/git_pr_waiter.go
index 7da05b6a..d36834cd 100644
--- a/internal/directives/git_pr_waiter.go
+++ b/internal/directives/git_pr_waiter.go
@@ -66,7 +66,7 @@ func (g *gitPRWaiter) runPromotionStep(
 	cfg builtin.GitWaitForPRConfig,
 ) (PromotionStepResult, error) {
 	var repoCreds *git.RepoCredentials
-	creds, found, err := stepCtx.CredentialsDB.Get(
+	creds, err := stepCtx.CredentialsDB.Get(
 		ctx,
 		stepCtx.Project,
 		credentials.TypeGit,
@@ -76,7 +76,7 @@ func (g *gitPRWaiter) runPromotionStep(
 		return PromotionStepResult{Status: kargoapi.PromotionPhaseErrored},
 			fmt.Errorf("error getting credentials for %s: %w", cfg.RepoURL, err)
 	}
-	if found {
+	if creds != nil {
 		repoCreds = &git.RepoCredentials{
 			Username:      creds.Username,
 			Password:      creds.Password,
diff --git a/internal/directives/git_pusher.go b/internal/directives/git_pusher.go
index 3ae22fe5..d3fc0cf1 100644
--- a/internal/directives/git_pusher.go
+++ b/internal/directives/git_pusher.go
@@ -93,17 +93,17 @@ func (g *gitPushPusher) runPromotionStep(
 		return PromotionStepResult{Status: kargoapi.PromotionPhaseErrored},
 			fmt.Errorf("error loading working tree from %s: %w", cfg.Path, err)
 	}
-	var creds credentials.Credentials
-	var found bool
-	if creds, found, err = stepCtx.CredentialsDB.Get(
+	creds, err := stepCtx.CredentialsDB.Get(
 		ctx,
 		stepCtx.Project,
 		credentials.TypeGit,
 		workTree.URL(),
-	); err != nil {
+	)
+	if err != nil {
 		return PromotionStepResult{Status: kargoapi.PromotionPhaseErrored},
 			fmt.Errorf("error getting credentials for %s: %w", workTree.URL(), err)
-	} else if found {
+	}
+	if creds != nil {
 		loadOpts.Credentials = &git.RepoCredentials{
 			Username:      creds.Username,
 			Password:      creds.Password,
diff --git a/internal/directives/helm_chart_updater.go b/internal/directives/helm_chart_updater.go
index e63a6559..b2e784c0 100644
--- a/internal/directives/helm_chart_updater.go
+++ b/internal/directives/helm_chart_updater.go
@@ -332,11 +332,11 @@ func (h *helmChartUpdater) setupDependencyRepositories(
 				URL:  dep.Repository,
 			}
 
-			creds, ok, err := credentialsDB.Get(ctx, project, credentials.TypeHelm, dep.Repository)
+			creds, err := credentialsDB.Get(ctx, project, credentials.TypeHelm, dep.Repository)
 			if err != nil {
 				return fmt.Errorf("failed to obtain credentials for chart repository %q: %w", dep.Repository, err)
 			}
-			if ok {
+			if creds != nil {
 				entry.Username = creds.Username
 				entry.Password = creds.Password
 			}
@@ -344,11 +344,11 @@ func (h *helmChartUpdater) setupDependencyRepositories(
 			repositoryFile.Update(entry)
 		case strings.HasPrefix(dep.Repository, "oci://"):
 			credURL := "oci://" + path.Join(helm.NormalizeChartRepositoryURL(dep.Repository), dep.Name)
-			creds, ok, err := credentialsDB.Get(ctx, project, credentials.TypeHelm, credURL)
+			creds, err := credentialsDB.Get(ctx, project, credentials.TypeHelm, credURL)
 			if err != nil {
 				return fmt.Errorf("failed to obtain credentials for chart repository %q: %w", dep.Repository, err)
 			}
-			if ok {
+			if creds != nil {
 				if err = registryClient.Login(
 					strings.TrimPrefix(dep.Repository, "oci://"),
 					registry.LoginOptBasicAuth(creds.Username, creds.Password),



Test patch:
diff --git a/internal/controller/warehouses/git_test.go b/internal/controller/warehouses/git_test.go
index 8bc39a29..dcdb0536 100644
--- a/internal/controller/warehouses/git_test.go
+++ b/internal/controller/warehouses/git_test.go
@@ -46,8 +46,8 @@ func TestDiscoverCommits(t *testing.T) {
 						string,
 						credentials.Type,
 						string,
-					) (credentials.Credentials, bool, error) {
-						return credentials.Credentials{}, false, errors.New("something went wrong")
+					) (*credentials.Credentials, error) {
+						return nil, errors.New("something went wrong")
 					},
 				},
 			},
diff --git a/internal/controller/warehouses/helm_test.go b/internal/controller/warehouses/helm_test.go
index 0aeb5ae1..7fc8996b 100644
--- a/internal/controller/warehouses/helm_test.go
+++ b/internal/controller/warehouses/helm_test.go
@@ -39,8 +39,8 @@ func TestDiscoverCharts(t *testing.T) {
 						string,
 						credentials.Type,
 						string,
-					) (credentials.Credentials, bool, error) {
-						return credentials.Credentials{}, false, fmt.Errorf("something went wrong")
+					) (*credentials.Credentials, error) {
+						return nil, fmt.Errorf("something went wrong")
 					},
 				},
 			},
diff --git a/internal/controller/warehouses/images_test.go b/internal/controller/warehouses/images_test.go
index 43f07f50..fa6261ed 100644
--- a/internal/controller/warehouses/images_test.go
+++ b/internal/controller/warehouses/images_test.go
@@ -39,8 +39,8 @@ func TestDiscoverImages(t *testing.T) {
 						string,
 						credentials.Type,
 						string,
-					) (credentials.Credentials, bool, error) {
-						return credentials.Credentials{}, false, fmt.Errorf("something went wrong")
+					) (*credentials.Credentials, error) {
+						return nil, fmt.Errorf("something went wrong")
 					},
 				},
 			},
diff --git a/internal/credentials/kubernetes/basic/basic_test.go b/internal/credentials/kubernetes/basic/basic_test.go
new file mode 100644
index 00000000..98c92fd3
--- /dev/null
+++ b/internal/credentials/kubernetes/basic/basic_test.go
@@ -0,0 +1,241 @@
+package basic
+
+import (
+	"context"
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+
+	"github.com/akuity/kargo/internal/credentials"
+)
+
+func TestCredentialProvider_Supports(t *testing.T) {
+	tests := []struct {
+		name     string
+		credType credentials.Type
+		repoURL  string
+		data     map[string][]byte
+		expected bool
+	}{
+		{
+			name:     "empty data",
+			credType: credentials.TypeGit,
+			repoURL:  "https://github.com/example/repository.git",
+			data:     map[string][]byte{},
+			expected: false,
+		},
+		{
+			name:     "username only",
+			credType: credentials.TypeGit,
+			repoURL:  "https://github.com/example/repository.git",
+			data: map[string][]byte{
+				usernameKey: []byte("user"),
+			},
+			expected: false,
+		},
+		{
+			name:     "password only",
+			credType: credentials.TypeGit,
+			repoURL:  "https://github.com/example/repository.git",
+			data: map[string][]byte{
+				passwordKey: []byte("pass"),
+			},
+			expected: false,
+		},
+		{
+			name:     "username and password",
+			credType: credentials.TypeGit,
+			repoURL:  "https://github.com/example/repository.git",
+			data: map[string][]byte{
+				usernameKey: []byte("user"),
+				passwordKey: []byte("pass"),
+			},
+			expected: true,
+		},
+		{
+			name:     "ssh private key only",
+			credType: credentials.TypeGit,
+			repoURL:  "https://github.com/example/repository.git",
+			data: map[string][]byte{
+				sshPrivateKey: []byte("key-data"),
+			},
+			expected: true,
+		},
+		{
+			name:     "all credentials",
+			credType: credentials.TypeGit,
+			repoURL:  "https://github.com/example/repository.git",
+			data: map[string][]byte{
+				usernameKey:   []byte("user"),
+				passwordKey:   []byte("pass"),
+				sshPrivateKey: []byte("key-data"),
+			},
+			expected: true,
+		},
+		{
+			name:     "nil username value",
+			credType: credentials.TypeGit,
+			repoURL:  "https://github.com/example/repository.git",
+			data: map[string][]byte{
+				usernameKey: nil,
+				passwordKey: []byte("pass"),
+			},
+			expected: false,
+		},
+		{
+			name:     "nil password value",
+			credType: credentials.TypeGit,
+			repoURL:  "https://github.com/example/repository.git",
+			data: map[string][]byte{
+				usernameKey: []byte("user"),
+				passwordKey: nil,
+			},
+			expected: false,
+		},
+		{
+			name:     "nil ssh private key value",
+			credType: credentials.TypeGit,
+			repoURL:  "https://github.com/example/repository.git",
+			data: map[string][]byte{
+				sshPrivateKey: nil,
+			},
+			expected: false,
+		},
+	}
+
+	provider := &CredentialProvider{}
+
+	for _, test := range tests {
+		t.Run(test.name, func(t *testing.T) {
+			result := provider.Supports(test.credType, test.repoURL, test.data)
+			assert.Equal(t, test.expected, result)
+		})
+	}
+}
+
+func TestCredentialProvider_GetCredentials(t *testing.T) {
+	tests := []struct {
+		name       string
+		credType   credentials.Type
+		repoURL    string
+		data       map[string][]byte
+		assertions func(t *testing.T, creds *credentials.Credentials, err error)
+	}{
+		{
+			name:     "empty data",
+			credType: credentials.TypeGit,
+			repoURL:  "https://github.com/example/repository.git",
+			data:     map[string][]byte{},
+			assertions: func(t *testing.T, creds *credentials.Credentials, err error) {
+				assert.NoError(t, err)
+				assert.Nil(t, creds)
+			},
+		},
+		{
+			name:     "username only",
+			credType: credentials.TypeGit,
+			repoURL:  "https://github.com/example/repository.git",
+			data: map[string][]byte{
+				usernameKey: []byte("user"),
+			},
+			assertions: func(t *testing.T, creds *credentials.Credentials, err error) {
+				assert.NoError(t, err)
+				assert.Nil(t, creds)
+			},
+		},
+		{
+			name:     "password only",
+			credType: credentials.TypeGit,
+			repoURL:  "https://github.com/example/repository.git",
+			data: map[string][]byte{
+				passwordKey: []byte("pass"),
+			},
+			assertions: func(t *testing.T, creds *credentials.Credentials, err error) {
+				assert.NoError(t, err)
+				assert.Nil(t, creds)
+			},
+		},
+		{
+			name:     "username and password",
+			credType: credentials.TypeGit,
+			repoURL:  "https://github.com/example/repository.git",
+			data: map[string][]byte{
+				usernameKey: []byte("user"),
+				passwordKey: []byte("pass"),
+			},
+			assertions: func(t *testing.T, creds *credentials.Credentials, err error) {
+				assert.NoError(t, err)
+				assert.NotNil(t, creds)
+				assert.Equal(t, "user", creds.Username)
+				assert.Equal(t, "pass", creds.Password)
+				assert.Empty(t, creds.SSHPrivateKey, "SSHPrivateKey should be empty")
+			},
+		},
+		{
+			name:     "ssh private key only",
+			credType: credentials.TypeGit,
+			repoURL:  "https://github.com/example/repository.git",
+			data: map[string][]byte{
+				sshPrivateKey: []byte("key-data"),
+			},
+			assertions: func(t *testing.T, creds *credentials.Credentials, err error) {
+				assert.NoError(t, err)
+				assert.NotNil(t, creds)
+				assert.Empty(t, creds.Username, "Username should be empty")
+				assert.Empty(t, creds.Password, "Password should be empty")
+				assert.Equal(t, "key-data", creds.SSHPrivateKey)
+			},
+		},
+		{
+			name:     "all credentials",
+			credType: credentials.TypeGit,
+			repoURL:  "https://github.com/example/repository.git",
+			data: map[string][]byte{
+				usernameKey:   []byte("user"),
+				passwordKey:   []byte("pass"),
+				sshPrivateKey: []byte("key-data"),
+			},
+			assertions: func(t *testing.T, creds *credentials.Credentials, err error) {
+				assert.NoError(t, err)
+				assert.NotNil(t, creds)
+				assert.Equal(t, "user", creds.Username)
+				assert.Equal(t, "pass", creds.Password)
+				assert.Equal(t, "key-data", creds.SSHPrivateKey)
+			},
+		},
+		{
+			name:     "empty username and password strings",
+			credType: credentials.TypeGit,
+			repoURL:  "https://github.com/example/repository.git",
+			data: map[string][]byte{
+				usernameKey: []byte(""),
+				passwordKey: []byte(""),
+			},
+			assertions: func(t *testing.T, creds *credentials.Credentials, err error) {
+				assert.NoError(t, err)
+				assert.Nil(t, creds)
+			},
+		},
+		{
+			name:     "empty ssh private key string",
+			credType: credentials.TypeGit,
+			repoURL:  "https://github.com/example/repository.git",
+			data: map[string][]byte{
+				sshPrivateKey: []byte(""),
+			},
+			assertions: func(t *testing.T, creds *credentials.Credentials, err error) {
+				assert.NoError(t, err)
+				assert.Nil(t, creds)
+			},
+		},
+	}
+
+	provider := &CredentialProvider{}
+
+	for _, test := range tests {
+		t.Run(test.name, func(t *testing.T) {
+			creds, err := provider.GetCredentials(context.Background(), "", test.credType, test.repoURL, test.data)
+			test.assertions(t, creds, err)
+		})
+	}
+}
diff --git a/internal/credentials/kubernetes/database_test.go b/internal/credentials/kubernetes/database_test.go
index b6579211..4e63df4d 100644
--- a/internal/credentials/kubernetes/database_test.go
+++ b/internal/credentials/kubernetes/database_test.go
@@ -327,7 +327,7 @@ func TestGet(t *testing.T) {
 
 	for _, testCase := range testCases {
 		t.Run(testCase.name, func(t *testing.T) {
-			creds, found, err := NewDatabase(
+			creds, err := NewDatabase(
 				context.Background(),
 				fake.NewClientBuilder().WithObjects(testCase.secrets...).Build(),
 				testCase.cfg,
@@ -340,12 +340,11 @@ func TestGet(t *testing.T) {
 			require.NoError(t, err)
 
 			if testCase.expected == nil {
-				require.False(t, found)
-				require.Empty(t, creds)
+				require.Nil(t, creds)
 				return
 			}
 
-			require.True(t, found)
+			require.NotNil(t, creds)
 			require.Equal(
 				t,
 				string(testCase.expected.Data["username"]),
diff --git a/internal/credentials/kubernetes/ecr/access_key_test.go b/internal/credentials/kubernetes/ecr/access_key_test.go
index 6f130c69..efa7823e 100644
--- a/internal/credentials/kubernetes/ecr/access_key_test.go
+++ b/internal/credentials/kubernetes/ecr/access_key_test.go
@@ -2,252 +2,317 @@ package ecr
 
 import (
 	"context"
-	"encoding/base64"
-	"fmt"
+	"errors"
 	"testing"
 
 	"github.com/patrickmn/go-cache"
-	"github.com/stretchr/testify/require"
-	corev1 "k8s.io/api/core/v1"
+	"github.com/stretchr/testify/assert"
 
 	"github.com/akuity/kargo/internal/credentials"
 )
 
-func TestAccessKeyCredentialHelper(t *testing.T) {
+func TestNewAccessKeyProvider(t *testing.T) {
+	provider := NewAccessKeyProvider()
+
+	assert.NotNil(t, provider)
+	assert.NotNil(t, provider.tokenCache)
+	assert.NotNil(t, provider.getAuthTokenFn)
+}
+
+func TestAccessKeyProvider_Supports(t *testing.T) {
 	const (
-		testAWSAccountID    = "123456789012"
-		testRegion          = "fake-region"
-		testAccessKeyID     = "fake-id"
-		testSecretAccessKey = "fake-secret"
-		testUsername        = "fake-username"
-		testPassword        = "fake-password"
-	)
-	testRepoURL := fmt.Sprintf("%s.dkr.ecr.%s.amazonaws.com/fake/repo/", testAWSAccountID, testRegion)
-	testToken := fmt.Sprintf("%s:%s", testUsername, testPassword)
-	testEncodedToken := base64.StdEncoding.EncodeToString([]byte(testToken))
-
-	warmTokenCache := cache.New(0, 0)
-	warmTokenCache.Set(
-		(&accessKeyCredentialHelper{}).tokenCacheKey(testRegion, testAccessKeyID, testSecretAccessKey),
-		testEncodedToken,
-		cache.DefaultExpiration,
+		fakeRepoURL    = "123456789012.dkr.ecr.us-west-2.amazonaws.com/my-repo"
+		fakeOCIRepoURL = "oci://123456789012.dkr.ecr.us-west-2.amazonaws.com/my-repo"
+
+		fakeRegion = "us-west-2"
+		fakeID     = "AKIAIOSFODNN7EXAMPLE"                     // nolint:gosec
+		fakeSecret = "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY" // nolint:gosec
 	)
 
 	testCases := []struct {
-		name       string
-		credType   credentials.Type
-		repoURL    string
-		secret     *corev1.Secret
-		helper     *accessKeyCredentialHelper
-		assertions func(t *testing.T, creds *credentials.Credentials, c *cache.Cache, err error)
+		name     string
+		credType credentials.Type
+		repoURL  string
+		data     map[string][]byte
+		expected bool
 	}{
 		{
-			name:     "cred type is not image",
-			credType: credentials.TypeGit,
-			secret:   &corev1.Secret{},
-			helper:   &accessKeyCredentialHelper{},
-			assertions: func(t *testing.T, creds *credentials.Credentials, _ *cache.Cache, err error) {
-				require.NoError(t, err)
-				require.Nil(t, creds)
+			name:     "valid image credentials",
+			credType: credentials.TypeImage,
+			repoURL:  fakeRepoURL,
+			data: map[string][]byte{
+				regionKey: []byte(fakeRegion),
+				idKey:     []byte(fakeID),
+				secretKey: []byte(fakeSecret),
 			},
+			expected: true,
 		},
 		{
-			name:     "cred type is not oci helm",
+			name:     "valid helm oci credentials",
 			credType: credentials.TypeHelm,
-			repoURL:  testRepoURL,
-			secret:   &corev1.Secret{},
-			helper:   &accessKeyCredentialHelper{},
-			assertions: func(t *testing.T, creds *credentials.Credentials, _ *cache.Cache, err error) {
-				require.NoError(t, err)
-				require.Nil(t, creds)
+			repoURL:  fakeOCIRepoURL,
+			data: map[string][]byte{
+				regionKey: []byte(fakeRegion),
+				idKey:     []byte(fakeID),
+				secretKey: []byte(fakeSecret),
 			},
+			expected: true,
 		},
 		{
-			name:     "secret is nil",
+			name:     "helm but not oci",
+			credType: credentials.TypeHelm,
+			repoURL:  "https://123456789012.dkr.ecr.us-west-2.amazonaws.com/my-repo",
+			data: map[string][]byte{
+				regionKey: []byte(fakeRegion),
+				idKey:     []byte(fakeID),
+				secretKey: []byte(fakeSecret),
+			},
+			expected: false,
+		},
+		{
+			name:     "missing region",
 			credType: credentials.TypeImage,
-			helper:   &accessKeyCredentialHelper{},
-			assertions: func(t *testing.T, creds *credentials.Credentials, _ *cache.Cache, err error) {
-				require.NoError(t, err)
-				require.Nil(t, creds)
+			repoURL:  fakeRepoURL,
+			data: map[string][]byte{
+				idKey:     []byte(fakeID),
+				secretKey: []byte(fakeSecret),
 			},
+			expected: false,
 		},
 		{
-			name:     "not an ecr url",
+			name:     "missing access key ID",
 			credType: credentials.TypeImage,
-			repoURL:  "not-an-ecr-url",
-			secret:   &corev1.Secret{},
-			helper:   &accessKeyCredentialHelper{},
-			assertions: func(t *testing.T, creds *credentials.Credentials, _ *cache.Cache, err error) {
-				require.NoError(t, err)
-				require.Nil(t, creds)
+			repoURL:  fakeRepoURL,
+			data: map[string][]byte{
+				regionKey: []byte(fakeRegion),
+				secretKey: []byte(fakeSecret),
 			},
+			expected: false,
 		},
 		{
-			name:     "no aws details provided",
+			name:     "missing secret key",
 			credType: credentials.TypeImage,
-			repoURL:  testRepoURL,
-			secret:   &corev1.Secret{},
-			helper:   &accessKeyCredentialHelper{},
-			assertions: func(t *testing.T, creds *credentials.Credentials, _ *cache.Cache, err error) {
-				require.NoError(t, err)
-				require.Nil(t, creds)
+			repoURL:  fakeRepoURL,
+			data: map[string][]byte{
+				regionKey: []byte(fakeRegion),
+				idKey:     []byte(fakeID),
 			},
+			expected: false,
 		},
 		{
-			name:     "region missing",
+			name:     "invalid URL format",
 			credType: credentials.TypeImage,
-			repoURL:  testRepoURL,
-			secret: &corev1.Secret{
-				Data: map[string][]byte{
-					idKey:     []byte(testAccessKeyID),
-					secretKey: []byte(testSecretAccessKey),
-				},
+			repoURL:  "not-an-ecr-url",
+			data: map[string][]byte{
+				regionKey: []byte(fakeRegion),
+				idKey:     []byte(fakeID),
+				secretKey: []byte(fakeSecret),
 			},
-			helper: &accessKeyCredentialHelper{},
-			assertions: func(t *testing.T, _ *credentials.Credentials, _ *cache.Cache, err error) {
-				require.ErrorContains(t, err, "must all be set or all be unset")
+			expected: false,
+		},
+		{
+			name:     "unsupported credential type",
+			credType: credentials.TypeGit,
+			repoURL:  fakeRepoURL,
+			data: map[string][]byte{
+				regionKey: []byte(fakeRegion),
+				idKey:     []byte(fakeID),
+				secretKey: []byte(fakeSecret),
 			},
+			expected: false,
 		},
 		{
-			name:     "access key id missing",
+			name:     "empty data",
 			credType: credentials.TypeImage,
-			repoURL:  testRepoURL,
-			secret: &corev1.Secret{
-				Data: map[string][]byte{
-					regionKey: []byte(testRegion),
-					secretKey: []byte(testSecretAccessKey),
-				},
+			repoURL:  fakeRepoURL,
+			data:     map[string][]byte{},
+			expected: false,
+		},
+	}
+
+	for _, tt := range testCases {
+		t.Run(tt.name, func(t *testing.T) {
+			provider := NewAccessKeyProvider()
+			result := provider.Supports(tt.credType, tt.repoURL, tt.data)
+			assert.Equal(t, tt.expected, result)
+		})
+	}
+}
+
+func TestAccessKeyProvider_GetCredentials(t *testing.T) {
+	ctx := context.Background()
+
+	const (
+		fakeRepoURL = "123456789012.dkr.ecr.us-west-2.amazonaws.com/my-repo"
+		fakeRegion  = "us-west-2"
+		fakeID      = "AKIAIOSFODNN7EXAMPLE"                     // nolint:gosec
+		fakeSecret  = "wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY" // nolint:gosec
+		// base64 of "AWS:password"
+		fakeToken = "QVdTOnBhc3N3b3Jk" // nolint:gosec
+	)
+
+	testCases := []struct {
+		name           string
+		credType       credentials.Type
+		repoURL        string
+		data           map[string][]byte
+		getAuthTokenFn func(ctx context.Context, region, accessKeyID, secretAccessKey string) (string, error)
+		setupCache     func(cache *cache.Cache)
+		assertions     func(t *testing.T, c *cache.Cache, creds *credentials.Credentials, err error)
+	}{
+		{
+			name:     "unsupported credentials",
+			credType: credentials.TypeGit,
+			repoURL:  "not-an-ecr-url",
+			data:     map[string][]byte{},
+			getAuthTokenFn: func(_ context.Context, _, _, _ string) (string, error) {
+				return "", nil
 			},
-			helper: &accessKeyCredentialHelper{},
-			assertions: func(t *testing.T, _ *credentials.Credentials, _ *cache.Cache, err error) {
-				require.ErrorContains(t, err, "must all be set or all be unset")
+			assertions: func(t *testing.T, _ *cache.Cache, creds *credentials.Credentials, err error) {
+				assert.Nil(t, creds)
+				assert.NoError(t, err)
 			},
 		},
 		{
-			name:     "secret access key missing",
+			name:     "cache hit",
 			credType: credentials.TypeImage,
-			repoURL:  testRepoURL,
-			secret: &corev1.Secret{
-				Data: map[string][]byte{
-					regionKey: []byte(testRegion),
-					idKey:     []byte(testAccessKeyID),
-				},
+			repoURL:  fakeRepoURL,
+			data: map[string][]byte{
+				regionKey: []byte(fakeRegion),
+				idKey:     []byte(fakeID),
+				secretKey: []byte(fakeSecret),
+			},
+			setupCache: func(c *cache.Cache) {
+				c.Set(
+					tokenCacheKey(fakeRegion, fakeID, fakeSecret),
+					fakeToken, // base64 of "AWS:password"
+					cache.DefaultExpiration,
+				)
 			},
-			helper: &accessKeyCredentialHelper{},
-			assertions: func(t *testing.T, _ *credentials.Credentials, _ *cache.Cache, err error) {
-				require.ErrorContains(t, err, "must all be set or all be unset")
+			assertions: func(t *testing.T, _ *cache.Cache, creds *credentials.Credentials, err error) {
+				assert.NoError(t, err)
+				assert.NotNil(t, creds)
+				assert.Equal(t, "AWS", creds.Username)
+				assert.Equal(t, "password", creds.Password)
 			},
 		},
 		{
-			name:     "cache hit",
+			name:     "cache miss, successful token fetch",
 			credType: credentials.TypeImage,
-			repoURL:  testRepoURL,
-			secret: &corev1.Secret{
-				Data: map[string][]byte{
-					regionKey: []byte(testRegion),
-					idKey:     []byte(testAccessKeyID),
-					secretKey: []byte(testSecretAccessKey),
-				},
+			repoURL:  fakeRepoURL,
+			data: map[string][]byte{
+				regionKey: []byte(fakeRegion),
+				idKey:     []byte(fakeID),
+				secretKey: []byte(fakeSecret),
 			},
-			helper: &accessKeyCredentialHelper{
-				tokenCache: warmTokenCache,
+			getAuthTokenFn: func(_ context.Context, _, _, _ string) (string, error) {
+				return fakeToken, nil
 			},
-			assertions: func(t *testing.T, creds *credentials.Credentials, _ *cache.Cache, err error) {
-				require.NoError(t, err)
-				require.NotNil(t, creds)
-				require.Equal(t, testUsername, creds.Username)
-				require.Equal(t, testPassword, creds.Password)
+			assertions: func(t *testing.T, c *cache.Cache, creds *credentials.Credentials, err error) {
+				assert.NoError(t, err)
+				assert.NotNil(t, creds)
+				assert.Equal(t, "AWS", creds.Username)
+				assert.Equal(t, "password", creds.Password)
+
+				// Verify the token was cached
+				token, found := c.Get(tokenCacheKey(fakeRegion, fakeID, fakeSecret))
+				assert.True(t, found)
+				assert.Equal(t, fakeToken, token)
 			},
 		},
 		{
-			name:     "cache miss; error getting auth token",
+			name:     "error in getAuthToken",
 			credType: credentials.TypeImage,
-			repoURL:  testRepoURL,
-			secret: &corev1.Secret{
-				Data: map[string][]byte{
-					regionKey: []byte(testRegion),
-					idKey:     []byte(testAccessKeyID),
-					secretKey: []byte(testSecretAccessKey),
-				},
+			repoURL:  fakeRepoURL,
+			data: map[string][]byte{
+				regionKey: []byte(fakeRegion),
+				idKey:     []byte(fakeID),
+				secretKey: []byte(fakeSecret),
 			},
-			helper: &accessKeyCredentialHelper{
-				tokenCache: cache.New(0, 0),
-				getAuthTokenFn: func(context.Context, string, string, string) (string, error) {
-					return "", fmt.Errorf("something went wrong")
-				},
+			getAuthTokenFn: func(_ context.Context, _, _, _ string) (string, error) {
+				return "", errors.New("auth token error")
 			},
-			assertions: func(t *testing.T, _ *credentials.Credentials, _ *cache.Cache, err error) {
-				require.ErrorContains(t, err, "error getting ECR auth token")
-				require.ErrorContains(t, err, "something went wrong")
+			assertions: func(t *testing.T, _ *cache.Cache, creds *credentials.Credentials, err error) {
+				assert.ErrorContains(t, err, "error getting ECR auth token")
+				assert.Nil(t, creds)
 			},
 		},
 		{
-			name:     "cache miss; success",
+			name:     "empty token from getAuthToken",
 			credType: credentials.TypeImage,
-			repoURL:  testRepoURL,
-			secret: &corev1.Secret{
-				Data: map[string][]byte{
-					regionKey: []byte(testRegion),
-					idKey:     []byte(testAccessKeyID),
-					secretKey: []byte(testSecretAccessKey),
-				},
-			},
-			helper: &accessKeyCredentialHelper{
-				tokenCache: cache.New(0, 0),
-				getAuthTokenFn: func(context.Context, string, string, string) (string, error) {
-					return testEncodedToken, nil
-				},
-			},
-			assertions: func(t *testing.T, creds *credentials.Credentials, c *cache.Cache, err error) {
-				require.NoError(t, err)
-				require.NotNil(t, creds)
-				require.Equal(t, testUsername, creds.Username)
-				require.Equal(t, testPassword, creds.Password)
-				_, found := c.Get(
-					(&accessKeyCredentialHelper{}).tokenCacheKey(testRegion, testAccessKeyID, testSecretAccessKey),
-				)
-				require.True(t, found)
+			repoURL:  fakeRepoURL,
+			data: map[string][]byte{
+				regionKey: []byte(fakeRegion),
+				idKey:     []byte(fakeID),
+				secretKey: []byte(fakeSecret),
+			},
+			getAuthTokenFn: func(_ context.Context, _, _, _ string) (string, error) {
+				return "", nil
+			},
+			assertions: func(t *testing.T, c *cache.Cache, creds *credentials.Credentials, err error) {
+				assert.Nil(t, creds)
+				assert.NoError(t, err)
+
+				// Verify the token was not cached
+				_, found := c.Get(tokenCacheKey(fakeRegion, fakeID, fakeSecret))
+				assert.False(t, found)
 			},
 		},
+	}
+
+	for _, tt := range testCases {
+		t.Run(tt.name, func(t *testing.T) {
+			provider := NewAccessKeyProvider()
+			provider.getAuthTokenFn = tt.getAuthTokenFn
+
+			if tt.setupCache != nil {
+				tt.setupCache(provider.tokenCache)
+			}
+
+			creds, err := provider.GetCredentials(ctx, "", tt.credType, tt.repoURL, tt.data)
+			tt.assertions(t, provider.tokenCache, creds, err)
+		})
+	}
+}
+
+func Test_decodeAuthToken(t *testing.T) {
+	testCases := []struct {
+		name       string
+		token      string
+		assertions func(t *testing.T, creds *credentials.Credentials, err error)
+	}{
 		{
-			name:     "cache miss; success (helm)",
-			credType: credentials.TypeHelm,
-			repoURL:  fmt.Sprintf("oci://%s", testRepoURL),
-			secret: &corev1.Secret{
-				Data: map[string][]byte{
-					regionKey: []byte(testRegion),
-					idKey:     []byte(testAccessKeyID),
-					secretKey: []byte(testSecretAccessKey),
-				},
-			},
-			helper: &accessKeyCredentialHelper{
-				tokenCache: cache.New(0, 0),
-				getAuthTokenFn: func(context.Context, string, string, string) (string, error) {
-					return testEncodedToken, nil
-				},
-			},
-			assertions: func(t *testing.T, creds *credentials.Credentials, c *cache.Cache, err error) {
-				require.NoError(t, err)
-				require.NotNil(t, creds)
-				require.Equal(t, testUsername, creds.Username)
-				require.Equal(t, testPassword, creds.Password)
-				_, found := c.Get(
-					(&accessKeyCredentialHelper{}).tokenCacheKey(testRegion, testAccessKeyID, testSecretAccessKey),
-				)
-				require.True(t, found)
+			name:  "valid token",
+			token: "QVdTOnBhc3N3b3Jk", // base64 of "AWS:password"
+			assertions: func(t *testing.T, creds *credentials.Credentials, err error) {
+				assert.NoError(t, err)
+				assert.NotNil(t, creds)
+				assert.Equal(t, "AWS", creds.Username)
+				assert.Equal(t, "password", creds.Password)
+			},
+		},
+		{
+			name:  "invalid base64",
+			token: "invalid-base64",
+			assertions: func(t *testing.T, creds *credentials.Credentials, err error) {
+				assert.ErrorContains(t, err, "error decoding token")
+				assert.Nil(t, creds)
+			},
+		},
+		{
+			name:  "valid base64 but invalid format",
+			token: "bm90LWEtdmFsaWQtdG9rZW4=", // base64 of "not-a-valid-token"
+			assertions: func(t *testing.T, creds *credentials.Credentials, err error) {
+				assert.ErrorContains(t, err, "invalid token format")
+				assert.Nil(t, creds)
 			},
 		},
 	}
-	for _, testCase := range testCases {
-		t.Run(testCase.name, func(t *testing.T) {
-			creds, err := testCase.helper.getCredentials(
-				context.Background(),
-				"", // project is irrelevant for this helper
-				testCase.credType,
-				testCase.repoURL,
-				testCase.secret,
-			)
-			testCase.assertions(t, creds, testCase.helper.tokenCache, err)
+
+	for _, tt := range testCases {
+		t.Run(tt.name, func(t *testing.T) {
+			creds, err := decodeAuthToken(tt.token)
+			tt.assertions(t, creds, err)
 		})
 	}
 }
diff --git a/internal/credentials/kubernetes/ecr/common_test.go b/internal/credentials/kubernetes/ecr/common_test.go
new file mode 100644
index 00000000..2aad079d
--- /dev/null
+++ b/internal/credentials/kubernetes/ecr/common_test.go
@@ -0,0 +1,40 @@
+package ecr
+
+import (
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+)
+
+func Test_tokenCacheKey(t *testing.T) {
+	testCases := []struct {
+		name  string
+		parts []string
+		want  string
+	}{
+		{
+			name:  "single part",
+			parts: []string{"region1"},
+			want:  "7507acda9c58034d4f38545edd121b4c8572483cbc5c7dc40f3daa2c74d8430a",
+		},
+		{
+			name:  "multiple parts",
+			parts: []string{"region1", "key1", "secret1"},
+			want:  "559495d4cca6055810e755d40dfdeeb1aa0a937f3030463be970b9cd2d586002",
+		},
+		{
+			name:  "no parts",
+			parts: []string{},
+			want:  "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
+		},
+	}
+
+	for _, tt := range testCases {
+		t.Run(tt.name, func(t *testing.T) {
+			for i := 0; i < 100000; i++ {
+				result := tokenCacheKey(tt.parts...)
+				assert.Equal(t, tt.want, result)
+			}
+		})
+	}
+}
diff --git a/internal/credentials/kubernetes/ecr/managed_identity_test.go b/internal/credentials/kubernetes/ecr/managed_identity_test.go
index cbeed03a..9439a97d 100644
--- a/internal/credentials/kubernetes/ecr/managed_identity_test.go
+++ b/internal/credentials/kubernetes/ecr/managed_identity_test.go
@@ -2,173 +2,222 @@ package ecr
 
 import (
 	"context"
-	"encoding/base64"
-	"fmt"
+	"errors"
 	"testing"
+	"time"
 
 	"github.com/patrickmn/go-cache"
-	"github.com/stretchr/testify/require"
+	"github.com/stretchr/testify/assert"
 
 	"github.com/akuity/kargo/internal/credentials"
 )
 
-func TestPodIdentityCredentialHelper(t *testing.T) {
+func TestManagedIdentityProvider_Supports(t *testing.T) {
 	const (
-		testAWSAccountID = "123456789012"
-		testRegion       = "fake-region"
-		testProject      = "fake-project"
-		testUsername     = "fake-username"
-		testPassword     = "fake-password"
-	)
-	testRepoURL := fmt.Sprintf("%s.dkr.ecr.%s.amazonaws.com/fake/repo/", testAWSAccountID, testRegion)
-	testToken := fmt.Sprintf("%s:%s", testUsername, testPassword)
-	testEncodedToken := base64.StdEncoding.EncodeToString([]byte(testToken))
-
-	warmTokenCache := cache.New(0, 0)
-	warmTokenCache.Set(
-		(&managedIdentityCredentialHelper{}).tokenCacheKey(testRegion, testProject),
-		testEncodedToken,
-		cache.DefaultExpiration,
+		fakeAccountID  = "123456789012"
+		fakeRepoURL    = "123456789012.dkr.ecr.us-west-2.amazonaws.com/my-repo"
+		fakeOCIRepoURL = "oci://123456789012.dkr.ecr.us-west-2.amazonaws.com/my-repo"
 	)
 
 	testCases := []struct {
-		name       string
-		credType   credentials.Type
-		repoURL    string
-		helper     *managedIdentityCredentialHelper
-		assertions func(*testing.T, *credentials.Credentials, *cache.Cache, error)
+		name     string
+		provider *ManagedIdentityProvider
+		credType credentials.Type
+		repoURL  string
+		expected bool
 	}{
 		{
-			name:     "cred type is not image",
-			credType: credentials.TypeGit,
-			helper: &managedIdentityCredentialHelper{
-				awsAccountID: testAWSAccountID,
-			},
-			assertions: func(t *testing.T, creds *credentials.Credentials, _ *cache.Cache, err error) {
-				require.NoError(t, err)
-				require.Nil(t, creds)
+			name: "no account ID configured",
+			provider: &ManagedIdentityProvider{
+				accountID: "",
 			},
+			credType: credentials.TypeImage,
+			repoURL:  fakeRepoURL,
+			expected: false,
 		},
 		{
-			name:     "EKS Pod Identity not in use",
+			name: "image credentials supported",
+			provider: &ManagedIdentityProvider{
+				accountID: fakeAccountID,
+			},
 			credType: credentials.TypeImage,
-			repoURL:  testRepoURL,
-			helper:   &managedIdentityCredentialHelper{},
-			assertions: func(t *testing.T, creds *credentials.Credentials, _ *cache.Cache, err error) {
-				require.NoError(t, err)
-				require.Nil(t, creds)
+			repoURL:  fakeRepoURL,
+			expected: true,
+		},
+		{
+			name: "OCI helm credentials supported",
+			provider: &ManagedIdentityProvider{
+				accountID: fakeAccountID,
 			},
+			credType: credentials.TypeHelm,
+			repoURL:  fakeOCIRepoURL,
+			expected: true,
 		},
 		{
-			name:     "repo URL does not match ECR URL regex",
-			credType: credentials.TypeImage,
-			repoURL:  "ghcr.io/fake-org/fake-repo",
-			helper: &managedIdentityCredentialHelper{
-				awsAccountID: testAWSAccountID,
+			name: "non-OCI helm credentials not supported",
+			provider: &ManagedIdentityProvider{
+				accountID: fakeAccountID,
 			},
-			assertions: func(t *testing.T, creds *credentials.Credentials, _ *cache.Cache, err error) {
-				require.NoError(t, err)
-				require.Nil(t, creds)
+			credType: credentials.TypeHelm,
+			repoURL:  "https://123456789012.dkr.ecr.us-west-2.amazonaws.com/repo",
+			expected: false,
+		},
+		{
+			name: "git credentials not supported",
+			provider: &ManagedIdentityProvider{
+				accountID: fakeAccountID,
 			},
+			credType: credentials.TypeGit,
+			repoURL:  fakeRepoURL,
+			expected: false,
 		},
+	}
+
+	for _, tt := range testCases {
+		t.Run(tt.name, func(t *testing.T) {
+			result := tt.provider.Supports(tt.credType, tt.repoURL, nil)
+			assert.Equal(t, tt.expected, result)
+		})
+	}
+}
+
+func TestManagedIdentityProvider_GetCredentials(t *testing.T) {
+	ctx := context.Background()
+
+	const (
+		fakeAccountID = "123456789012"
+		fakeProject   = "fake-project"
+		fakeRepoURL   = "123456789012.dkr.ecr.us-west-2.amazonaws.com/repo"
+		fakeRegion    = "us-west-2"
+		// base64 of "AWS:password"
+		fakeToken = "QVdTOnBhc3N3b3Jk" // nolint:gosec
+	)
+
+	testCases := []struct {
+		name       string
+		provider   *ManagedIdentityProvider
+		project    string
+		credType   credentials.Type
+		repoURL    string
+		setupCache func(cache *cache.Cache)
+		assertions func(t *testing.T, c *cache.Cache, creds *credentials.Credentials, err error)
+	}{
 		{
-			name:     "helm repo URL does not match ECR URL regex",
-			credType: credentials.TypeHelm,
-			repoURL:  testRepoURL,
-			helper: &managedIdentityCredentialHelper{
-				awsAccountID: testAWSAccountID,
+			name: "not supported",
+			provider: &ManagedIdentityProvider{
+				accountID:  fakeAccountID,
+				tokenCache: cache.New(10*time.Hour, time.Hour),
 			},
-			assertions: func(t *testing.T, creds *credentials.Credentials, _ *cache.Cache, err error) {
-				require.NoError(t, err)
-				require.Nil(t, creds)
+			project:  fakeProject,
+			credType: credentials.TypeGit,
+			repoURL:  "git://repo",
+			assertions: func(t *testing.T, _ *cache.Cache, creds *credentials.Credentials, err error) {
+				assert.Nil(t, creds)
+				assert.NoError(t, err)
 			},
 		},
 		{
-			name:     "cache hit",
-			credType: credentials.TypeImage,
-			repoURL:  testRepoURL,
-			helper: &managedIdentityCredentialHelper{
-				awsAccountID: testAWSAccountID,
-				tokenCache:   warmTokenCache,
+			name: "non-ECR URL",
+			provider: &ManagedIdentityProvider{
+				accountID:  fakeAccountID,
+				tokenCache: cache.New(10*time.Hour, time.Hour),
 			},
-			assertions: func(t *testing.T, creds *credentials.Credentials, _ *cache.Cache, err error) {
-				require.NoError(t, err)
-				require.NotNil(t, creds)
-				require.Equal(t, testUsername, creds.Username)
-				require.Equal(t, testPassword, creds.Password)
+			project:  fakeProject,
+			credType: credentials.TypeImage,
+			repoURL:  "not-an-ecr-url",
+			assertions: func(t *testing.T, _ *cache.Cache, creds *credentials.Credentials, err error) {
+				assert.Nil(t, creds)
+				assert.NoError(t, err)
 			},
 		},
 		{
-			name:     "cache miss; error getting auth token",
+			name: "cache hit",
+			provider: &ManagedIdentityProvider{
+				accountID:  fakeAccountID,
+				tokenCache: cache.New(10*time.Hour, time.Hour),
+			},
+			project:  fakeProject,
 			credType: credentials.TypeImage,
-			repoURL:  testRepoURL,
-			helper: &managedIdentityCredentialHelper{
-				awsAccountID: testAWSAccountID,
-				tokenCache:   cache.New(0, 0),
-				getAuthTokenFn: func(context.Context, string, string) (string, error) {
-					return "", fmt.Errorf("something went wrong")
-				},
+			repoURL:  fakeRepoURL,
+			setupCache: func(c *cache.Cache) {
+				cacheKey := tokenCacheKey(fakeRegion, fakeProject)
+				c.Set(cacheKey, fakeToken, cache.DefaultExpiration)
 			},
-			assertions: func(t *testing.T, _ *credentials.Credentials, _ *cache.Cache, err error) {
-				require.ErrorContains(t, err, "error getting ECR auth token")
-				require.ErrorContains(t, err, "something went wrong")
+			assertions: func(t *testing.T, _ *cache.Cache, creds *credentials.Credentials, err error) {
+				assert.NoError(t, err)
+				assert.NotNil(t, creds)
+				assert.Equal(t, "AWS", creds.Username)
+				assert.Equal(t, "password", creds.Password)
 			},
 		},
 		{
-			name:     "cache miss; success",
+			name: "cache miss, successful token fetch",
+			provider: &ManagedIdentityProvider{
+				accountID:  fakeAccountID,
+				tokenCache: cache.New(10*time.Hour, time.Hour),
+				getAuthTokenFn: func(_ context.Context, _, _ string) (string, error) {
+					return fakeToken, nil
+				},
+			},
+			project:  fakeProject,
 			credType: credentials.TypeImage,
-			repoURL:  testRepoURL,
-			helper: &managedIdentityCredentialHelper{
-				awsAccountID: testAWSAccountID,
-				tokenCache:   cache.New(0, 0),
-				getAuthTokenFn: func(context.Context, string, string) (string, error) {
-					return testEncodedToken, nil
+			repoURL:  fakeRepoURL,
+			assertions: func(t *testing.T, c *cache.Cache, creds *credentials.Credentials, err error) {
+				assert.NoError(t, err)
+				assert.NotNil(t, creds)
+				assert.Equal(t, "AWS", creds.Username)
+				assert.Equal(t, "password", creds.Password)
+
+				// Verify the token was cached
+				cachedToken, found := c.Get(tokenCacheKey(fakeRegion, fakeProject))
+				assert.True(t, found)
+				assert.Equal(t, fakeToken, cachedToken)
+			},
+		},
+		{
+			name: "error in getAuthToken",
+			provider: &ManagedIdentityProvider{
+				accountID:  fakeAccountID,
+				tokenCache: cache.New(10*time.Hour, time.Hour),
+				getAuthTokenFn: func(_ context.Context, _, _ string) (string, error) {
+					return "", errors.New("auth token error")
 				},
 			},
-			assertions: func(t *testing.T, creds *credentials.Credentials, c *cache.Cache, err error) {
-				require.NoError(t, err)
-				require.NotNil(t, creds)
-				require.Equal(t, testUsername, creds.Username)
-				require.Equal(t, testPassword, creds.Password)
-				_, found := c.Get(
-					(&managedIdentityCredentialHelper{}).tokenCacheKey(testRegion, testProject),
-				)
-				require.True(t, found)
+			project:  fakeProject,
+			credType: credentials.TypeImage,
+			repoURL:  fakeRepoURL,
+			assertions: func(t *testing.T, _ *cache.Cache, creds *credentials.Credentials, err error) {
+				assert.ErrorContains(t, err, "error getting ECR auth token")
+				assert.Nil(t, creds)
 			},
 		},
 		{
-			name:     "cache miss; success (helm)",
-			credType: credentials.TypeHelm,
-			repoURL:  fmt.Sprintf("oci://%s", testRepoURL),
-			helper: &managedIdentityCredentialHelper{
-				awsAccountID: testAWSAccountID,
-				tokenCache:   cache.New(0, 0),
-				getAuthTokenFn: func(context.Context, string, string) (string, error) {
-					return testEncodedToken, nil
+			name: "empty token from getAuthToken",
+			provider: &ManagedIdentityProvider{
+				accountID:  fakeAccountID,
+				tokenCache: cache.New(10*time.Hour, time.Hour),
+				getAuthTokenFn: func(_ context.Context, _, _ string) (string, error) {
+					return "", nil
 				},
 			},
-			assertions: func(t *testing.T, creds *credentials.Credentials, c *cache.Cache, err error) {
-				require.NoError(t, err)
-				require.NotNil(t, creds)
-				require.Equal(t, testUsername, creds.Username)
-				require.Equal(t, testPassword, creds.Password)
-				_, found := c.Get(
-					(&managedIdentityCredentialHelper{}).tokenCacheKey(testRegion, testProject),
-				)
-				require.True(t, found)
+			project:  fakeProject,
+			credType: credentials.TypeImage,
+			repoURL:  fakeRepoURL,
+			assertions: func(t *testing.T, _ *cache.Cache, creds *credentials.Credentials, err error) {
+				assert.Nil(t, creds)
+				assert.NoError(t, err)
 			},
 		},
 	}
-	for _, testCase := range testCases {
-		t.Run(testCase.name, func(t *testing.T) {
-			creds, err := testCase.helper.getCredentials(
-				context.Background(),
-				testProject,
-				testCase.credType,
-				testCase.repoURL,
-				nil, // Secret not used by this helper
-			)
-			testCase.assertions(t, creds, testCase.helper.tokenCache, err)
+
+	for _, tt := range testCases {
+		t.Run(tt.name, func(t *testing.T) {
+			if tt.setupCache != nil {
+				tt.setupCache(tt.provider.tokenCache)
+			}
+
+			creds, err := tt.provider.GetCredentials(ctx, tt.project, tt.credType, tt.repoURL, nil)
+			tt.assertions(t, tt.provider.tokenCache, creds, err)
 		})
 	}
 }
diff --git a/internal/credentials/kubernetes/gar/common_test.go b/internal/credentials/kubernetes/gar/common_test.go
new file mode 100644
index 00000000..23364201
--- /dev/null
+++ b/internal/credentials/kubernetes/gar/common_test.go
@@ -0,0 +1,30 @@
+package gar
+
+import (
+	"testing"
+
+	"github.com/stretchr/testify/assert"
+)
+
+func Test_tokenCacheKey(t *testing.T) {
+	tests := []struct {
+		key  string
+		want string
+	}{
+		{
+			key:  "test123",
+			want: "ecd71870d1963316a97e3ac3408c9835ad8cf0f3c1bc703527c30265534f75ae",
+		},
+		{
+			key:  "123test",
+			want: "a8327d4a49d4631631d090a72297d8d749337a30e6eb0416bd3655b71e36481b",
+		},
+	}
+	for _, tt := range tests {
+		t.Run("", func(t *testing.T) {
+			for i := 0; i < 100000; i++ {
+				assert.Equal(t, tt.want, tokenCacheKey(tt.key))
+			}
+		})
+	}
+}
diff --git a/internal/credentials/kubernetes/gar/service_account_key_test.go b/internal/credentials/kubernetes/gar/service_account_key_test.go
index eaa27c9e..9eacfb0c 100644
--- a/internal/credentials/kubernetes/gar/service_account_key_test.go
+++ b/internal/credentials/kubernetes/gar/service_account_key_test.go
@@ -2,159 +2,209 @@ package gar
 
 import (
 	"context"
-	"encoding/base64"
-	"fmt"
+	"errors"
 	"testing"
 
 	"github.com/patrickmn/go-cache"
-	"github.com/stretchr/testify/require"
-	corev1 "k8s.io/api/core/v1"
+	"github.com/stretchr/testify/assert"
 
 	"github.com/akuity/kargo/internal/credentials"
 )
 
-func TestServiceAccountKeyCredentialHelper(t *testing.T) {
-	const (
-		testGCPProjectID      = "fake-project-123456"
-		testRegion            = "fake-region"
-		testServiceAccountKey = "fake-key"
-		testAccessToken       = "fake-token"
-	)
-	testRepoURL := fmt.Sprintf("%s-docker.pkg.dev/%s/debian/debian", testRegion, testGCPProjectID)
-	testEncodedServiceAccountKey := base64.StdEncoding.EncodeToString([]byte(testServiceAccountKey))
+func TestNewServiceAccountKeyProvider(t *testing.T) {
+	provider := NewServiceAccountKeyProvider()
+
+	assert.NotNil(t, provider)
+	assert.NotNil(t, provider.tokenCache)
+	assert.NotNil(t, provider.getAccessTokenFn)
+}
 
-	warmTokenCache := cache.New(0, 0)
-	warmTokenCache.Set(
-		(&serviceAccountKeyCredentialHelper{}).tokenCacheKey(testEncodedServiceAccountKey),
-		testAccessToken,
-		cache.DefaultExpiration,
+func TestServiceAccountKeyProvider_Supports(t *testing.T) {
+	const (
+		fakeGCRRepoURL        = "gcr.io/my-project/my-repo"
+		fakeGARRepoURL        = "us-central1-docker.pkg.dev/my-project/my-repo"
+		fakeServiceAccountKey = "base64-encoded-service-account-key"
 	)
 
 	testCases := []struct {
-		name       string
-		credType   credentials.Type
-		repoURL    string
-		secret     *corev1.Secret
-		helper     *serviceAccountKeyCredentialHelper
-		assertions func(*testing.T, *credentials.Credentials, *cache.Cache, error)
+		name     string
+		credType credentials.Type
+		repoURL  string
+		data     map[string][]byte
+		expected bool
 	}{
 		{
-			name:     "cred type is not image",
-			credType: credentials.TypeGit,
-			secret:   &corev1.Secret{},
-			helper:   &serviceAccountKeyCredentialHelper{},
-			assertions: func(t *testing.T, creds *credentials.Credentials, _ *cache.Cache, err error) {
-				require.NoError(t, err)
-				require.Nil(t, creds)
+			name:     "valid GAR repo with service account key",
+			credType: credentials.TypeImage,
+			repoURL:  fakeGARRepoURL,
+			data: map[string][]byte{
+				serviceAccountKeyKey: []byte(fakeServiceAccountKey),
 			},
+			expected: true,
 		},
 		{
-			name:     "secret is nil",
+			name:     "valid GCR repo with service account key",
 			credType: credentials.TypeImage,
-			helper:   &serviceAccountKeyCredentialHelper{},
-			assertions: func(t *testing.T, creds *credentials.Credentials, _ *cache.Cache, err error) {
-				require.NoError(t, err)
-				require.Nil(t, creds)
+			repoURL:  fakeGCRRepoURL,
+			data: map[string][]byte{
+				serviceAccountKeyKey: []byte(fakeServiceAccountKey),
 			},
+			expected: true,
 		},
 		{
-			name:     "not a gar url",
-			credType: credentials.TypeImage,
-			repoURL:  "not-a-gar-url",
-			secret:   &corev1.Secret{},
-			helper:   &serviceAccountKeyCredentialHelper{},
-			assertions: func(t *testing.T, creds *credentials.Credentials, _ *cache.Cache, err error) {
-				require.NoError(t, err)
-				require.Nil(t, creds)
+			name:     "wrong credential type",
+			credType: credentials.TypeHelm,
+			repoURL:  fakeGARRepoURL,
+			data: map[string][]byte{
+				serviceAccountKeyKey: []byte(fakeServiceAccountKey),
 			},
+			expected: false,
 		},
 		{
-			name:     "no service account key provided",
+			name:     "missing service account key",
 			credType: credentials.TypeImage,
-			repoURL:  testRepoURL,
-			secret:   &corev1.Secret{},
-			helper:   &serviceAccountKeyCredentialHelper{},
-			assertions: func(t *testing.T, creds *credentials.Credentials, _ *cache.Cache, err error) {
-				require.NoError(t, err)
-				require.Nil(t, creds)
+			repoURL:  fakeGARRepoURL,
+			data:     map[string][]byte{},
+			expected: false,
+		},
+		{
+			name:     "nil data",
+			credType: credentials.TypeImage,
+			repoURL:  fakeGARRepoURL,
+			data:     nil,
+			expected: false,
+		},
+		{
+			name:     "non-GAR/GCR URL",
+			credType: credentials.TypeImage,
+			repoURL:  "docker.io/library/nginx",
+			data: map[string][]byte{
+				serviceAccountKeyKey: []byte(fakeServiceAccountKey),
+			},
+			expected: false,
+		},
+	}
+
+	for _, tt := range testCases {
+		t.Run(tt.name, func(t *testing.T) {
+			provider := NewServiceAccountKeyProvider()
+			result := provider.Supports(tt.credType, tt.repoURL, tt.data)
+			assert.Equal(t, tt.expected, result)
+		})
+	}
+}
+
+func TestServiceAccountKeyProvider_GetCredentials(t *testing.T) {
+	ctx := context.Background()
+
+	const (
+		fakeGARRepoURL        = "us-central1-docker.pkg.dev/my-project/my-repo"
+		fakeServiceAccountKey = "base64-encoded-service-account-key"
+		fakeAccessToken       = "fake-access-token"
+	)
+
+	testCases := []struct {
+		name             string
+		credType         credentials.Type
+		repoURL          string
+		data             map[string][]byte
+		getAccessTokenFn func(ctx context.Context, encodedServiceAccountKey string) (string, error)
+		setupCache       func(c *cache.Cache)
+		assertions       func(t *testing.T, c *cache.Cache, creds *credentials.Credentials, err error)
+	}{
+		{
+			name:     "unsupported credentials",
+			credType: credentials.TypeHelm,
+			repoURL:  fakeGARRepoURL,
+			data:     map[string][]byte{},
+			assertions: func(t *testing.T, _ *cache.Cache, creds *credentials.Credentials, err error) {
+				assert.Nil(t, creds)
+				assert.NoError(t, err)
 			},
 		},
 		{
 			name:     "cache hit",
 			credType: credentials.TypeImage,
-			repoURL:  testRepoURL,
-			secret: &corev1.Secret{
-				Data: map[string][]byte{
-					serviceAccountKeyKey: []byte(testEncodedServiceAccountKey),
-				},
+			repoURL:  fakeGARRepoURL,
+			data: map[string][]byte{
+				serviceAccountKeyKey: []byte(fakeServiceAccountKey),
 			},
-			helper: &serviceAccountKeyCredentialHelper{
-				tokenCache: warmTokenCache,
+			setupCache: func(c *cache.Cache) {
+				cacheKey := tokenCacheKey(fakeServiceAccountKey)
+				c.Set(cacheKey, fakeAccessToken, cache.DefaultExpiration)
 			},
-			assertions: func(t *testing.T, creds *credentials.Credentials, _ *cache.Cache, err error) {
-				require.NoError(t, err)
-				require.NotNil(t, creds)
-				require.Equal(t, accessTokenUsername, creds.Username)
-				require.Equal(t, testAccessToken, creds.Password)
+			assertions: func(t *testing.T, _ *cache.Cache, creds *credentials.Credentials, err error) {
+				assert.NoError(t, err)
+				assert.NotNil(t, creds)
+				assert.Equal(t, accessTokenUsername, creds.Username)
+				assert.Equal(t, fakeAccessToken, creds.Password)
 			},
 		},
 		{
-			name:     "cache miss; error getting access token",
+			name:     "cache miss, successful token fetch",
 			credType: credentials.TypeImage,
-			repoURL:  testRepoURL,
-			secret: &corev1.Secret{
-				Data: map[string][]byte{
-					serviceAccountKeyKey: []byte(testEncodedServiceAccountKey),
-				},
-			},
-			helper: &serviceAccountKeyCredentialHelper{
-				tokenCache: cache.New(0, 0),
-				getAccessTokenFn: func(context.Context, string) (string, error) {
-					return "", fmt.Errorf("something went wrong")
-				},
-			},
-			assertions: func(t *testing.T, _ *credentials.Credentials, _ *cache.Cache, err error) {
-				require.ErrorContains(t, err, "error getting GCP access token")
-				require.ErrorContains(t, err, "something went wrong")
+			repoURL:  fakeGARRepoURL,
+			data: map[string][]byte{
+				serviceAccountKeyKey: []byte(fakeServiceAccountKey),
+			},
+			getAccessTokenFn: func(_ context.Context, _ string) (string, error) {
+				return fakeAccessToken, nil
+			},
+			assertions: func(t *testing.T, c *cache.Cache, creds *credentials.Credentials, err error) {
+				assert.NoError(t, err)
+				assert.NotNil(t, creds)
+				assert.Equal(t, accessTokenUsername, creds.Username)
+				assert.Equal(t, fakeAccessToken, creds.Password)
+
+				// Verify the token was cached
+				cachedToken, found := c.Get(tokenCacheKey(fakeServiceAccountKey))
+				assert.True(t, found)
+				assert.Equal(t, fakeAccessToken, cachedToken)
 			},
 		},
 		{
-			name:     "cache miss; success",
+			name:     "error in getAccessToken",
 			credType: credentials.TypeImage,
-			repoURL:  testRepoURL,
-			secret: &corev1.Secret{
-				Data: map[string][]byte{
-					serviceAccountKeyKey: []byte(testEncodedServiceAccountKey),
-				},
-			},
-			helper: &serviceAccountKeyCredentialHelper{
-				tokenCache: cache.New(0, 0),
-				getAccessTokenFn: func(context.Context, string) (string, error) {
-					return testAccessToken, nil
-				},
-			},
-			assertions: func(t *testing.T, creds *credentials.Credentials, c *cache.Cache, err error) {
-				require.NoError(t, err)
-				require.NotNil(t, creds)
-				require.Equal(t, accessTokenUsername, creds.Username)
-				require.Equal(t, testAccessToken, creds.Password)
-				_, found := c.Get(
-					(&serviceAccountKeyCredentialHelper{}).tokenCacheKey(testEncodedServiceAccountKey),
-				)
-				require.True(t, found)
+			repoURL:  fakeGARRepoURL,
+			data: map[string][]byte{
+				serviceAccountKeyKey: []byte(fakeServiceAccountKey),
+			},
+			getAccessTokenFn: func(_ context.Context, _ string) (string, error) {
+				return "", errors.New("access token error")
+			},
+			assertions: func(t *testing.T, _ *cache.Cache, creds *credentials.Credentials, err error) {
+				assert.ErrorContains(t, err, "error getting GCP access token")
+				assert.Nil(t, creds)
+			},
+		},
+		{
+			name:     "empty token from getAccessToken",
+			credType: credentials.TypeImage,
+			repoURL:  fakeGARRepoURL,
+			data: map[string][]byte{
+				serviceAccountKeyKey: []byte(fakeServiceAccountKey),
+			},
+			getAccessTokenFn: func(_ context.Context, _ string) (string, error) {
+				return "", nil
+			},
+			assertions: func(t *testing.T, _ *cache.Cache, creds *credentials.Credentials, err error) {
+				assert.Nil(t, creds)
+				assert.NoError(t, err)
 			},
 		},
 	}
-	for _, testCase := range testCases {
-		t.Run(testCase.name, func(t *testing.T) {
-			creds, err := testCase.helper.getCredentials(
-				context.Background(),
-				"", // project is irrelevant for this helper
-				testCase.credType,
-				testCase.repoURL,
-				testCase.secret,
-			)
-			testCase.assertions(t, creds, testCase.helper.tokenCache, err)
+
+	for _, tt := range testCases {
+		t.Run(tt.name, func(t *testing.T) {
+			provider := NewServiceAccountKeyProvider()
+			provider.getAccessTokenFn = tt.getAccessTokenFn
+
+			if tt.setupCache != nil {
+				tt.setupCache(provider.tokenCache)
+			}
+
+			creds, err := provider.GetCredentials(ctx, "", tt.credType, tt.repoURL, tt.data)
+			tt.assertions(t, provider.tokenCache, creds, err)
 		})
 	}
 }
diff --git a/internal/credentials/kubernetes/gar/workload_identity_federation_test.go b/internal/credentials/kubernetes/gar/workload_identity_federation_test.go
index 657a4993..955b2b67 100644
--- a/internal/credentials/kubernetes/gar/workload_identity_federation_test.go
+++ b/internal/credentials/kubernetes/gar/workload_identity_federation_test.go
@@ -4,144 +4,208 @@ import (
 	"context"
 	"fmt"
 	"testing"
+	"time"
 
 	"github.com/patrickmn/go-cache"
-	"github.com/stretchr/testify/require"
+	"github.com/stretchr/testify/assert"
 
 	"github.com/akuity/kargo/internal/credentials"
 )
 
-func TestWorkloadIdentityFederationCredentialHelper(t *testing.T) {
+func TestWorkloadIdentityFederationProvider_Supports(t *testing.T) {
 	const (
-		testGCPProjectID = "fake-project-123456"
-		testRegion       = "fake-region"
-		testKargoProject = "fake-project"
-		testToken        = "fake-token"
+		fakeProjectID  = "test-project"
+		fakeGCRRepoURL = "gcr.io/my-project/my-repo"
+		fakeGARRepoURL = "us-central1-docker.pkg.dev/my-project/my-repo"
 	)
-	testRepoURL := fmt.Sprintf("%s-docker.pkg.dev/%s/debian/debian", testRegion, testGCPProjectID)
-
-	warmTokenCache := cache.New(0, 0)
-	warmTokenCache.Set(testKargoProject, testToken, cache.DefaultExpiration)
 
 	testCases := []struct {
-		name       string
-		credType   credentials.Type
-		repoURL    string
-		helper     *workloadIdentityFederationCredentialHelper
-		assertions func(*testing.T, *credentials.Credentials, *cache.Cache, error)
+		name     string
+		provider *WorkloadIdentityFederationProvider
+		credType credentials.Type
+		repoURL  string
+		assert   func(t *testing.T, result bool)
 	}{
 		{
-			name:     "cred type is not image",
-			credType: credentials.TypeGit,
-			helper:   &workloadIdentityFederationCredentialHelper{},
-			assertions: func(t *testing.T, creds *credentials.Credentials, _ *cache.Cache, err error) {
-				require.NoError(t, err)
-				require.Nil(t, creds)
+			name: "supports image credentials for GAR URL",
+			provider: &WorkloadIdentityFederationProvider{
+				projectID: fakeProjectID,
+			},
+			credType: credentials.TypeImage,
+			repoURL:  fakeGARRepoURL,
+			assert: func(t *testing.T, result bool) {
+				assert.True(t, result, "should support GAR URL with image credentials")
 			},
 		},
 		{
-			name:     "GCP Workload Identity Federation not in use",
+			name: "supports image credentials for GCR URL",
+			provider: &WorkloadIdentityFederationProvider{
+				projectID: fakeProjectID,
+			},
 			credType: credentials.TypeImage,
-			helper:   &workloadIdentityFederationCredentialHelper{},
-			assertions: func(t *testing.T, creds *credentials.Credentials, _ *cache.Cache, err error) {
-				require.NoError(t, err)
-				require.Nil(t, creds)
+			repoURL:  fakeGCRRepoURL,
+			assert: func(t *testing.T, result bool) {
+				assert.True(t, result, "should support GCR URL with image credentials")
 			},
 		},
 		{
-			name:     "repo URL does not match Artifact Registry URL regex",
-			credType: credentials.TypeImage,
-			repoURL:  "ghcr.io/fake-org/fake-repo",
-			helper: &workloadIdentityFederationCredentialHelper{
-				gcpProjectID: testGCPProjectID,
+			name: "rejects non-image credentials",
+			provider: &WorkloadIdentityFederationProvider{
+				projectID: fakeProjectID,
 			},
-			assertions: func(t *testing.T, creds *credentials.Credentials, _ *cache.Cache, err error) {
-				require.NoError(t, err)
-				require.Nil(t, creds)
+			credType: credentials.TypeGit,
+			repoURL:  fakeGARRepoURL,
+			assert: func(t *testing.T, result bool) {
+				assert.False(t, result, "should not support non-image credentials")
 			},
 		},
 		{
-			name:     "cache hit",
-			credType: credentials.TypeImage,
-			repoURL:  testRepoURL,
-			helper: &workloadIdentityFederationCredentialHelper{
-				gcpProjectID: testGCPProjectID,
-				tokenCache:   warmTokenCache,
+			name: "rejects non-GAR/GCR URL",
+			provider: &WorkloadIdentityFederationProvider{
+				projectID: fakeProjectID,
 			},
-			assertions: func(t *testing.T, creds *credentials.Credentials, _ *cache.Cache, err error) {
-				require.NoError(t, err)
-				require.NotNil(t, creds)
-				require.Equal(t, accessTokenUsername, creds.Username)
-				require.Equal(t, testToken, creds.Password)
+			credType: credentials.TypeImage,
+			repoURL:  "docker.io/library/alpine",
+			assert: func(t *testing.T, result bool) {
+				assert.False(t, result, "should not support non-GAR/GCR URL")
 			},
 		},
 		{
-			name:     "cache miss; error getting access token",
+			name:     "rejects empty project ID",
+			provider: &WorkloadIdentityFederationProvider{},
 			credType: credentials.TypeImage,
-			repoURL:  testRepoURL,
-			helper: &workloadIdentityFederationCredentialHelper{
-				gcpProjectID: testGCPProjectID,
-				tokenCache:   cache.New(0, 0),
-				getAccessTokenFn: func(context.Context, string) (string, error) {
-					return "", fmt.Errorf("something went wrong")
-				},
+			repoURL:  fakeGARRepoURL,
+			assert: func(t *testing.T, result bool) {
+				assert.False(t, result, "should not support when project ID is empty")
 			},
-			assertions: func(t *testing.T, _ *credentials.Credentials, _ *cache.Cache, err error) {
-				require.ErrorContains(t, err, "error getting GCP access token")
-				require.ErrorContains(t, err, "something went wrong")
+		},
+	}
+
+	for _, tt := range testCases {
+		t.Run(tt.name, func(t *testing.T) {
+			result := tt.provider.Supports(tt.credType, tt.repoURL, nil)
+			tt.assert(t, result)
+		})
+	}
+}
+
+func TestWorkloadIdentityFederationProvider_GetCredentials(t *testing.T) {
+	const (
+		fakeProjectID  = "test-project"
+		fakeProject    = "kargo-project"
+		fakeGCRRepoURL = "gcr.io/my-project/my-repo"
+		fakeToken      = "fake-token"
+	)
+
+	testCases := []struct {
+		name       string
+		provider   *WorkloadIdentityFederationProvider
+		setupCache func(c *cache.Cache)
+		project    string
+		credType   credentials.Type
+		repoURL    string
+		assert     func(t *testing.T, c *cache.Cache, creds *credentials.Credentials, err error)
+	}{
+		{
+			name: "returns nil when not supported",
+			provider: &WorkloadIdentityFederationProvider{
+				projectID: fakeProjectID,
+			},
+			project:  fakeProject,
+			credType: credentials.TypeGit, // Not supported
+			repoURL:  fakeGCRRepoURL,
+			assert: func(t *testing.T, _ *cache.Cache, creds *credentials.Credentials, err error) {
+				assert.NoError(t, err)
+				assert.Nil(t, creds)
 			},
 		},
 		{
-			name:     "cache miss; success (artifact registry)",
+			name: "cache hit",
+			provider: &WorkloadIdentityFederationProvider{
+				projectID:  fakeProjectID,
+				tokenCache: cache.New(10*time.Hour, time.Hour),
+			},
+			setupCache: func(c *cache.Cache) {
+				c.Set(tokenCacheKey(fakeProject), fakeToken, cache.DefaultExpiration)
+			},
+			project:  fakeProject,
 			credType: credentials.TypeImage,
-			repoURL:  testRepoURL,
-			helper: &workloadIdentityFederationCredentialHelper{
-				gcpProjectID: testGCPProjectID,
-				tokenCache:   cache.New(0, 0),
-				getAccessTokenFn: func(context.Context, string) (string, error) {
-					return testToken, nil
+			repoURL:  fakeGCRRepoURL,
+			assert: func(t *testing.T, _ *cache.Cache, creds *credentials.Credentials, err error) {
+				assert.NoError(t, err)
+				assert.NotNil(t, creds)
+				assert.Equal(t, accessTokenUsername, creds.Username)
+				assert.Equal(t, fakeToken, creds.Password)
+			},
+		},
+		{
+			name: "cache miss, successful token fetch",
+			provider: &WorkloadIdentityFederationProvider{
+				projectID:  fakeProjectID,
+				tokenCache: cache.New(10*time.Hour, time.Hour),
+				getAccessTokenFn: func(_ context.Context, _ string) (string, error) {
+					return fakeToken, nil
 				},
 			},
-			assertions: func(t *testing.T, creds *credentials.Credentials, c *cache.Cache, err error) {
-				require.NoError(t, err)
-				require.NotNil(t, creds)
-				require.Equal(t, accessTokenUsername, creds.Username)
-				require.Equal(t, testToken, creds.Password)
-				_, found := c.Get(testKargoProject)
-				require.True(t, found)
+			project:  fakeProject,
+			credType: credentials.TypeImage,
+			repoURL:  fakeGCRRepoURL,
+			assert: func(t *testing.T, c *cache.Cache, creds *credentials.Credentials, err error) {
+				assert.NoError(t, err)
+				assert.NotNil(t, creds)
+				assert.Equal(t, accessTokenUsername, creds.Username)
+				assert.Equal(t, fakeToken, creds.Password)
+
+				// Verify the token was cached
+				token, found := c.Get(tokenCacheKey(fakeProject))
+				assert.True(t, found)
+				assert.Equal(t, fakeToken, token)
 			},
 		},
 		{
-			name:     "cache miss; success (container registry; legacy)",
+			name: "error in getAccessToken",
+			provider: &WorkloadIdentityFederationProvider{
+				projectID:  fakeProjectID,
+				tokenCache: cache.New(10*time.Hour, time.Hour),
+				getAccessTokenFn: func(_ context.Context, _ string) (string, error) {
+					return "", fmt.Errorf("token fetch error")
+				},
+			},
+			project:  fakeProject,
 			credType: credentials.TypeImage,
-			repoURL:  "us.gcr.io/fake-project/fake-image:fake-tag",
-			helper: &workloadIdentityFederationCredentialHelper{
-				gcpProjectID: testGCPProjectID,
-				tokenCache:   cache.New(0, 0),
-				getAccessTokenFn: func(context.Context, string) (string, error) {
-					return testToken, nil
+			repoURL:  fakeGCRRepoURL,
+			assert: func(t *testing.T, _ *cache.Cache, creds *credentials.Credentials, err error) {
+				assert.ErrorContains(t, err, "token fetch error")
+				assert.Nil(t, creds)
+			},
+		},
+		{
+			name: "empty token from getAccessToken",
+			provider: &WorkloadIdentityFederationProvider{
+				projectID:  fakeProjectID,
+				tokenCache: cache.New(10*time.Hour, time.Hour),
+				getAccessTokenFn: func(_ context.Context, _ string) (string, error) {
+					return "", nil
 				},
 			},
-			assertions: func(t *testing.T, creds *credentials.Credentials, c *cache.Cache, err error) {
-				require.NoError(t, err)
-				require.NotNil(t, creds)
-				require.Equal(t, accessTokenUsername, creds.Username)
-				require.Equal(t, testToken, creds.Password)
-				_, found := c.Get(testKargoProject)
-				require.True(t, found)
+			project:  fakeProject,
+			credType: credentials.TypeImage,
+			repoURL:  fakeGCRRepoURL,
+			assert: func(t *testing.T, _ *cache.Cache, creds *credentials.Credentials, err error) {
+				assert.NoError(t, err)
+				assert.Nil(t, creds)
 			},
 		},
 	}
-	for _, testCase := range testCases {
-		t.Run(testCase.name, func(t *testing.T) {
-			creds, err := testCase.helper.getCredentials(
-				context.Background(),
-				testKargoProject,
-				testCase.credType,
-				testCase.repoURL,
-				nil, // Secret not used by this helper
-			)
-			testCase.assertions(t, creds, testCase.helper.tokenCache, err)
+
+	for _, tt := range testCases {
+		t.Run(tt.name, func(t *testing.T) {
+			if tt.setupCache != nil {
+				tt.setupCache(tt.provider.tokenCache)
+			}
+
+			creds, err := tt.provider.GetCredentials(context.Background(), tt.project, tt.credType, tt.repoURL, nil)
+			tt.assert(t, tt.provider.tokenCache, creds, err)
 		})
 	}
 }
diff --git a/internal/credentials/kubernetes/github/app_test.go b/internal/credentials/kubernetes/github/app_test.go
index 87f29323..7c3cb217 100644
--- a/internal/credentials/kubernetes/github/app_test.go
+++ b/internal/credentials/kubernetes/github/app_test.go
@@ -3,193 +3,323 @@ package github
 import (
 	"context"
 	"encoding/base64"
-	"fmt"
-	"strconv"
+	"errors"
 	"testing"
 
 	"github.com/patrickmn/go-cache"
+	"github.com/stretchr/testify/assert"
 	"github.com/stretchr/testify/require"
-	corev1 "k8s.io/api/core/v1"
 
 	"github.com/akuity/kargo/internal/credentials"
 )
 
-func TestAppCredentialHelper(t *testing.T) {
-	const (
-		testAppIDStr          = "12345"
-		testInstallationIDStr = "67890"
-		testPrivateKey        = "fake-private-key"
-		testAccessToken       = "fake-access-token"
-	)
-	testInstallationID, err := strconv.ParseInt(testInstallationIDStr, 10, 64)
-	require.NoError(t, err)
-	testAppID, err := strconv.ParseInt(testAppIDStr, 10, 64)
-	require.NoError(t, err)
-
-	warmTokenCache := cache.New(0, 0)
-	warmTokenCache.Set(
-		(&appCredentialHelper{}).tokenCacheKey(testAppID, testInstallationID, testPrivateKey),
-		testAccessToken,
-		cache.DefaultExpiration,
-	)
+func TestNewAppCredentialProvider(t *testing.T) {
+	provider := NewAppCredentialProvider()
+
+	assert.NotNil(t, provider)
+	assert.NotNil(t, provider.tokenCache)
+	assert.NotNil(t, provider.getAccessTokenFn)
+}
+
+func TestAppCredentialProvider_Supports(t *testing.T) {
+	p := NewAppCredentialProvider()
 
 	testCases := []struct {
-		name       string
-		credType   credentials.Type
-		secret     *corev1.Secret
-		helper     *appCredentialHelper
-		assertions func(*testing.T, *credentials.Credentials, *cache.Cache, error)
+		name     string
+		credType credentials.Type
+		repoURL  string
+		data     map[string][]byte
+		expected bool
 	}{
 		{
-			name:     "cred type is not git",
-			credType: credentials.TypeImage,
-			helper:   &appCredentialHelper{},
-			assertions: func(t *testing.T, creds *credentials.Credentials, _ *cache.Cache, err error) {
-				require.NoError(t, err)
-				require.Nil(t, creds)
+			name:     "supports valid Git credential with all required fields",
+			credType: credentials.TypeGit,
+			repoURL:  "https://github.com/akuity/kargo",
+			data: map[string][]byte{
+				appIDKey:          []byte("123"),
+				installationIDKey: []byte("456"),
+				privateKeyKey:     []byte("private-key"),
 			},
+			expected: true,
 		},
 		{
-			name:     "secret is nil",
+			name:     "does not support non-Git credential type",
+			credType: credentials.Type("other"),
+			repoURL:  "https://github.com/akuity/kargo",
+			data: map[string][]byte{
+				appIDKey:          []byte("123"),
+				installationIDKey: []byte("456"),
+				privateKeyKey:     []byte("private-key"),
+			},
+			expected: false,
+		},
+		{
+			name:     "does not support empty data",
 			credType: credentials.TypeGit,
-			helper:   &appCredentialHelper{},
-			assertions: func(t *testing.T, creds *credentials.Credentials, _ *cache.Cache, err error) {
-				require.NoError(t, err)
-				require.Nil(t, creds)
+			repoURL:  "https://github.com/akuity/kargo",
+			data:     map[string][]byte{},
+			expected: false,
+		},
+		{
+			name:     "does not support missing appID",
+			credType: credentials.TypeGit,
+			repoURL:  "https://github.com/akuity/kargo",
+			data: map[string][]byte{
+				installationIDKey: []byte("456"),
+				privateKeyKey:     []byte("private-key"),
 			},
+			expected: false,
 		},
 		{
-			name:     "no github details provided",
+			name:     "does not support missing installationID",
 			credType: credentials.TypeGit,
-			secret:   &corev1.Secret{},
-			helper:   &appCredentialHelper{},
-			assertions: func(t *testing.T, creds *credentials.Credentials, _ *cache.Cache, err error) {
-				require.NoError(t, err)
-				require.Nil(t, creds)
+			repoURL:  "https://github.com/akuity/kargo",
+			data: map[string][]byte{
+				appIDKey:      []byte("123"),
+				privateKeyKey: []byte("private-key"),
 			},
+			expected: false,
 		},
 		{
-			name:     "app ID missing",
+			name:     "does not support missing privateKey",
 			credType: credentials.TypeGit,
-			secret: &corev1.Secret{
-				Data: map[string][]byte{
-					installationIDKey: []byte(testInstallationIDStr),
-					privateKeyKey:     []byte(testPrivateKey),
-				},
+			repoURL:  "https://github.com/akuity/kargo",
+			data: map[string][]byte{
+				appIDKey:          []byte("123"),
+				installationIDKey: []byte("456"),
 			},
-			helper: &appCredentialHelper{},
-			assertions: func(t *testing.T, _ *credentials.Credentials, _ *cache.Cache, err error) {
-				require.ErrorContains(t, err, "must all be set or all be unset")
+			expected: false,
+		},
+	}
+
+	for _, tt := range testCases {
+		t.Run(tt.name, func(t *testing.T) {
+			result := p.Supports(tt.credType, tt.repoURL, tt.data)
+			assert.Equal(t, tt.expected, result)
+		})
+	}
+}
+
+func TestAppCredentialProvider_GetCredentials(t *testing.T) {
+	ctx := context.Background()
+
+	testCases := []struct {
+		name             string
+		credType         credentials.Type
+		repoURL          string
+		data             map[string][]byte
+		getAccessTokenFn func(appID, installationID int64, encodedPrivateKey, baseURL string) (string, error)
+		assertions       func(t *testing.T, creds *credentials.Credentials, err error)
+	}{
+		{
+			name:     "unsupported credential type",
+			credType: credentials.Type("other"),
+			repoURL:  "https://github.com/akuity/kargo",
+			data: map[string][]byte{
+				appIDKey:          []byte("123"),
+				installationIDKey: []byte("456"),
+				privateKeyKey:     []byte("private-key"),
+			},
+			assertions: func(t *testing.T, creds *credentials.Credentials, err error) {
+				assert.Nil(t, creds)
+				assert.NoError(t, err)
 			},
 		},
 		{
-			name:     "installation ID missing",
+			name:     "invalid app ID",
 			credType: credentials.TypeGit,
-			secret: &corev1.Secret{
-				Data: map[string][]byte{
-					appIDKey:      []byte(testAppIDStr),
-					privateKeyKey: []byte(testPrivateKey),
-				},
+			repoURL:  "https://github.com/akuity/kargo",
+			data: map[string][]byte{
+				appIDKey:          []byte("invalid"),
+				installationIDKey: []byte("456"),
+				privateKeyKey:     []byte("private-key"),
 			},
-			helper: &appCredentialHelper{},
-			assertions: func(t *testing.T, _ *credentials.Credentials, _ *cache.Cache, err error) {
-				require.ErrorContains(t, err, "must all be set or all be unset")
+			assertions: func(t *testing.T, creds *credentials.Credentials, err error) {
+				assert.Nil(t, creds)
+				assert.Error(t, err)
+				assert.ErrorContains(t, err, "error parsing app ID")
 			},
 		},
 		{
-			name:     "private key missing",
+			name:     "invalid installation ID",
 			credType: credentials.TypeGit,
-			secret: &corev1.Secret{
-				Data: map[string][]byte{
-					appIDKey:          []byte(testAppIDStr),
-					installationIDKey: []byte(testInstallationIDStr),
-				},
+			repoURL:  "https://github.com/akuity/kargo",
+			data: map[string][]byte{
+				appIDKey:          []byte("123"),
+				installationIDKey: []byte("invalid"),
+				privateKeyKey:     []byte("private-key"),
 			},
-			helper: &appCredentialHelper{},
-			assertions: func(t *testing.T, _ *credentials.Credentials, _ *cache.Cache, err error) {
-				require.ErrorContains(t, err, "must all be set or all be unset")
+			assertions: func(t *testing.T, creds *credentials.Credentials, err error) {
+				assert.Nil(t, creds)
+				assert.Error(t, err)
+				assert.ErrorContains(t, err, "error parsing installation ID")
 			},
 		},
 		{
-			name:     "cache hit",
+			name:     "invalid repo URL",
 			credType: credentials.TypeGit,
-			secret: &corev1.Secret{
-				Data: map[string][]byte{
-					appIDKey:          []byte(testAppIDStr),
-					installationIDKey: []byte(testInstallationIDStr),
-					privateKeyKey:     []byte(testPrivateKey),
-				},
-			},
-			helper: &appCredentialHelper{
-				tokenCache: warmTokenCache,
+			repoURL:  "://invalid",
+			data: map[string][]byte{
+				appIDKey:          []byte("123"),
+				installationIDKey: []byte("456"),
+				privateKeyKey:     []byte("private-key"),
 			},
-			assertions: func(t *testing.T, creds *credentials.Credentials, _ *cache.Cache, err error) {
-				require.NoError(t, err)
-				require.NotNil(t, creds)
-				require.Equal(t, "kargo", creds.Username)
-				require.Equal(t, testAccessToken, creds.Password)
+			assertions: func(t *testing.T, creds *credentials.Credentials, err error) {
+				assert.Nil(t, creds)
+				assert.Error(t, err)
+				assert.ErrorContains(t, err, "error extracting base URL")
 			},
 		},
 		{
-			name:     "cache miss; error getting access token",
+			name:     "error getting token",
 			credType: credentials.TypeGit,
-			secret: &corev1.Secret{
-				Data: map[string][]byte{
-					appIDKey:          []byte(testAppIDStr),
-					installationIDKey: []byte(testInstallationIDStr),
-					privateKeyKey:     []byte(testPrivateKey),
-				},
+			repoURL:  "https://github.com/akuity/kargo",
+			data: map[string][]byte{
+				appIDKey:          []byte("123"),
+				installationIDKey: []byte("456"),
+				privateKeyKey:     []byte("private-key"),
 			},
-			helper: &appCredentialHelper{
-				tokenCache: cache.New(0, 0),
-				getAccessTokenFn: func(int64, int64, string) (string, error) {
-					return "", fmt.Errorf("something went wrong")
-				},
+			getAccessTokenFn: func(_, _ int64, _, _ string) (string, error) {
+				return "", errors.New("token error")
 			},
-			assertions: func(t *testing.T, _ *credentials.Credentials, _ *cache.Cache, err error) {
-				require.ErrorContains(t, err, "error getting installation access token")
-				require.ErrorContains(t, err, "something went wrong")
+			assertions: func(t *testing.T, creds *credentials.Credentials, err error) {
+				assert.Nil(t, creds)
+				assert.Error(t, err)
+				assert.ErrorContains(t, err, "token error")
 			},
 		},
 		{
-			name:     "cache miss; success",
+			name:     "successful token retrieval",
 			credType: credentials.TypeGit,
-			secret: &corev1.Secret{
-				Data: map[string][]byte{
-					appIDKey:          []byte(testAppIDStr),
-					installationIDKey: []byte(testInstallationIDStr),
-					privateKeyKey:     []byte(testPrivateKey),
-				},
-			},
-			helper: &appCredentialHelper{
-				tokenCache: cache.New(0, 0),
-				getAccessTokenFn: func(int64, int64, string) (string, error) {
-					return testAccessToken, nil
-				},
-			},
-			assertions: func(t *testing.T, creds *credentials.Credentials, _ *cache.Cache, err error) {
-				require.NoError(t, err)
-				require.NotNil(t, creds)
-				require.Equal(t, "kargo", creds.Username)
-				require.Equal(t, testAccessToken, creds.Password)
+			repoURL:  "https://github.com/akuity/kargo",
+			data: map[string][]byte{
+				appIDKey:          []byte("123"),
+				installationIDKey: []byte("456"),
+				privateKeyKey:     []byte("private-key"),
+			},
+			getAccessTokenFn: func(_, _ int64, _, _ string) (string, error) {
+				return "test-token", nil
+			},
+			assertions: func(t *testing.T, creds *credentials.Credentials, err error) {
+				assert.NoError(t, err)
+				assert.NotNil(t, creds)
+				assert.Equal(t, accessTokenUsername, creds.Username)
+				assert.Equal(t, "test-token", creds.Password)
 			},
 		},
 	}
-	for _, testCase := range testCases {
-		t.Run(testCase.name, func(t *testing.T) {
-			creds, err := testCase.helper.getCredentials(
-				context.Background(),
-				"", // project is irrelevant for this helper
-				testCase.credType,
-				"", // repoURL is irrelevant for this helper
-				testCase.secret,
-			)
-			testCase.assertions(t, creds, testCase.helper.tokenCache, err)
+
+	for _, tt := range testCases {
+		t.Run(tt.name, func(t *testing.T) {
+			provider := NewAppCredentialProvider()
+			if tt.getAccessTokenFn != nil {
+				provider.getAccessTokenFn = tt.getAccessTokenFn
+			}
+
+			creds, err := provider.GetCredentials(ctx, "", tt.credType, tt.repoURL, tt.data)
+			tt.assertions(t, creds, err)
+		})
+	}
+}
+
+func TestAppCredentialProvider_getUsernameAndPassword(t *testing.T) {
+	const (
+		fakeAppID          = int64(123)
+		fakeInstallationID = int64(456)
+		fakePrivateKey     = "private-key"
+		fakeBaseURL        = "https://github.com"
+		fakeAccessToken    = "test-token"
+	)
+
+	testCases := []struct {
+		name              string
+		appID             int64
+		installationID    int64
+		encodedPrivateKey string
+		baseURL           string
+		setupCache        func(c *cache.Cache)
+		getAccessTokenFn  func(appID, installationID int64, encodedPrivateKey, baseURL string) (string, error)
+		assertions        func(t *testing.T, c *cache.Cache, creds *credentials.Credentials, err error)
+	}{
+		{
+			name:              "cache hit",
+			appID:             fakeAppID,
+			installationID:    fakeInstallationID,
+			encodedPrivateKey: fakePrivateKey,
+			baseURL:           fakeBaseURL,
+			setupCache: func(c *cache.Cache) {
+				cacheKey := tokenCacheKey(fakeBaseURL, fakeAppID, fakeInstallationID, fakePrivateKey)
+				c.Set(cacheKey, fakeAccessToken, cache.DefaultExpiration)
+			},
+			assertions: func(t *testing.T, _ *cache.Cache, creds *credentials.Credentials, err error) {
+				assert.NoError(t, err)
+				assert.NotNil(t, creds)
+				assert.Equal(t, accessTokenUsername, creds.Username)
+				assert.Equal(t, fakeAccessToken, creds.Password)
+			},
+		},
+		{
+			name:              "cache miss, successful token fetch",
+			appID:             fakeAppID,
+			installationID:    fakeInstallationID,
+			encodedPrivateKey: fakePrivateKey,
+			baseURL:           fakeBaseURL,
+			getAccessTokenFn: func(_, _ int64, _, _ string) (string, error) {
+				return fakeAccessToken, nil
+			},
+			assertions: func(t *testing.T, c *cache.Cache, creds *credentials.Credentials, err error) {
+				assert.NoError(t, err)
+				assert.NotNil(t, creds)
+				assert.Equal(t, accessTokenUsername, creds.Username)
+				assert.Equal(t, fakeAccessToken, creds.Password)
+
+				// Verify the token was cached
+				cacheKey := tokenCacheKey(fakeBaseURL, fakeAppID, fakeInstallationID, fakePrivateKey)
+				cachedToken, found := c.Get(cacheKey)
+				assert.True(t, found)
+				assert.Equal(t, fakeAccessToken, cachedToken)
+			},
+		},
+		{
+			name:              "error in getAccessToken",
+			appID:             fakeAppID,
+			installationID:    fakeInstallationID,
+			encodedPrivateKey: fakePrivateKey,
+			baseURL:           fakeBaseURL,
+			getAccessTokenFn: func(_, _ int64, _, _ string) (string, error) {
+				return "", errors.New("token error")
+			},
+			assertions: func(t *testing.T, c *cache.Cache, creds *credentials.Credentials, err error) {
+				assert.ErrorContains(t, err, "token error")
+				assert.Nil(t, creds)
+
+				// Verify the token was not cached
+				cacheKey := tokenCacheKey(fakeBaseURL, fakeAppID, fakeInstallationID, fakePrivateKey)
+				_, found := c.Get(cacheKey)
+				assert.False(t, found)
+			},
+		},
+	}
+
+	for _, tt := range testCases {
+		t.Run(tt.name, func(t *testing.T) {
+			provider := NewAppCredentialProvider()
+
+			if tt.setupCache != nil {
+				tt.setupCache(provider.tokenCache)
+			}
+
+			if tt.getAccessTokenFn != nil {
+				provider.getAccessTokenFn = tt.getAccessTokenFn
+			}
+
+			creds, err := provider.getUsernameAndPassword(tt.appID, tt.installationID, tt.encodedPrivateKey, tt.baseURL)
+			tt.assertions(t, provider.tokenCache, creds, err)
 		})
 	}
 }
 
-func TestGetAccessToken(t *testing.T) {
+func Test_decodeKey(t *testing.T) {
 	const key = "-----BEGIN PRIVATE KEY-----\nfakekey\n-----END PRIVATE KEY-----"
 	testCases := []struct {
 		name        string
@@ -225,3 +355,50 @@ func TestGetAccessToken(t *testing.T) {
 		})
 	}
 }
+
+func Test_extractBaseURL(t *testing.T) {
+	testCases := []struct {
+		name        string
+		url         string
+		expected    string
+		shouldError bool
+	}{
+		{
+			name:        "valid HTTPS URL",
+			url:         "https://github.com/akuity/kargo",
+			expected:    "https://github.com",
+			shouldError: false,
+		},
+		{
+			name:        "valid HTTP URL",
+			url:         "http://github.com/akuity/kargo",
+			expected:    "http://github.com",
+			shouldError: false,
+		},
+		{
+			name:        "invalid URL",
+			url:         "://invalid",
+			expected:    "",
+			shouldError: true,
+		},
+		{
+			name:        "URL with port",
+			url:         "https://github.com:8080/akuity/kargo",
+			expected:    "https://github.com:8080",
+			shouldError: false,
+		},
+	}
+
+	for _, tt := range testCases {
+		t.Run(tt.name, func(t *testing.T) {
+			result, err := extractBaseURL(tt.url)
+
+			if tt.shouldError {
+				assert.Error(t, err)
+			} else {
+				assert.NoError(t, err)
+				assert.Equal(t, tt.expected, result)
+			}
+		})
+	}
+}
diff --git a/internal/directives/helm_chart_updater_test.go b/internal/directives/helm_chart_updater_test.go
index eea32eab..63e86ccb 100644
--- a/internal/directives/helm_chart_updater_test.go
+++ b/internal/directives/helm_chart_updater_test.go
@@ -470,11 +470,11 @@ func Test_helmChartUpdater_updateDependencies(t *testing.T) {
 
 		// Prepare the credentials database
 		credentialsDB := &credentials.FakeDB{
-			GetFn: func(context.Context, string, credentials.Type, string) (credentials.Credentials, bool, error) {
-				return credentials.Credentials{
+			GetFn: func(context.Context, string, credentials.Type, string) (*credentials.Credentials, error) {
+				return &credentials.Credentials{
 					Username: "username",
 					Password: "password",
-				}, true, nil
+				}, nil
 			},
 		}
 
@@ -504,8 +504,8 @@ func Test_helmChartUpdater_updateDependencies(t *testing.T) {
 		{
 			name: "error loading dependency credentials",
 			credentialsDB: &credentials.FakeDB{
-				GetFn: func(context.Context, string, credentials.Type, string) (credentials.Credentials, bool, error) {
-					return credentials.Credentials{}, false, fmt.Errorf("something went wrong")
+				GetFn: func(context.Context, string, credentials.Type, string) (*credentials.Credentials, error) {
+					return nil, fmt.Errorf("something went wrong")
 				},
 			},
 			chartDependencies: []chartDependency{
@@ -522,11 +522,11 @@ func Test_helmChartUpdater_updateDependencies(t *testing.T) {
 		{
 			name: "writes repository file",
 			credentialsDB: &credentials.FakeDB{
-				GetFn: func(context.Context, string, credentials.Type, string) (credentials.Credentials, bool, error) {
-					return credentials.Credentials{
+				GetFn: func(context.Context, string, credentials.Type, string) (*credentials.Credentials, error) {
+					return &credentials.Credentials{
 						Username: "username",
 						Password: "password",
-					}, true, nil
+					}, nil
 				},
 			},
 			chartDependencies: []chartDependency{
@@ -707,11 +707,11 @@ func Test_helmChartUpdater_setupDependencyRepositories(t *testing.T) {
 		{
 			name: "HTTPS repository with credentials",
 			credentialsDB: &credentials.FakeDB{
-				GetFn: func(context.Context, string, credentials.Type, string) (credentials.Credentials, bool, error) {
-					return credentials.Credentials{
+				GetFn: func(context.Context, string, credentials.Type, string) (*credentials.Credentials, error) {
+					return &credentials.Credentials{
 						Username: "username",
 						Password: "password",
-					}, true, nil
+					}, nil
 				},
 			},
 			repositoryFile: repo.NewFile(),
@@ -737,11 +737,11 @@ func Test_helmChartUpdater_setupDependencyRepositories(t *testing.T) {
 		{
 			name: "HTTP repository without credentials",
 			credentialsDB: &credentials.FakeDB{
-				GetFn: func(context.Context, string, credentials.Type, string) (credentials.Credentials, bool, error) {
-					return credentials.Credentials{
+				GetFn: func(context.Context, string, credentials.Type, string) (*credentials.Credentials, error) {
+					return &credentials.Credentials{
 						Username: "username",
 						Password: "password",
-					}, true, nil
+					}, nil
 				},
 			},
 			repositoryFile: repo.NewFile(),
@@ -767,11 +767,11 @@ func Test_helmChartUpdater_setupDependencyRepositories(t *testing.T) {
 		{
 			name: "mixed HTTP and HTTPS repositories",
 			credentialsDB: &credentials.FakeDB{
-				GetFn: func(context.Context, string, credentials.Type, string) (credentials.Credentials, bool, error) {
-					return credentials.Credentials{
+				GetFn: func(context.Context, string, credentials.Type, string) (*credentials.Credentials, error) {
+					return &credentials.Credentials{
 						Username: "username",
 						Password: "password",
-					}, true, nil
+					}, nil
 				},
 			},
 			repositoryFile: repo.NewFile(),
@@ -818,11 +818,11 @@ func Test_helmChartUpdater_setupDependencyRepositories(t *testing.T) {
 		{
 			name: "OCI credentials",
 			credentialsDB: &credentials.FakeDB{
-				GetFn: func(context.Context, string, credentials.Type, string) (credentials.Credentials, bool, error) {
-					return credentials.Credentials{
+				GetFn: func(context.Context, string, credentials.Type, string) (*credentials.Credentials, error) {
+					return &credentials.Credentials{
 						Username: "username",
 						Password: "password",
-					}, true, nil
+					}, nil
 				},
 			},
 			buildDependencies: func(registryURL string) []chartDependency {
@@ -855,11 +855,11 @@ func Test_helmChartUpdater_setupDependencyRepositories(t *testing.T) {
 		{
 			name: "multiple HTTPS repositories",
 			credentialsDB: &credentials.FakeDB{
-				GetFn: func(context.Context, string, credentials.Type, string) (credentials.Credentials, bool, error) {
-					return credentials.Credentials{
+				GetFn: func(context.Context, string, credentials.Type, string) (*credentials.Credentials, error) {
+					return &credentials.Credentials{
 						Username: "username",
 						Password: "password",
-					}, true, nil
+					}, nil
 				},
 			},
 			repositoryFile: repo.NewFile(),
@@ -892,8 +892,8 @@ func Test_helmChartUpdater_setupDependencyRepositories(t *testing.T) {
 		{
 			name: "error getting credentials",
 			credentialsDB: &credentials.FakeDB{
-				GetFn: func(context.Context, string, credentials.Type, string) (credentials.Credentials, bool, error) {
-					return credentials.Credentials{}, false, fmt.Errorf("something went wrong")
+				GetFn: func(context.Context, string, credentials.Type, string) (*credentials.Credentials, error) {
+					return nil, fmt.Errorf("something went wrong")
 				},
 			},
 			buildDependencies: func(string) []chartDependency {
@@ -915,8 +915,8 @@ func Test_helmChartUpdater_setupDependencyRepositories(t *testing.T) {
 		{
 			name: "unauthenticated HTTPS repository",
 			credentialsDB: &credentials.FakeDB{
-				GetFn: func(context.Context, string, credentials.Type, string) (credentials.Credentials, bool, error) {
-					return credentials.Credentials{}, false, nil
+				GetFn: func(context.Context, string, credentials.Type, string) (*credentials.Credentials, error) {
+					return nil, nil
 				},
 			},
 			repositoryFile: repo.NewFile(),