Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions cmd/diffx-server/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,13 @@ func parseSettings(arguments []string, stderr io.Writer) (remoteserver.Settings,
if _, err := remoteserver.Retention(settings.RetentionDays); err != nil {
return settings, false, err
}
if err := values.GitHub.Validate(settings.InsecureCookies); err != nil {
return settings, false, err
}
if github := values.GitHub; github.Enabled() {
settings.GitHub = &remoteserver.GitHubSettings{AppID: github.AppID, ClientID: github.ClientID, ClientSecret: github.ClientSecret,
PrivateKeyFile: github.PrivateKeyFile, WebhookSecret: github.WebhookSecret, TokenKey: github.TokenKey, PublicURL: github.PublicURL}
}
return settings, false, nil
}
func main() {
Expand Down
5 changes: 5 additions & 0 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,11 @@ personal lifecycle discovery.
- `reviewdata`: storage-independent identities, results and errors.
- `reviewsession`: a resolved review and its source/policy capabilities.
- `identityservice`: account, password, session and API-token policies.
- `github`: GitHub wire adapter: app JWT, installation tokens, OAuth web flow,
REST reads, webhook signatures and payloads. No storage or diffx policy.
- `githubsync`: GitHub integration rules: which accounts may follow which
repositories, sealed user tokens, webhook deduplication, coalesced pull
request refresh intent and reconciliation. Remote composition only.
- `reviewstore`: SQLite transactions, identity persistence, stored reads,
retention and queue adapter.
- `ingestionqueue`: admission/lease contract and worker failure policy; jobs fail
Expand Down
33 changes: 33 additions & 0 deletions docs/system.md
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,39 @@ Transient ingestion notifications remain hints; clients reload durable catalog
state after reconnecting. Queue workers may execute out of order, but stream
freshness follows collection time and acceptance sequence.

### GitHub pull requests

The remote server optionally integrates a GitHub App, enabled only when every
`DIFFX_SERVER_GITHUB_*` setting is present; secrets come from the environment
only. Local composition has no GitHub routes.

Access is the intersection of the GitHub user and the app installation. An
account connects GitHub in Settings through the app's OAuth web flow; the
authorization state is bound to the starting account and consumed once, and the
callback, a cross-site top-level navigation, completes only for that account's
session. User access and refresh tokens are sealed with AES-GCM under
`DIFFX_SERVER_GITHUB_TOKEN_KEY`, bound to the account. An account may follow
only repositories GitHub lists for that user's installations. Listing,
following, Resync and startup reconciliation recheck access; delivery to a
follower rechecks it when its last verification is older than eight hours.
Lost access, uninstalls, removed repositories, revoked authorizations and
rejected refreshes mark follows lost; lost follows receive nothing until access
is verified again. GitHub management routes need a browser session and CSRF
token, like account management, because they handle GitHub credentials.

Webhooks arrive at `POST /api/v2/github/webhooks`, authenticated only by the
`X-Hub-Signature-256` HMAC, verified before parsing. A delivery ID is recorded
only after its effects apply, and recorded IDs are acknowledged without effect,
so GitHub redeliveries are safe; every effect is idempotent. Pull request events
for actively followed repositories coalesce into one refresh intent per pull
request by advancing its generation. GitHub never retries failed deliveries, so
the server reconciles once at startup and on Settings Resync: it requests
refreshes for every open pull request of each followed repository and for
previously open ones missing from GitHub's list, which closed without a
delivery. Resync's run identity makes the resulting submissions fresh, extending
retention of pull requests idle since their last change; startup
reconciliation and webhooks do not. There is no periodic poll.

### Browse and review

The comparison selector reads scopes of the open observation. It does not
Expand Down
72 changes: 72 additions & 0 deletions internal/config/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,16 @@ package config

import (
"bytes"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"net"
"net/url"
"os"
"strconv"
"strings"
)

// DefaultServerPort keeps diffx-server off the local `diffx [PATH]` range (7981-7990)
Expand All @@ -24,6 +27,62 @@ type ServerValues struct {
SignupEnabled bool `json:"signupEnabled"`
InsecureCookies bool `json:"insecureCookies"`
RetentionDays int `json:"retentionDays"`
GitHub GitHub `json:"github"`
}

// GitHub configures the optional GitHub App integration. Secrets come only from
// DIFFX_SERVER_GITHUB_* environment variables, never the config file.
type GitHub struct {
AppID int64 `json:"appId"`
ClientID string `json:"clientId"`
PrivateKeyFile string `json:"privateKeyFile"`
PublicURL string `json:"publicUrl"`
ClientSecret string `json:"-"`
WebhookSecret string `json:"-"`
TokenKey string `json:"-"`
}

// Enabled reports whether any GitHub setting is present; Validate requires all.
func (github GitHub) Enabled() bool {
return github != GitHub{}
}

// Validate requires the complete integration or none of it. publicUrl is the
// browser-facing origin for OAuth callbacks; plain HTTP is development only.
func (github GitHub) Validate(insecureCookies bool) error {
if !github.Enabled() {
return nil
}
missing := make([]string, 0)
for _, field := range []struct {
name string
present bool
}{
{"DIFFX_SERVER_GITHUB_APP_ID", github.AppID > 0},
{"DIFFX_SERVER_GITHUB_CLIENT_ID", github.ClientID != ""},
{"DIFFX_SERVER_GITHUB_PRIVATE_KEY_FILE", github.PrivateKeyFile != ""},
{"DIFFX_SERVER_GITHUB_PUBLIC_URL", github.PublicURL != ""},
{"DIFFX_SERVER_GITHUB_CLIENT_SECRET", github.ClientSecret != ""},
{"DIFFX_SERVER_GITHUB_WEBHOOK_SECRET", github.WebhookSecret != ""},
{"DIFFX_SERVER_GITHUB_TOKEN_KEY", github.TokenKey != ""},
} {
if !field.present {
missing = append(missing, field.name)
}
}
if len(missing) > 0 {
return fmt.Errorf("incomplete GitHub integration; also set %s", strings.Join(missing, ", "))
}
public, err := url.Parse(github.PublicURL)
if err != nil || public.Host == "" || public.User != nil || public.RawQuery != "" || public.Fragment != "" || strings.TrimSuffix(public.Path, "/") != "" ||
public.Scheme != "https" && (public.Scheme != "http" || !insecureCookies) {
return errors.New("GitHub publicUrl must be an https origin such as https://diffx.example.com (http only with insecure cookies)")
}
key, err := base64.StdEncoding.DecodeString(github.TokenKey)
if err != nil || len(key) != 32 {
return errors.New("DIFFX_SERVER_GITHUB_TOKEN_KEY must be 32 random bytes in standard base64, such as the output of: openssl rand -base64 32")
}
return nil
}

// LoadServerFile resolves defaults, an explicitly selected file, then environment.
Expand Down Expand Up @@ -59,6 +118,12 @@ func LoadServerFile(path string) (ServerValues, error) {
{"DIFFX_SERVER_HOST", &values.Host},
{"DIFFX_SERVER_STATE", &values.State},
{"DIFFX_SERVER_USERNAME", &values.Username},
{"DIFFX_SERVER_GITHUB_CLIENT_ID", &values.GitHub.ClientID},
{"DIFFX_SERVER_GITHUB_PRIVATE_KEY_FILE", &values.GitHub.PrivateKeyFile},
{"DIFFX_SERVER_GITHUB_PUBLIC_URL", &values.GitHub.PublicURL},
{"DIFFX_SERVER_GITHUB_CLIENT_SECRET", &values.GitHub.ClientSecret},
{"DIFFX_SERVER_GITHUB_WEBHOOK_SECRET", &values.GitHub.WebhookSecret},
{"DIFFX_SERVER_GITHUB_TOKEN_KEY", &values.GitHub.TokenKey},
} {
if value, exists := os.LookupEnv(field.name); exists {
*field.target = value
Expand All @@ -79,6 +144,13 @@ func LoadServerFile(path string) (ServerValues, error) {
*field.target = value
}
}
if raw, exists := os.LookupEnv("DIFFX_SERVER_GITHUB_APP_ID"); exists {
value, err := strconv.ParseInt(raw, 10, 64)
if err != nil {
return values, fmt.Errorf("invalid DIFFX_SERVER_GITHUB_APP_ID: %w", err)
}
values.GitHub.AppID = value
}
for _, field := range []struct {
name string
target *bool
Expand Down
50 changes: 50 additions & 0 deletions internal/config/server_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"errors"
"os"
"path/filepath"
"strings"
"testing"
)

Expand Down Expand Up @@ -86,3 +87,52 @@ func TestServerConfigRejectsMalformedFiles(t *testing.T) {
})
}
}

