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).
This commit is contained in:
prosolis
2026-07-03 17:11:58 -07:00
parent 20d1f92f97
commit 6387380d0a
9 changed files with 889 additions and 143 deletions

204
internal/bot/appservice.go Normal file
View File

@@ -0,0 +1,204 @@
package bot
import (
"context"
"fmt"
"log/slog"
"os"
"path/filepath"
"sync"
"github.com/rs/zerolog"
"maunium.net/go/mautrix"
"maunium.net/go/mautrix/appservice"
"maunium.net/go/mautrix/crypto/cryptohelper"
"maunium.net/go/mautrix/event"
"maunium.net/go/mautrix/id"
)
// cryptoToDeviceTypes are the to-device event types the crypto machine must see
// to establish Olm/Megolm sessions and share/receive room keys. In /sync mode
// the cryptohelper gets these automatically; under the appservice transaction
// model we route each one to mach.HandleToDeviceEvent ourselves.
var cryptoToDeviceTypes = []event.Type{
event.ToDeviceEncrypted,
event.ToDeviceRoomKey,
event.ToDeviceForwardedRoomKey,
event.ToDeviceRoomKeyRequest,
event.ToDeviceRoomKeyWithheld,
event.ToDeviceOrgMatrixRoomKeyWithheld,
event.ToDeviceSecretRequest,
event.ToDeviceSecretSend,
event.ToDeviceDummy,
}
// newAppserviceSession builds the appservice-mode Session: as_token auth, a
// cryptohelper that mints the bot's device via MSC4190, and an EventProcessor
// fed by Synapse's transaction pushes (in place of /sync). The bot is an
// appservice user — Synapse forbids AS users from /sync, so all events, plus the
// E2EE extensions (to-device / device lists / OTK counts), arrive over the
// transaction API instead.
func newAppserviceSession(cfg Config) (*Session, 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")
}
if cfg.RegistrationPath == "" {
return nil, fmt.Errorf("AS_REGISTRATION is required in appservice mode")
}
if cfg.HomeserverDomain == "" {
return nil, fmt.Errorf("HOMESERVER_DOMAIN is required in appservice mode (server_name, e.g. parodia.dev)")
}
if !((&appservice.HostConfig{Hostname: cfg.ListenHost, Port: cfg.ListenPort}).IsConfigured()) {
return nil, fmt.Errorf("AS_LISTEN_HOST/AS_LISTEN_PORT must be set in appservice mode")
}
ctx := context.Background()
reg, err := appservice.LoadRegistration(cfg.RegistrationPath)
if err != nil {
return nil, fmt.Errorf("load appservice registration %q: %w", cfg.RegistrationPath, err)
}
// Gate for to-device delivery: handleTransaction only pumps to-device events
// when this is set (appservice/http.go). Force it on regardless of the yaml so
// E2EE key exchange can't silently break on a missing field.
reg.EphemeralEvents = true
as, err := appservice.CreateFull(appservice.CreateOpts{
Registration: reg,
HomeserverDomain: cfg.HomeserverDomain,
HomeserverURL: cfg.Homeserver,
HostConfig: appservice.HostConfig{Hostname: cfg.ListenHost, Port: cfg.ListenPort},
})
if err != nil {
return nil, fmt.Errorf("create appservice: %w", err)
}
// Surface the HTTP listener + transaction logs (default is a silent Nop).
as.Log = zerolog.New(os.Stderr).With().Timestamp().Str("component", "appservice").Logger().
Level(zerolog.InfoLevel)
userID := id.UserID(cfg.UserID)
if as.BotMXID() != userID {
return nil, fmt.Errorf("registration sender_localpart resolves to %s but BOT_USER_ID is %s", as.BotMXID(), userID)
}
client := as.BotClient() // as_token auth + SetAppServiceUserID (?user_id=) assertion
// Validate the token + identity before we start listening.
whoami, err := client.Whoami(ctx)
if err != nil {
return nil, fmt.Errorf("appservice token validation failed (whoami): %w", err)
}
if whoami.UserID != userID {
return nil, fmt.Errorf("appservice identity mismatch: token resolves to %s but BOT_USER_ID is %s", whoami.UserID, userID)
}
slog.Info("appservice token valid", "user_id", whoami.UserID)
// ---- E2EE via cryptohelper (MSC4190 device creation, no /login) ----
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)
}
// MSC4190: the appservice creates/refreshes its own device via PUT /devices
// instead of the UIA-gated login. The crypto store persists the device ID, so
// restarts reuse it. LoginAs carries only the display name (never calls /login
// in MSC4190 mode). client.Syncer is nil here, so Init does NOT wire /sync
// handlers — we drive the crypto machine from transactions below instead.
ch.MSC4190 = true
ch.LoginAs = &mautrix.ReqLogin{InitialDeviceDisplayName: cfg.DisplayName}
if err := ch.Init(ctx); err != nil {
return nil, fmt.Errorf("crypto helper init (MSC4190 device create): %w", err)
}
client.Crypto = ch
// ---- Event processor: replicate the cryptohelper's /sync wiring against
// the appservice transaction channels ----
ep := appservice.NewEventProcessor(as)
mach := ch.Machine()
// Crypto plumbing that /sync would otherwise carry:
ep.OnOTK(mach.HandleOTKCounts)
ep.OnDeviceList(mach.HandleDeviceLists)
for _, t := range cryptoToDeviceTypes {
ep.On(t, mach.HandleToDeviceEvent)
}
// Keep the client's StateStore current so outgoing sends know which rooms are
// encrypted and who to share keys with (no /sync backfill here), and let the
// crypto machine track membership for key sharing. PrependHandler so state is
// updated before app handlers (auto-join/moderation) run for the same event.
ep.PrependHandler(event.StateEncryption, func(ctx context.Context, evt *event.Event) {
mautrix.UpdateStateStore(ctx, as.StateStore, evt)
})
ep.PrependHandler(event.StateMember, func(ctx context.Context, evt *event.Event) {
mautrix.UpdateStateStore(ctx, as.StateStore, evt)
mach.HandleMemberEvent(ctx, evt)
})
// Without /sync there is no state backfill, so the client's StateStore starts
// empty. Before we hand off a decrypted event (whose reply may need to be
// encrypted), resolve the room once: mark it encrypted and populate its member
// list. Otherwise outbound sends go plaintext (IsEncrypted=false) and, worse,
// the group session is shared to nobody (GetRoomJoinedOrInvitedMembers empty)
// so recipients can't decrypt the bot's replies.
var resolved sync.Map // roomID -> struct{}, resolved once
resolveRoom := func(ctx context.Context, roomID id.RoomID) {
if _, done := resolved.LoadOrStore(roomID, struct{}{}); done {
return
}
var enc event.EncryptionEventContent
if err := client.StateEvent(ctx, roomID, event.StateEncryption, "", &enc); err == nil && enc.Algorithm != "" {
_ = as.StateStore.SetEncryptionEvent(ctx, roomID, &enc)
}
if members, err := client.Members(ctx, roomID); err == nil {
_ = as.StateStore.ReplaceCachedMembers(ctx, roomID, members.Chunk)
} else {
slog.Warn("appservice: failed to fetch room members; will retry", "room", roomID, "err", err)
resolved.Delete(roomID) // allow a later event to retry
}
}
// Decrypt inbound encrypted room events and re-dispatch the plaintext so the
// normal message/reaction handlers fire (mirrors cryptohelper.HandleEncrypted).
ep.On(event.EventEncrypted, func(ctx context.Context, evt *event.Event) {
resolveRoom(ctx, evt.RoomID)
decrypted, err := ch.Decrypt(ctx, evt)
if err != nil {
slog.Warn("appservice: failed to decrypt event", "room", evt.RoomID, "event", evt.ID, "err", err)
return
}
ep.Dispatch(ctx, decrypted)
})
return &Session{
Client: client,
mode: "appservice",
as: as,
ep: ep,
}, nil
}
// runAppservice starts the transaction dispatchers and the HTTP listener, then
// blocks until ctx is cancelled.
func (s *Session) runAppservice(ctx context.Context) error {
s.ep.Start(ctx)
s.as.Ready = true
errCh := make(chan struct{})
go func() {
s.as.Start() // blocks in ListenAndServe until Stop()
close(errCh)
}()
slog.Info("appservice listener started", "address", s.as.Host.Address())
select {
case <-ctx.Done():
return nil
case <-errCh:
return fmt.Errorf("appservice HTTP listener exited unexpectedly")
}
}

