blob: 6af31aaf5af0c3996fc7c46ff843d88e845f9c78 [file]
/*
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
)
type TokenProvider struct {
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, encryptionSecret string) *TokenProvider {
return &TokenProvider{
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,
}
}
func (tp *TokenProvider) GetToken() (string, errors.Error) {
tp.mu.Lock()
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
}
}
return tp.conn.Token, nil
}
func (tp *TokenProvider) needsRefresh() bool {
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
}
}
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
}
return time.Now().Add(buffer).After(*tp.conn.TokenExpiresAt)
}
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{
"refresh_token": tp.conn.RefreshToken,
"grant_type": "refresh_token",
"client_id": tp.conn.AppId,
"client_secret": tp.conn.SecretKey,
}
jsonData, _ := json.Marshal(data)
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()
body, err := io.ReadAll(resp.Body)
if err != nil {
return errors.Convert(err)
}
if resp.StatusCode != http.StatusOK {
// Log the response body to aid in debugging token refresh failures.
if tp.logger != nil {
tp.logger.Error(nil, "failed to refresh token from GitHub, status=%d, body=%s", resp.StatusCode, string(body))
}
return errors.Default.New(fmt.Sprintf("failed to refresh token: %d, body: %s", resp.StatusCode, string(body)))
}
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 == "" {
bodyStr := string(body)
const maxBodySnippet = 512
if len(bodyStr) > maxBodySnippet {
bodyStr = bodyStr[:maxBodySnippet] + "…"
}
return errors.Default.New(fmt.Sprintf("empty access token returned; response body: %s", bodyStr))
}
tokenExpiredAt := time.Now().Add(time.Duration(result.ExpiresIn) * time.Second)
refreshTokenExpiredAt := time.Now().Add(time.Duration(result.RefreshTokenExpiresIn) * time.Second)
tp.conn.UpdateToken(
result.AccessToken,
result.RefreshToken,
&tokenExpiredAt,
&refreshTokenExpiredAt,
)
if tp.dal != 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")
}
}
return nil
}
// ForceRefresh refreshes the access token if the current token is still equal to oldToken.
// The oldToken parameter should be the token value observed by the caller when it determined
// that a refresh might be needed; if the token has changed since then, another goroutine has
// already refreshed it and this method returns without performing a redundant refresh.
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 {
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()
}