func TestGitHubConfigIsAllOrNothingWithSecretsFromEnvironment(t *testing.T) {
t.Setenv("DIFFX_SERVER_CONFIG", "")
key := "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA="
complete := map[string]string{
"DIFFX_SERVER_GITHUB_APP_ID": "12", "DIFFX_SERVER_GITHUB_CLIENT_ID": "client", "DIFFX_SERVER_GITHUB_PRIVATE_KEY_FILE": "/keys/app.pem",
"DIFFX_SERVER_GITHUB_PUBLIC_URL": "https://diffx.example.com", "DIFFX_SERVER_GITHUB_CLIENT_SECRET": "secret",
"DIFFX_SERVER_GITHUB_WEBHOOK_SECRET": "hook", "DIFFX_SERVER_GITHUB_TOKEN_KEY": key,
}
for name, value := range complete {
t.Setenv(name, value)
}
values, err := LoadServerFile("")
if err != nil || values.GitHub.AppID != 12 || values.GitHub.TokenKey != key || values.GitHub.Validate(false) != nil {
t.Fatalf("complete integration: %+v %v %v", values.GitHub, err, values.GitHub.Validate(false))
}
for _, tc := range []struct{ name, value, want string }{
{"DIFFX_SERVER_GITHUB_WEBHOOK_SECRET", "", "DIFFX_SERVER_GITHUB_WEBHOOK_SECRET"},
{"DIFFX_SERVER_GITHUB_PUBLIC_URL", "http://diffx.example.com", "https origin"},
{"DIFFX_SERVER_GITHUB_PUBLIC_URL", "https://diffx.example.com/app", "https origin"},
{"DIFFX_SERVER_GITHUB_TOKEN_KEY", "short", "32 random bytes"},
} {
t.Run(tc.name+"="+tc.value, func(t *testing.T) {
t.Setenv(tc.name, tc.value)
values, err := LoadServerFile("")
if err != nil {
t.Fatal(err)
}
if err := values.GitHub.Validate(false); err == nil || !strings.Contains(err.Error(), tc.want) {
t.Fatalf("validation %v, want %q", err, tc.want)
}
})
}
t.Setenv("DIFFX_SERVER_GITHUB_PUBLIC_URL", "http://127.0.0.1:7980")
values, _ = LoadServerFile("")
if err := values.GitHub.Validate(true); err != nil {
t.Fatalf("development http public url with insecure cookies: %v", err)
}
path := filepath.Join(t.TempDir(), "server.json")
if err := os.WriteFile(path, []byte(`{"github":{"appId":1,"clientSecret":"in-file"}}`), 0o600); err != nil {
t.Fatal(err)
}
if _, err := LoadServerFile(path); err == nil || !strings.Contains(err.Error(), "clientSecret") {
t.Fatalf("secret accepted from config file: %v", err)
}
if (GitHub{}).Validate(false) != nil || (GitHub{}).Enabled() {
t.Fatal("absent integration must be valid and disabled")
}
}
148 changes: 148 additions & 0 deletions internal/github/app.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
package github

