555 lines
18 KiB
Go
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
|
|
}
|