Files

555 lines
18 KiB
Go

package github
import (
"context"
"errors"
"fmt"
"net/http"
"net/url"
"strings"
"sync"
"time"
"golang.org/x/sync/singleflight"
)
const (
personalTokenRefreshMargin = 5 * time.Minute
personalTokenRefreshTimeout = 30 * time.Second
)
var (
ErrPersonalIdentityChanged = errors.New("refreshed GitHub identity does not match the connected user")
ErrPersonalTokenInvalid = errors.New("personal GitHub token is missing or expired")
ErrPersonalConnectionStale = errors.New("personal GitHub connection changed")
)
type personalOAuth interface {
githubAppOAuth
RefreshUserToken(context.Context, string) (GitHubOAuthTokens, error)
}
// PersonalConnectionRepository owns the atomic boundary between encrypted
// personal secrets and user-connection metadata. Replace must either commit
// all values or preserve the previous row and both previous secrets.
type PersonalConnectionRepository interface {
GetWorkspaceConnection(context.Context, string) (*WorkspaceConnection, error)
GetUserConnection(context.Context, string, string) (*UserConnection, error)
GetPersonalConnectionGeneration(context.Context, string, string) (int64, error)
GetPersonalTokens(context.Context, string, string) (GitHubOAuthTokens, error)
ReplacePersonalConnection(context.Context, *UserConnection, GitHubOAuthTokens, int64) error
ReplacePersonalConnectionForFlow(
context.Context,
*UserConnection,
GitHubOAuthTokens,
int64,
WorkspaceConnectionExpectation,
) error
MarkPersonalConnectionInvalid(context.Context, string, string, int64, error) error
RevokePersonalConnection(context.Context, string, string) error
RevokePersonalConnectionIfUnchanged(context.Context, *UserConnection) (bool, error)
RevokeWorkspacePersonalConnections(context.Context, string) error
}
type PersonalAuthConfig struct {
RegistrationID string
ClientID string
CallbackURL string
}
type PersonalAuthStart struct {
URL string `json:"url"`
State string `json:"state"`
ExpiresAt time.Time `json:"expires_at"`
}
type PersonalAuthCallback struct {
WorkspaceID string
UserID string
State string
Code string
}
type PersonalAuthResult struct {
WorkspaceID string
Connection UserConnection
}
type PersonalAuthService struct {
config PersonalAuthConfig
flows *OAuthFlowManager
repo PersonalConnectionRepository
oauth personalOAuth
now func() time.Time
refresh singleflight.Group
workspaceMutationLock func(string) *sync.Mutex
}
func NewPersonalAuthService(
config PersonalAuthConfig,
flows *OAuthFlowManager,
repo PersonalConnectionRepository,
oauth personalOAuth,
) *PersonalAuthService {
return &PersonalAuthService{config: config, flows: flows, repo: repo, oauth: oauth, now: time.Now}
}
func (s *PersonalAuthService) SetWorkspaceMutationLock(lock func(string) *sync.Mutex) {
if s != nil {
s.workspaceMutationLock = lock
}
}
func (s *PersonalAuthService) Start(
ctx context.Context,
workspaceID, userID string,
) (PersonalAuthStart, error) {
if s == nil || s.flows == nil || s.repo == nil {
return PersonalAuthStart{}, errors.New("personal GitHub authentication is not configured")
}
if err := validatePersonalAuthConfig(s.config); err != nil {
return PersonalAuthStart{}, err
}
workspace, err := s.requireActiveAppWorkspace(ctx, workspaceID)
if err != nil {
return PersonalAuthStart{}, err
}
personalGeneration, err := s.repo.GetPersonalConnectionGeneration(ctx, workspaceID, userID)
if err != nil {
return PersonalAuthStart{}, fmt.Errorf("load personal GitHub connection: %w", err)
}
expected := workspaceConnectionExpectation(workspace)
flow, err := s.flows.Start(ctx, OAuthFlowRequest{
WorkspaceID: workspaceID,
UserID: userID,
AppRegistrationID: s.config.RegistrationID,
Kind: AuthFlowKindPersonal,
ExpectedWorkspaceSource: expected.Source,
ExpectedWorkspaceGeneration: expected.CredentialGeneration,
ExpectedInstallationID: expected.InstallationID,
ExpectedWorkspaceAppRegistrationID: expected.AppRegistrationID,
ExpectedPersonalGeneration: personalGeneration,
})
if err != nil {
return PersonalAuthStart{}, err
}
authorizeURL := &url.URL{Scheme: "https", Host: "github.com", Path: "/login/oauth/authorize"}
query := authorizeURL.Query()
query.Set("client_id", s.config.ClientID)
query.Set("redirect_uri", s.config.CallbackURL)
query.Set("state", flow.State)
query.Set("code_challenge", flow.PKCEChallenge)
query.Set("code_challenge_method", "S256")
authorizeURL.RawQuery = query.Encode()
return PersonalAuthStart{URL: authorizeURL.String(), State: flow.State, ExpiresAt: flow.ExpiresAt}, nil
}
func (s *PersonalAuthService) Complete(
ctx context.Context,
callback PersonalAuthCallback,
) (PersonalAuthResult, error) {
if s == nil || s.flows == nil || s.repo == nil || s.oauth == nil {
return PersonalAuthResult{}, errors.New("personal GitHub authentication is not configured")
}
flow, err := consumeCallbackFlow(
ctx, s.flows, callback.State, callback.WorkspaceID, callback.UserID,
s.config.RegistrationID, AuthFlowKindPersonal,
)
if err != nil {
return PersonalAuthResult{}, err
}
unlock := s.lockWorkspace(flow.WorkspaceID)
defer unlock()
workspace, err := s.requireActiveAppWorkspace(ctx, flow.WorkspaceID)
if err != nil {
return PersonalAuthResult{}, err
}
if !matchesWorkspaceConnectionExpectation(workspace, authFlowWorkspaceExpectation(flow)) {
return PersonalAuthResult{}, ErrOAuthFlowStale
}
tokens, user, err := s.verifyPersonalCallback(ctx, callback, flow, *workspace.InstallationID)
if err != nil {
return PersonalAuthResult{}, err
}
connection, err := s.savePersonalConnection(ctx, flow, user, tokens)
if err != nil {
return PersonalAuthResult{}, err
}
return PersonalAuthResult{WorkspaceID: flow.WorkspaceID, Connection: connection}, nil
}
func (s *PersonalAuthService) verifyPersonalCallback(
ctx context.Context,
callback PersonalAuthCallback,
flow *AuthFlow,
installationID int64,
) (GitHubOAuthTokens, GitHubOAuthUser, error) {
if callback.Code == "" || flow.PKCEVerifier == "" {
return GitHubOAuthTokens{}, GitHubOAuthUser{}, ErrPersonalTokenInvalid
}
tokens, err := s.oauth.ExchangeUserCode(ctx, callback.Code, flow.PKCEVerifier, s.config.CallbackURL)
if err != nil {
return GitHubOAuthTokens{}, GitHubOAuthUser{}, fmt.Errorf(
"exchange personal GitHub authorization: %w", err,
)
}
if err := validatePersonalTokens(tokens, s.now()); err != nil {
return GitHubOAuthTokens{}, GitHubOAuthUser{}, err
}
user, err := s.oauth.GetOAuthUser(ctx, tokens.AccessToken)
if err != nil || user.ID <= 0 || strings.TrimSpace(user.Login) == "" {
return GitHubOAuthTokens{}, GitHubOAuthUser{}, fmt.Errorf(
"verify personal GitHub user: %w", associationError(err),
)
}
accessible, err := s.oauth.UserCanAccessInstallation(ctx, tokens.AccessToken, installationID)
if err != nil || !accessible {
return GitHubOAuthTokens{}, GitHubOAuthUser{}, fmt.Errorf(
"verify personal GitHub installation access: %w", associationError(err),
)
}
return tokens, user, nil
}
func (s *PersonalAuthService) savePersonalConnection(
ctx context.Context,
flow *AuthFlow,
user GitHubOAuthUser,
tokens GitHubOAuthTokens,
) (UserConnection, error) {
existing, err := s.repo.GetUserConnection(ctx, flow.WorkspaceID, flow.UserID)
if err != nil {
return UserConnection{}, fmt.Errorf("load personal GitHub connection: %w", err)
}
currentGeneration, err := s.repo.GetPersonalConnectionGeneration(ctx, flow.WorkspaceID, flow.UserID)
if err != nil {
return UserConnection{}, fmt.Errorf("load personal GitHub generation: %w", err)
}
connection := newUserConnection(
flow.WorkspaceID, flow.UserID, flow.AppRegistrationID, user, tokens, existing,
)
connection.CredentialGeneration = flow.ExpectedPersonalGeneration + 1
if currentGeneration != flow.ExpectedPersonalGeneration {
return UserConnection{}, ErrOAuthFlowStale
}
if err := s.repo.ReplacePersonalConnectionForFlow(
ctx,
&connection,
tokens,
flow.ExpectedPersonalGeneration,
authFlowWorkspaceExpectation(flow),
); err != nil {
return UserConnection{}, fmt.Errorf("atomically save personal GitHub connection: %w", err)
}
return connection, nil
}
func (s *PersonalAuthService) ResolvePersonalToken(
ctx context.Context,
workspaceID, userID string,
) (string, *UserConnection, error) {
if s == nil || s.repo == nil || s.oauth == nil {
return "", nil, ErrGitHubPersonalRequired
}
workspace, err := s.requireActiveAppWorkspace(ctx, workspaceID)
if err != nil {
return "", nil, err
}
connection, tokens, err := s.loadPersonalConnection(ctx, workspaceID, userID)
if err != nil {
return "", nil, err
}
now := s.now().UTC()
if tokens.AccessToken != "" && tokens.AccessExpiresAt.After(now.Add(personalTokenRefreshMargin)) {
return tokens.AccessToken, connection, nil
}
return s.coalescedPersonalTokenRefresh(ctx, workspaceID, userID, *workspace.InstallationID)
}
type personalRefreshResult struct {
token string
connection *UserConnection
}
func (s *PersonalAuthService) coalescedPersonalTokenRefresh(
ctx context.Context,
workspaceID, userID string,
installationID int64,
) (string, *UserConnection, error) {
key := fmt.Sprintf(
"%d:%s|%d:%s|%d:%s",
len(s.config.RegistrationID), s.config.RegistrationID,
len(workspaceID), workspaceID, len(userID), userID,
)
resultChannel := s.refresh.DoChan(key, func() (any, error) {
refreshCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), personalTokenRefreshTimeout)
defer cancel()
connection, tokens, loadErr := s.loadPersonalConnection(refreshCtx, workspaceID, userID)
if loadErr != nil {
return nil, loadErr
}
now := s.now().UTC()
if tokens.AccessToken != "" && tokens.AccessExpiresAt.After(now.Add(personalTokenRefreshMargin)) {
return personalRefreshResult{token: tokens.AccessToken, connection: connection}, nil
}
token, refreshedConnection, refreshErr := s.refreshPersonalToken(
refreshCtx, workspaceID, userID, installationID, connection, tokens, now,
)
if refreshErr != nil {
return nil, refreshErr
}
return personalRefreshResult{token: token, connection: refreshedConnection}, nil
})
select {
case <-ctx.Done():
return "", nil, ctx.Err()
case result := <-resultChannel:
if result.Err != nil {
return "", nil, result.Err
}
value := result.Val.(personalRefreshResult)
return value.token, value.connection, nil
}
}
func (s *PersonalAuthService) loadPersonalConnection(
ctx context.Context,
workspaceID, userID string,
) (*UserConnection, GitHubOAuthTokens, error) {
connection, err := s.repo.GetUserConnection(ctx, workspaceID, userID)
if err != nil {
return nil, GitHubOAuthTokens{}, fmt.Errorf("load personal GitHub connection: %w", err)
}
if connection == nil || connection.Status != ConnectionStatusActive {
return nil, GitHubOAuthTokens{}, ErrGitHubPersonalRequired
}
if connection.AppRegistrationID != s.config.RegistrationID {
return nil, GitHubOAuthTokens{}, ErrGitHubPersonalRequired
}
tokens, err := s.repo.GetPersonalTokens(ctx, workspaceID, userID)
if err != nil {
return nil, GitHubOAuthTokens{}, fmt.Errorf("load personal GitHub tokens: %w", err)
}
return connection, tokens, nil
}
func (s *PersonalAuthService) refreshPersonalToken(
ctx context.Context,
workspaceID, userID string,
installationID int64,
connection *UserConnection,
tokens GitHubOAuthTokens,
now time.Time,
) (string, *UserConnection, error) {
if tokens.RefreshToken == "" || (tokens.RefreshExpiresAt != nil && !tokens.RefreshExpiresAt.After(now)) {
s.markInvalid(ctx, workspaceID, userID, connection.CredentialGeneration, ErrPersonalTokenInvalid)
return "", nil, ErrPersonalTokenInvalid
}
refreshed, err := s.oauth.RefreshUserToken(ctx, tokens.RefreshToken)
if err != nil {
if isDefinitivePersonalAuthFailure(err) {
s.markInvalid(ctx, workspaceID, userID, connection.CredentialGeneration, err)
}
return "", nil, fmt.Errorf("refresh personal GitHub token: %w", err)
}
if err := validatePersonalTokens(refreshed, now); err != nil {
s.markInvalid(ctx, workspaceID, userID, connection.CredentialGeneration, err)
return "", nil, err
}
user, err := s.verifyRefreshedPersonalUser(ctx, workspaceID, userID, installationID, connection, refreshed)
if err != nil {
return "", nil, err
}
replacement := newUserConnection(
workspaceID, userID, s.config.RegistrationID, user, refreshed, connection,
)
if err := s.repo.ReplacePersonalConnection(
ctx, &replacement, refreshed, connection.CredentialGeneration,
); err != nil {
return "", nil, fmt.Errorf("atomically rotate personal GitHub tokens: %w", err)
}
return refreshed.AccessToken, &replacement, nil
}
func (s *PersonalAuthService) verifyRefreshedPersonalUser(
ctx context.Context,
workspaceID, userID string,
installationID int64,
connection *UserConnection,
refreshed GitHubOAuthTokens,
) (GitHubOAuthUser, error) {
user, err := s.oauth.GetOAuthUser(ctx, refreshed.AccessToken)
if err != nil {
if isDefinitivePersonalAuthFailure(err) {
s.markInvalid(ctx, workspaceID, userID, connection.CredentialGeneration, err)
}
return GitHubOAuthUser{}, fmt.Errorf("verify refreshed personal GitHub user: %w", err)
}
if user.ID != connection.GitHubUserID {
s.markInvalid(ctx, workspaceID, userID, connection.CredentialGeneration, ErrPersonalIdentityChanged)
return GitHubOAuthUser{}, ErrPersonalIdentityChanged
}
accessible, err := s.oauth.UserCanAccessInstallation(ctx, refreshed.AccessToken, installationID)
if err != nil || !accessible {
associationErr := associationError(err)
if err == nil || isDefinitivePersonalAuthFailure(err) {
s.markInvalid(ctx, workspaceID, userID, connection.CredentialGeneration, associationErr)
}
return GitHubOAuthUser{}, associationErr
}
return user, nil
}
func (s *PersonalAuthService) Revoke(ctx context.Context, workspaceID, userID string) error {
if s == nil || s.repo == nil {
return nil
}
unlock := s.lockWorkspace(workspaceID)
defer unlock()
return s.repo.RevokePersonalConnection(ctx, workspaceID, userID)
}
func (s *PersonalAuthService) lockWorkspace(workspaceID string) func() {
if s.workspaceMutationLock == nil {
return func() {}
}
lock := s.workspaceMutationLock(workspaceID)
lock.Lock()
return lock.Unlock
}
func (s *PersonalAuthService) RevokeWorkspace(ctx context.Context, workspaceID string) error {
if s == nil || s.repo == nil {
return nil
}
return s.repo.RevokeWorkspacePersonalConnections(ctx, workspaceID)
}
func (s *PersonalAuthService) ReconcileAuthorizationRevocation(
ctx context.Context,
snapshot *UserConnection,
) (bool, error) {
if s == nil || s.repo == nil || s.oauth == nil || snapshot == nil {
return false, nil
}
unlock := s.lockWorkspace(snapshot.WorkspaceID)
defer unlock()
current, err := s.repo.GetUserConnection(ctx, snapshot.WorkspaceID, snapshot.UserID)
if err != nil || !sameUserConnectionVersion(current, snapshot) {
return false, err
}
tokens, err := s.repo.GetPersonalTokens(ctx, snapshot.WorkspaceID, snapshot.UserID)
if err != nil {
return false, err
}
user, err := s.oauth.GetOAuthUser(ctx, tokens.AccessToken)
if err == nil && user.ID == current.GitHubUserID {
return false, nil
}
if err != nil && !isDefinitivePersonalAuthFailure(err) {
return false, err
}
return s.repo.RevokePersonalConnectionIfUnchanged(ctx, current)
}
func isDefinitivePersonalAuthFailure(err error) bool {
var oauthErr *GitHubOAuthError
if errors.As(err, &oauthErr) {
return oauthErr.Code == "invalid_grant" || oauthErr.StatusCode == http.StatusUnauthorized
}
var apiErr *GitHubAPIError
return errors.As(err, &apiErr) && apiErr.StatusCode == http.StatusUnauthorized
}
func (s *PersonalAuthService) requireActiveAppWorkspace(
ctx context.Context,
workspaceID string,
) (*WorkspaceConnection, error) {
connection, err := s.repo.GetWorkspaceConnection(ctx, workspaceID)
if err != nil {
return nil, fmt.Errorf("load GitHub workspace connection: %w", err)
}
if connection == nil || connection.Source != ConnectionSourceGitHubAppInstallation ||
connection.Status != ConnectionStatusActive || connection.InstallationID == nil ||
connection.AppRegistrationID != s.config.RegistrationID {
return nil, ErrGitHubPersonalRequired
}
return connection, nil
}
func (s *PersonalAuthService) markInvalid(
ctx context.Context,
workspaceID, userID string,
expectedGeneration int64,
reason error,
) {
_ = s.repo.MarkPersonalConnectionInvalid(ctx, workspaceID, userID, expectedGeneration, reason)
}
func personalConnectionGeneration(connection *UserConnection) int64 {
if connection == nil {
return 0
}
return connection.CredentialGeneration
}
func sameUserConnectionVersion(current, expected *UserConnection) bool {
return current != nil && expected != nil &&
current.WorkspaceID == expected.WorkspaceID && current.UserID == expected.UserID &&
current.AppRegistrationID == expected.AppRegistrationID &&
current.GitHubUserID == expected.GitHubUserID &&
current.CredentialGeneration == expected.CredentialGeneration &&
current.UpdatedAt.Equal(expected.UpdatedAt)
}
func newUserConnection(
workspaceID, userID, registrationID string,
user GitHubOAuthUser,
tokens GitHubOAuthTokens,
existing *UserConnection,
) UserConnection {
generation := int64(1)
var createdAt time.Time
if existing != nil {
generation = existing.CredentialGeneration + 1
createdAt = existing.CreatedAt
}
return UserConnection{
WorkspaceID: workspaceID,
UserID: userID,
AppRegistrationID: registrationID,
GitHubUserID: user.ID,
Login: user.Login,
Status: ConnectionStatusActive,
AccessExpiresAt: tokens.AccessExpiresAt,
RefreshExpiresAt: tokens.RefreshExpiresAt,
CredentialGeneration: generation,
CreatedAt: createdAt,
}
}
func validatePersonalTokens(tokens GitHubOAuthTokens, now time.Time) error {
if tokens.AccessToken == "" || !tokens.AccessExpiresAt.After(now) {
return ErrPersonalTokenInvalid
}
if tokens.RefreshExpiresAt != nil && !tokens.RefreshExpiresAt.After(now) {
return ErrPersonalTokenInvalid
}
return nil
}
func validatePersonalAuthConfig(config PersonalAuthConfig) error {
if strings.TrimSpace(config.RegistrationID) == "" {
return errors.New("GitHub App registration ID is required")
}
if strings.TrimSpace(config.ClientID) == "" {
return errors.New("GitHub App client ID is required")
}
callback, err := url.Parse(config.CallbackURL)
if err != nil || !callback.IsAbs() || callback.Host == "" {
return errors.New("personal GitHub callback URL must be absolute")
}
return nil
}