import (
"context"
"crypto"
"crypto/rand"
"crypto/rsa"
"crypto/sha256"
"crypto/x509"
"encoding/base64"
"encoding/json"
"encoding/pem"
"errors"
"fmt"
"net/http"
"strconv"
"time"
)

const tokenRefreshMargin = 5 * time.Minute

type Token struct {
Value string
ExpiresAt time.Time
}

// ParsePrivateKey accepts PKCS#1 ("RSA PRIVATE KEY") or PKCS#8 ("PRIVATE KEY") PEM.
func ParsePrivateKey(pemBytes []byte) (*rsa.PrivateKey, error) {
block, _ := pem.Decode(pemBytes)
if block == nil {
return nil, errors.New("github app private key must be PEM encoded")
}
switch block.Type {
case "RSA PRIVATE KEY":
key, err := x509.ParsePKCS1PrivateKey(block.Bytes)
if err != nil {
return nil, fmt.Errorf("invalid github app private key: %w", err)
}
return key, nil
case "PRIVATE KEY":
parsed, err := x509.ParsePKCS8PrivateKey(block.Bytes)
if err != nil {
return nil, fmt.Errorf("invalid github app private key: %w", err)
}
key, ok := parsed.(*rsa.PrivateKey)
if !ok {
return nil, errors.New("github app private key must be RSA")
}
return key, nil
}
return nil, fmt.Errorf("unsupported github app private key PEM type %q", block.Type)
}