View File

@@ -6,6 +6,7 @@ import (
"log/slog"
"os"
"path/filepath"
"time"
"maunium.net/go/mautrix"
"maunium.net/go/mautrix/crypto/cryptohelper"
@@ -15,133 +16,172 @@ import (
// Config holds the bot's startup configuration.
type Config struct {
Homeserver string
UserID string // Bot's full Matrix user ID, e.g. @gogobee:example.com
ASToken string // Appservice as_token from registration.yaml
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 as an
// application service, with E2EE via the cryptohelper.
// 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.
//
// Why appservice instead of password login:
// 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.
//
// Under Matrix Authentication Service (MAS), the legacy password login
// (m.login.password) and User-Interactive Auth (UIA) flows are being removed —
// auth moves to OAuth2/OIDC. A bot built on password login + password-UIA
// cross-signing (the old code) breaks in a MAS world: short-lived tokens with
// no refresh, and UIA-gated key uploads that simply fail.
//
// The appservice model sidesteps MAS entirely. The as_token is a Synapse-level
// trust relationship declared in registration.yaml — Synapse validates it
// directly, not via MAS — so it never expires and needs no login flow. Device
// creation uses MSC4190 (PUT /devices), which replaces the UIA-gated device
// dance for appservice users. This is the MAS-durable path.
//
// The cryptohelper still handles everything E2EE:
// - Persistent crypto store in SQLite (device keys, sessions, cross-signing)
// - Megolm session sharing / key exchange, Olm device-to-device sessions
// - The bot trusts all users' devices by default (appropriate for a bot)
// - No interactive emoji/SAS verification needed
// 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.ASToken == "" {
return nil, fmt.Errorf("AS_TOKEN is required (appservice registration as_token)")
}
if cfg.UserID == "" {
return nil, fmt.Errorf("BOT_USER_ID is required")
}
ctx := context.Background()
userID := id.UserID(cfg.UserID)
// The as_token IS the access token. No login flow, no refresh — Synapse
// trusts it for any user in the registration's namespace.
client, err := mautrix.NewClient(cfg.Homeserver, userID, cfg.ASToken)
// ---- 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)
// Assert our own user ID on every request (appservice identity assertion,
// ?user_id=). Required so Synapse knows which namespaced user we're acting
// as — including on /sync and the MSC4190 device creation below.
client.SetAppServiceUserID = true
// Validate the token + homeserver reachability up front with a clear error.
// Validate the token + identity before proceeding.
whoami, err := client.Whoami(ctx)
if err != nil {
return nil, fmt.Errorf("appservice token validation failed (whoami): %w", err)
return nil, fmt.Errorf("token validation failed (whoami): %w", err)
}
if whoami.UserID != userID {
return nil, fmt.Errorf("appservice identity mismatch: token resolves to %s but BOT_USER_ID is %s", whoami.UserID, userID)
return nil, fmt.Errorf("identity mismatch: token resolves to %s but BOT_USER_ID is %s", whoami.UserID, userID)
}
slog.Info("appservice token valid", "user_id", whoami.UserID)
slog.Info("MAS auth: token valid", "user_id", whoami.UserID, "device_id", client.DeviceID)
// Set up E2EE. The cryptohelper persists all crypto state in its own SQLite
// DB (device keys, olm/megolm sessions, cross-signing keys, device trust),
// separate from the main app database. The device ID is persisted there too,
// so restarts reuse the same device — no device.json needed.
// ---- 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)
}
// MSC4190: create/refresh the bot's device via PUT /devices instead of the
// UIA-gated login dance. On first run the crypto store has no device ID, so
// a fresh one is generated and persisted; on restart the stored ID is
// reused (the PUT is idempotent). LoginAs carries only the display name —
// in MSC4190 mode the cryptohelper never calls /login.
ch.MSC4190 = true
ch.LoginAs = &mautrix.ReqLogin{
InitialDeviceDisplayName: cfg.DisplayName,
}
// 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 (MSC4190 device create): %w", err)
return nil, fmt.Errorf("crypto helper init: %w", err)
}
client.Crypto = ch
// Best-effort cross-signing bootstrap. This makes the bot's device show as
// "verified" to users who have verified the bot's master key. It is NOT
// required for E2EE to function — the bot already trusts all devices and
// shares keys — so any failure is logged and ignored.
//
// Under MSC3202 (appservice device assertion) Synapse may accept the key
// upload without UIA, in which case the callback below is never invoked. If
// the homeserver still demands UIA, we have no password credential to
// satisfy it (that's the whole point of going MAS-native), so it fails soft.
// 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()
recoveryKey, _, err := mach.GenerateAndUploadCrossSigningKeys(ctx, func(ui *mautrix.RespUserInteractive) interface{} {
slog.Warn("cross-signing: homeserver requested UIA, but appservice auth has no password credential to satisfy it — skipping verified-shield bootstrap")
if recoveryKey, _, err := mach.GenerateAndUploadCrossSigningKeys(ctx, func(ui *mautrix.RespUserInteractive) interface{} {
return map[string]interface{}{"session": ui.Session}
}, "")
if err != nil {
slog.Warn("cross-signing: key upload skipped (may already exist or UIA required)", "err", err)
}, ""); 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)
} else {
slog.Info("cross-signing: own device signed")
}
if err := mach.SignOwnMasterKey(ctx); err != nil {
slog.Warn("cross-signing: sign master key failed", "err", err)
} else {
slog.Info("cross-signing: master key signed")
}
// ---- 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", "appservice+msc4190",
"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))
}
}

