f82d841955
Co-authored-by: Carlos Florencio <carlosmflorencio@gmail.com>
298 lines
9.2 KiB
Go
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,
|
|
})
|
|
}
|