// appJWT signs an RS256 app token; iat is backdated for clock drift and exp stays under GitHub's 10m cap.
func (c *Client) appJWT(now time.Time) (string, error) {
claims, err := json.Marshal(struct {
IssuedAt int64 `json:"iat"`
ExpiresAt int64 `json:"exp"`
Issuer string `json:"iss"`
}{now.Add(-60 * time.Second).Unix(), now.Add(9 * time.Minute).Unix(), strconv.FormatInt(c.app.ID, 10)})
if err != nil {
return "", err
}
unsigned := base64.RawURLEncoding.EncodeToString([]byte(`{"alg":"RS256","typ":"JWT"}`)) + "." +
base64.RawURLEncoding.EncodeToString(claims)
digest := sha256.Sum256([]byte(unsigned))
signature, err := rsa.SignPKCS1v15(rand.Reader, c.app.PrivateKey, crypto.SHA256, digest[:])
if err != nil {
return "", errors.New("sign github app JWT failed")
}
return unsigned + "." + base64.RawURLEncoding.EncodeToString(signature), nil
}

func (c *Client) AppSlug(ctx context.Context) (string, error) {
jwt, err := c.appJWT(time.Now())
if err != nil {
return "", err
}
var app struct {
Slug string `json:"slug"`
}
if _, err := c.get(ctx, c.apiURL+"/app", jwt, &app); err != nil {
return "", err
}
if app.Slug == "" {
return "", errors.New("github app response missing slug")
}
return app.Slug, nil
}

// InstallationToken mints a token narrowed to repositoryIDs and permissions when given.
func (c *Client) InstallationToken(ctx context.Context, installationID int64, repositoryIDs []int64, permissions map[string]string) (Token, error) {
if installationID <= 0 {
return Token{}, errors.New("github installation ID is required")
}
jwt, err := c.appJWT(time.Now())
if err != nil {
return Token{}, err
}
body := struct {
RepositoryIDs []int64 `json:"repository_ids,omitempty"`
Permissions map[string]string `json:"permissions,omitempty"`
}{repositoryIDs, permissions}
target := c.apiURL + "/app/installations/" + strconv.FormatInt(installationID, 10) + "/access_tokens"
request, err := c.newRequest(ctx, http.MethodPost, target, jwt, body)
if err != nil {
return Token{}, err
}
var response struct {
Token string `json:"token"`
ExpiresAt time.Time `json:"expires_at"`
}
if _, err := c.send(request, &response); err != nil {
return Token{}, err
}
if response.Token == "" || response.ExpiresAt.IsZero() {
return Token{}, errors.New("github installation token response incomplete")
}
return Token{Value: response.Token, ExpiresAt: response.ExpiresAt}, nil
}

// installationBearer reuses an installation-wide token until tokenRefreshMargin before expiry.
func (c *Client) installationBearer(ctx context.Context, installationID int64) (string, error) {
c.mu.Lock()
token, ok := c.tokens[installationID]
c.mu.Unlock()
if ok && time.Until(token.ExpiresAt) > tokenRefreshMargin {
return token.Value, nil
}
token, err := c.InstallationToken(ctx, installationID, nil, nil)
if err != nil {
return "", err
}
c.mu.Lock()
c.tokens[installationID] = token
c.mu.Unlock()
return token.Value, nil
}

// forgetRejected drops a cached token GitHub no longer accepts.
func (c *Client) forgetRejected(installationID int64, err error) error {
if errors.Is(err, ErrUnauthorized) {
c.mu.Lock()
delete(c.tokens, installationID)
c.mu.Unlock()
}
return err
}
Loading
Loading