Files
kandev/apps/backend/internal/github/app_installation_service.go
2026-08-12 11:13:05 +01:00

298 lines
9.2 KiB
Go

package github
import (
"context"
"errors"
"fmt"
"net/url"
"strings"
"time"
)
var ErrInstallationAssociationUnverified = errors.New("GitHub App installation association could not be verified")
type GitHubOAuthTokens struct {
AccessToken string
RefreshToken string
AccessExpiresAt time.Time
RefreshExpiresAt *time.Time
}
type GitHubOAuthUser struct {
ID int64 `json:"id"`
Login string `json:"login"`
}
type githubAppOAuth interface {
ExchangeUserCode(ctx context.Context, code, pkceVerifier, redirectURI string) (GitHubOAuthTokens, error)
GetOAuthUser(ctx context.Context, accessToken string) (GitHubOAuthUser, error)
UserCanAccessInstallation(ctx context.Context, accessToken string, installationID int64) (bool, error)
}
type appInstallationVerifier interface {
GetInstallation(context.Context, int64) (AppInstallation, error)
}
type appInstallationStore interface {
GetWorkspaceConnection(context.Context, string) (*WorkspaceConnection, error)
ReplaceWorkspaceConnection(
context.Context,
*WorkspaceConnection,
WorkspaceConnectionExpectation,
) error
}
type AppInstallationConfig struct {
RegistrationID string
Slug string
CallbackURL string
}
type AppInstallationStart struct {
URL string `json:"url"`
State string `json:"state"`
ExpiresAt time.Time `json:"expires_at"`
}
type AppInstallationCallback struct {
WorkspaceID string
UserID string
State string
Code string
SetupAction string
InstallationID int64
}
type AppInstallationResult struct {
WorkspaceID string
Installation AppInstallation
AuthorizingUser GitHubOAuthUser
}
type AppInstallationService struct {
config AppInstallationConfig
flows *OAuthFlowManager
store appInstallationStore
app appInstallationVerifier
oauth githubAppOAuth
installationAccessRetryDelays []time.Duration
}
func NewAppInstallationService(
config AppInstallationConfig,
flows *OAuthFlowManager,
store appInstallationStore,
app appInstallationVerifier,
oauth githubAppOAuth,
) *AppInstallationService {
return &AppInstallationService{
config: config, flows: flows, store: store, app: app, oauth: oauth,
installationAccessRetryDelays: []time.Duration{
250 * time.Millisecond, 500 * time.Millisecond, time.Second, 2 * time.Second,
},
}
}
func (s *AppInstallationService) Start(
ctx context.Context,
workspaceID, userID string,
) (AppInstallationStart, error) {
if s == nil || s.flows == nil || s.store == nil {
return AppInstallationStart{}, errors.New("GitHub App installation service is not configured")
}
if err := validateAppInstallationConfig(s.config); err != nil {
return AppInstallationStart{}, err
}
existing, err := s.store.GetWorkspaceConnection(ctx, workspaceID)
if err != nil {
return AppInstallationStart{}, fmt.Errorf("load GitHub workspace connection: %w", err)
}
expected := workspaceConnectionExpectation(existing)
flow, err := s.flows.Start(ctx, OAuthFlowRequest{
WorkspaceID: workspaceID,
UserID: userID,
AppRegistrationID: s.config.RegistrationID,
Kind: AuthFlowKindAppInstallation,
ExpectedWorkspaceSource: expected.Source,
ExpectedWorkspaceGeneration: expected.CredentialGeneration,
ExpectedInstallationID: expected.InstallationID,
ExpectedWorkspaceAppRegistrationID: expected.AppRegistrationID,
})
if err != nil {
return AppInstallationStart{}, err
}
installURL := &url.URL{
Scheme: "https",
Host: "github.com",
Path: "/apps/" + s.config.Slug + "/installations/new",
}
query := installURL.Query()
query.Set("state", flow.State)
installURL.RawQuery = query.Encode()
return AppInstallationStart{URL: installURL.String(), State: flow.State, ExpiresAt: flow.ExpiresAt}, nil
}
func (s *AppInstallationService) Complete(
ctx context.Context,
callback AppInstallationCallback,
) (AppInstallationResult, error) {
if s == nil || s.flows == nil || s.store == nil || s.app == nil || s.oauth == nil {
return AppInstallationResult{}, errors.New("GitHub App installation service is not configured")
}
flow, err := consumeCallbackFlow(
ctx, s.flows, callback.State, callback.WorkspaceID, callback.UserID,
s.config.RegistrationID, AuthFlowKindAppInstallation,
)
if err != nil {
return AppInstallationResult{}, err
}
installation, user, err := s.verifyInstallationCallback(ctx, callback)
if err != nil {
return AppInstallationResult{}, err
}
connection := s.newInstallationConnection(flow, installation)
if err := s.store.ReplaceWorkspaceConnection(
ctx, connection, authFlowWorkspaceExpectation(flow),
); err != nil {
return AppInstallationResult{}, fmt.Errorf("save GitHub App installation: %w", err)
}
return AppInstallationResult{
WorkspaceID: flow.WorkspaceID, Installation: installation, AuthorizingUser: user,
}, nil
}
func (s *AppInstallationService) verifyInstallationCallback(
ctx context.Context,
callback AppInstallationCallback,
) (AppInstallation, GitHubOAuthUser, error) {
if callback.InstallationID <= 0 {
return AppInstallation{}, GitHubOAuthUser{}, fmt.Errorf(
"validate GitHub App installation ID: %w", ErrInstallationAssociationUnverified,
)
}
if callback.Code == "" {
return AppInstallation{}, GitHubOAuthUser{}, fmt.Errorf(
"validate GitHub App authorizer code: %w", ErrInstallationAssociationUnverified,
)
}
// GitHub starts OAuth-on-install without a caller-supplied PKCE challenge.
tokens, err := s.oauth.ExchangeUserCode(ctx, callback.Code, "", s.config.CallbackURL)
if err != nil || tokens.AccessToken == "" {
return AppInstallation{}, GitHubOAuthUser{}, fmt.Errorf(
"exchange GitHub App authorizer code: %w", associationError(err),
)
}
user, err := s.oauth.GetOAuthUser(ctx, tokens.AccessToken)
if err != nil || user.ID <= 0 || strings.TrimSpace(user.Login) == "" {
return AppInstallation{}, GitHubOAuthUser{}, fmt.Errorf(
"verify GitHub App authorizing user: %w", associationError(err),
)
}
accessible, err := s.userCanAccessInstallation(ctx, tokens.AccessToken, callback.InstallationID)
if err != nil || !accessible {
return AppInstallation{}, GitHubOAuthUser{}, fmt.Errorf(
"verify GitHub App installation access: %w", associationError(err),
)
}
installation, err := s.app.GetInstallation(ctx, callback.InstallationID)
if err != nil || installation.ID != callback.InstallationID {
return AppInstallation{}, GitHubOAuthUser{}, fmt.Errorf(
"verify GitHub App installation: %w", associationError(err),
)
}
return installation, user, nil
}
func (s *AppInstallationService) userCanAccessInstallation(
ctx context.Context,
accessToken string,
installationID int64,
) (bool, error) {
for attempt := 0; ; attempt++ {
accessible, err := s.oauth.UserCanAccessInstallation(ctx, accessToken, installationID)
if err != nil || accessible || attempt >= len(s.installationAccessRetryDelays) {
return accessible, err
}
timer := time.NewTimer(s.installationAccessRetryDelays[attempt])
select {
case <-ctx.Done():
timer.Stop()
return false, ctx.Err()
case <-timer.C:
}
}
}
func (s *AppInstallationService) newInstallationConnection(
flow *AuthFlow,
installation AppInstallation,
) *WorkspaceConnection {
status := ConnectionStatusActive
if installation.SuspendedAt != nil {
status = ConnectionStatusSuspended
}
return &WorkspaceConnection{
WorkspaceID: flow.WorkspaceID,
Source: ConnectionSourceGitHubAppInstallation,
GitHubHost: "github.com",
InstallationID: &installation.ID,
InstallationAccountLogin: installation.AccountLogin,
InstallationAccountType: installation.AccountType,
AppRegistrationID: flow.AppRegistrationID,
Status: status,
CredentialGeneration: flow.ExpectedWorkspaceGeneration + 1,
}
}
func validateAppInstallationConfig(config AppInstallationConfig) error {
if strings.TrimSpace(config.RegistrationID) == "" {
return errors.New("GitHub App registration ID is required")
}
if !validGitHubAppSlug(config.Slug) {
return errors.New("GitHub App slug is required")
}
callback, err := url.Parse(config.CallbackURL)
if err != nil || !callback.IsAbs() || callback.Host == "" {
return errors.New("GitHub App callback URL must be absolute")
}
return nil
}
func validGitHubAppSlug(slug string) bool {
if slug == "" {
return false
}
for _, character := range slug {
if (character < 'a' || character > 'z') && (character < '0' || character > '9') && character != '-' {
return false
}
}
return true
}
func associationError(err error) error {
if err == nil {
return ErrInstallationAssociationUnverified
}
return fmt.Errorf("%w: %v", ErrInstallationAssociationUnverified, err)
}
func consumeCallbackFlow(
ctx context.Context,
flows *OAuthFlowManager,
state, workspaceID, userID string,
registrationID string,
kind AuthFlowKind,
) (*AuthFlow, error) {
if workspaceID == "" && userID == "" {
return flows.ConsumeBound(ctx, state, registrationID, kind)
}
return flows.Consume(ctx, state, OAuthFlowExpectation{
WorkspaceID: workspaceID,
UserID: userID,
AppRegistrationID: registrationID,
Kind: kind,
})
}