Files
gogobee/internal/bot/client.go
prosolis 6387380d0a Add appservice auth mode behind AUTH_MODE; land device grant
Two things, entangled in the working tree because 20d1f92 was mislabeled
(its message claimed "device grant" but it only deleted registration.yaml.example
and left the failed appservice+/sync hybrid in client.go):

1. Land the MAS OAuth 2.0 device grant that was running in prod but never
   committed: masauth.go + client.go rewrite. Bot stays a normal user so /sync
   works; refresh token persisted in data/mas_auth.json.

2. Add "appservice" as an alternate transport behind AUTH_MODE (default
   "masdevice" = unchanged device-grant path, instant rollback):
   - internal/bot/session.go: Session abstraction unifying both transports
     (OnEventType/Run/Stop); NewSession dispatches on AuthMode.
   - internal/bot/appservice.go: as_token auth (MAS-durable, no login/MFA/expiry),
     cryptohelper MSC4190 device creation, crypto machine fed from Synapse
     transaction pushes (to-device/device-lists/OTK) instead of /sync, inbound
     decrypt+redispatch, and lazy per-room StateStore backfill (encryption +
     members) so replies in encrypted rooms encrypt to the right recipients.
   - main.go: NewSession/sess.OnEventType/sess.Run in place of the /sync loop.
   - .env.example: AUTH_MODE, AS_REGISTRATION, AS_LISTEN_HOST/PORT, HOMESERVER_DOMAIN.

The appservice path fixes the earlier hybrid's fatal flaw (Synapse forbids AS
users from /sync) by using the transaction/push model. Bot-side only; Synapse
registration + experimental_features (msc2409/msc3202/msc4190) and E2EE cutover
are the next steps. Build + vet green (CGO=1 -tags goolm).
2026-07-03 17:11:58 -07:00

188 lines
6.9 KiB
Go

