Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions backend/helpers/pluginhelper/api/api_client.go
Original file line number Diff line number Diff line change
Expand Up @@ -299,6 +299,11 @@ func (apiClient *ApiClient) SetLogger(logger log.Logger) {
apiClient.logger = logger
}

// GetClient returns the underlying http.Client
func (apiClient *ApiClient) GetClient() *http.Client {
return apiClient.client
}

func (apiClient *ApiClient) logDebug(format string, a ...interface{}) {
if apiClient.logger != nil {
apiClient.logger.Debug(format, a...)
Expand Down
17 changes: 16 additions & 1 deletion backend/plugins/github/models/connection.go
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,21 @@ type GithubConn struct {
helper.MultiAuth `mapstructure:",squash"`
GithubAccessToken `mapstructure:",squash" authMethod:"AccessToken"`
GithubAppKey `mapstructure:",squash" authMethod:"AppKey"`
RefreshToken string `mapstructure:"refreshToken" json:"refreshToken" gorm:"type:text;serializer:encdec"`
TokenExpiresAt time.Time `mapstructure:"tokenExpiresAt" json:"tokenExpiresAt"`
RefreshTokenExpiresAt time.Time `mapstructure:"refreshTokenExpiresAt" json:"refreshTokenExpiresAt"`
}

// UpdateToken updates the token and refresh token information
Comment thread
ysinghc marked this conversation as resolved.
func (conn *GithubConn) UpdateToken(newToken, newRefreshToken string, expiry, refreshExpiry time.Time) {
conn.Token = newToken
conn.RefreshToken = newRefreshToken
conn.TokenExpiresAt = expiry
conn.RefreshTokenExpiresAt = refreshExpiry

// Update the internal tokens slice used by SetupAuthentication
conn.tokens = []string{newToken}
conn.tokenIndex = 0
Comment on lines +71 to +73
Copy link

Copilot AI Dec 21, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The UpdateToken method resets the tokens slice and tokenIndex, but this could cause issues with concurrent access. If PrepareApiClient has been called previously and split the Token into multiple tokens for rotation, UpdateToken will replace that slice with a single token. This could lead to unexpected behavior when tokens are rotated, especially since there's no synchronization mechanism to protect against concurrent modifications of these fields.

Copilot uses AI. Check for mistakes.
}
Comment on lines +65 to 74
Copy link

Copilot AI Jan 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new UpdateToken method lacks test coverage. Since this method modifies critical security-related state (tokens and expiry times), it should have unit tests to verify that all fields are updated correctly, including the internal tokens slice and tokenIndex.

Copilot uses AI. Check for mistakes.

// PrepareApiClient splits Token to tokens for SetupAuthentication to utilize
Expand Down Expand Up @@ -249,7 +264,7 @@ func (conn *GithubConn) typeIs(token string) string {
// total len is 40, {prefix}{showPrefix}{secret}{showSuffix}
// fine-grained tokens
// github_pat_{82_characters}
classicalTokenClassicalPrefixes := []string{"ghp_", "gho_", "ghs_", "ghr_"}
classicalTokenClassicalPrefixes := []string{"ghp_", "gho_", "ghs_", "ghr_", "ghu_"}
classicalTokenFindGrainedPrefixes := []string{"github_pat_"}
for _, prefix := range classicalTokenClassicalPrefixes {
if strings.HasPrefix(token, prefix) {
Expand Down
11 changes: 11 additions & 0 deletions backend/plugins/github/models/connection_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -227,3 +227,14 @@ func TestGithubConnection_Sanitize(t *testing.T) {
})
}
}

func TestIsUserToServerToken(t *testing.T) {
Comment thread
ysinghc marked this conversation as resolved.
Outdated
conn := &GithubConn{}
assert.Equal(t, GithubTokenTypeClassical, conn.typeIs("ghp_123"))
assert.Equal(t, GithubTokenTypeClassical, conn.typeIs("gho_123"))
assert.Equal(t, GithubTokenTypeClassical, conn.typeIs("ghu_123"))
assert.Equal(t, GithubTokenTypeClassical, conn.typeIs("ghs_123"))
assert.Equal(t, GithubTokenTypeClassical, conn.typeIs("ghr_123"))
assert.Equal(t, GithubTokenTypeFineGrained, conn.typeIs("github_pat_123"))
assert.Equal(t, GithubTokenTypeUnknown, conn.typeIs("some_other_token"))
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
/*
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 migrationscripts

import (
"github.com/apache/incubator-devlake/core/context"
"github.com/apache/incubator-devlake/core/errors"
"github.com/apache/incubator-devlake/helpers/migrationhelper"
"github.com/apache/incubator-devlake/plugins/github/models"
)

type addRefreshTokenFields struct{}

func (*addRefreshTokenFields) Up(basicRes context.BasicRes) errors.Error {
return migrationhelper.AutoMigrateTables(
basicRes,
&models.GithubConnection{},
)
}

func (*addRefreshTokenFields) Version() uint64 {
return 20241120000001
}

func (*addRefreshTokenFields) Name() string {
return "add refresh token fields to github_connections"
}
1 change: 1 addition & 0 deletions backend/plugins/github/models/migrationscripts/register.go
Original file line number Diff line number Diff line change
Expand Up @@ -55,5 +55,6 @@ func All() []plugin.MigrationScript {
new(addIsDraftToPr),
new(changeIssueComponentType),
new(addIndexToGithubJobs),
new(addRefreshTokenFields),
}
}
19 changes: 19 additions & 0 deletions backend/plugins/github/tasks/api_client.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import (
"github.com/apache/incubator-devlake/core/plugin"
"github.com/apache/incubator-devlake/helpers/pluginhelper/api"
"github.com/apache/incubator-devlake/plugins/github/models"
"github.com/apache/incubator-devlake/plugins/github/token"
)

func CreateApiClient(taskCtx plugin.TaskContext, connection *models.GithubConnection) (*api.ApiAsyncClient, errors.Error) {
Expand All @@ -34,6 +35,24 @@ 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)

// Wrap the transport
baseTransport := apiClient.GetClient().Transport
if baseTransport == nil {
baseTransport = http.DefaultTransport
}

rt := token.NewRefreshRoundTripper(baseTransport, tp)
apiClient.GetClient().Transport = rt
}
Comment on lines +38 to +54
Copy link

Copilot AI Dec 21, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The RefreshRoundTripper wraps the existing transport, but this could conflict with the existing authentication setup in SetupAuthentication method. The connection's SetupAuthentication is likely already being used elsewhere to set the Authorization header, and now the RoundTripper is also setting it. This could lead to duplicate or conflicting authorization headers depending on the order of middleware execution. Consider documenting this interaction or ensuring the RoundTripper is the exclusive mechanism for setting authorization when refresh tokens are present.

Copilot uses AI. Check for mistakes.

// create rate limit calculator
rateLimiter := &api.ApiRateLimitCalculator{
UserRateLimitPerHour: connection.RateLimitPerHour,
Expand Down
76 changes: 76 additions & 0 deletions backend/plugins/github/token/round_tripper.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
/*
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 (
"net/http"
)

type RefreshRoundTripper struct {
base http.RoundTripper
tokenProvider *TokenProvider
}

Comment thread
ysinghc marked this conversation as resolved.
func NewRefreshRoundTripper(base http.RoundTripper, tp *TokenProvider) *RefreshRoundTripper {
return &RefreshRoundTripper{
base: base,
tokenProvider: tp,
}
}
Comment on lines +29 to +39
Copy link

Copilot AI Dec 21, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The RefreshRoundTripper struct and RoundTrip method lack documentation. These should include comments explaining that this is an HTTP transport middleware that automatically refreshes OAuth tokens and retries requests on authentication failures. The RoundTrip method should document its behavior of cloning requests, handling 401 responses, and potential retry attempts.

Copilot uses AI. Check for mistakes.

func (rt *RefreshRoundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
// Get token
token, err := rt.tokenProvider.GetToken()
if err != nil {
return nil, err
}

// Clone request before modifying
reqClone := req.Clone(req.Context())
Copy link

Copilot AI Jan 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The req.Clone() method does not clone the request body, which means if the original request has a body (e.g., POST/PUT requests), attempting to retry the request after a 401 error will fail because the body has already been read. The body needs to be preserved or made replayable for retry scenarios. Consider using req.GetBody if available, or document that this implementation only supports requests without bodies.

Copilot uses AI. Check for mistakes.
reqClone.Header.Set("Authorization", "Bearer "+token)

// Execute request
resp, reqErr := rt.base.RoundTrip(reqClone)
if reqErr != nil {
return nil, reqErr
}

// Reactive refresh on 401
if resp.StatusCode == http.StatusUnauthorized {
// Close previous response body
resp.Body.Close()

// Force refresh
if err := rt.tokenProvider.ForceRefresh(token); err != nil {
return nil, err
}

// Get new token
newToken, err := rt.tokenProvider.GetToken()
if err != nil {
return nil, err
}

// Retry request with new token
reqRetry := req.Clone(req.Context())
Copy link

Copilot AI Jan 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The req.Clone() method is called again for the retry request, but request bodies cannot be replayed after being read. If this is a POST/PUT/PATCH request with a body, the retry will fail or send an empty body. This is the same issue as line 58 - the request body needs to be preserved or made replayable.

Copilot uses AI. Check for mistakes.
reqRetry.Header.Set("Authorization", "Bearer "+newToken)
return rt.base.RoundTrip(reqRetry)
Comment thread
ysinghc marked this conversation as resolved.
Outdated
}

return resp, nil
}
Comment thread
ysinghc marked this conversation as resolved.
171 changes: 171 additions & 0 deletions backend/plugins/github/token/token_provider.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,171 @@
/*
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"
"fmt"
"io"
"net/http"
"os"
"strconv"
"sync"
"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/plugins/github/models"
)

const (
DefaultRefreshBuffer = 5 * time.Minute
)

Comment thread
ysinghc marked this conversation as resolved.
type TokenProvider struct {
conn *models.GithubConnection
dal dal.Dal
httpClient *http.Client
logger log.Logger
mu sync.Mutex
refreshURL string
}
Comment thread
ysinghc marked this conversation as resolved.

Comment thread
ysinghc marked this conversation as resolved.
func NewTokenProvider(conn *models.GithubConnection, d dal.Dal, client *http.Client, logger log.Logger) *TokenProvider {
return &TokenProvider{
conn: conn,
dal: d,
httpClient: client,
logger: logger,
refreshURL: "https://github.com/login/oauth/access_token",
}
}

Comment thread
ysinghc marked this conversation as resolved.
func (tp *TokenProvider) GetToken() (string, errors.Error) {
tp.mu.Lock()
defer tp.mu.Unlock()

if tp.needsRefresh() {
if err := tp.refreshToken(); err != nil {
return "", err
}
}
return tp.conn.Token, nil
}

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 {
buffer = time.Duration(val) * time.Minute
}
}
Comment on lines +80 to +85
Copy link

Copilot AI Jan 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The environment variable check is performed on every call to needsRefresh(), which could be inefficient. Consider caching the buffer value once during initialization or using sync.Once for lazy initialization to avoid repeated environment variable lookups and parsing.

Copilot uses AI. Check for mistakes.

return time.Now().Add(buffer).After(tp.conn.TokenExpiresAt)
}
Comment on lines +75 to +88
Copy link

Copilot AI Dec 21, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The needsRefresh method doesn't check if TokenExpiresAt is a zero value before comparing it with the current time. If TokenExpiresAt is not set (zero value), the comparison 'time.Now().Add(buffer).After(tp.conn.TokenExpiresAt)' will always return true, causing unnecessary refresh attempts. Consider adding a check to return false if TokenExpiresAt.IsZero() to handle the case where expiry time is not set.

Copilot uses AI. Check for mistakes.

func (tp *TokenProvider) refreshToken() errors.Error {
tp.logger.Info("Refreshing GitHub token for connection %d", tp.conn.ID)

data := map[string]string{
"refresh_token": tp.conn.RefreshToken,
"grant_type": "refresh_token",
"client_id": tp.conn.AppId,
"client_secret": tp.conn.SecretKey,
}
Comment on lines +93 to +98
Copy link

Copilot AI Dec 21, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sensitive authentication credentials (client_id and client_secret) are logged in plain text in the request data during token refresh. If debug logging is enabled or if there's an error that logs the request, these credentials could be exposed in log files. Consider sanitizing or redacting sensitive fields before logging to prevent credential leakage.

Copilot uses AI. Check for mistakes.
jsonData, _ := json.Marshal(data)
Copy link

Copilot AI Jan 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The error from json.Marshal is silently ignored. While marshaling a simple map[string]string is unlikely to fail, errors should still be handled properly for robustness and to follow best practices. Consider returning or logging the error if it occurs.

Suggested change
jsonData, _ := json.Marshal(data)
jsonData, err := json.Marshal(data)
if err != nil {
return errors.Convert(err)
}

Copilot uses AI. Check for mistakes.

ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()

req, err := http.NewRequestWithContext(ctx, "POST", tp.refreshURL, bytes.NewBuffer(jsonData))
if err != nil {
return errors.Convert(err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")

resp, err := tp.httpClient.Do(req)
if err != nil {
return errors.Convert(err)
}
defer resp.Body.Close()

if resp.StatusCode != http.StatusOK {
return errors.Default.New(fmt.Sprintf("failed to refresh token: %d", resp.StatusCode))
}

body, err := io.ReadAll(resp.Body)
if err != nil {
return errors.Convert(err)
}

Comment thread
ysinghc marked this conversation as resolved.
Outdated
var result struct {
AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token"`
ExpiresIn int `json:"expires_in"`
RefreshTokenExpiresIn int `json:"refresh_token_expires_in"`
}
if err := json.Unmarshal(body, &result); err != nil {
return errors.Convert(err)
}

if result.AccessToken == "" {
return errors.Default.New("empty access token returned")
Comment thread
ysinghc marked this conversation as resolved.
Outdated
}

tp.conn.UpdateToken(
result.AccessToken,
result.RefreshToken,
time.Now().Add(time.Duration(result.ExpiresIn)*time.Second),
time.Now().Add(time.Duration(result.RefreshTokenExpiresIn)*time.Second),
)

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 {
tp.logger.Warn(err, "failed to persist refreshed token")
Comment thread
ysinghc marked this conversation as resolved.
}
}

return nil
}

Comment thread
ysinghc marked this conversation as resolved.
func (tp *TokenProvider) ForceRefresh(oldToken string) errors.Error {
tp.mu.Lock()
defer tp.mu.Unlock()

// If the token has changed since the request was made, it means another thread
// has already refreshed it.
if tp.conn.Token != oldToken {
return nil
}

return tp.refreshToken()
}
Comment thread
ysinghc marked this conversation as resolved.