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

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])
}