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
5 changes: 4 additions & 1 deletion cmd/diffx-server/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -71,9 +71,12 @@ func parseSettings(arguments []string, stderr io.Writer) (remoteserver.Settings,
return settings, false, fmt.Errorf("state must be a database path or memory")
}
values.PublicURL, values.InsecureCookies = settings.PublicURL, settings.InsecureCookies
if err := values.ValidatePublicURL(); err != nil {
if err := values.ValidateIdentity(); err != nil {
return settings, false, err
}
if values.Google.Enabled() {
settings.Google = &remoteserver.GoogleSettings{ClientID: values.Google.ClientID, ClientSecret: values.Google.ClientSecret}
}
if _, err := remoteserver.Retention(settings.RetentionDays); err != nil {
return settings, false, err
}
Expand Down
17 changes: 17 additions & 0 deletions cmd/diffx-server/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,23 @@ func TestRemoteIdentityConfigPrecedence(t *testing.T) {
}
}

func TestRemoteGoogleSignInSettings(t *testing.T) {
t.Setenv("DIFFX_SERVER_CONFIG", "")
settings, _, err := parseSettings(nil, io.Discard)
if err != nil || settings.Google != nil {
t.Fatalf("Google sign-in without configuration: %+v %v", settings.Google, err)
}
t.Setenv("DIFFX_SERVER_GOOGLE_CLIENT_ID", "client")
t.Setenv("DIFFX_SERVER_GOOGLE_CLIENT_SECRET", "secret")
if _, _, err := parseSettings(nil, io.Discard); err == nil {
t.Fatal("Google sign-in accepted without a public URL")
}
settings, _, err = parseSettings([]string{"--public-url", "https://diffx.example.com"}, io.Discard)
if err != nil || settings.Google == nil || settings.Google.ClientID != "client" || settings.Google.ClientSecret != "secret" {
t.Fatalf("Google sign-in settings: %+v %v", settings.Google, err)
}
}

