Repository: akuity/kargo (2025-07-26T16:12:38Z)

Issue:
Support github clientID in addition to appID
# Checklist

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

# Proposed Feature

Add support for Github clientID where appID is used now as well

When I tried using clientID in place of appID, I got an error message around failure to convert the string to integer.

# Motivation

https://docs.github.com/en/apps/creating-github-apps/authenticating-with-a-github-app/generating-a-json-web-token-jwt-for-a-github-app

Github is recommending usage of clientID instead of the numeric appID when authenticating apps now. Kargo should support this.


Discussion:

Patch:
diff --git a/docs/docs/50-user-guide/50-security/30-managing-credentials.md b/docs/docs/50-user-guide/50-security/30-managing-credentials.md
index 3b253d5a5a..b29414a7d8 100644
--- a/docs/docs/50-user-guide/50-security/30-managing-credentials.md
+++ b/docs/docs/50-user-guide/50-security/30-managing-credentials.md
@@ -312,7 +312,7 @@ create or install a GitHub App.
     1. Under <Hlt>Where can this GitHub App be installed?</Hlt>,
        leave <Hlt>Only on this account</Hlt> selected.
     1. Click <Hlt>Create GitHub App</Hlt>.
-    1. Take note of the <Hlt>App ID</Hlt>.
+    1. Take note of the <Hlt>Client ID</Hlt>.
     1. Scroll to the bottom of the page and click <Hlt>Generate a private
        key</Hlt>. The resulting key will be downloaded immediately. Store
        it securely.
@@ -331,6 +331,29 @@ create or install a GitHub App.
 
 2. Create a `Secret` resource with the following structure:
 
+    ```yaml
+    apiVersion: v1
+    kind: Secret
+    metadata:
+      name: <name>
+      namespace: <project namespace>
+      labels:
+        kargo.akuity.io/cred-type: git
+    stringData:
+      githubAppClientID: <client id>
+      githubAppPrivateKey: <PEM-encoded private key>
+      githubAppInstallationID: <installation id>
+      repoURL: <repo url>
+      repoURLIsRegex: <true if repoURL is a pattern matching multiple repositories>
+    ```
+
+    :::info
+    GitHub currently recommends using an App's alphanumeric client ID whenever
+    possible, but Kargo does support the deprecated, numeric App ID as an
+    alternative identifier for a GitHub App. The following example is thus
+    valid until such time that GitHub themselves may remove support for the
+    deprecated App ID.
+
     ```yaml
     apiVersion: v1
     kind: Secret
@@ -347,6 +370,12 @@ create or install a GitHub App.
       repoURLIsRegex: <true if repoURL is a pattern matching multiple repositories>
     ```
 
+    In the event that a `Secret`'s data map includes values for _both_ the
+    `githubAppClientID` and `githubAppID` keys, Kargo will prioritize the value
+    of the `githubAppClientID` key as its means of uniquely identifying the
+    GitHub App.
+    :::
+
     :::note
     The `kargo create/update credentials` commands do not support creating or
     updating non username/password credentials. To create or update a `Secret`