375
internal/bot/masauth.go Normal file
View File

@@ -0,0 +1,375 @@
package bot
import (
"context"
"crypto/rand"
"encoding/json"
"fmt"
"io"
"log/slog"
"net/http"
"net/url"
"os"
"path/filepath"
"strings"
"sync"
"time"
)
// masAuth performs MAS (Matrix Authentication Service) OAuth 2.0 device-grant
// authentication for the bot and keeps a valid access token available.
//
// Why this exists: MAS replaces Matrix's legacy password login with OAuth2. A
// bot can't do the interactive authorization-code flow, so we use the OAuth 2.0
// Device Authorization Grant (RFC 8628): on first run the bot prints a URL +
// user code, a human approves the login as the bot's user ONCE in a browser,
// and MAS returns an access token + refresh token. Thereafter the bot refreshes
// silently forever — no password, no expiry surprises, and the bot stays a
// normal Matrix user so /sync keeps working (unlike an appservice user, which
// Synapse forbids from /sync).
//
// The refresh token is rotated by MAS on every refresh, so we persist the new
// one each time.
type masAuth struct {
http *http.Client
storePath string
scope string
// discovered OAuth endpoints
deviceEndpoint string
tokenEndpoint string
registrationEndpoint string
mu sync.RWMutex
clientID string
deviceID string
accessToken string
refreshToken string
expiresAt time.Time
}
// masStore is the on-disk persisted state (data/mas_auth.json).
type masStore struct {
ClientID string `json:"client_id"`
DeviceID string `json:"device_id"`
AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token"`
ExpiresAt time.Time `json:"expires_at"`
}
const masScopeAPI = "urn:matrix:org.matrix.msc2967.client:api:*"
func newMASAuth(dataDir string) *masAuth {
return &masAuth{
http: &http.Client{Timeout: 30 * time.Second},
storePath: filepath.Join(dataDir, "mas_auth.json"),
}
}
func (m *masAuth) load() {
data, err := os.ReadFile(m.storePath)
if err != nil {
return // fresh install
}
var s masStore
if err := json.Unmarshal(data, &s); err != nil {
slog.Warn("mas_auth: corrupt store, ignoring", "err", err)
return
}
m.clientID = s.ClientID
m.deviceID = s.DeviceID
m.accessToken = s.AccessToken
m.refreshToken = s.RefreshToken
m.expiresAt = s.ExpiresAt
}
func (m *masAuth) save() {
m.mu.RLock()
s := masStore{
ClientID: m.clientID,
DeviceID: m.deviceID,
AccessToken: m.accessToken,
RefreshToken: m.refreshToken,
ExpiresAt: m.expiresAt,
}
m.mu.RUnlock()
data, err := json.MarshalIndent(s, "", " ")
if err != nil {
slog.Error("mas_auth: marshal failed", "err", err)
return
}
if err := os.WriteFile(m.storePath, data, 0o600); err != nil {
slog.Error("mas_auth: write failed", "err", err)
}
}
func (m *masAuth) token() string {
m.mu.RLock()
defer m.mu.RUnlock()
return m.accessToken
}
// discover resolves the MAS OAuth endpoints from the homeserver's well-known
// document and the issuer's OIDC discovery document.
func (m *masAuth) discover(homeserver string) error {
var wk struct {
Auth struct {
Issuer string `json:"issuer"`
} `json:"org.matrix.msc2965.authentication"`
}
if err := m.getJSON(strings.TrimRight(homeserver, "/")+"/.well-known/matrix/client", &wk); err != nil {
return fmt.Errorf("fetch well-known: %w", err)
}
if wk.Auth.Issuer == "" {
return fmt.Errorf("homeserver does not advertise a MAS issuer (msc2965) — is MAS enabled?")
}
var oidc struct {
DeviceEndpoint string `json:"device_authorization_endpoint"`
TokenEndpoint string `json:"token_endpoint"`
RegistrationEndpoint string `json:"registration_endpoint"`
}
if err := m.getJSON(strings.TrimRight(wk.Auth.Issuer, "/")+"/.well-known/openid-configuration", &oidc); err != nil {
return fmt.Errorf("fetch OIDC discovery: %w", err)
}
if oidc.DeviceEndpoint == "" || oidc.TokenEndpoint == "" {
return fmt.Errorf("MAS issuer %q does not advertise a device/token endpoint", wk.Auth.Issuer)
}
m.deviceEndpoint = oidc.DeviceEndpoint
m.tokenEndpoint = oidc.TokenEndpoint
m.registrationEndpoint = oidc.RegistrationEndpoint
slog.Info("mas_auth: discovered endpoints", "issuer", wk.Auth.Issuer)
return nil
}
// ensureClient registers a public OAuth client via dynamic registration if we
// don't already have a client_id persisted.
func (m *masAuth) ensureClient(displayName string) error {
if m.clientID != "" {
return nil
}
if m.registrationEndpoint == "" {
return fmt.Errorf("no registration endpoint discovered")
}
body := map[string]any{
"client_name": displayName + " (bot)",
"application_type": "native",
"token_endpoint_auth_method": "none",
"grant_types": []string{"urn:ietf:params:oauth:grant-type:device_code", "refresh_token"},
"response_types": []string{},
"client_uri": "https://github.com/prosolis/gogobee",
}
raw, _ := json.Marshal(body)
resp, err := m.http.Post(m.registrationEndpoint, "application/json", strings.NewReader(string(raw)))
if err != nil {
return fmt.Errorf("client registration request: %w", err)
}
defer resp.Body.Close()
rb, _ := io.ReadAll(resp.Body)
if resp.StatusCode != 200 && resp.StatusCode != 201 {
return fmt.Errorf("client registration failed (HTTP %d): %s", resp.StatusCode, string(rb))
}
var out struct {
ClientID string `json:"client_id"`
}
if err := json.Unmarshal(rb, &out); err != nil || out.ClientID == "" {
return fmt.Errorf("client registration: no client_id in response: %s", string(rb))
}
m.mu.Lock()
m.clientID = out.ClientID
m.mu.Unlock()
m.save()
slog.Info("mas_auth: registered OAuth client", "client_id", out.ClientID)
return nil
}
// deviceFlow runs the interactive device-authorization grant. It blocks until
// the user approves the login (or the code expires).
func (m *masAuth) deviceFlow(ctx context.Context) error {
if m.deviceID == "" {
m.deviceID = randomDeviceID()
}
scope := masScopeAPI + " urn:matrix:org.matrix.msc2967.client:device:" + m.deviceID
form := url.Values{"client_id": {m.clientID}, "scope": {scope}}
var da struct {
DeviceCode string `json:"device_code"`
UserCode string `json:"user_code"`
VerificationURI string `json:"verification_uri"`
VerificationURIComplete string `json:"verification_uri_complete"`
ExpiresIn int `json:"expires_in"`
Interval int `json:"interval"`
}
if err := m.postForm(m.deviceEndpoint, form, &da); err != nil {
return fmt.Errorf("device authorization request: %w", err)
}
// Surface the approval prompt prominently — the operator must act on it.
uri := da.VerificationURI
if da.VerificationURIComplete != "" {
uri = da.VerificationURIComplete
}
banner := fmt.Sprintf(`
================= TwinBee needs you to authorize its login =================
Open this URL in a browser (logged in as the bot's Matrix user):
%s
and enter code: %s
(expires in %d minutes)
============================================================================
`, uri, da.UserCode, da.ExpiresIn/60)
fmt.Print(banner)
slog.Warn("mas_auth: device authorization required", "verification_uri", da.VerificationURI, "user_code", da.UserCode, "expires_in_s", da.ExpiresIn)
interval := da.Interval
if interval <= 0 {
interval = 5
}
deadline := time.Now().Add(time.Duration(da.ExpiresIn) * time.Second)
for time.Now().Before(deadline) {
select {
case <-ctx.Done():
return ctx.Err()
case <-time.After(time.Duration(interval) * time.Second):
}
pending, err := m.pollToken(url.Values{
"grant_type": {"urn:ietf:params:oauth:grant-type:device_code"},
"device_code": {da.DeviceCode},
"client_id": {m.clientID},
})
if err != nil {
return err
}
if !pending {
slog.Info("mas_auth: device authorization approved", "device_id", m.deviceID)
return nil
}
}
return fmt.Errorf("device authorization timed out (not approved within %d minutes)", da.ExpiresIn/60)
}
// pollToken hits the token endpoint. Returns pending=true if the user hasn't
// approved yet (keep polling); on success it stores the tokens.
func (m *masAuth) pollToken(form url.Values) (pending bool, err error) {
resp, err := m.http.PostForm(m.tokenEndpoint, form)
if err != nil {
return false, fmt.Errorf("token request: %w", err)
}
defer resp.Body.Close()
rb, _ := io.ReadAll(resp.Body)
if resp.StatusCode == 200 {
return false, m.storeTokenResponse(rb)
}
var oerr struct {
Error string `json:"error"`
}
_ = json.Unmarshal(rb, &oerr)
switch oerr.Error {
case "authorization_pending":
return true, nil
case "slow_down":
return true, nil
default:
return false, fmt.Errorf("token endpoint error (HTTP %d): %s", resp.StatusCode, string(rb))
}
}
// refresh exchanges the stored refresh token for a fresh access token. MAS
// rotates the refresh token, so we persist the new one.
func (m *masAuth) refresh() error {
m.mu.RLock()
rt, cid := m.refreshToken, m.clientID
m.mu.RUnlock()
if rt == "" {
return fmt.Errorf("no refresh token")
}
resp, err := m.http.PostForm(m.tokenEndpoint, url.Values{
"grant_type": {"refresh_token"},
"refresh_token": {rt},
"client_id": {cid},
})
if err != nil {
return fmt.Errorf("refresh request: %w", err)
}
defer resp.Body.Close()
rb, _ := io.ReadAll(resp.Body)
if resp.StatusCode != 200 {
return fmt.Errorf("refresh failed (HTTP %d): %s", resp.StatusCode, string(rb))
}
return m.storeTokenResponse(rb)
}
func (m *masAuth) storeTokenResponse(rb []byte) error {
var tr struct {
AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token"`
ExpiresIn int `json:"expires_in"`
}
if err := json.Unmarshal(rb, &tr); err != nil {
return fmt.Errorf("parse token response: %w", err)
}
if tr.AccessToken == "" {
return fmt.Errorf("token response missing access_token: %s", string(rb))
}
m.mu.Lock()
m.accessToken = tr.AccessToken
if tr.RefreshToken != "" {
m.refreshToken = tr.RefreshToken
}
ttl := tr.ExpiresIn
if ttl <= 0 {
ttl = 300
}
m.expiresAt = time.Now().Add(time.Duration(ttl) * time.Second)
m.mu.Unlock()
m.save()
return nil
}
func (m *masAuth) expiry() time.Time {
m.mu.RLock()
defer m.mu.RUnlock()
return m.expiresAt
}
// getJSON GETs a URL and decodes JSON.
func (m *masAuth) getJSON(u string, out any) error {
resp, err := m.http.Get(u)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
b, _ := io.ReadAll(io.LimitReader(resp.Body, 512))
return fmt.Errorf("GET %s: HTTP %d: %s", u, resp.StatusCode, string(b))
}
return json.NewDecoder(resp.Body).Decode(out)
}
// postForm POSTs a form and decodes a JSON success body (200).
func (m *masAuth) postForm(u string, form url.Values, out any) error {
resp, err := m.http.PostForm(u, form)
if err != nil {
return err
}
defer resp.Body.Close()
rb, _ := io.ReadAll(resp.Body)
if resp.StatusCode != 200 {
return fmt.Errorf("POST %s: HTTP %d: %s", u, resp.StatusCode, string(rb))
}
return json.Unmarshal(rb, out)
}
// randomDeviceID mirrors mautrix's device-id style: 10 uppercase letters.
func randomDeviceID() string {
const alphabet = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"
b := make([]byte, 10)
if _, err := rand.Read(b); err != nil {
// rand.Read essentially never fails; fall back to a fixed prefix.
return "GOGOBEEBOT"
}
for i := range b {
b[i] = alphabet[int(b[i])%len(alphabet)]
}
return "GB" + string(b[:8])
}

