Files
2026-04-10 12:06:01 +02:00

435 lines
13 KiB
Go

package handlers
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"os"
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/trackeep/backend/models"
"github.com/trackeep/backend/utils"
"gorm.io/gorm"
)
var (
gitHubAuthorizeURL = "https://github.com/login/oauth/authorize"
gitHubTokenURL = "https://github.com/login/oauth/access_token"
gitHubAPIBaseURL = "https://api.github.com"
)
const (
gitHubAuthStateCookieName = "github_auth_state"
gitHubAuthFrontendRedirectCookieName = "github_auth_frontend_redirect"
gitHubAuthCookieMaxAgeSeconds = 600
gitHubTokenRefreshSkew = 2 * time.Minute
)
type gitHubUserTokenResponse struct {
AccessToken string `json:"access_token"`
TokenType string `json:"token_type"`
Scope string `json:"scope"`
RefreshToken string `json:"refresh_token"`
ExpiresIn int64 `json:"expires_in"`
RefreshTokenExpiresIn int64 `json:"refresh_token_expires_in"`
Error string `json:"error"`
ErrorDescription string `json:"error_description"`
ErrorURI string `json:"error_uri"`
}
type gitHubUserEmail struct {
Email string `json:"email"`
Primary bool `json:"primary"`
Verified bool `json:"verified"`
}
type gitHubUserInstallationsResponse struct {
Installations []struct {
ID int64 `json:"id"`
} `json:"installations"`
}
func getGitHubAppClientID() string {
return strings.TrimSpace(os.Getenv("GITHUB_APP_CLIENT_ID"))
}
func getGitHubAppClientSecret() string {
return strings.TrimSpace(os.Getenv("GITHUB_APP_CLIENT_SECRET"))
}
func hasGitHubUserAuthConfig() bool {
return getGitHubAppClientID() != "" && getGitHubAppClientSecret() != ""
}
func isSecureRequest(r *http.Request) bool {
if strings.EqualFold(headerValue(r.Header, "X-Forwarded-Proto"), "https") {
return true
}
return r.TLS != nil
}
func setGitHubAuthCookie(c *gin.Context, name, value string, maxAge int) {
c.SetSameSite(http.SameSiteLaxMode)
c.SetCookie(name, value, maxAge, "/", "", isSecureRequest(c.Request), true)
}
func storeGitHubAuthFlowState(c *gin.Context, state, frontendRedirect string) {
setGitHubAuthCookie(c, gitHubAuthStateCookieName, state, gitHubAuthCookieMaxAgeSeconds)
if frontendRedirect != "" {
setGitHubAuthCookie(c, gitHubAuthFrontendRedirectCookieName, frontendRedirect, gitHubAuthCookieMaxAgeSeconds)
return
}
setGitHubAuthCookie(c, gitHubAuthFrontendRedirectCookieName, "", -1)
}
func clearGitHubAuthFlowState(c *gin.Context) {
setGitHubAuthCookie(c, gitHubAuthStateCookieName, "", -1)
setGitHubAuthCookie(c, gitHubAuthFrontendRedirectCookieName, "", -1)
}
func getGitHubFrontendRedirectFromCookie(c *gin.Context) string {
raw, err := c.Cookie(gitHubAuthFrontendRedirectCookieName)
if err != nil {
return ""
}
return normalizeFrontendRedirectURL(raw)
}
func postGitHubTokenRequest(ctx context.Context, form url.Values) (*gitHubUserTokenResponse, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodPost, gitHubTokenURL, strings.NewReader(form.Encode()))
if err != nil {
return nil, err
}
req.Header.Set("Accept", "application/json")
req.Header.Set("Content-Type", "application/x-www-form-urlencoded")
req.Header.Set("User-Agent", "Trackeep")
client := &http.Client{Timeout: 30 * time.Second}
resp, err := client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return nil, err
}
if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
return nil, fmt.Errorf("GitHub token endpoint returned %d: %s", resp.StatusCode, truncateString(string(body), 220))
}
var payload gitHubUserTokenResponse
if err := json.Unmarshal(body, &payload); err != nil {
return nil, err
}
if payload.Error != "" {
message := payload.ErrorDescription
if message == "" {
message = payload.Error
}
return nil, fmt.Errorf("GitHub token exchange failed: %s", message)
}
if strings.TrimSpace(payload.AccessToken) == "" {
return nil, errors.New("GitHub returned an empty access token")
}
return &payload, nil
}
func exchangeGitHubAuthorizationCode(ctx context.Context, code, redirectURL string) (*gitHubUserTokenResponse, error) {
if strings.TrimSpace(code) == "" {
return nil, errors.New("missing GitHub authorization code")
}
if !hasGitHubUserAuthConfig() {
return nil, errors.New("GitHub App sign-in is not configured")
}
form := url.Values{}
form.Set("client_id", getGitHubAppClientID())
form.Set("client_secret", getGitHubAppClientSecret())
form.Set("code", code)
if redirectURL != "" {
form.Set("redirect_uri", redirectURL)
}
return postGitHubTokenRequest(ctx, form)
}
func refreshGitHubUserAccessToken(ctx context.Context, refreshToken string) (*gitHubUserTokenResponse, error) {
if strings.TrimSpace(refreshToken) == "" {
return nil, errors.New("missing GitHub refresh token")
}
if !hasGitHubUserAuthConfig() {
return nil, errors.New("GitHub App sign-in is not configured")
}
form := url.Values{}
form.Set("client_id", getGitHubAppClientID())
form.Set("client_secret", getGitHubAppClientSecret())
form.Set("grant_type", "refresh_token")
form.Set("refresh_token", refreshToken)
return postGitHubTokenRequest(ctx, form)
}
func tokenExpiryFromSeconds(seconds int64) *time.Time {
if seconds <= 0 {
return nil
}
expiresAt := time.Now().Add(time.Duration(seconds) * time.Second)
return &expiresAt
}
func upsertGitHubUserAuth(db *gorm.DB, userID uint, gitHubUser *GitHubUser, tokenResponse *gitHubUserTokenResponse) error {
if db == nil {
return errors.New("database not available")
}
if gitHubUser == nil {
return errors.New("GitHub user is required")
}
if tokenResponse == nil || strings.TrimSpace(tokenResponse.AccessToken) == "" {
return errors.New("GitHub access token is required")
}
encryptedAccessToken, err := utils.Encrypt(tokenResponse.AccessToken)
if err != nil {
return fmt.Errorf("failed to encrypt GitHub access token: %w", err)
}
encryptedRefreshToken := ""
if strings.TrimSpace(tokenResponse.RefreshToken) != "" {
encryptedRefreshToken, err = utils.Encrypt(tokenResponse.RefreshToken)
if err != nil {
return fmt.Errorf("failed to encrypt GitHub refresh token: %w", err)
}
}
now := time.Now()
var existing models.GitHubUserAuth
lookupErr := db.Where("user_id = ? OR github_user_id = ?", userID, gitHubUser.ID).First(&existing).Error
switch {
case errors.Is(lookupErr, gorm.ErrRecordNotFound):
record := models.GitHubUserAuth{
UserID: userID,
GitHubUserID: gitHubUser.ID,
GitHubLogin: gitHubUser.Login,
AccessToken: encryptedAccessToken,
RefreshToken: encryptedRefreshToken,
AccessTokenExpiresAt: tokenExpiryFromSeconds(tokenResponse.ExpiresIn),
RefreshTokenExpiresAt: tokenExpiryFromSeconds(tokenResponse.RefreshTokenExpiresIn),
LastRefreshedAt: &now,
}
return db.Create(&record).Error
case lookupErr != nil:
return lookupErr
default:
updates := map[string]interface{}{
"user_id": userID,
"github_user_id": gitHubUser.ID,
"github_login": gitHubUser.Login,
"access_token": encryptedAccessToken,
"access_token_expires_at": tokenExpiryFromSeconds(tokenResponse.ExpiresIn),
"last_refreshed_at": &now,
}
if encryptedRefreshToken != "" {
updates["refresh_token"] = encryptedRefreshToken
updates["refresh_token_expires_at"] = tokenExpiryFromSeconds(tokenResponse.RefreshTokenExpiresIn)
}
return db.Model(&existing).Updates(updates).Error
}
}
func getGitHubUserAuthRecord(db *gorm.DB, userID uint) (*models.GitHubUserAuth, error) {
var auth models.GitHubUserAuth
if err := db.Where("user_id = ?", userID).First(&auth).Error; err != nil {
return nil, err
}
return &auth, nil
}
func decryptGitHubUserToken(ciphertext string) (string, error) {
plaintext, err := utils.Decrypt(ciphertext)
if err != nil {
return "", err
}
return strings.TrimSpace(plaintext), nil
}
func getGitHubUserAccessTokenForUser(ctx context.Context, db *gorm.DB, userID uint) (string, *models.GitHubUserAuth, error) {
authRecord, err := getGitHubUserAuthRecord(db, userID)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return "", nil, errors.New("GitHub sign-in is not connected for this user")
}
return "", nil, err
}
if authRecord.AccessTokenExpiresAt == nil || time.Until(*authRecord.AccessTokenExpiresAt) > gitHubTokenRefreshSkew {
accessToken, err := decryptGitHubUserToken(authRecord.AccessToken)
if err != nil {
return "", nil, fmt.Errorf("failed to decrypt GitHub access token: %w", err)
}
if accessToken == "" {
return "", nil, errors.New("GitHub access token is empty")
}
return accessToken, authRecord, nil
}
if authRecord.RefreshTokenExpiresAt != nil && time.Now().After(*authRecord.RefreshTokenExpiresAt) {
return "", nil, errors.New("GitHub session expired. Please sign in with GitHub again")
}
if strings.TrimSpace(authRecord.RefreshToken) == "" {
return "", nil, errors.New("GitHub session expired. Please sign in with GitHub again")
}
refreshToken, err := decryptGitHubUserToken(authRecord.RefreshToken)
if err != nil {
return "", nil, fmt.Errorf("failed to decrypt GitHub refresh token: %w", err)
}
refreshedToken, err := refreshGitHubUserAccessToken(ctx, refreshToken)
if err != nil {
return "", nil, err
}
if refreshedToken.RefreshToken == "" {
refreshedToken.RefreshToken = refreshToken
if authRecord.RefreshTokenExpiresAt != nil {
remaining := time.Until(*authRecord.RefreshTokenExpiresAt)
if remaining > 0 {
refreshedToken.RefreshTokenExpiresIn = int64(remaining.Seconds())
}
}
}
if err := upsertGitHubUserAuth(db, userID, &GitHubUser{
ID: authRecord.GitHubUserID,
Login: authRecord.GitHubLogin,
}, refreshedToken); err != nil {
return "", nil, err
}
updatedRecord, err := getGitHubUserAuthRecord(db, userID)
if err != nil {
return "", nil, err
}
accessToken, err := decryptGitHubUserToken(updatedRecord.AccessToken)
if err != nil {
return "", nil, fmt.Errorf("failed to decrypt refreshed GitHub access token: %w", err)
}
return accessToken, updatedRecord, nil
}
func fetchGitHubPrimaryVerifiedEmail(accessToken string) (string, error) {
req, err := http.NewRequest(http.MethodGet, strings.TrimRight(gitHubAPIBaseURL, "/")+"/user/emails", nil)
if err != nil {
return "", err
}
req.Header.Set("Authorization", "Bearer "+accessToken)
req.Header.Set("Accept", "application/vnd.github+json")
req.Header.Set("X-GitHub-Api-Version", "2022-11-28")
req.Header.Set("User-Agent", "Trackeep")
client := &http.Client{Timeout: 30 * time.Second}
resp, err := client.Do(req)
if err != nil {
return "", err
}
defer resp.Body.Close()
body, err := io.ReadAll(resp.Body)
if err != nil {
return "", err
}
if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
return "", fmt.Errorf("GitHub email API returned %d: %s", resp.StatusCode, truncateString(string(body), 220))
}
var emails []gitHubUserEmail
if err := json.Unmarshal(body, &emails); err != nil {
return "", err
}
for _, email := range emails {
if email.Primary && email.Verified {
return strings.TrimSpace(email.Email), nil
}
}
for _, email := range emails {
if email.Verified {
return strings.TrimSpace(email.Email), nil
}
}
return "", errors.New("no verified GitHub email found")
}
func listGitHubUserInstallations(ctx context.Context, accessToken string) ([]int64, error) {
installations := make([]int64, 0)
client := &http.Client{Timeout: 30 * time.Second}
for page := 1; page <= 10; page++ {
reqURL := fmt.Sprintf("%s/user/installations?per_page=100&page=%d", strings.TrimRight(gitHubAPIBaseURL, "/"), page)
req, err := http.NewRequestWithContext(ctx, http.MethodGet, reqURL, nil)
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+accessToken)
req.Header.Set("Accept", "application/vnd.github+json")
req.Header.Set("X-GitHub-Api-Version", "2022-11-28")
req.Header.Set("User-Agent", "Trackeep")
resp, err := client.Do(req)
if err != nil {
return nil, err
}
body, readErr := io.ReadAll(resp.Body)
resp.Body.Close()
if readErr != nil {
return nil, readErr
}
if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
return nil, fmt.Errorf("GitHub installations API returned %d: %s", resp.StatusCode, truncateString(string(body), 220))
}
var payload gitHubUserInstallationsResponse
if err := json.Unmarshal(body, &payload); err != nil {
return nil, err
}
for _, installation := range payload.Installations {
installations = append(installations, installation.ID)
}
if len(payload.Installations) < 100 {
break
}
}
return installations, nil
}
func verifyGitHubInstallationAccessForUser(ctx context.Context, db *gorm.DB, userID uint, installationID int64) error {
accessToken, _, err := getGitHubUserAccessTokenForUser(ctx, db, userID)
if err != nil {
return err
}
installations, err := listGitHubUserInstallations(ctx, accessToken)
if err != nil {
return err
}
for _, id := range installations {
if id == installationID {
return nil
}
}
return errors.New("the GitHub installation is not accessible to the signed-in GitHub user")
}