func TestRemoteRejectsInvalidDeploymentSettings(t *testing.T) {
for _, tc := range []struct{ key, value string }{
{"DIFFX_SERVER_HOST", "localhost"},
Expand Down
4 changes: 4 additions & 0 deletions compose.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,10 @@ services:
# Until the admin exists, each start logs a one-time setup link on this
# origin; no default credentials are generated.
DIFFX_SERVER_PUBLIC_URL: "${DIFFX_SERVER_PUBLIC_URL:-}"
# Google sign-in for invited accounts: set both (empty runs admin-only).
# Needs DIFFX_SERVER_PUBLIC_URL for the callback.
DIFFX_SERVER_GOOGLE_CLIENT_ID: "${DIFFX_SERVER_GOOGLE_CLIENT_ID:-}"
DIFFX_SERVER_GOOGLE_CLIENT_SECRET: "${DIFFX_SERVER_GOOGLE_CLIENT_SECRET:-}"
# Loopback HTTP development only. Use false behind production HTTPS.
DIFFX_SERVER_INSECURE_COOKIES: "${DIFFX_SERVER_INSECURE_COOKIES:-true}"
# GitHub App integration: set all or none (empty disables it), then start
Expand Down
5 changes: 4 additions & 1 deletion docs-website/src/pages/agent-hooks.md
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,10 @@ DIFFX_SERVER_INSECURE_COOKIES=true DIFFX_SERVER_STATE=./test-data/state.db diffx

Open the setup link the server logs to create the admin account. The admin signs
in with that password at `/admin/login`; everyone else signs in with Google from
an admin invite. Then open the top-right account settings to create
an admin invite. Invites need a Google OAuth web client: set
`DIFFX_SERVER_GOOGLE_CLIENT_ID`, `DIFFX_SERVER_GOOGLE_CLIENT_SECRET` and
`DIFFX_SERVER_PUBLIC_URL`, and register
`<public URL>/api/v2/auth/google/callback` as its redirect URI. Then open the top-right account settings to create
a named API token. `collector` tokens support sync and plugins; `api` tokens also
support the user's review REST/MCP APIs. Both expire after 90 days by default
(selectable 1–365 days), can be revoked, and show their secret only once. Browser
Expand Down
6 changes: 4 additions & 2 deletions docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -71,8 +71,10 @@ personal lifecycle discovery.
- `reviewservice`: shared snapshot reads, comments, marks and capability checks.
- `reviewdata`: storage-independent identities, results and errors.
- `reviewsession`: a resolved review and its source/policy capabilities.
- `identityservice`: account, admin setup, password, session, API-token and
account administration policies.
- `identityservice`: account, admin setup, password, session, API-token,
account administration, invite and Google sign-in policies.
- `google`: Google OpenID Connect wire adapter: authorization URL with PKCE and
nonce, code exchange and ID token claim checks. No storage or diffx policy.
- `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
Expand Down
7 changes: 6 additions & 1 deletion docs/development.md
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,7 @@ building the image.
| `DIFFX_SERVER_RETENTION_DAYS` | `7` | `7` | `--retention-days DAYS` |
| `DIFFX_SERVER_PUBLIC_URL` | Unset | Unset | `--public-url URL` |
| `DIFFX_SERVER_INSECURE_COOKIES` | `false` | Compose: `true` | `--insecure-cookies` |
| `DIFFX_SERVER_GOOGLE_CLIENT_ID` | Unset | Unset | None |

The server listens on 7980 so a local `diffx [PATH]` (7981–7990) and the server
run side by side. The `DIFFX_SERVER_` prefix keeps deployment variables from
Expand All @@ -202,7 +203,11 @@ unset, the link uses the listen address. `memory` and `:memory:` disable
persistence.

The admin signs in at `/admin/login`; every other account signs in with Google
and comes from an admin invite. Create a named token in Settings. Use the
and comes from an admin invite. Google sign-in needs
`DIFFX_SERVER_GOOGLE_CLIENT_ID`, `DIFFX_SERVER_GOOGLE_CLIENT_SECRET` (environment
only, never the config file) and `DIFFX_SERVER_PUBLIC_URL`; register
`<public URL>/api/v2/auth/google/callback` as the OAuth client's redirect URI.
Without them the server runs admin-only and cannot create invites. Create a named token in Settings. Use the
`collector` preset for producers, or `api` for review APIs/MCP. Token secrets are
shown once; expiry defaults to 90 days (selectable 1–365). Tokens cannot manage
accounts. Browser sessions are seven-day opaque cookies with CSRF protection on
Expand Down
13 changes: 13 additions & 0 deletions docs/system.md
Original file line number Diff line number Diff line change
Expand Up @@ -364,6 +364,19 @@ concurrent attempts yield one admin. Only the admin signs in with a password, at
login is indistinguishable from a wrong password. Offline `user reset-password`
recovers only the admin.

Accounts other than the admin exist only through invites. The admin invites an
email; the link carries a hashed one-time secret in its fragment, is shown once,
expires after 48 hours and replaces that email's pending invite. Accepting
requires Google sign-in reporting a verified email equal to the invite's, and
uses the invite and creates the account in one transaction. The username derives
from the email. Google accounts are identified by their stable subject, not
email: a changed email keeps the account and updates its recorded email. Google
sign-in uses the authorization code flow with PKCE, state and nonce; a short-lived
HttpOnly cookie binds the flow to the starting browser and each state is consumed
once. A Google account without an account or invite cannot sign up. Without Google
configuration the server runs admin-only: Google routes are absent and invites
cannot be created.

The admin lists accounts and disables, enables or deletes any account except
itself. Disabling revokes the account's browser sessions and API tokens in the
same transaction and refuses new ones; enabling restores sign-in, not those
Expand Down
27 changes: 25 additions & 2 deletions internal/config/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,8 +32,19 @@ type ServerValues struct {
// InternalPort listens beside Port, on the same host, when GitHub is enabled.
InternalPort int `json:"internalPort"`
GitHub GitHub `json:"github"`
Google Google `json:"google"`
}

// Google configures Google sign-in for invited accounts. The secret comes only
// from DIFFX_SERVER_GOOGLE_CLIENT_SECRET, never the config file.
type Google struct {
ClientID string `json:"clientId"`
ClientSecret string `json:"-"`
}

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

// GitHub configures the optional GitHub App integration. Secrets come only from
// DIFFX_SERVER_GITHUB_* environment variables, never the config file.
type GitHub struct {
Expand Down Expand Up @@ -100,11 +111,21 @@ func validOrigin(raw string, insecureCookies bool) bool {
(public.Scheme == "https" || public.Scheme == "http" && insecureCookies)
}

// ValidatePublicURL checks the optional origin used in links the server issues.
func (values ServerValues) ValidatePublicURL() error {
// ValidateIdentity checks the optional origin used in links the server issues,
// and Google sign-in, which needs both credentials and that origin.
func (values ServerValues) ValidateIdentity() error {
if values.PublicURL != "" && !validOrigin(values.PublicURL, values.InsecureCookies) {
return errors.New("publicUrl must be an https origin such as https://diffx.example.com (http only with insecure cookies)")
}
if !values.Google.Enabled() {
return nil
}
if values.Google.ClientID == "" || values.Google.ClientSecret == "" {
return errors.New("incomplete Google sign-in; set both DIFFX_SERVER_GOOGLE_CLIENT_ID and DIFFX_SERVER_GOOGLE_CLIENT_SECRET")
}
if values.PublicURL == "" {
return errors.New("Google sign-in needs DIFFX_SERVER_PUBLIC_URL for its callback")
}
return nil
}

Expand Down Expand Up @@ -141,6 +162,8 @@ func LoadServerFile(path string) (ServerValues, error) {
{"DIFFX_SERVER_HOST", &values.Host},
{"DIFFX_SERVER_STATE", &values.State},
{"DIFFX_SERVER_PUBLIC_URL", &values.PublicURL},
{"DIFFX_SERVER_GOOGLE_CLIENT_ID", &values.Google.ClientID},
{"DIFFX_SERVER_GOOGLE_CLIENT_SECRET", &values.Google.ClientSecret},
{"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},
Expand Down
37 changes: 37 additions & 0 deletions internal/config/server_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -140,6 +140,43 @@ func TestGitHubConfigIsAllOrNothingWithSecretsFromEnvironment(t *testing.T) {
}
}

func TestGoogleSignInNeedsBothCredentialsAndPublicURL(t *testing.T) {
t.Setenv("DIFFX_SERVER_CONFIG", "")
t.Setenv("DIFFX_SERVER_GOOGLE_CLIENT_ID", "client")
t.Setenv("DIFFX_SERVER_GOOGLE_CLIENT_SECRET", "secret")
t.Setenv("DIFFX_SERVER_PUBLIC_URL", "https://diffx.example.com")
values, err := LoadServerFile("")
if err != nil || !values.Google.Enabled() || values.ValidateIdentity() != nil {
t.Fatalf("complete Google sign-in: %+v %v", values.Google, err)
}
for _, tc := range []struct{ name, want string }{
{"DIFFX_SERVER_GOOGLE_CLIENT_SECRET", "DIFFX_SERVER_GOOGLE_CLIENT_SECRET"},
{"DIFFX_SERVER_GOOGLE_CLIENT_ID", "DIFFX_SERVER_GOOGLE_CLIENT_ID"},
{"DIFFX_SERVER_PUBLIC_URL", "DIFFX_SERVER_PUBLIC_URL"},
} {
t.Run(tc.name, func(t *testing.T) {
t.Setenv(tc.name, "")
values, err := LoadServerFile("")
if err != nil {
t.Fatal(err)
}
if err := values.ValidateIdentity(); err == nil || !strings.Contains(err.Error(), tc.want) {
t.Fatalf("validation %v, want %q", err, tc.want)
}
})
}
path := filepath.Join(t.TempDir(), "server.json")
if err := os.WriteFile(path, []byte(`{"google":{"clientId":"client","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 (Google{}).Enabled() || (ServerValues{}).ValidateIdentity() != nil {
t.Fatal("absent Google sign-in must be valid and disabled")
}
}

func TestInternalListenerSharesHostOnSeparatePort(t *testing.T) {
t.Setenv("DIFFX_SERVER_CONFIG", "")
values, err := LoadServerFile("")
Expand Down
147 changes: 147 additions & 0 deletions internal/google/google.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
// Package google is the Google OpenID Connect wire adapter: authorization URLs
// and the authorization-code exchange. It holds no storage or diffx policy.
package google

import (
"context"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
)

const (
authorizeURL = "https://accounts.google.com/o/oauth2/v2/auth"
tokenURL = "https://oauth2.googleapis.com/token"
// maxTokenResponse bounds the token endpoint body.
maxTokenResponse = 64 << 10
)

// ErrRejected reports an exchange or ID token Google or this client refused.
var ErrRejected = errors.New("google sign-in rejected")

// Identity is the verified subject of an ID token. Subject is stable for the
// Google account; Email can change.
type Identity struct {
Subject string
Email string
EmailVerified bool
}

type Client struct {
clientID, clientSecret, redirectURI string
authorizeURL, tokenURL string
http *http.Client
now func() time.Time
}

type Option func(*Client)

// WithEndpoints replaces Google's endpoints in tests.
func WithEndpoints(authorize, token string) Option {
return func(c *Client) { c.authorizeURL, c.tokenURL = authorize, token }
}

func New(clientID, clientSecret, redirectURI string, options ...Option) *Client {
client := &Client{clientID: clientID, clientSecret: clientSecret, redirectURI: redirectURI, authorizeURL: authorizeURL, tokenURL: tokenURL,
http: &http.Client{Timeout: 15 * time.Second}, now: time.Now}
for _, option := range options {
option(client)
}
return client
}

// Challenge derives the S256 PKCE challenge for a verifier.
func Challenge(verifier string) string {
digest := sha256.Sum256([]byte(verifier))
return base64.RawURLEncoding.EncodeToString(digest[:])
}

func (c *Client) AuthorizeURL(state, nonce, verifier string) string {
query := url.Values{
"client_id": {c.clientID}, "redirect_uri": {c.redirectURI}, "response_type": {"code"}, "scope": {"openid email"},
"state": {state}, "nonce": {nonce}, "code_challenge": {Challenge(verifier)}, "code_challenge_method": {"S256"}, "prompt": {"select_account"},
}
return c.authorizeURL + "?" + query.Encode()
}

// Exchange redeems the code and validates the returned ID token's claims. The
// token comes directly from Google's token endpoint over TLS, so its signature
// is not checked (OpenID Connect Core 3.1.3.7).
func (c *Client) Exchange(ctx context.Context, code, verifier, nonce string) (Identity, error) {
if code == "" {
return Identity{}, fmt.Errorf("%w: missing code", ErrRejected)
}
form := url.Values{"code": {code}, "client_id": {c.clientID}, "client_secret": {c.clientSecret}, "redirect_uri": {c.redirectURI},
"grant_type": {"authorization_code"}, "code_verifier": {verifier}}
request, err := http.NewRequestWithContext(ctx, http.MethodPost, c.tokenURL, strings.NewReader(form.Encode()))
if err != nil {
return Identity{}, err
}
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
request.Header.Set("Accept", "application/json")
response, err := c.http.Do(request)
if err != nil {
return Identity{}, err
}
defer response.Body.Close()
body, err := io.ReadAll(io.LimitReader(response.Body, maxTokenResponse))
if err != nil {
return Identity{}, err
}
if response.StatusCode == http.StatusBadRequest || response.StatusCode == http.StatusUnauthorized {
return Identity{}, fmt.Errorf("%w: token endpoint returned %d", ErrRejected, response.StatusCode)
}
if response.StatusCode != http.StatusOK {
return Identity{}, fmt.Errorf("google token endpoint returned %d", response.StatusCode)
}
var token struct {
IDToken string `json:"id_token"`
}
if err := json.Unmarshal(body, &token); err != nil || token.IDToken == "" {
return Identity{}, fmt.Errorf("%w: token response missing id_token", ErrRejected)
}
return c.identity(token.IDToken, nonce)
}

func (c *Client) identity(idToken, nonce string) (Identity, error) {
parts := strings.Split(idToken, ".")
if len(parts) != 3 {
return Identity{}, fmt.Errorf("%w: malformed id_token", ErrRejected)
}
payload, err := base64.RawURLEncoding.DecodeString(parts[1])
if err != nil {
return Identity{}, fmt.Errorf("%w: malformed id_token", ErrRejected)
}
var claims struct {
Issuer string `json:"iss"`
Audience string `json:"aud"`
Expiry int64 `json:"exp"`
Nonce string `json:"nonce"`
Subject string `json:"sub"`
Email string `json:"email"`
EmailVerified bool `json:"email_verified"`
}
if err := json.Unmarshal(payload, &claims); err != nil {
return Identity{}, fmt.Errorf("%w: malformed id_token claims", ErrRejected)
}
switch {
case claims.Issuer != "https://accounts.google.com" && claims.Issuer != "accounts.google.com":
return Identity{}, fmt.Errorf("%w: unexpected issuer", ErrRejected)
case claims.Audience != c.clientID:
return Identity{}, fmt.Errorf("%w: unexpected audience", ErrRejected)
case claims.Expiry <= c.now().Unix():
return Identity{}, fmt.Errorf("%w: expired id_token", ErrRejected)
case nonce == "" || claims.Nonce != nonce:
return Identity{}, fmt.Errorf("%w: nonce mismatch", ErrRejected)
case claims.Subject == "" || claims.Email == "":
return Identity{}, fmt.Errorf("%w: id_token missing subject or email", ErrRejected)
}
return Identity{Subject: claims.Subject, Email: claims.Email, EmailVerified: claims.EmailVerified}, nil
}
Loading
Loading