package bot
import (
"context"
"fmt"
"log/slog"
"os"
"path/filepath"
"time"
"maunium.net/go/mautrix"
"maunium.net/go/mautrix/crypto/cryptohelper"
"maunium.net/go/mautrix/id"
)
// Config holds the bot's startup configuration.
type Config struct {
Homeserver string
UserID string // Bot's full Matrix user ID, e.g. @twinbee:parodia.dev
DataDir string
DisplayName string
// AuthMode selects how the bot connects to Matrix:
// "masdevice" (default) — MAS OAuth 2.0 device grant + /sync (masauth.go).
// "appservice" — Matrix appservice: as_token auth + Synapse→bot
// transaction push (appservice.go). MAS-durable:
// no login, no MFA, no token expiry ever.
// The device-grant path is retained as an instant rollback: flip AUTH_MODE
// back to masdevice and restart, no rebuild.
AuthMode string
// ---- appservice mode only ----
// RegistrationPath points at the appservice registration YAML (id, as_token,
// hs_token, sender_localpart, namespaces). Synapse and the bot share it.
RegistrationPath string
// ListenHost/ListenPort is where the bot's HTTP listener binds to receive
// Synapse's transaction pushes (Synapse reaches it via the registration url).
ListenHost string
ListenPort uint16
// HomeserverDomain is the server_name (e.g. parodia.dev), needed to derive
// the bot's MXID from sender_localpart.
HomeserverDomain string
}
// NewClient creates and configures a mautrix client authenticated against
// Matrix Authentication Service (MAS) via the OAuth 2.0 device grant, with
// E2EE via the cryptohelper.
//
// Auth flow (see masauth.go for detail):
// - Discover MAS OAuth endpoints from the homeserver's well-known + OIDC docs.
// - If we have a persisted refresh token, refresh it for a fresh access token.
// - Otherwise run the device-authorization grant: print a URL + code for a
// human to approve ONCE in a browser (as the bot's user), then store the
// resulting access + refresh tokens.
// - A background goroutine refreshes the access token before it expires and
// updates the live client, so the bot runs indefinitely without re-auth.
//
// The bot stays a normal Matrix user (not an appservice), so /sync works — an
// appservice user is forbidden from /sync by Synapse. E2EE is unchanged: the
// cryptohelper persists device keys, olm/megolm sessions and cross-signing in
// its own SQLite store, and the bot trusts all users' devices by default.
func NewClient(cfg Config) (*mautrix.Client, error) {
if err := os.MkdirAll(cfg.DataDir, 0o755); err != nil {
return nil, fmt.Errorf("create data dir: %w", err)
}
if cfg.UserID == "" {
return nil, fmt.Errorf("BOT_USER_ID is required")
}
ctx := context.Background()
// ---- MAS OAuth device-grant authentication ----
auth := newMASAuth(cfg.DataDir)
auth.load()
if err := auth.discover(cfg.Homeserver); err != nil {
return nil, fmt.Errorf("MAS discovery: %w", err)
}
if auth.refreshToken != "" {
// Returning start: refresh the stored token.
if err := auth.refresh(); err != nil {
return nil, fmt.Errorf("MAS token refresh failed (delete %s to re-authorize): %w",
filepath.Join(cfg.DataDir, "mas_auth.json"), err)
}
slog.Info("MAS auth: refreshed access token from stored session", "device_id", auth.deviceID)
} else {
// First run: register a client and run the interactive device grant.
if err := auth.ensureClient(cfg.DisplayName); err != nil {
return nil, fmt.Errorf("MAS client registration: %w", err)
}
if err := auth.deviceFlow(ctx); err != nil {
return nil, fmt.Errorf("MAS device authorization: %w", err)
}
}
userID := id.UserID(cfg.UserID)
client, err := mautrix.NewClient(cfg.Homeserver, userID, auth.token())
if err != nil {
return nil, fmt.Errorf("create client: %w", err)
}
client.DeviceID = id.DeviceID(auth.deviceID)
// Validate the token + identity before proceeding.
whoami, err := client.Whoami(ctx)
if err != nil {
return nil, fmt.Errorf("token validation failed (whoami): %w", err)
}
if whoami.UserID != userID {
return nil, fmt.Errorf("identity mismatch: token resolves to %s but BOT_USER_ID is %s", whoami.UserID, userID)
}
slog.Info("MAS auth: token valid", "user_id", whoami.UserID, "device_id", client.DeviceID)
// ---- E2EE via cryptohelper ----
// The crypto store persists device keys, olm/megolm sessions, cross-signing
// and device trust in its own SQLite DB. The stored device ID must match the
// one bound to our OAuth token (device: scope); a fresh mas_auth.json is
// paired with a fresh crypto.db.
cryptoDBPath := filepath.Join(cfg.DataDir, "crypto.db")
ch, err := cryptohelper.NewCryptoHelper(client, []byte("gogobee_pickle_key"), cryptoDBPath)
if err != nil {
return nil, fmt.Errorf("init crypto helper: %w", err)
}
// No LoginAs: we already have a token + device ID; the cryptohelper just
// attaches E2EE to the existing session.
if err := ch.Init(ctx); err != nil {
return nil, fmt.Errorf("crypto helper init: %w", err)
}
client.Crypto = ch
// Best-effort cross-signing bootstrap (makes the bot's device show as
// verified to users who trust its master key). Not required for E2EE to
// function; under MAS the key upload may be refused, which we ignore.
mach := ch.Machine()
if recoveryKey, _, err := mach.GenerateAndUploadCrossSigningKeys(ctx, func(ui *mautrix.RespUserInteractive) interface{} {
return map[string]interface{}{"session": ui.Session}
}, ""); err != nil {
slog.Warn("cross-signing: key upload skipped (may already exist or need UIA)", "err", err)
} else {
slog.Info("cross-signing: keys uploaded", "recovery_key", recoveryKey)
}
if err := mach.SignOwnDevice(ctx, mach.OwnIdentity()); err != nil {
slog.Warn("cross-signing: sign own device failed", "err", err)
}
if err := mach.SignOwnMasterKey(ctx); err != nil {
slog.Warn("cross-signing: sign master key failed", "err", err)
}
// ---- Background token refresher ----
// Refresh ~60s before expiry and push the new token into the live client so
// in-flight /sync and API calls keep authenticating.
go refreshLoop(context.Background(), auth, client)
slog.Info("E2EE initialized",
"user_id", client.UserID,
"device_id", client.DeviceID,
"crypto_store", "sqlite-persistent",
"auth", "mas-oauth-device-grant",
)
return client, nil
}
// refreshLoop keeps the client's access token fresh for the life of the process.
func refreshLoop(ctx context.Context, auth *masAuth, client *mautrix.Client) {
for {
wait := time.Until(auth.expiry()) - 60*time.Second
if wait < 10*time.Second {
wait = 10 * time.Second
}
select {
case <-ctx.Done():
return
case <-time.After(wait):
}
if err := auth.refresh(); err != nil {
slog.Error("MAS token refresh failed; retrying in 30s", "err", err)
select {
case <-ctx.Done():
return
case <-time.After(30 * time.Second):
}
continue
}
client.AccessToken = auth.token()
slog.Debug("MAS token refreshed", "expires_at", auth.expiry().Format(time.RFC3339))
}
}