This is an automated email from the ASF dual-hosted git repository.
klesh pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/incubator-devlake.git
The following commit(s) were added to refs/heads/main by this push:
new 611556cd0 feat: GitHub App token refresh (#8746)
611556cd0 is described below
commit 611556cd0f3e4dd8a1f2df257e2d7602dabb301a
Author: Leif Roger Frøysaa <[email protected]>
AuthorDate: Thu Mar 12 13:48:10 2026 +0100
feat: GitHub App token refresh (#8746)
* feat(github): auto-refresh GitHub App installation tokens
Add transport-level token refresh for GitHub App (AppKey) connections.
GitHub App installation tokens expire after ~1 hour; this adds proactive
refresh (before expiry) and reactive refresh (on 401) using the existing
TokenProvider/RefreshRoundTripper infrastructure.
New files:
- app_installation_refresh.go: refresh logic + DB persistence
- refresh_api_client.go: minimal ApiClient for token refresh POST
- cmd/test_refresh/main.go: manual test script for real GitHub Apps
Modified:
- connection.go: export GetInstallationAccessToken, parse ExpiresAt
- token_provider.go: add refreshFn for pluggable refresh strategies
- round_tripper.go: document dual Authorization header interaction
- api_client.go: wire AppKey connections into refresh infrastructure
- Tests updated for new constructors and AppKey refresh flow
* feat(github): add diagnostic logging to GitHub App token refresh
Add structured logging at key decision points for token refresh:
- Token provider creation (connection ID, installation ID, expiry)
- Round tripper installation (connection ID, auth method)
- Proactive refresh trigger (near-expiry detection)
- Refresh start/success/failure (old/new token prefixes, expiry times)
- DB persistence success/failure
- Reactive 401 refresh and skip-due-to-concurrent-refresh
All logs route through the DevLake logger to pipeline log files.
* fix(github): prevent deadlock and fix token persistence in App token
refresh
Deadlock fix: NewAppInstallationTokenProvider now captures client.Transport
(the base transport) before wrapping with RefreshRoundTripper. The refresh
function uses newRefreshApiClientWithTransport(baseTransport) to POST for
new installation tokens, bypassing the RefreshRoundTripper entirely.
Token persistence fix: PersistEncryptedTokenColumns() manually encrypts
tokens via plugin.Encrypt() then writes ciphertext via dal.UpdateColumns
with conn.TableName() (a string) as the first argument. Passing the table
name string makes GORM use Table() instead of Model(), preventing the
encdec serializer from corrupting the in-memory token value. The encryption
secret is threaded from taskCtx.GetConfig(ENCRYPTION_SECRET) through
CreateApiClient to TokenProvider to persist functions.
Also persists the initial App token at startup for DB consistency, and
adds TestProactiveRefreshNoDeadlock with a real RSA key to verify the
deadlock scenario is resolved.
* fix(grafana): update dashboard descriptions to list all supported data
sources (#8741)
Several dashboard introduction panels hardcoded "GitHub and Jira" as
required data sources, even though the underlying queries use generic
domain layer tables that work with any supported Git tool or issue
tracker. Updated to list all supported sources following the pattern
already used by DORA and WorkLogs dashboards.
Closes #8740
Co-authored-by: Spiff Azeta <[email protected]>
* fix: modify cicd_deployments name from varchar to text (#8724)
* fix: modify cicd_deployments name from varchar to text
* fix: update the year
* fix(q_dev): replace MariaDB-specific IF NOT EXISTS syntax with DAL
methods for MySQL 8.x compatibility (#8745)
* fix(azuredevops): default empty entities and add CROSS to repo scope in
makeScopeV200 (#8751)
When scopeConfig.Entities is empty (common when no entities are
explicitly selected in the UI), makeScopeV200 produced zero scopes,
leaving project_mapping with no rows. Additionally, the repo scope
condition did not check for DOMAIN_TYPE_CROSS, so selecting only
CROSS would not create a repo scope, breaking DORA metrics.
This adds the same fixes applied to GitLab in #8743.
Closes #8749
* fix(bitbucket): default empty entities to all domain types in
makeScopesV200 (#8750)
When scopeConfig.Entities is empty (common when no entities are
explicitly selected in the UI), makeScopesV200 produced zero scopes,
leaving project_mapping with no repo rows. This adds the same
empty-entities default applied to GitLab in #8743.
Closes #8748
* fix(github): remove unused refresh client constructor and update tests
---------
Co-authored-by: Spiff Azeta <[email protected]>
Co-authored-by: Spiff Azeta <[email protected]>
Co-authored-by: Dan Crews <[email protected]>
Co-authored-by: Tomoya Kawaguchi
<[email protected]>
---
backend/plugins/github/models/connection.go | 21 +-
backend/plugins/github/tasks/api_client.go | 32 ++-
.../github/token/app_installation_refresh.go | 152 +++++++++++++
.../plugins/github/token/cmd/test_refresh/main.go | 240 +++++++++++++++++++++
backend/plugins/github/token/refresh_api_client.go | 115 ++++++++++
.../github/token/refresh_api_client_test.go | 106 +++++++++
backend/plugins/github/token/round_tripper.go | 5 +
backend/plugins/github/token/round_tripper_test.go | 193 ++++++++++++++++-
backend/plugins/github/token/token_provider.go | 102 +++++++--
.../plugins/github/token/token_provider_test.go | 117 +++++++---
10 files changed, 1019 insertions(+), 64 deletions(-)
diff --git a/backend/plugins/github/models/connection.go
b/backend/plugins/github/models/connection.go
index d03353c18..51034f67c 100644
--- a/backend/plugins/github/models/connection.go
+++ b/backend/plugins/github/models/connection.go
@@ -81,13 +81,15 @@ func (conn *GithubConn) PrepareApiClient(apiClient
plugin.ApiClient) errors.Erro
}
if conn.AuthMethod == AppKey && conn.InstallationID != 0 {
- token, err := conn.getInstallationAccessToken(apiClient)
+ token, err := conn.GetInstallationAccessToken(apiClient)
if err != nil {
return err
}
-
- conn.Token = token.Token
- conn.tokens = []string{token.Token}
+ var expiresAt *time.Time
+ if !token.ExpiresAt.IsZero() {
+ expiresAt = &token.ExpiresAt
+ }
+ conn.UpdateToken(token.Token, "", expiresAt, nil)
}
return nil
@@ -354,7 +356,8 @@ type GithubUserOfToken struct {
}
type InstallationToken struct {
- Token string `json:"token"`
+ Token string `json:"token"`
+ ExpiresAt time.Time `json:"expires_at"`
}
type GithubApp struct {
@@ -397,7 +400,7 @@ func (gak *GithubAppKey) CreateJwt() (string, errors.Error)
{
return tokenString, nil
}
-func (gak *GithubAppKey) getInstallationAccessToken(
+func (gak *GithubAppKey) GetInstallationAccessToken(
apiClient plugin.ApiClient,
) (*InstallationToken, errors.Error) {
@@ -417,12 +420,18 @@ func (gak *GithubAppKey) getInstallationAccessToken(
if err != nil {
return nil, err
}
+ if resp.StatusCode != http.StatusCreated && resp.StatusCode !=
http.StatusOK {
+ return nil,
errors.HttpStatus(resp.StatusCode).New(fmt.Sprintf("unexpected status code
while getting installation access token: %s", string(body)))
+ }
var installationToken InstallationToken
err = errors.Convert(json.Unmarshal(body, &installationToken))
if err != nil {
return nil, err
}
+ if installationToken.Token == "" {
+ return nil, errors.Default.New("empty installation access token
returned")
+ }
return &installationToken, nil
}
diff --git a/backend/plugins/github/tasks/api_client.go
b/backend/plugins/github/tasks/api_client.go
index 268af8ece..42181ff13 100644
--- a/backend/plugins/github/tasks/api_client.go
+++ b/backend/plugins/github/tasks/api_client.go
@@ -35,14 +35,18 @@ func CreateApiClient(taskCtx plugin.TaskContext, connection
*models.GithubConnec
return nil, err
}
- // Inject TokenProvider if refresh token is present
- if connection.RefreshToken != "" {
- logger := taskCtx.GetLogger()
- db := taskCtx.GetDal()
-
- // Create TokenProvider
- tp := token.NewTokenProvider(connection, db,
apiClient.GetClient(), logger)
+ logger := taskCtx.GetLogger()
+ db := taskCtx.GetDal()
+ encryptionSecret := taskCtx.GetConfig(plugin.EncodeKeyEnvStr)
+ // Inject TokenProvider for OAuth refresh or GitHub App installation
tokens.
+ var tp *token.TokenProvider
+ if connection.RefreshToken != "" {
+ tp = token.NewTokenProvider(connection, db,
apiClient.GetClient(), logger, encryptionSecret)
+ } else if connection.AuthMethod == models.AppKey &&
connection.InstallationID != 0 {
+ tp = token.NewAppInstallationTokenProvider(connection, db,
apiClient.GetClient(), logger, encryptionSecret)
+ }
+ if tp != nil {
// Wrap the transport
baseTransport := apiClient.GetClient().Transport
if baseTransport == nil {
@@ -51,6 +55,20 @@ func CreateApiClient(taskCtx plugin.TaskContext, connection
*models.GithubConnec
rt := token.NewRefreshRoundTripper(baseTransport, tp)
apiClient.GetClient().Transport = rt
+ logger.Info("Installed token refresh round tripper for
connection %d (authMethod=%s)",
+ connection.ID, connection.AuthMethod)
+ }
+
+ // Persist the freshly minted token so the DB has a correctly encrypted
value.
+ // PrepareApiClient (called by NewApiClientFromConnection) mints the
token
+ // in-memory but does not persist it; without this, the DB may contain
a stale
+ // or corrupted token that breaks GET /connections.
+ if connection.AuthMethod == models.AppKey && connection.Token != "" {
+ if err := token.PersistEncryptedTokenColumns(db, connection,
encryptionSecret, logger, false); err != nil {
+ logger.Warn(err, "Failed to persist initial token for
connection %d", connection.ID)
+ } else {
+ logger.Info("Persisted initial token for connection
%d", connection.ID)
+ }
}
// create rate limit calculator
diff --git a/backend/plugins/github/token/app_installation_refresh.go
b/backend/plugins/github/token/app_installation_refresh.go
new file mode 100644
index 000000000..2f4cf04b8
--- /dev/null
+++ b/backend/plugins/github/token/app_installation_refresh.go
@@ -0,0 +1,152 @@
+/*
+Licensed to the Apache Software Foundation (ASF) under one or more
+contributor license agreements. See the NOTICE file distributed with
+this work for additional information regarding copyright ownership.
+The ASF licenses this file to You under the Apache License, Version 2.0
+(the "License"); you may not use this file except in compliance with
+the License. You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package token
+
+import (
+ "time"
+
+ "github.com/apache/incubator-devlake/core/dal"
+ "github.com/apache/incubator-devlake/core/errors"
+ "github.com/apache/incubator-devlake/core/log"
+ "github.com/apache/incubator-devlake/core/plugin"
+ "github.com/apache/incubator-devlake/plugins/github/models"
+)
+
+// tokenPrefix returns the first n characters of a token for safe logging.
+func tokenPrefix(token string, n int) string {
+ if len(token) <= n {
+ return token
+ }
+ return token[:n] + "..."
+}
+
+func refreshGitHubAppInstallationToken(tp *TokenProvider) errors.Error {
+ if tp == nil || tp.conn == nil {
+ return errors.Default.New("missing github connection for app
token refresh")
+ }
+ if tp.conn.AuthMethod != models.AppKey || tp.conn.InstallationID == 0 {
+ return errors.Default.New("invalid github app connection for
token refresh")
+ }
+ if tp.conn.Endpoint == "" {
+ return errors.Default.New("missing github endpoint for token
refresh")
+ }
+
+ oldToken := tp.conn.Token
+ if tp.logger != nil {
+ expiresStr := "unknown"
+ if tp.conn.TokenExpiresAt != nil {
+ expiresStr = tp.conn.TokenExpiresAt.Format(time.RFC3339)
+ }
+ tp.logger.Info(
+ "Refreshing GitHub App installation token for
connection %d (installation %d), old token=%s, expires_at=%s",
+ tp.conn.ID, tp.conn.InstallationID,
+ tokenPrefix(oldToken, 8),
+ expiresStr,
+ )
+ }
+
+ // Use baseTransport (the unwrapped transport) to avoid deadlock.
+ // The httpClient's transport may be the RefreshRoundTripper which would
+ // re-enter GetToken() and deadlock on the mutex.
+ apiClient := newRefreshApiClientWithTransport(tp.conn.Endpoint,
tp.baseTransport)
+ installationToken, err :=
tp.conn.GithubAppKey.GetInstallationAccessToken(apiClient)
+ if err != nil {
+ if tp.logger != nil {
+ tp.logger.Error(err, "Failed to refresh GitHub App
installation token for connection %d", tp.conn.ID)
+ }
+ return err
+ }
+
+ var expiresAt *time.Time
+ if !installationToken.ExpiresAt.IsZero() {
+ expiresAt = &installationToken.ExpiresAt
+ }
+ tp.conn.UpdateToken(installationToken.Token, "", expiresAt, nil)
+
+ if tp.logger != nil {
+ tp.logger.Info(
+ "Successfully refreshed GitHub App installation token
for connection %d, new token=%s, new expires_at=%s",
+ tp.conn.ID,
+ tokenPrefix(installationToken.Token, 8),
+ installationToken.ExpiresAt.Format(time.RFC3339),
+ )
+ }
+
+ persistAppToken(tp.dal, tp.conn, tp.encryptionSecret, tp.logger)
+ return nil
+}
+
+func persistAppToken(d dal.Dal, conn *models.GithubConnection,
encryptionSecret string, logger log.Logger) {
+ if d == nil || conn == nil {
+ return
+ }
+ if err := PersistEncryptedTokenColumns(d, conn, encryptionSecret,
logger, false); err != nil {
+ if logger != nil {
+ logger.Warn(err, "Failed to persist refreshed app
installation token for connection %d", conn.ID)
+ }
+ } else if logger != nil {
+ logger.Info("Persisted refreshed app installation token for
connection %d", conn.ID)
+ }
+}
+
+// PersistEncryptedTokenColumns manually encrypts token fields and writes them
+// to the DB using UpdateColumns (map-based), which only touches the specified
+// columns. This avoids two problems:
+// - dal.Update (GORM Save) writes ALL fields, including
refresh_token_expires_at
+// which may have Go zero time that MySQL rejects as '0000-00-00'.
+// - dal.UpdateColumns with plaintext bypasses the GORM encdec serializer,
+// writing unencrypted tokens that corrupt subsequent reads.
+//
+// IMPORTANT: We pass the table name string (not the conn struct) to
UpdateColumns
+// so that GORM uses Table() instead of Model(). When Model(conn) is used, GORM
+// processes the encdec serializer on the struct's Token field during statement
+// preparation, which overwrites conn.Token in memory with the encrypted
ciphertext.
+// This corrupts the in-memory token causing immediate 401s on the next API
call.
+//
+// If includeRefreshToken is true, refresh_token and refresh_token_expires_at
+// are also written (used by the OAuth refresh path where these values are
valid).
+func PersistEncryptedTokenColumns(d dal.Dal, conn *models.GithubConnection,
encryptionSecret string, logger log.Logger, includeRefreshToken bool)
errors.Error {
+ encToken, err := plugin.Encrypt(encryptionSecret, conn.Token)
+ if err != nil {
+ return errors.Default.Wrap(err, "failed to encrypt token for
persistence")
+ }
+
+ sets := []dal.DalSet{
+ {ColumnName: "token", Value: encToken},
+ {ColumnName: "token_expires_at", Value: conn.TokenExpiresAt},
+ }
+
+ if includeRefreshToken {
+ encRefreshToken, err := plugin.Encrypt(encryptionSecret,
conn.RefreshToken)
+ if err != nil {
+ return errors.Default.Wrap(err, "failed to encrypt
refresh_token for persistence")
+ }
+ sets = append(sets,
+ dal.DalSet{ColumnName: "refresh_token", Value:
encRefreshToken},
+ dal.DalSet{ColumnName: "refresh_token_expires_at",
Value: conn.RefreshTokenExpiresAt},
+ )
+ }
+
+ // Use the table name string instead of the conn struct to prevent GORM
from
+ // running the encdec serializer on conn.Token during Model()
processing.
+ return d.UpdateColumns(
+ conn.TableName(),
+ sets,
+ dal.Where("id = ?", conn.ID),
+ )
+}
diff --git a/backend/plugins/github/token/cmd/test_refresh/main.go
b/backend/plugins/github/token/cmd/test_refresh/main.go
new file mode 100644
index 000000000..be1854dcc
--- /dev/null
+++ b/backend/plugins/github/token/cmd/test_refresh/main.go
@@ -0,0 +1,240 @@
+/*
+Licensed to the Apache Software Foundation (ASF) under one or more
+contributor license agreements. See the NOTICE file distributed with
+this work for additional information regarding copyright ownership.
+The ASF licenses this file to You under the Apache License, Version 2.0
+(the "License"); you may not use this file except in compliance with
+the License. You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+// This script lists GitHub App installations and tests the token refresh flow.
+//
+// Usage:
+//
+// GITHUB_APP_ID=123456 GITHUB_APP_PEM="$(cat private-key.pem)" go run
./plugins/github/token/cmd/test_refresh/
+//
+// Or if the key is in a file:
+//
+// GITHUB_APP_ID=123456 GITHUB_APP_PEM_FILE=/path/to/private-key.pem go
run ./plugins/github/token/cmd/test_refresh/
+package main
+
+import (
+ "encoding/json"
+ "fmt"
+ "io"
+ "net/http"
+ "os"
+ "time"
+
+ "github.com/golang-jwt/jwt/v5"
+)
+
+type installation struct {
+ ID int `json:"id"`
+ Account struct {
+ Login string `json:"login"`
+ } `json:"account"`
+ AppID int `json:"app_id"`
+}
+
+type installationToken struct {
+ Token string `json:"token"`
+ ExpiresAt time.Time `json:"expires_at"`
+}
+
+func main() {
+ appID := os.Getenv("GITHUB_APP_ID")
+ if appID == "" {
+ fatal("GITHUB_APP_ID env var is required")
+ }
+
+ pemData := os.Getenv("GITHUB_APP_PEM")
+ if pemData == "" {
+ pemFile := os.Getenv("GITHUB_APP_PEM_FILE")
+ if pemFile == "" {
+ fatal("Set GITHUB_APP_PEM (contents) or
GITHUB_APP_PEM_FILE (path)")
+ }
+ data, err := os.ReadFile(pemFile)
+ if err != nil {
+ fatal("failed to read PEM file: %v", err)
+ }
+ pemData = string(data)
+ }
+
+ // Step 1: Create a JWT signed with the app's private key
+ fmt.Println("=== Step 1: Creating JWT from App ID and private key ===")
+ jwtToken, err := createJWT(appID, pemData)
+ if err != nil {
+ fatal("failed to create JWT: %v", err)
+ }
+ fmt.Printf("JWT created (first 20 chars): %s...\n\n", jwtToken[:20])
+
+ // Step 2: List installations
+ fmt.Println("=== Step 2: Listing installations for this app ===")
+ installations, err := listInstallations(jwtToken)
+ if err != nil {
+ fatal("failed to list installations: %v", err)
+ }
+ if len(installations) == 0 {
+ fatal("no installations found for this app")
+ }
+ for _, inst := range installations {
+ fmt.Printf(" Installation ID: %d Account: %s\n", inst.ID,
inst.Account.Login)
+ }
+ fmt.Println()
+
+ // Step 3: Get an installation token for the first installation
+ inst := installations[0]
+ fmt.Printf("=== Step 3: Minting installation token for %s (ID: %d)
===\n", inst.Account.Login, inst.ID)
+ token1, err := getInstallationToken(jwtToken, inst.ID)
+ if err != nil {
+ fatal("failed to get installation token: %v", err)
+ }
+ fmt.Printf("Token 1: %s... Expires: %s\n\n", token1.Token[:10],
token1.ExpiresAt.Format(time.RFC3339))
+
+ // Step 4: Make an API call with the token to verify it works
+ fmt.Println("=== Step 4: Verifying token works (GET
/installation/repositories) ===")
+ err = verifyToken(token1.Token)
+ if err != nil {
+ fatal("token verification failed: %v", err)
+ }
+ fmt.Printf("Token is valid and working.\n\n")
+
+ // Step 5: Simulate the refresh flow — mint a second token (as our
refreshFn would)
+ fmt.Println("=== Step 5: Simulating token refresh (minting a second
token) ===")
+ jwtToken2, err := createJWT(appID, pemData)
+ if err != nil {
+ fatal("failed to create second JWT: %v", err)
+ }
+ token2, err := getInstallationToken(jwtToken2, inst.ID)
+ if err != nil {
+ fatal("failed to get second installation token: %v", err)
+ }
+ fmt.Printf("Token 2: %s... Expires: %s\n", token2.Token[:10],
token2.ExpiresAt.Format(time.RFC3339))
+
+ if token1.Token == token2.Token {
+ fmt.Println("Note: Both tokens are identical (GitHub may cache
short-lived tokens)")
+ } else {
+ fmt.Println("Tokens are different — refresh produced a new
token.")
+ }
+ fmt.Println()
+
+ // Step 6: Verify the new token works
+ fmt.Println("=== Step 6: Verifying refreshed token works ===")
+ err = verifyToken(token2.Token)
+ if err != nil {
+ fatal("refreshed token verification failed: %v", err)
+ }
+ fmt.Println("Refreshed token is valid and working.")
+
+ fmt.Println("\n=== All steps passed. The token refresh flow works
correctly. ===")
+ fmt.Printf("\nFor reference, your Installation ID is: %d\n", inst.ID)
+}
+
+func createJWT(appID, pemData string) (string, error) {
+ privateKey, err := jwt.ParseRSAPrivateKeyFromPEM([]byte(pemData))
+ if err != nil {
+ return "", fmt.Errorf("invalid PEM key: %w", err)
+ }
+
+ now := time.Now().Unix()
+ token := jwt.NewWithClaims(jwt.SigningMethodRS256, jwt.MapClaims{
+ "iat": now,
+ "exp": now + (10 * 60), // 10 minutes
+ "iss": appID,
+ })
+
+ signed, err := token.SignedString(privateKey)
+ if err != nil {
+ return "", fmt.Errorf("failed to sign JWT: %w", err)
+ }
+ return signed, nil
+}
+
+func listInstallations(jwtToken string) ([]installation, error) {
+ req, err := http.NewRequest("GET",
"https://api.github.com/app/installations", nil)
+ if err != nil {
+ return nil, err
+ }
+ req.Header.Set("Authorization", "Bearer "+jwtToken)
+ req.Header.Set("Accept", "application/vnd.github+json")
+
+ resp, err := http.DefaultClient.Do(req)
+ if err != nil {
+ return nil, err
+ }
+ defer resp.Body.Close()
+
+ body, _ := io.ReadAll(resp.Body)
+ if resp.StatusCode != 200 {
+ return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode,
string(body))
+ }
+
+ var result []installation
+ if err := json.Unmarshal(body, &result); err != nil {
+ return nil, fmt.Errorf("failed to parse response: %w", err)
+ }
+ return result, nil
+}
+
+func getInstallationToken(jwtToken string, installationID int)
(*installationToken, error) {
+ url :=
fmt.Sprintf("https://api.github.com/app/installations/%d/access_tokens",
installationID)
+ req, err := http.NewRequest("POST", url, nil)
+ if err != nil {
+ return nil, err
+ }
+ req.Header.Set("Authorization", "Bearer "+jwtToken)
+ req.Header.Set("Accept", "application/vnd.github+json")
+
+ resp, err := http.DefaultClient.Do(req)
+ if err != nil {
+ return nil, err
+ }
+ defer resp.Body.Close()
+
+ body, _ := io.ReadAll(resp.Body)
+ if resp.StatusCode != http.StatusCreated {
+ return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode,
string(body))
+ }
+
+ var token installationToken
+ if err := json.Unmarshal(body, &token); err != nil {
+ return nil, fmt.Errorf("failed to parse token response: %w",
err)
+ }
+ return &token, nil
+}
+
+func verifyToken(token string) error {
+ req, err := http.NewRequest("GET",
"https://api.github.com/installation/repositories?per_page=1", nil)
+ if err != nil {
+ return err
+ }
+ req.Header.Set("Authorization", "Bearer "+token)
+ req.Header.Set("Accept", "application/vnd.github+json")
+
+ resp, err := http.DefaultClient.Do(req)
+ if err != nil {
+ return err
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode != 200 {
+ body, _ := io.ReadAll(resp.Body)
+ return fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(body))
+ }
+ fmt.Printf(" HTTP 200 OK (X-RateLimit-Remaining: %s)\n",
resp.Header.Get("X-RateLimit-Remaining"))
+ return nil
+}
+
+func fatal(format string, args ...interface{}) {
+ fmt.Fprintf(os.Stderr, "FATAL: "+format+"\n", args...)
+ os.Exit(1)
+}
diff --git a/backend/plugins/github/token/refresh_api_client.go
b/backend/plugins/github/token/refresh_api_client.go
new file mode 100644
index 000000000..1db6f6917
--- /dev/null
+++ b/backend/plugins/github/token/refresh_api_client.go
@@ -0,0 +1,115 @@
+/*
+Licensed to the Apache Software Foundation (ASF) under one or more
+contributor license agreements. See the NOTICE file distributed with
+this work for additional information regarding copyright ownership.
+The ASF licenses this file to You under the Apache License, Version 2.0
+(the "License"); you may not use this file except in compliance with
+the License. You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package token
+
+import (
+ "bytes"
+ "context"
+ "encoding/json"
+ "net/http"
+ "net/url"
+ "time"
+
+ "github.com/apache/incubator-devlake/core/errors"
+ "github.com/apache/incubator-devlake/core/plugin"
+ "github.com/apache/incubator-devlake/helpers/pluginhelper/api"
+)
+
+type refreshApiClient struct {
+ endpoint string
+ client *http.Client
+ timeout time.Duration
+}
+
+// newRefreshApiClientWithTransport creates a refreshApiClient using a specific
+// http.RoundTripper instead of an existing *http.Client. This is critical for
+// avoiding deadlocks: the caller's *http.Client may have a RefreshRoundTripper
+// transport that re-enters GetToken() and deadlocks on the mutex.
+func newRefreshApiClientWithTransport(endpoint string, transport
http.RoundTripper) plugin.ApiClient {
+ if transport == nil {
+ transport = http.DefaultTransport
+ }
+ return &refreshApiClient{
+ endpoint: endpoint,
+ client: &http.Client{Transport: transport},
+ timeout: 10 * time.Second,
+ }
+}
+
+func (c *refreshApiClient) SetData(name string, data interface{}) {}
+
+func (c *refreshApiClient) GetData(name string) interface{} { return nil }
+
+func (c *refreshApiClient) SetHeaders(headers map[string]string) {}
+
+func (c *refreshApiClient) SetBeforeFunction(callback
plugin.ApiClientBeforeRequest) {}
+
+func (c *refreshApiClient) GetBeforeFunction() plugin.ApiClientBeforeRequest {
return nil }
+
+func (c *refreshApiClient) SetAfterFunction(callback
plugin.ApiClientAfterResponse) {}
+
+func (c *refreshApiClient) GetAfterFunction() plugin.ApiClientAfterResponse {
return nil }
+
+func (c *refreshApiClient) Get(path string, query url.Values, headers
http.Header) (*http.Response, errors.Error) {
+ return c.do(http.MethodGet, path, query, nil, headers)
+}
+
+func (c *refreshApiClient) Post(path string, query url.Values, body
interface{}, headers http.Header) (*http.Response, errors.Error) {
+ return c.do(http.MethodPost, path, query, body, headers)
+}
+
+func (c *refreshApiClient) do(method, path string, query url.Values, body
interface{}, headers http.Header) (*http.Response, errors.Error) {
+ uri, err := api.GetURIStringPointer(c.endpoint, path, query)
+ if err != nil {
+ return nil, err
+ }
+
+ var reqBody *bytes.Reader
+ if body != nil {
+ payload, err := json.Marshal(body)
+ if err != nil {
+ return nil, errors.Convert(err)
+ }
+ reqBody = bytes.NewReader(payload)
+ } else {
+ reqBody = bytes.NewReader(nil)
+ }
+
+ ctx, cancel := context.WithTimeout(context.Background(), c.timeout)
+ defer cancel()
+
+ req, err := errors.Convert01(http.NewRequestWithContext(ctx, method,
*uri, reqBody))
+ if err != nil {
+ return nil, err
+ }
+ if body != nil {
+ req.Header.Set("Content-Type", "application/json")
+ }
+
+ for name, values := range headers {
+ for _, value := range values {
+ req.Header.Add(name, value)
+ }
+ }
+
+ res, err := errors.Convert01(c.client.Do(req))
+ if err != nil {
+ return nil, err
+ }
+ return res, nil
+}
diff --git a/backend/plugins/github/token/refresh_api_client_test.go
b/backend/plugins/github/token/refresh_api_client_test.go
new file mode 100644
index 000000000..60cdbba2f
--- /dev/null
+++ b/backend/plugins/github/token/refresh_api_client_test.go
@@ -0,0 +1,106 @@
+/*
+Licensed to the Apache Software Foundation (ASF) under one or more
+contributor license agreements. See the NOTICE file distributed with
+this work for additional information regarding copyright ownership.
+The ASF licenses this file to You under the Apache License, Version 2.0
+(the "License"); you may not use this file except in compliance with
+the License. You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+*/
+
+package token
+
+import (
+ "io"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/stretchr/testify/assert"
+)
+
+func TestRefreshApiClientPost(t *testing.T) {
+ var receivedMethod string
+ var receivedPath string
+ var receivedAuth string
+
+ server := httptest.NewServer(http.HandlerFunc(func(w
http.ResponseWriter, r *http.Request) {
+ receivedMethod = r.Method
+ receivedPath = r.URL.Path
+ receivedAuth = r.Header.Get("Authorization")
+ w.WriteHeader(http.StatusCreated)
+
w.Write([]byte(`{"token":"ghs_test123","expires_at":"2026-03-02T12:00:00Z"}`))
+ }))
+ defer server.Close()
+
+ client := newRefreshApiClientWithTransport(server.URL,
server.Client().Transport)
+
+ headers := http.Header{
+ "Authorization": []string{"Bearer jwt_token_here"},
+ }
+ resp, err := client.Post("/app/installations/123/access_tokens", nil,
nil, headers)
+
+ assert.Nil(t, err)
+ assert.Equal(t, http.StatusCreated, resp.StatusCode)
+ assert.Equal(t, "POST", receivedMethod)
+ assert.Equal(t, "/app/installations/123/access_tokens", receivedPath)
+ assert.Equal(t, "Bearer jwt_token_here", receivedAuth)
+
+ body, _ := io.ReadAll(resp.Body)
+ resp.Body.Close()
+ assert.Contains(t, string(body), "ghs_test123")
+}
+
+func TestRefreshApiClientGet(t *testing.T) {
+ var receivedMethod string
+ var receivedQuery string
+
+ server := httptest.NewServer(http.HandlerFunc(func(w
http.ResponseWriter, r *http.Request) {
+ receivedMethod = r.Method
+ receivedQuery = r.URL.Query().Get("page")
+ w.WriteHeader(http.StatusOK)
+ w.Write([]byte(`{"ok":true}`))
+ }))
+ defer server.Close()
+
+ client := newRefreshApiClientWithTransport(server.URL,
server.Client().Transport)
+
+ resp, err := client.Get("/test", map[string][]string{"page": {"2"}},
nil)
+ assert.Nil(t, err)
+ assert.Equal(t, http.StatusOK, resp.StatusCode)
+ assert.Equal(t, "GET", receivedMethod)
+ assert.Equal(t, "2", receivedQuery)
+ resp.Body.Close()
+}
+
+func TestRefreshApiClientTimeout(t *testing.T) {
+ server := httptest.NewServer(http.HandlerFunc(func(w
http.ResponseWriter, r *http.Request) {
+ // Sleep longer than the timeout
+ time.Sleep(2 * time.Second)
+ w.WriteHeader(http.StatusOK)
+ }))
+ defer server.Close()
+
+ // Use a client without its own timeout so the context timeout is the
only constraint
+ client := newRefreshApiClientWithTransport(server.URL,
http.DefaultTransport)
+ // Override the timeout to something short for the test
+ client.(*refreshApiClient).timeout = 100 * time.Millisecond
+
+ resp, err := client.Post("/slow", nil, nil, nil)
+ assert.NotNil(t, err)
+ if resp != nil {
+ resp.Body.Close()
+ }
+ // Verify the error is a deadline/context error
+ assert.True(t, strings.Contains(err.Error(), "deadline") ||
strings.Contains(err.Error(), "context"),
+ "expected deadline/context error, got: %s", err.Error())
+}
diff --git a/backend/plugins/github/token/round_tripper.go
b/backend/plugins/github/token/round_tripper.go
index 45ba3e9a7..8868572da 100644
--- a/backend/plugins/github/token/round_tripper.go
+++ b/backend/plugins/github/token/round_tripper.go
@@ -26,6 +26,11 @@ import (
// On 401's the round tripper will:
// - Force a refresh of the OAuth token via the TokenProvider
// - Retry the original request with the new token
+//
+// Note: When active, the RefreshRoundTripper overwrites the Authorization
header
+// on every request, superseding any header previously set by
SetupAuthentication.
+// This is intentional — for connections using token refresh (OAuth or GitHub
App),
+// the round tripper is the single source of truth for the current bearer
token.
type RefreshRoundTripper struct {
base http.RoundTripper
tokenProvider *TokenProvider
diff --git a/backend/plugins/github/token/round_tripper_test.go
b/backend/plugins/github/token/round_tripper_test.go
index e766adb88..2748d59e1 100644
--- a/backend/plugins/github/token/round_tripper_test.go
+++ b/backend/plugins/github/token/round_tripper_test.go
@@ -24,6 +24,7 @@ import (
"testing"
"time"
+ "github.com/apache/incubator-devlake/core/errors"
"github.com/apache/incubator-devlake/helpers/pluginhelper/api"
"github.com/apache/incubator-devlake/impls/logruslog"
"github.com/apache/incubator-devlake/plugins/github/models"
@@ -32,6 +33,36 @@ import (
"github.com/stretchr/testify/mock"
)
+// testRSAKey is a throwaway 2048-bit RSA private key used only in tests.
+// It is NOT a real credential.
+const testRSAKey = `-----BEGIN RSA PRIVATE KEY-----
+MIIEowIBAAKCAQEA1xJuX407giVTO/FY2pbp6bdB/XxaiPqAuvWIcEqabzq+d3ft
+O7fGtbXSQrCdtxEQt5dHFKdJofHcGlKPDnq1BNjWM3/xLWsQWQPSwUZ9H1qy/nDI
+GX+ciXmP8hbzoe5B1OXidAdrJUGWH3ox8Yp8OVd/yK9p34teCbzPnqVEc9tkgUT1
+94gHKLmvP28VefybFyGbYB3ujVIuA8Z26c4gQsyFzR2v1fVeDIu1e1afyH5WTFgr
+EWaztOo6pI5stzH00U7fNMzULBuYQ+ufQ4iQ7Ewt7fK5yyQNkx3pX0o1OQ/aYxQr
+hyBrHakxHBe78eq8rSR2KwSr5nDuYzIAOUhtnwIDAQABAoH/EknhZ6EkvRPR7G5e
+bq4/NduuSriDcITwbWuuuFPufzgdIZVzlu8xHfPOT9152qE2aD7XahGUk85fwbXh
+EeP64x/cA3FnmrAxPYfuUFnB0+i7KHGpzW2Wa/LqXlz3vS/UQePoDwl+xDYeUt2u
+xALHoSxZdlTWv6HLhLmw7ge9QJLc/xQO9dC678c4Y4JTCRrEvhE+eZiDEXb6HL2D
+uNMEwFqMLTxOurYKXE+iyzKg0e4D6oDkw6BC+vOUBnuH+9iKV6wnal24E1WnyhCa
+vy7iHBoc8npeKYAHjU5wKDQiWPMT9DjYBRucHvSyEYTeS5eyQHed42PGv+Ss2IL4
+w0JZAoGBAPjxiEJwck29NyfElw5+/P0IKIE8BkuIOSC8JoGfo+JpKYWFIXaLRbK8
+qvOiLDotHgC5IxFZyN8pejVe7Zvif5PepR9qMcV3e6Uz2nmJqtp5gFlzjNUSzbeM
+J6gqkejtwn7wJY3dwZhfbTTDDY7cZ9f3Cydarx+iu07unGCRT9DDAoGBAN0rG/F9
+tsOSwrgsnPwNeVPfJn2Zw6mvb1xuOoJl11tDnpS6tH50FBJ/TkERZak9+VZ761pq
+CvuMjLtePgC31rsAcUEEt1OwYCxne8gNxuzl2mWKD98ActbFf96VM4Q68Jvntxl1
+eOMMnSA+/yjvhpxdVabm3180mRu9eBv6SaH1AoGBAMpwL6xHoMwS6L1QIr7JCZYC
+gl3FoCDgIAS8vFuApFbDyd4oSvQJgZ49yo7g/DI66kEQTLIZXz4KjrTEA1lWsQRg
+c8q+Isc/yK6pIirfhq6vS25yhr3m0p9GPCGGrKzMW/O5+fAJuxrbzwSu8WGRXmjD
+HrDcD7kcLlGbvFLTGCLdAoGBALNID7W5Z16v6AItv++d6Hzxhh0IeRBi8s2lWO59
+KY6EiNcdZdSfuemoosGiHZuMbkMJ3qWDEnYI38e+xFoGrB0YZbYD4awIbF1yYWew
+q1E7ncbznJvznCO3I0lF/uWwdXyb39PWYvECN5h9GI+RYrf7/MN3oRhm5boT43oi
+cG/FAoGBALD1zSG9qrysLg8fw4dc4cs6dAHZhAszh47zw2WiMmUVWQZCbB+uhs44
+qGCAaer5KdnTqy9NdwW6rlcr4Y8jUFnMlMFA5HdmHkIfgTi+zi4Qf1mb9yzpJqnU
+jKflsh1Lyyqv2KsoIMz4vjew+lCVn80FZaEEQ1q9tAkyu5m53w65
+-----END RSA PRIVATE KEY-----`
+
func TestRoundTripper401Refresh(t *testing.T) {
mockRT := new(MockRoundTripper)
client := &http.Client{Transport: mockRT}
@@ -56,7 +87,7 @@ func TestRoundTripper401Refresh(t *testing.T) {
}
logger, _ := logruslog.NewDefaultLogger(logrus.New())
- tp := NewTokenProvider(conn, nil, client, logger)
+ tp := NewTokenProvider(conn, nil, client, logger, "")
rt := NewRefreshRoundTripper(mockRT, tp)
// Request
@@ -100,3 +131,163 @@ func TestRoundTripper401Refresh(t *testing.T) {
mockRT.AssertExpectations(t)
}
+
+func TestRoundTripper401WithAppKeyRefresh(t *testing.T) {
+ mockRT := new(MockRoundTripper)
+
+ expiry := time.Now().Add(10 * time.Minute) // Not expired (proactive
refresh won't trigger)
+ conn := &models.GithubConnection{
+ GithubConn: models.GithubConn{
+ GithubAccessToken: models.GithubAccessToken{
+ AccessToken: api.AccessToken{
+ Token: "old_app_token",
+ },
+ },
+ TokenExpiresAt: &expiry,
+ },
+ }
+ // Use tokens slice so GetToken returns the current token
+ conn.UpdateToken("old_app_token", "", &expiry, nil)
+
+ // refreshFn simulates minting a new installation token
+ refreshCalled := 0
+ tp := &TokenProvider{
+ conn: conn,
+ refreshFn: func(tp *TokenProvider) errors.Error {
+ refreshCalled++
+ newExpiry := time.Now().Add(1 * time.Hour)
+ tp.conn.UpdateToken("new_app_token", "", &newExpiry,
nil)
+ return nil
+ },
+ }
+
+ rt := NewRefreshRoundTripper(mockRT, tp)
+
+ req, _ := http.NewRequest("GET",
"https://api.github.com/repos/test/test", nil)
+
+ // 1. First call returns 401
+ resp401 := &http.Response{
+ StatusCode: 401,
+ Body: io.NopCloser(bytes.NewBufferString("Bad
credentials")),
+ }
+ mockRT.On("RoundTrip", mock.MatchedBy(func(r *http.Request) bool {
+ return r.Header.Get("Authorization") == "Bearer old_app_token"
+ })).Return(resp401, nil).Once()
+
+ // 2. Retry call with new token (after refreshFn runs)
+ resp200 := &http.Response{
+ StatusCode: 200,
+ Body:
io.NopCloser(bytes.NewBufferString(`{"full_name":"test/test"}`)),
+ }
+ mockRT.On("RoundTrip", mock.MatchedBy(func(r *http.Request) bool {
+ return r.Header.Get("Authorization") == "Bearer new_app_token"
+ })).Return(resp200, nil).Once()
+
+ // Execute
+ resp, err := rt.RoundTrip(req)
+ assert.NoError(t, err)
+ assert.Equal(t, 200, resp.StatusCode)
+ assert.Equal(t, 1, refreshCalled, "refreshFn should have been called
exactly once")
+
+ body, _ := io.ReadAll(resp.Body)
+ assert.Equal(t, `{"full_name":"test/test"}`, string(body))
+
+ mockRT.AssertExpectations(t)
+}
+
+// TestProactiveRefreshNoDeadlock verifies that when the RefreshRoundTripper
wraps
+// the same http.Client's transport, a proactive token refresh does not
deadlock.
+// This reproduces the real-world scenario: GetToken() holds the mutex, calls
+// refreshGitHubAppInstallationToken, which makes an HTTP request. If that
request
+// goes through the RefreshRoundTripper (re-entering GetToken), it would
deadlock.
+// The fix is that the refresh uses baseTransport directly.
+func TestProactiveRefreshNoDeadlock(t *testing.T) {
+ // Set up a mock transport that will serve both:
+ // 1. The installation token refresh POST
+ // 2. The actual API GET after refresh
+ mockRT := new(MockRoundTripper)
+ client := &http.Client{Transport: mockRT}
+
+ // Token is expired — proactive refresh WILL trigger on GetToken()
+ expired := time.Now().Add(-1 * time.Minute)
+ conn := &models.GithubConnection{
+ GithubConn: models.GithubConn{
+ RestConnection: api.RestConnection{
+ Endpoint: "https://api.github.com/",
+ },
+ MultiAuth: api.MultiAuth{
+ AuthMethod: models.AppKey,
+ },
+ GithubAccessToken: models.GithubAccessToken{
+ AccessToken: api.AccessToken{
+ Token: "expired_ghs_token",
+ },
+ },
+ GithubAppKey: models.GithubAppKey{
+ AppKey: api.AppKey{
+ AppId: "12345",
+ SecretKey: testRSAKey,
+ },
+ InstallationID: 99999,
+ },
+ TokenExpiresAt: &expired,
+ },
+ }
+ conn.UpdateToken("expired_ghs_token", "", &expired, nil)
+
+ // Create the TokenProvider with baseTransport = mockRT (the unwrapped
transport).
+ // This is what NewAppInstallationTokenProvider does: it captures
client.Transport
+ // BEFORE the caller wraps it with RefreshRoundTripper.
+ tp := NewAppInstallationTokenProvider(conn, nil, client, nil, "")
+
+ // Now wrap the client's transport with RefreshRoundTripper (simulating
what
+ // CreateApiClient does). After this, client.Transport =
RefreshRoundTripper,
+ // but tp.baseTransport still points to mockRT.
+ rt := NewRefreshRoundTripper(mockRT, tp)
+ client.Transport = rt
+
+ // Mock: installation token refresh POST (goes through baseTransport,
not RT)
+ newExpiry := time.Now().Add(1 * time.Hour)
+ installTokenBody := `{"token":"new_ghs_token","expires_at":"` +
newExpiry.Format(time.RFC3339) + `"}`
+ mockRT.On("RoundTrip", mock.MatchedBy(func(r *http.Request) bool {
+ return r.Method == "POST" && r.URL.Path ==
"/app/installations/99999/access_tokens"
+ })).Return(&http.Response{
+ StatusCode: 201,
+ Body:
io.NopCloser(bytes.NewBufferString(installTokenBody)),
+ }, nil).Once()
+
+ // Mock: the actual API request (goes through RefreshRoundTripper →
GetToken → mockRT)
+ mockRT.On("RoundTrip", mock.MatchedBy(func(r *http.Request) bool {
+ return r.Method == "GET" && r.URL.Path == "/repos/test/test"
+ })).Return(&http.Response{
+ StatusCode: 200,
+ Body:
io.NopCloser(bytes.NewBufferString(`{"full_name":"test/test"}`)),
+ }, nil).Once()
+
+ // Execute through the RefreshRoundTripper with a timeout to detect
deadlocks.
+ done := make(chan struct{})
+ var resp *http.Response
+ var reqErr error
+ go func() {
+ req, _ := http.NewRequest("GET",
"https://api.github.com/repos/test/test", nil)
+ resp, reqErr = rt.RoundTrip(req)
+ close(done)
+ }()
+
+ select {
+ case <-done:
+ // Success — no deadlock
+ case <-time.After(5 * time.Second):
+ t.Fatal("DEADLOCK: RoundTrip did not complete within 5 seconds
— " +
+ "the refresh call is likely going through
RefreshRoundTripper instead of baseTransport")
+ }
+
+ assert.NoError(t, reqErr)
+ assert.Equal(t, 200, resp.StatusCode)
+ assert.Equal(t, "new_ghs_token", conn.Token, "token should have been
refreshed")
+
+ body, _ := io.ReadAll(resp.Body)
+ assert.Equal(t, `{"full_name":"test/test"}`, string(body))
+
+ mockRT.AssertExpectations(t)
+}
diff --git a/backend/plugins/github/token/token_provider.go
b/backend/plugins/github/token/token_provider.go
index ed3fa2256..6af31aaf5 100644
--- a/backend/plugins/github/token/token_provider.go
+++ b/backend/plugins/github/token/token_provider.go
@@ -40,23 +40,57 @@ const (
)
type TokenProvider struct {
- conn *models.GithubConnection
- dal dal.Dal
- httpClient *http.Client
- logger log.Logger
- mu sync.Mutex
- refreshURL string
+ conn *models.GithubConnection
+ dal dal.Dal
+ encryptionSecret string
+ httpClient *http.Client
+ baseTransport http.RoundTripper // original transport, before
RefreshRoundTripper wrapping
+ logger log.Logger
+ mu sync.Mutex
+ refreshURL string
+ refreshFn func(*TokenProvider) errors.Error
}
// NewTokenProvider creates a TokenProvider for the given GitHub connection
using
// the provided DAL, HTTP client, and logger, and returns a pointer to it.
-func NewTokenProvider(conn *models.GithubConnection, d dal.Dal, client
*http.Client, logger log.Logger) *TokenProvider {
+func NewTokenProvider(conn *models.GithubConnection, d dal.Dal, client
*http.Client, logger log.Logger, encryptionSecret string) *TokenProvider {
return &TokenProvider{
- conn: conn,
- dal: d,
- httpClient: client,
- logger: logger,
- refreshURL: "https://github.com/login/oauth/access_token",
+ conn: conn,
+ dal: d,
+ encryptionSecret: encryptionSecret,
+ httpClient: client,
+ logger: logger,
+ refreshURL: "https://github.com/login/oauth/access_token",
+ }
+}
+
+// NewAppInstallationTokenProvider creates a TokenProvider that refreshes
GitHub App installation tokens.
+// IMPORTANT: Call this BEFORE wrapping the client's transport with
RefreshRoundTripper,
+// so that baseTransport captures the unwrapped transport and refresh calls
don't deadlock.
+func NewAppInstallationTokenProvider(conn *models.GithubConnection, d dal.Dal,
client *http.Client, logger log.Logger, encryptionSecret string) *TokenProvider
{
+ if logger != nil {
+ expiresStr := "unknown"
+ if conn.TokenExpiresAt != nil {
+ expiresStr = conn.TokenExpiresAt.Format(time.RFC3339)
+ }
+ logger.Info("Created AppInstallation token provider for
connection %d (installation %d, token expires at %s)",
+ conn.ID, conn.InstallationID, expiresStr)
+ }
+ // Capture the transport now, before the caller wraps it with
RefreshRoundTripper.
+ // This avoids a deadlock: refresh calls must bypass the
RefreshRoundTripper that
+ // holds the TokenProvider mutex during GetToken().
+ baseTransport := client.Transport
+ if baseTransport == nil {
+ baseTransport = http.DefaultTransport
+ }
+ return &TokenProvider{
+ conn: conn,
+ dal: d,
+ encryptionSecret: encryptionSecret,
+ httpClient: client,
+ baseTransport: baseTransport,
+ logger: logger,
+ refreshFn: refreshGitHubAppInstallationToken,
}
}
@@ -65,6 +99,14 @@ func (tp *TokenProvider) GetToken() (string, errors.Error) {
defer tp.mu.Unlock()
if tp.needsRefresh() {
+ if tp.logger != nil {
+ expiresStr := "unknown"
+ if tp.conn.TokenExpiresAt != nil {
+ expiresStr =
tp.conn.TokenExpiresAt.Format(time.RFC3339)
+ }
+ tp.logger.Info("Proactive token refresh triggered for
connection %d (token expires at %s)",
+ tp.conn.ID, expiresStr)
+ }
if err := tp.refreshToken(); err != nil {
return "", err
}
@@ -73,10 +115,6 @@ func (tp *TokenProvider) GetToken() (string, errors.Error) {
}
func (tp *TokenProvider) needsRefresh() bool {
- if tp.conn.RefreshToken == "" {
- return false
- }
-
buffer := DefaultRefreshBuffer
if envBuffer := os.Getenv("GITHUB_TOKEN_REFRESH_BUFFER_MINUTES");
envBuffer != "" {
if val, err := strconv.Atoi(envBuffer); err == nil {
@@ -84,6 +122,16 @@ func (tp *TokenProvider) needsRefresh() bool {
}
}
+ if tp.refreshFn != nil {
+ if tp.conn.TokenExpiresAt == nil {
+ return false
+ }
+ return time.Now().Add(buffer).After(*tp.conn.TokenExpiresAt)
+ }
+
+ if tp.conn.RefreshToken == "" {
+ return false
+ }
if tp.conn.TokenExpiresAt == nil {
return false
}
@@ -91,6 +139,9 @@ func (tp *TokenProvider) needsRefresh() bool {
}
func (tp *TokenProvider) refreshToken() errors.Error {
+ if tp.refreshFn != nil {
+ return tp.refreshFn(tp)
+ }
tp.logger.Info("Refreshing GitHub token for connection %d", tp.conn.ID)
data := map[string]string{
@@ -159,13 +210,12 @@ func (tp *TokenProvider) refreshToken() errors.Error {
)
if tp.dal != nil {
- err := tp.dal.UpdateColumns(tp.conn, []dal.DalSet{
- {ColumnName: "token", Value: tp.conn.Token},
- {ColumnName: "refresh_token", Value:
tp.conn.RefreshToken},
- {ColumnName: "token_expires_at", Value:
tp.conn.TokenExpiresAt},
- {ColumnName: "refresh_token_expires_at", Value:
tp.conn.RefreshTokenExpiresAt},
- })
- if err != nil {
+ // Manually encrypt and use UpdateColumns to persist only the
token-related
+ // columns. We cannot use dal.Update (GORM Save) because it
writes ALL fields
+ // including refresh_token_expires_at which may have Go zero
time that MySQL
+ // rejects. We cannot use UpdateColumns with plaintext because
it bypasses the
+ // GORM encdec serializer. So we encrypt manually and write the
ciphertext.
+ if err := PersistEncryptedTokenColumns(tp.dal, tp.conn,
tp.encryptionSecret, tp.logger, true); err != nil {
tp.logger.Warn(err, "failed to persist refreshed token")
}
}
@@ -184,8 +234,14 @@ func (tp *TokenProvider) ForceRefresh(oldToken string)
errors.Error {
// If the token has changed since the request was made, it means
another thread
// has already refreshed it.
if tp.conn.Token != oldToken {
+ if tp.logger != nil {
+ tp.logger.Info("Skipping reactive token refresh for
connection %d — token already changed by another goroutine", tp.conn.ID)
+ }
return nil
}
+ if tp.logger != nil {
+ tp.logger.Info("Reactive token refresh triggered for connection
%d (received 401)", tp.conn.ID)
+ }
return tp.refreshToken()
}
diff --git a/backend/plugins/github/token/token_provider_test.go
b/backend/plugins/github/token/token_provider_test.go
index 3319f5591..a754ad63c 100644
--- a/backend/plugins/github/token/token_provider_test.go
+++ b/backend/plugins/github/token/token_provider_test.go
@@ -29,7 +29,6 @@ import (
"github.com/apache/incubator-devlake/core/errors"
"github.com/apache/incubator-devlake/helpers/pluginhelper/api"
"github.com/apache/incubator-devlake/impls/logruslog"
- mockdal "github.com/apache/incubator-devlake/mocks/core/dal"
"github.com/apache/incubator-devlake/plugins/github/models"
"github.com/sirupsen/logrus"
"github.com/stretchr/testify/assert"
@@ -93,7 +92,7 @@ func TestTokenProviderConcurrency(t *testing.T) {
}
logger, _ := logruslog.NewDefaultLogger(logrus.New())
- tp := NewTokenProvider(conn, nil, client, logger)
+ tp := NewTokenProvider(conn, nil, client, logger, "")
// Mock response for refresh
respBody :=
`{"access_token":"new_token","refresh_token":"new_refresh_token","expires_in":3600,"refresh_token_expires_in":3600}`
@@ -143,44 +142,108 @@ func TestConfigurableBuffer(t *testing.T) {
assert.False(t, tp.needsRefresh())
}
-func TestPersistenceFailure(t *testing.T) {
- mockRT := new(MockRoundTripper)
- client := &http.Client{Transport: mockRT}
- mockDal := new(mockdal.Dal)
+// fakeRefreshFn returns a refreshFn that updates the connection token to
newToken
+// and increments the call counter pointed to by count.
+func fakeRefreshFn(newToken string, count *int) func(*TokenProvider)
errors.Error {
+ return func(tp *TokenProvider) errors.Error {
+ *count++
+ newExpiry := time.Now().Add(1 * time.Hour)
+ tp.conn.UpdateToken(newToken, "", &newExpiry, nil)
+ return nil
+ }
+}
+
+func TestNeedsRefreshWithRefreshFn(t *testing.T) {
+ callCount := 0
+ tp := &TokenProvider{
+ conn: &models.GithubConnection{},
+ refreshFn: fakeRefreshFn("unused", &callCount),
+ }
+
+ // Token not expired — outside default 5m buffer
+ expiry1 := time.Now().Add(10 * time.Minute)
+ tp.conn.TokenExpiresAt = &expiry1
+ assert.False(t, tp.needsRefresh(), "should not refresh when token is
10m from expiry")
+
+ // Token inside 5m buffer
+ expiry2 := time.Now().Add(2 * time.Minute)
+ tp.conn.TokenExpiresAt = &expiry2
+ assert.True(t, tp.needsRefresh(), "should refresh when token is 2m from
expiry")
+ // Token already expired
+ expiry3 := time.Now().Add(-1 * time.Minute)
+ tp.conn.TokenExpiresAt = &expiry3
+ assert.True(t, tp.needsRefresh(), "should refresh when token is
expired")
+
+ // TokenExpiresAt is nil — can't determine expiry, don't refresh (401
fallback covers this)
+ tp.conn.TokenExpiresAt = nil
+ assert.False(t, tp.needsRefresh(), "should not refresh when
TokenExpiresAt is nil")
+
+ // Provider with neither refreshFn nor RefreshToken — should never
refresh
+ tp2 := &TokenProvider{
+ conn: &models.GithubConnection{},
+ }
+ expiry4 := time.Now().Add(-1 * time.Minute)
+ tp2.conn.TokenExpiresAt = &expiry4
+ assert.False(t, tp2.needsRefresh(), "should not refresh without
refreshFn or RefreshToken")
+}
+
+func TestAppKeyGetTokenTriggersRefresh(t *testing.T) {
+ callCount := 0
+ expired := time.Now().Add(-1 * time.Minute)
conn := &models.GithubConnection{
GithubConn: models.GithubConn{
- RefreshToken: "refresh_token",
GithubAccessToken: models.GithubAccessToken{
AccessToken: api.AccessToken{
- Token: "old_token",
+ Token: "expired_token",
},
},
- GithubAppKey: models.GithubAppKey{
- AppKey: api.AppKey{
- AppId: "123",
- SecretKey: "secret",
+ TokenExpiresAt: &expired,
+ },
+ }
+
+ tp := &TokenProvider{
+ conn: conn,
+ refreshFn: fakeRefreshFn("refreshed_app_token", &callCount),
+ }
+
+ token, err := tp.GetToken()
+ assert.NoError(t, err)
+ assert.Equal(t, "refreshed_app_token", token)
+ assert.Equal(t, 1, callCount, "refreshFn should have been called
exactly once")
+
+ // Second call — token is now fresh, should not trigger refresh
+ token2, err := tp.GetToken()
+ assert.NoError(t, err)
+ assert.Equal(t, "refreshed_app_token", token2)
+ assert.Equal(t, 1, callCount, "refreshFn should not be called again for
a fresh token")
+}
+
+func TestAppKeyForceRefresh(t *testing.T) {
+ callCount := 0
+ conn := &models.GithubConnection{
+ GithubConn: models.GithubConn{
+ GithubAccessToken: models.GithubAccessToken{
+ AccessToken: api.AccessToken{
+ Token: "old_app_token",
},
},
},
}
- logger, _ := logruslog.NewDefaultLogger(logrus.New())
- tp := NewTokenProvider(conn, mockDal, client, logger)
-
- // Mock response for refresh
- respBody :=
`{"access_token":"new_token","refresh_token":"new_refresh_token","expires_in":3600,"refresh_token_expires_in":3600}`
- resp := &http.Response{
- StatusCode: 200,
- Body: io.NopCloser(bytes.NewBufferString(respBody)),
+ tp := &TokenProvider{
+ conn: conn,
+ refreshFn: fakeRefreshFn("new_app_token", &callCount),
}
- mockRT.On("RoundTrip", mock.Anything).Return(resp, nil).Once()
- // Mock DAL failure
- mockDal.On("UpdateColumns", mock.Anything, mock.Anything,
mock.AnythingOfType("[]dal.Clause")).Return(errors.Default.New("db error"))
- err := tp.ForceRefresh("old_token")
- assert.NoError(t, err) // Should not return error even if persistence
fails
+ // ForceRefresh with matching old token — should trigger refresh
+ err := tp.ForceRefresh("old_app_token")
+ assert.NoError(t, err)
+ assert.Equal(t, 1, callCount)
+ assert.Equal(t, "new_app_token", conn.Token)
- mockRT.AssertExpectations(t)
- mockDal.AssertExpectations(t)
+ // ForceRefresh with stale old token — token has already changed,
should be a no-op
+ err = tp.ForceRefresh("old_app_token")
+ assert.NoError(t, err)
+ assert.Equal(t, 1, callCount, "should not refresh when token has
already changed")
}