diff --git a/internal/credentials/kubernetes/github/app.go b/internal/credentials/kubernetes/github/app.go
index f4f074b0e4..b6c606485a 100644
--- a/internal/credentials/kubernetes/github/app.go
+++ b/internal/credentials/kubernetes/github/app.go
@@ -20,6 +20,7 @@ import (
 )
 
 const (
+	clientIDKey       = "githubAppClientID"
 	appIDKey          = "githubAppID"
 	installationIDKey = "githubAppInstallationID"
 	privateKeyKey     = "githubAppPrivateKey"
@@ -34,7 +35,12 @@ var base64Regex = regexp.MustCompile(`^[a-zA-Z0-9+/]*={0,2}$`)
 type AppCredentialProvider struct {
 	tokenCache *cache.Cache
 
-	getAccessTokenFn func(appID, installationID int64, encodedPrivateKey, baseURL string) (string, error)
+	getAccessTokenFn func(
+		clientID string,
+		installationID int64,
+		encodedPrivateKey string,
+		baseURL string,
+	) (string, error)
 }
 
 // NewAppCredentialProvider returns an implementation of credentials.Provider.
@@ -50,12 +56,17 @@ func NewAppCredentialProvider() credentials.Provider {
 	return p
 }
 
-func (p *AppCredentialProvider) Supports(credType credentials.Type, _ string, data map[string][]byte) bool {
+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
+	return (strings.TrimSpace(string(data[clientIDKey])) != "" || strings.TrimSpace(string(data[appIDKey])) != "") &&
+		strings.TrimSpace(string(data[installationIDKey])) != "" && strings.TrimSpace(string(data[privateKeyKey])) != ""
 }
 
 func (p *AppCredentialProvider) GetCredentials(
@@ -69,9 +80,12 @@ func (p *AppCredentialProvider) GetCredentials(
 		return nil, nil
 	}
 
-	appID, err := strconv.ParseInt(string(data[appIDKey]), 10, 64)
-	if err != nil {
-		return nil, fmt.Errorf("error parsing app ID: %w", err)
+	// Client ID is the newer unique identifier for GitHub Apps. GitHub recommends
+	// using this when possible. If no client ID is found in the data map, we will
+	// fall back on the old/deprecated unique identifier, App ID.
+	clientID := string(data[clientIDKey])
+	if clientID == "" {
+		clientID = string(data[appIDKey])
 	}
 
 	installID, err := strconv.ParseInt(string(data[installationIDKey]), 10, 64)
@@ -84,19 +98,29 @@ func (p *AppCredentialProvider) GetCredentials(
 		return nil, fmt.Errorf("error extracting base URL from : %w", err)
 	}
 
-	return p.getUsernameAndPassword(appID, installID, string(data[privateKeyKey]), baseURL)
+	return p.getUsernameAndPassword(
+		clientID,
+		installID,
+		string(data[privateKeyKey]),
+		baseURL,
+	)
 }
 
-// getUsernameAndPassword gets a username and password for the given app and
-// installation IDs. The private key is the PEM-encoded private key for the
+// getUsernameAndPassword gets a username and password for the given client ID
+// and installation ID. 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,
+	clientID string,
 	installationID int64,
 	encodedPrivateKey, baseURL string,
 ) (*credentials.Credentials, error) {
-	cacheKey := tokenCacheKey(baseURL, appID, installationID, encodedPrivateKey)
+	cacheKey := tokenCacheKey(
+		baseURL,
+		clientID,
+		installationID,
+		encodedPrivateKey,
+	)
 
 	// Check the cache for the token
 	if entry, exists := p.tokenCache.Get(cacheKey); exists {
@@ -108,8 +132,10 @@ func (p *AppCredentialProvider) getUsernameAndPassword(
 
 	// Cache miss, get a new token
 	accessToken, err := p.getAccessTokenFn(
-		appID, installationID,
-		encodedPrivateKey, baseURL,
+		clientID,
+		installationID,
+		encodedPrivateKey,
+		baseURL,
 	)
 	if err != nil {
 		return nil, fmt.Errorf("error getting installation access token: %w", err)
@@ -129,15 +155,17 @@ func (p *AppCredentialProvider) getUsernameAndPassword(
 // 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,
+	clientID string,
+	installationID int64,
+	encodedPrivateKey string,
+	baseURL string,
 ) (string, error) {
 	decodedKey, err := decodeKey(encodedPrivateKey)
 	if err != nil {
 		return "", err
 	}
 
-	appTokenSource, err := githubauth.NewApplicationTokenSource(appID, decodedKey)
+	appTokenSource, err := newApplicationTokenSource(clientID, decodedKey)
 	if err != nil {
 		return "", fmt.Errorf("error creating application token source: %w", err)
 	}
@@ -163,15 +191,20 @@ func (p *AppCredentialProvider) getAccessToken(
 }
 
 // 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 {
+// is a hash of the hostname, client 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,
+	clientID string,
+	installationID int64,
+	encodedPrivateKey string,
+) string {
 	return fmt.Sprintf(
 		"%x",
 		sha256.Sum256([]byte(
 			fmt.Sprintf(
-				"%s:%d:%d:%s",
-				baseURL, appID, installationID, encodedPrivateKey,
+				"%s:%s:%d:%s",
+				baseURL, clientID, installationID, encodedPrivateKey,
 			),
 		)),
 	)
diff --git a/internal/credentials/kubernetes/github/app_token_source.go b/internal/credentials/kubernetes/github/app_token_source.go
new file mode 100644
index 0000000000..a63877e15d
--- /dev/null
+++ b/internal/credentials/kubernetes/github/app_token_source.go
@@ -0,0 +1,82 @@
+package github
+
+import (
+	"crypto/rsa"
+	"errors"
+	"time"
+
+	jwt "github.com/golang-jwt/jwt/v5"
+	"golang.org/x/oauth2"
+)
+
+// appTokenSource is an implementation of ouath2.TokenSource that returns tokens
+// for a GitHub App to access portions of the GitHub API. We've implemented our
+// own instead of using the implementation from github.com/jferrl/go-githubauth
+// because that package's NewInstallationTokenSource() function and the
+// ouath2.TokenSource it returns do not yet allow for the possibility of using
+// the alphanumeric client ID (string) as an alternative to the numeric (int64)
+// App ID as the token issuer. GitHub recommends using client ID when possible.
+// Our implementation is nearly identical to theirs, but uses a string type
+// for the issuer.
+type appTokenSource struct {
+	issuer     string
+	privateKey *rsa.PrivateKey
+	expiration time.Duration
+}
+
+// newApplicationTokenSource creates a new GitHub App token source using the
+// provided issuer identifier and private key. The issuer may be an alphanumeric
+// client ID or a string representation of a numeric App ID.
+func newApplicationTokenSource(
+	issuer string,
+	privateKey []byte,
+) (oauth2.TokenSource, error) {
+	if issuer == "" {
+		return nil, errors.New("issuer is required")
+	}
+
+	if len(privateKey) == 0 {
+		return nil, errors.New("private key is required")
+	}
+
+	privKey, err := jwt.ParseRSAPrivateKeyFromPEM(privateKey)
+	if err != nil {
+		return nil, err
+	}
+
+	t := &appTokenSource{
+		issuer:     issuer,
+		privateKey: privKey,
+		expiration: 10 * time.Minute,
+	}
+
+	return t, nil
+}
+
+// Token implements oauth2.TokenSource.
+func (a *appTokenSource) Token() (*oauth2.Token, error) {
+	// GitHub recommends setting the iat claim (issued at time) to 60 seconds in
+	// the past to guard against issues arising from clock drift.
+	//
+	// nolint: lll
+	//   https://docs.github.com/en/apps/creating-github-apps/authenticating-with-a-github-app/generating-a-json-web-token-jwt-for-a-github-app#about-json-web-tokens-jwts
+	now := time.Now().Add(-60 * time.Second)
+	expiresAt := now.Add(a.expiration)
+
+	token := jwt.NewWithClaims(jwt.SigningMethodRS256, jwt.RegisteredClaims{
+		Issuer:    a.issuer,
+		IssuedAt:  jwt.NewNumericDate(now),
+		ExpiresAt: jwt.NewNumericDate(expiresAt),
+	})
+
+	tokenString, err := token.SignedString(a.privateKey)
+	if err != nil {
+		return nil, err
+	}
+
+	return &oauth2.Token{
+		AccessToken: tokenString,
+		TokenType:   "Bearer",
+		Expiry:      expiresAt,
+	}, nil
+}


Test patch:
diff --git a/internal/credentials/kubernetes/github/app_test.go b/internal/credentials/kubernetes/github/app_test.go
index 7073062ef8..0958fc90b3 100644
--- a/internal/credentials/kubernetes/github/app_test.go
+++ b/internal/credentials/kubernetes/github/app_test.go
@@ -4,6 +4,7 @@ import (
 	"context"
 	"encoding/base64"
 	"errors"
+	"maps"
 	"testing"
 
 	"github.com/patrickmn/go-cache"
@@ -24,77 +25,143 @@ func TestNewAppCredentialProvider(t *testing.T) {
 func TestAppCredentialProvider_Supports(t *testing.T) {
 	p := NewAppCredentialProvider()
 
+	// This is a control. Each test case will tweak a clone of this supported map.
+	supportedDataMap := map[string][]byte{
+		clientIDKey:       []byte("client"),
+		appIDKey:          []byte("123"),
+		installationIDKey: []byte("456"),
+		privateKeyKey:     []byte("private-key"),
+	}
+	require.True(t, p.Supports(credentials.TypeGit, "", supportedDataMap))
+
 	testCases := []struct {
-		name     string
-		credType credentials.Type
-		repoURL  string
-		data     map[string][]byte
-		expected bool
+		name       string
+		credType   credentials.Type
+		repoURL    string
+		getDataMap func() map[string][]byte
+		expected   bool
 	}{
 		{
-			name:     "supports valid Git credential with all required fields",
+			name:     "non-Git credential type",
+			credType: credentials.Type("other"),
+			repoURL:  "https://github.com/akuity/kargo",
+			getDataMap: func() map[string][]byte {
+				return supportedDataMap
+			},
+			expected: false,
+		},
+		{
+			name:     "nil data map",
 			credType: credentials.TypeGit,
 			repoURL:  "https://github.com/akuity/kargo",
-			data: map[string][]byte{
-				appIDKey:          []byte("123"),
-				installationIDKey: []byte("456"),
-				privateKeyKey:     []byte("private-key"),
+			getDataMap: func() map[string][]byte {
+				return nil
 			},
-			expected: true,
+			expected: false,
 		},
 		{
-			name:     "does not support non-Git credential type",
-			credType: credentials.Type("other"),
+			name:     "empty data map",
+			credType: credentials.TypeGit,
 			repoURL:  "https://github.com/akuity/kargo",
-			data: map[string][]byte{
-				appIDKey:          []byte("123"),
-				installationIDKey: []byte("456"),
-				privateKeyKey:     []byte("private-key"),
+			getDataMap: func() map[string][]byte {
+				return map[string][]byte{}
 			},
 			expected: false,
 		},
 		{
-			name:     "does not support empty data",
+			name:     "no client ID or app ID in data map",
 			credType: credentials.TypeGit,
 			repoURL:  "https://github.com/akuity/kargo",
-			data:     map[string][]byte{},
+			getDataMap: func() map[string][]byte {
+				dm := maps.Clone(supportedDataMap)
+				delete(dm, appIDKey)
+				delete(dm, clientIDKey)
+				return dm
+			},
 			expected: false,
 		},
 		{
-			name:     "does not support missing appID",
+			name:     "client ID and app ID are empty(ish)",
 			credType: credentials.TypeGit,
 			repoURL:  "https://github.com/akuity/kargo",
-			data: map[string][]byte{
-				installationIDKey: []byte("456"),
-				privateKeyKey:     []byte("private-key"),
+			getDataMap: func() map[string][]byte {
+				dm := maps.Clone(supportedDataMap)
+				dm[appIDKey] = []byte(" ")
+				dm[clientIDKey] = []byte(" ")
+				return dm
 			},
 			expected: false,
 		},
 		{
-			name:     "does not support missing installationID",
+			name:     "no installation ID in data map",
 			credType: credentials.TypeGit,
 			repoURL:  "https://github.com/akuity/kargo",
-			data: map[string][]byte{
-				appIDKey:      []byte("123"),
-				privateKeyKey: []byte("private-key"),
+			getDataMap: func() map[string][]byte {
+				dm := maps.Clone(supportedDataMap)
+				delete(dm, installationIDKey)
+				return dm
 			},
 			expected: false,
 		},
 		{
-			name:     "does not support missing privateKey",
+			name:     "installation ID is empty(ish)",
 			credType: credentials.TypeGit,
 			repoURL:  "https://github.com/akuity/kargo",
-			data: map[string][]byte{
-				appIDKey:          []byte("123"),
-				installationIDKey: []byte("456"),
+			getDataMap: func() map[string][]byte {
+				dm := maps.Clone(supportedDataMap)
+				dm[installationIDKey] = []byte(" ")
+				return dm
 			},
 			expected: false,
 		},
+		{
+			name:     "no private key in data map",
+			credType: credentials.TypeGit,
+			repoURL:  "https://github.com/akuity/kargo",
+			getDataMap: func() map[string][]byte {
+				dm := maps.Clone(supportedDataMap)
+				delete(dm, privateKeyKey)
+				return dm
+			},
+			expected: false,
+		},
+		{
+			name:     "private key is empty(ish)",
+			credType: credentials.TypeGit,
+			repoURL:  "https://github.com/akuity/kargo",
+			getDataMap: func() map[string][]byte {
+				dm := maps.Clone(supportedDataMap)
+				dm[privateKeyKey] = []byte(" ")
+				return dm
+			},
+			expected: false,
+		},
+		{
+			name:     "valid with client ID",
+			credType: credentials.TypeGit,
+			repoURL:  "https://github.com/akuity/kargo",
+			getDataMap: func() map[string][]byte {
+				dm := maps.Clone(supportedDataMap)
+				delete(dm, appIDKey)
+				return dm
+			},
+			expected: true,
+		},
+		{
+			name:     "valid with App ID",
+			credType: credentials.TypeGit,
+			repoURL:  "https://github.com/akuity/kargo",
+			getDataMap: func() map[string][]byte {
+				dm := maps.Clone(supportedDataMap)
+				delete(dm, clientIDKey)
+				return dm
+			},
+			expected: true,
+		},
 	}
-
 	for _, tt := range testCases {
 		t.Run(tt.name, func(t *testing.T) {
-			result := p.Supports(tt.credType, tt.repoURL, tt.data)
+			result := p.Supports(tt.credType, tt.repoURL, tt.getDataMap())
 			assert.Equal(t, tt.expected, result)
 		})
 	}
@@ -108,8 +175,13 @@ func TestAppCredentialProvider_GetCredentials(t *testing.T) {
 		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)
+		getAccessTokenFn func(
+			appOrClientID string,
+			installationID int64,
+			encodedPrivateKey string,
+			baseURL string,
+		) (string, error)
+		assertions func(t *testing.T, creds *credentials.Credentials, err error)
 	}{
 		{
 			name:     "unsupported credential type",
@@ -125,21 +197,6 @@ func TestAppCredentialProvider_GetCredentials(t *testing.T) {
 				assert.NoError(t, err)
 			},
 		},
-		{
-			name:     "invalid app ID",
-			credType: credentials.TypeGit,
-			repoURL:  "https://github.com/akuity/kargo",
-			data: map[string][]byte{
-				appIDKey:          []byte("invalid"),
-				installationIDKey: []byte("456"),
-				privateKeyKey:     []byte("private-key"),
-			},
-			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:     "invalid installation ID",
 			credType: credentials.TypeGit,
@@ -179,7 +236,7 @@ func TestAppCredentialProvider_GetCredentials(t *testing.T) {
 				installationIDKey: []byte("456"),
 				privateKeyKey:     []byte("private-key"),
 			},
-			getAccessTokenFn: func(_, _ int64, _, _ string) (string, error) {
+			getAccessTokenFn: func(_ string, _ int64, _, _ string) (string, error) {
 				return "", errors.New("token error")
 			},
 			assertions: func(t *testing.T, creds *credentials.Credentials, err error) {
@@ -197,7 +254,7 @@ func TestAppCredentialProvider_GetCredentials(t *testing.T) {
 				installationIDKey: []byte("456"),
 				privateKeyKey:     []byte("private-key"),
 			},
-			getAccessTokenFn: func(_, _ int64, _, _ string) (string, error) {
+			getAccessTokenFn: func(_ string, _ int64, _, _ string) (string, error) {
 				return "test-token", nil
 			},
 			assertions: func(t *testing.T, creds *credentials.Credentials, err error) {
@@ -217,7 +274,13 @@ func TestAppCredentialProvider_GetCredentials(t *testing.T) {
 				provider.getAccessTokenFn = tt.getAccessTokenFn
 			}
 
-			creds, err := provider.GetCredentials(ctx, "", tt.credType, tt.repoURL, tt.data)
+			creds, err := provider.GetCredentials(
+				ctx,
+				"",
+				tt.credType,
+				tt.repoURL,
+				tt.data,
+			)
 			tt.assertions(t, creds, err)
 		})
 	}
@@ -225,7 +288,7 @@ func TestAppCredentialProvider_GetCredentials(t *testing.T) {
 
 func TestAppCredentialProvider_getUsernameAndPassword(t *testing.T) {
 	const (
-		fakeAppID          = int64(123)
+		fakeAppOrClientID  = "fake-id"
 		fakeInstallationID = int64(456)
 		fakePrivateKey     = "private-key"
 		fakeBaseURL        = "https://github.com"
@@ -234,25 +297,40 @@ func TestAppCredentialProvider_getUsernameAndPassword(t *testing.T) {
 
 	testCases := []struct {
 		name              string
-		appID             int64
+		appOrClientID     string
 		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)
+		getAccessTokenFn  func(
+			appOrClientID string,
+			installationID int64,
+			encodedPrivateKey string,
+			baseURL string,
+		) (string, error)
+		assertions func(*testing.T, *cache.Cache, *credentials.Credentials, error)
 	}{
 		{
 			name:              "cache hit",
-			appID:             fakeAppID,
+			appOrClientID:     fakeAppOrClientID,
 			installationID:    fakeInstallationID,
 			encodedPrivateKey: fakePrivateKey,
 			baseURL:           fakeBaseURL,
 			setupCache: func(c *cache.Cache) {
-				cacheKey := tokenCacheKey(fakeBaseURL, fakeAppID, fakeInstallationID, fakePrivateKey)
+				cacheKey := tokenCacheKey(
+					fakeBaseURL,
+					fakeAppOrClientID,
+					fakeInstallationID,
+					fakePrivateKey,
+				)
 				c.Set(cacheKey, fakeAccessToken, cache.DefaultExpiration)
 			},
-			assertions: func(t *testing.T, _ *cache.Cache, creds *credentials.Credentials, err error) {
+			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)
@@ -261,21 +339,31 @@ func TestAppCredentialProvider_getUsernameAndPassword(t *testing.T) {
 		},
 		{
 			name:              "cache miss, successful token fetch",
-			appID:             fakeAppID,
+			appOrClientID:     fakeAppOrClientID,
 			installationID:    fakeInstallationID,
 			encodedPrivateKey: fakePrivateKey,
 			baseURL:           fakeBaseURL,
-			getAccessTokenFn: func(_, _ int64, _, _ string) (string, error) {
+			getAccessTokenFn: func(_ string, _ int64, _, _ string) (string, error) {
 				return fakeAccessToken, nil
 			},
-			assertions: func(t *testing.T, c *cache.Cache, creds *credentials.Credentials, err error) {
+			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)
+				cacheKey := tokenCacheKey(
+					fakeBaseURL,
+					fakeAppOrClientID,
+					fakeInstallationID,
+					fakePrivateKey,
+				)
 				cachedToken, found := c.Get(cacheKey)
 				assert.True(t, found)
 				assert.Equal(t, fakeAccessToken, cachedToken)
@@ -283,19 +371,29 @@ func TestAppCredentialProvider_getUsernameAndPassword(t *testing.T) {
 		},
 		{
 			name:              "error in getAccessToken",
-			appID:             fakeAppID,
+			appOrClientID:     fakeAppOrClientID,
 			installationID:    fakeInstallationID,
 			encodedPrivateKey: fakePrivateKey,
 			baseURL:           fakeBaseURL,
-			getAccessTokenFn: func(_, _ int64, _, _ string) (string, error) {
+			getAccessTokenFn: func(_ string, _ int64, _, _ string) (string, error) {
 				return "", errors.New("token error")
 			},
-			assertions: func(t *testing.T, c *cache.Cache, creds *credentials.Credentials, err 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)
+				cacheKey := tokenCacheKey(
+					fakeBaseURL,
+					fakeAppOrClientID,
+					fakeInstallationID,
+					fakePrivateKey,
+				)
 				_, found := c.Get(cacheKey)
 				assert.False(t, found)
 			},
@@ -314,7 +412,12 @@ func TestAppCredentialProvider_getUsernameAndPassword(t *testing.T) {
 				provider.getAccessTokenFn = tt.getAccessTokenFn
 			}
 
-			creds, err := provider.getUsernameAndPassword(tt.appID, tt.installationID, tt.encodedPrivateKey, tt.baseURL)
+			creds, err := provider.getUsernameAndPassword(
+				tt.appOrClientID,
+				tt.installationID,
+				tt.encodedPrivateKey,
+				tt.baseURL,
+			)
 			tt.assertions(t, provider.tokenCache, creds, err)
 		})
 	}
diff --git a/internal/credentials/kubernetes/github/app_token_source_test.go b/internal/credentials/kubernetes/github/app_token_source_test.go
new file mode 100644
index 0000000000..3e0284e4fc
--- /dev/null
+++ b/internal/credentials/kubernetes/github/app_token_source_test.go
@@ -0,0 +1,69 @@
+package github
+
+import (
+	"crypto/rand"
+	"crypto/rsa"
+	"crypto/x509"
+	"encoding/pem"
+	"testing"
+
+	"github.com/stretchr/testify/require"
+	"golang.org/x/oauth2"
+)
+
+func TestNewApplicationTokenSource(t *testing.T) {
+	privateKey, err := generatePrivateKey()
+	require.NoError(t, err)
+
+	testCases := []struct {
+		name       string
+		issuer     string
+		privateKey []byte
+		assertions func(*testing.T, oauth2.TokenSource, error)
+	}{
+		{
+			name: "issuer is not provided",
+			assertions: func(t *testing.T, _ oauth2.TokenSource, err error) {
+				require.ErrorContains(t, err, "issuer is required")
+			},
+		},
+		{
+			name:   "private key is not provided",
+			issuer: "abc",
+			assertions: func(t *testing.T, _ oauth2.TokenSource, err error) {
+				require.ErrorContains(t, err, "private key is required")
+			},
+		},
+		{
+			name:       "valid application token source",
+			issuer:     "abc",
+			privateKey: privateKey,
+			assertions: func(t *testing.T, ts oauth2.TokenSource, err error) {
+				require.NoError(t, err)
+				require.NotNil(t, ts)
+			},
+		},
+	}
+	for _, testCase := range testCases {
+		t.Run(testCase.name, func(t *testing.T) {
+			tokenSource, err := newApplicationTokenSource(
+				testCase.issuer,
+				testCase.privateKey,
+			)
+			testCase.assertions(t, tokenSource, err)
+		})
+	}
+}
+
+func generatePrivateKey() ([]byte, error) {
+	privateKey, err := rsa.GenerateKey(rand.Reader, 2048)
+	if err != nil {
+		return nil, err
+	}
+	// Encode the private key in the PEM format
+	privateKeyPEM := &pem.Block{
+		Type:  "RSA PRIVATE KEY",
+		Bytes: x509.MarshalPKCS1PrivateKey(privateKey),
+	}
+	return pem.EncodeToMemory(privateKeyPEM), nil
+}