122
internal/bot/session.go Normal file
View File

@@ -0,0 +1,122 @@
package bot
import (
"context"
"fmt"
"log/slog"
"time"
"maunium.net/go/mautrix"
"maunium.net/go/mautrix/appservice"
"maunium.net/go/mautrix/event"
)
// Session is the bot's live Matrix connection plus its event source. It hides
// the difference between the two transports the bot can run under:
//
// - masdevice: a normal Matrix user authenticated via the MAS OAuth device
// grant, receiving events over /sync (mautrix DefaultSyncer).
// - appservice: a Synapse appservice authenticated by its as_token, receiving
// events pushed over the appservice transaction API (mautrix EventProcessor).
//
// Callers register the same three handlers (message/member/reaction) via
// OnEventType and call Run regardless of mode; the plugin layer never knows which
// transport is in use.
type Session struct {
Client *mautrix.Client
mode string
// masdevice
syncer *mautrix.DefaultSyncer
// appservice
as *appservice.AppService
ep *appservice.EventProcessor
}
// EventHandler matches both DefaultSyncer.OnEventType and EventProcessor.On.
type EventHandler = func(ctx context.Context, evt *event.Event)
// NewSession builds a Session for the configured auth mode. Default and
// "masdevice" use the MAS device grant; "appservice" uses the transaction model.
func NewSession(cfg Config) (*Session, error) {
switch cfg.AuthMode {
case "appservice":
return newAppserviceSession(cfg)
case "", "masdevice":
return newMASDeviceSession(cfg)
default:
return nil, fmt.Errorf("unknown AUTH_MODE %q (want appservice or masdevice)", cfg.AuthMode)
}
}
// newMASDeviceSession wraps the existing device-grant client (NewClient) and its
// DefaultSyncer. This is the retained rollback path — unchanged behaviour.
func newMASDeviceSession(cfg Config) (*Session, error) {
client, err := NewClient(cfg)
if err != nil {
return nil, err
}
return &Session{
Client: client,
mode: "masdevice",
syncer: client.Syncer.(*mautrix.DefaultSyncer),
}, nil
}
// OnEventType registers a handler for the given event type against whichever
// event source backs this session.
func (s *Session) OnEventType(evtType event.Type, fn EventHandler) {
switch s.mode {
case "appservice":
s.ep.On(evtType, fn)
default:
s.syncer.OnEventType(evtType, fn)
}
}
// Run blocks, delivering events to registered handlers, until ctx is cancelled.
func (s *Session) Run(ctx context.Context) error {
switch s.mode {
case "appservice":
return s.runAppservice(ctx)
default:
return s.runSync(ctx)
}
}
// Stop halts the event source. Safe to call once.
func (s *Session) Stop() {
switch s.mode {
case "appservice":
if s.ep != nil {
s.ep.Stop()
}
if s.as != nil {
s.as.Stop()
}
default:
s.Client.StopSync()
}
}
// runSync is the device-grant /sync loop (moved verbatim from main.go): restart
// on transient failure, exit on context cancel.
func (s *Session) runSync(ctx context.Context) error {
for {
err := s.Client.SyncWithContext(ctx)
if ctx.Err() != nil {
return nil // shutdown requested
}
if err != nil {
slog.Error("sync stopped, restarting in 5s", "err", err)
} else {
slog.Warn("sync returned without error, restarting in 5s")
}
select {
case <-time.After(5 * time.Second):
case <-ctx.Done():
return nil
}
}
}