403 lines
13 KiB
Go
403 lines
13 KiB
Go
package github
|
|
|
|
import (
|
|
"context"
|
|
"crypto/rand"
|
|
"crypto/sha256"
|
|
"encoding/base64"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"net/url"
|
|
"strings"
|
|
"time"
|
|
)
|
|
|
|
const (
|
|
oauthRandomBytes = 32
|
|
appInstallationStateTTL = 10 * time.Minute
|
|
personalOAuthStateTTL = 10 * time.Minute
|
|
)
|
|
|
|
var (
|
|
ErrOAuthStateInvalid = errors.New("GitHub OAuth state is expired, consumed, or invalid")
|
|
ErrOAuthStateMismatch = errors.New("GitHub OAuth state does not match the current request")
|
|
ErrOAuthFlowStale = errors.New("GitHub authentication changed after authorization started")
|
|
)
|
|
|
|
func workspaceConnectionExpectation(connection *WorkspaceConnection) WorkspaceConnectionExpectation {
|
|
if connection == nil {
|
|
return WorkspaceConnectionExpectation{}
|
|
}
|
|
return WorkspaceConnectionExpectation{
|
|
Source: connection.Source,
|
|
CredentialGeneration: connection.CredentialGeneration,
|
|
InstallationID: cloneInt64Pointer(connection.InstallationID),
|
|
AppRegistrationID: connection.AppRegistrationID,
|
|
}
|
|
}
|
|
|
|
func authFlowWorkspaceExpectation(flow *AuthFlow) WorkspaceConnectionExpectation {
|
|
if flow == nil {
|
|
return WorkspaceConnectionExpectation{}
|
|
}
|
|
return WorkspaceConnectionExpectation{
|
|
Source: flow.ExpectedWorkspaceSource,
|
|
CredentialGeneration: flow.ExpectedWorkspaceGeneration,
|
|
InstallationID: cloneInt64Pointer(flow.ExpectedInstallationID),
|
|
AppRegistrationID: flow.ExpectedWorkspaceAppRegistrationID,
|
|
}
|
|
}
|
|
|
|
func matchesWorkspaceConnectionExpectation(
|
|
connection *WorkspaceConnection,
|
|
expected WorkspaceConnectionExpectation,
|
|
) bool {
|
|
if connection == nil {
|
|
return expected.Source == "" && expected.CredentialGeneration == 0 &&
|
|
expected.InstallationID == nil && expected.AppRegistrationID == ""
|
|
}
|
|
return connection.Source == expected.Source &&
|
|
connection.CredentialGeneration == expected.CredentialGeneration &&
|
|
equalInt64Pointers(connection.InstallationID, expected.InstallationID) &&
|
|
connection.AppRegistrationID == expected.AppRegistrationID
|
|
}
|
|
|
|
func cloneInt64Pointer(value *int64) *int64 {
|
|
if value == nil {
|
|
return nil
|
|
}
|
|
cloned := *value
|
|
return &cloned
|
|
}
|
|
|
|
func equalInt64Pointers(left, right *int64) bool {
|
|
return (left == nil && right == nil) ||
|
|
(left != nil && right != nil && *left == *right)
|
|
}
|
|
|
|
// GitHubOAuthError preserves the machine-readable OAuth error code so token
|
|
// lifecycle decisions do not depend on parsing user-facing text.
|
|
type GitHubOAuthError struct {
|
|
StatusCode int
|
|
Code string
|
|
Description string
|
|
}
|
|
|
|
func (e *GitHubOAuthError) Error() string {
|
|
return "GitHub OAuth exchange failed: " + strings.TrimSpace(e.Code+" "+e.Description)
|
|
}
|
|
|
|
type oauthFlowStore interface {
|
|
CreateAuthFlow(context.Context, *AuthFlow) error
|
|
ConsumeAuthFlow(context.Context, string, string, AuthFlowKind, time.Time) (*AuthFlow, error)
|
|
}
|
|
|
|
type OAuthFlowRequest struct {
|
|
WorkspaceID string
|
|
UserID string
|
|
AppRegistrationID string
|
|
Kind AuthFlowKind
|
|
ExpectedWorkspaceSource ConnectionSource
|
|
ExpectedWorkspaceGeneration int64
|
|
ExpectedInstallationID *int64
|
|
ExpectedWorkspaceAppRegistrationID string
|
|
ExpectedPersonalGeneration int64
|
|
}
|
|
|
|
type OAuthFlowExpectation = OAuthFlowRequest
|
|
|
|
type OAuthFlowStart struct {
|
|
State string
|
|
PKCEChallenge string
|
|
ExpiresAt time.Time
|
|
}
|
|
|
|
type OAuthFlowManager struct {
|
|
store oauthFlowStore
|
|
now func() time.Time
|
|
random io.Reader
|
|
}
|
|
|
|
func NewOAuthFlowManager(store oauthFlowStore) *OAuthFlowManager {
|
|
return &OAuthFlowManager{store: store, now: time.Now, random: rand.Reader}
|
|
}
|
|
|
|
func (m *OAuthFlowManager) Start(ctx context.Context, request OAuthFlowRequest) (OAuthFlowStart, error) {
|
|
if m == nil || m.store == nil {
|
|
return OAuthFlowStart{}, errors.New("GitHub OAuth flow store is not configured")
|
|
}
|
|
if request.WorkspaceID == "" || request.UserID == "" || request.AppRegistrationID == "" {
|
|
return OAuthFlowStart{}, errors.New("workspace, user, and App registration are required for GitHub OAuth")
|
|
}
|
|
ttl, err := oauthFlowTTL(request.Kind)
|
|
if err != nil {
|
|
return OAuthFlowStart{}, err
|
|
}
|
|
state, err := randomBase64URL(m.random)
|
|
if err != nil {
|
|
return OAuthFlowStart{}, fmt.Errorf("generate GitHub OAuth state: %w", err)
|
|
}
|
|
|
|
var verifier, challenge string
|
|
if request.Kind == AuthFlowKindPersonal {
|
|
verifier, err = randomBase64URL(m.random)
|
|
if err != nil {
|
|
return OAuthFlowStart{}, fmt.Errorf("generate GitHub OAuth PKCE verifier: %w", err)
|
|
}
|
|
challengeDigest := sha256.Sum256([]byte(verifier))
|
|
challenge = base64.RawURLEncoding.EncodeToString(challengeDigest[:])
|
|
}
|
|
|
|
now := m.now().UTC()
|
|
stateDigest := sha256.Sum256([]byte(state))
|
|
flow := &AuthFlow{
|
|
StateHash: stateDigestString(stateDigest),
|
|
WorkspaceID: request.WorkspaceID,
|
|
UserID: request.UserID,
|
|
AppRegistrationID: request.AppRegistrationID,
|
|
Kind: request.Kind,
|
|
PKCEVerifier: verifier,
|
|
ExpectedWorkspaceSource: request.ExpectedWorkspaceSource,
|
|
ExpectedWorkspaceGeneration: request.ExpectedWorkspaceGeneration,
|
|
ExpectedInstallationID: request.ExpectedInstallationID,
|
|
ExpectedWorkspaceAppRegistrationID: request.ExpectedWorkspaceAppRegistrationID,
|
|
ExpectedPersonalGeneration: request.ExpectedPersonalGeneration,
|
|
ExpiresAt: now.Add(ttl),
|
|
CreatedAt: now,
|
|
}
|
|
if err := m.store.CreateAuthFlow(ctx, flow); err != nil {
|
|
return OAuthFlowStart{}, fmt.Errorf("persist GitHub OAuth flow: %w", err)
|
|
}
|
|
return OAuthFlowStart{State: state, PKCEChallenge: challenge, ExpiresAt: flow.ExpiresAt}, nil
|
|
}
|
|
|
|
func (m *OAuthFlowManager) Consume(
|
|
ctx context.Context,
|
|
state string,
|
|
expected OAuthFlowExpectation,
|
|
) (*AuthFlow, error) {
|
|
flow, err := m.ConsumeBound(ctx, state, expected.AppRegistrationID, expected.Kind)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if flow.WorkspaceID != expected.WorkspaceID || flow.UserID != expected.UserID {
|
|
return nil, ErrOAuthStateMismatch
|
|
}
|
|
return flow, nil
|
|
}
|
|
|
|
// ConsumeBound is for public callbacks that receive only the opaque state.
|
|
// The workspace and user are recovered from the consumed server-side row,
|
|
// never from untrusted callback query parameters.
|
|
func (m *OAuthFlowManager) ConsumeBound(
|
|
ctx context.Context,
|
|
state string,
|
|
registrationID string,
|
|
expectedKind AuthFlowKind,
|
|
) (*AuthFlow, error) {
|
|
if m == nil || m.store == nil || state == "" {
|
|
return nil, ErrOAuthStateInvalid
|
|
}
|
|
digest := sha256.Sum256([]byte(state))
|
|
flow, err := m.store.ConsumeAuthFlow(
|
|
ctx, stateDigestString(digest), registrationID, expectedKind, m.now().UTC(),
|
|
)
|
|
if err != nil {
|
|
if errors.Is(err, ErrAuthFlowUnavailable) {
|
|
return nil, ErrOAuthStateInvalid
|
|
}
|
|
return nil, fmt.Errorf("consume GitHub OAuth flow: %w", err)
|
|
}
|
|
if flow == nil || flow.Kind != expectedKind || flow.AppRegistrationID != registrationID {
|
|
return nil, ErrOAuthStateMismatch
|
|
}
|
|
return flow, nil
|
|
}
|
|
|
|
func oauthFlowTTL(kind AuthFlowKind) (time.Duration, error) {
|
|
switch kind {
|
|
case AuthFlowKindAppInstallation:
|
|
return appInstallationStateTTL, nil
|
|
case AuthFlowKindPersonal:
|
|
return personalOAuthStateTTL, nil
|
|
default:
|
|
return 0, fmt.Errorf("unsupported GitHub OAuth flow kind %q", kind)
|
|
}
|
|
}
|
|
|
|
func randomBase64URL(source io.Reader) (string, error) {
|
|
value := make([]byte, oauthRandomBytes)
|
|
if _, err := io.ReadFull(source, value); err != nil {
|
|
return "", err
|
|
}
|
|
return base64.RawURLEncoding.EncodeToString(value), nil
|
|
}
|
|
|
|
func stateDigestString(digest [sha256.Size]byte) string {
|
|
return hex.EncodeToString(digest[:])
|
|
}
|
|
|
|
// GitHubOAuthClient implements GitHub App user authorization and refresh. It
|
|
// never persists tokens; callers hand successful results to the atomic
|
|
// personal-connection repository.
|
|
type GitHubOAuthClient struct {
|
|
clientID string
|
|
clientSecret string
|
|
httpClient *http.Client
|
|
webBaseURL string
|
|
apiBaseURL string
|
|
now func() time.Time
|
|
}
|
|
|
|
func NewGitHubOAuthClient(clientID, clientSecret string) *GitHubOAuthClient {
|
|
return &GitHubOAuthClient{
|
|
clientID: clientID, clientSecret: clientSecret,
|
|
httpClient: &http.Client{Timeout: 30 * time.Second},
|
|
webBaseURL: "https://github.com", apiBaseURL: githubAPIBase, now: time.Now,
|
|
}
|
|
}
|
|
|
|
func (c *GitHubOAuthClient) ExchangeUserCode(
|
|
ctx context.Context,
|
|
code, pkceVerifier, redirectURI string,
|
|
) (GitHubOAuthTokens, error) {
|
|
form := url.Values{
|
|
"client_id": {c.clientID},
|
|
"client_secret": {c.clientSecret},
|
|
"code": {code},
|
|
"redirect_uri": {redirectURI},
|
|
}
|
|
if pkceVerifier != "" {
|
|
form.Set("code_verifier", pkceVerifier)
|
|
}
|
|
return c.exchangeTokens(ctx, form)
|
|
}
|
|
|
|
func (c *GitHubOAuthClient) RefreshUserToken(
|
|
ctx context.Context,
|
|
refreshToken string,
|
|
) (GitHubOAuthTokens, error) {
|
|
return c.exchangeTokens(ctx, url.Values{
|
|
"client_id": {c.clientID},
|
|
"client_secret": {c.clientSecret},
|
|
"grant_type": {"refresh_token"},
|
|
"refresh_token": {refreshToken},
|
|
})
|
|
}
|
|
|
|
func (c *GitHubOAuthClient) GetOAuthUser(ctx context.Context, accessToken string) (GitHubOAuthUser, error) {
|
|
request, err := c.apiRequest(ctx, http.MethodGet, "/user", accessToken)
|
|
if err != nil {
|
|
return GitHubOAuthUser{}, err
|
|
}
|
|
response, err := c.httpClient.Do(request)
|
|
if err != nil {
|
|
return GitHubOAuthUser{}, fmt.Errorf("request GitHub OAuth user: %w", err)
|
|
}
|
|
defer func() { _ = response.Body.Close() }()
|
|
if response.StatusCode >= http.StatusBadRequest {
|
|
return GitHubOAuthUser{}, &GitHubAPIError{StatusCode: response.StatusCode, Endpoint: "/user"}
|
|
}
|
|
var user GitHubOAuthUser
|
|
if err := json.NewDecoder(io.LimitReader(response.Body, maxGitHubAppResponseSize)).Decode(&user); err != nil {
|
|
return GitHubOAuthUser{}, fmt.Errorf("decode GitHub OAuth user: %w", err)
|
|
}
|
|
return user, nil
|
|
}
|
|
|
|
func (c *GitHubOAuthClient) UserCanAccessInstallation(
|
|
ctx context.Context,
|
|
accessToken string,
|
|
installationID int64,
|
|
) (bool, error) {
|
|
endpoint := fmt.Sprintf("/user/installations/%d", installationID)
|
|
request, err := c.apiRequest(ctx, http.MethodGet, endpoint, accessToken)
|
|
if err != nil {
|
|
return false, err
|
|
}
|
|
response, err := c.httpClient.Do(request)
|
|
if err != nil {
|
|
return false, fmt.Errorf("request GitHub user installation: %w", err)
|
|
}
|
|
defer func() { _ = response.Body.Close() }()
|
|
if response.StatusCode == http.StatusNotFound {
|
|
return false, nil
|
|
}
|
|
if response.StatusCode >= http.StatusBadRequest {
|
|
return false, &GitHubAPIError{StatusCode: response.StatusCode, Endpoint: endpoint}
|
|
}
|
|
return true, nil
|
|
}
|
|
|
|
func (c *GitHubOAuthClient) exchangeTokens(ctx context.Context, form url.Values) (GitHubOAuthTokens, error) {
|
|
if c == nil || c.clientID == "" || c.clientSecret == "" {
|
|
return GitHubOAuthTokens{}, errors.New("GitHub App OAuth client is not configured")
|
|
}
|
|
request, err := http.NewRequestWithContext(
|
|
ctx,
|
|
http.MethodPost,
|
|
strings.TrimRight(c.webBaseURL, "/")+"/login/oauth/access_token",
|
|
strings.NewReader(form.Encode()),
|
|
)
|
|
if err != nil {
|
|
return GitHubOAuthTokens{}, err
|
|
}
|
|
request.Header.Set("Accept", "application/json")
|
|
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
|
|
response, err := c.httpClient.Do(request)
|
|
if err != nil {
|
|
return GitHubOAuthTokens{}, fmt.Errorf("exchange GitHub OAuth token: %w", err)
|
|
}
|
|
defer func() { _ = response.Body.Close() }()
|
|
var body struct {
|
|
AccessToken string `json:"access_token"`
|
|
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"`
|
|
}
|
|
if err := json.NewDecoder(io.LimitReader(response.Body, maxGitHubAppResponseSize)).Decode(&body); err != nil {
|
|
return GitHubOAuthTokens{}, fmt.Errorf("decode GitHub OAuth token response: %w", err)
|
|
}
|
|
if response.StatusCode >= http.StatusBadRequest || body.Error != "" {
|
|
return GitHubOAuthTokens{}, &GitHubOAuthError{
|
|
StatusCode: response.StatusCode,
|
|
Code: body.Error, Description: body.ErrorDescription,
|
|
}
|
|
}
|
|
now := c.now().UTC()
|
|
tokens := GitHubOAuthTokens{
|
|
AccessToken: body.AccessToken,
|
|
RefreshToken: body.RefreshToken,
|
|
}
|
|
if body.ExpiresIn > 0 {
|
|
tokens.AccessExpiresAt = now.Add(time.Duration(body.ExpiresIn) * time.Second)
|
|
}
|
|
if body.RefreshTokenExpiresIn > 0 {
|
|
expiresAt := now.Add(time.Duration(body.RefreshTokenExpiresIn) * time.Second)
|
|
tokens.RefreshExpiresAt = &expiresAt
|
|
}
|
|
return tokens, nil
|
|
}
|
|
|
|
func (c *GitHubOAuthClient) apiRequest(
|
|
ctx context.Context,
|
|
method, endpoint, accessToken string,
|
|
) (*http.Request, error) {
|
|
request, err := http.NewRequestWithContext(
|
|
ctx, method, strings.TrimRight(c.apiBaseURL, "/")+endpoint, nil,
|
|
)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
request.Header.Set("Accept", githubAccept)
|
|
request.Header.Set("Authorization", "Bearer "+accessToken)
|
|
request.Header.Set("X-GitHub-Api-Version", githubAPIVersion)
|
|
return request, nil
|
|
}
|