diff --git a/internal/api/api.go b/internal/api/api.go index e1a4c20..87c3069 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -50,6 +50,7 @@ func (a *API) Register(app core.App) { e.Router.GET("/api/dashboard", a.dashboard) e.Router.GET("/api/connector/gmessages/status", a.gmessagesStatus) e.Router.POST("/api/connector/gmessages/pair/google", a.gmessagesGooglePair).Bind(apis.BodyLimit(maxGMessagesPairBytes)) + e.Router.POST("/api/connector/gmessages/reauth/google", a.gmessagesGoogleReauth).Bind(apis.BodyLimit(maxGMessagesPairBytes)) e.Router.POST("/api/connector/gmessages/pair/qr", a.gmessagesPair) e.Router.POST("/api/connector/gmessages/pair/qr/refresh", a.gmessagesPairRefresh) // Backward-compatible QR aliases from the first PayGate rebuild. @@ -289,6 +290,23 @@ func (a *API) gmessagesGooglePair(e *core.RequestEvent) error { }) } +func (a *API) gmessagesGoogleReauth(e *core.RequestEvent) error { + if !a.dashboardAuth(e) { + return e.UnauthorizedError("dashboard authentication is required", nil) + } + if a.GMessages == nil { + return e.BadRequestError("Google Messages connector is unavailable", nil) + } + var body googleMessagesPairBody + if err := decodeJSON(e, &body); err != nil { + return e.BadRequestError("invalid JSON body", err) + } + if err := a.GMessages.ReauthenticateGoogle(strings.TrimSpace(body.CookieData)); err != nil { + return e.BadRequestError(err.Error(), nil) + } + return e.JSON(http.StatusOK, a.GMessages.Status()) +} + func (a *API) gmessagesPair(e *core.RequestEvent) error { if !a.dashboardAuth(e) { return e.UnauthorizedError("dashboard authentication is required", nil) diff --git a/internal/api/gmessages_reauth_test.go b/internal/api/gmessages_reauth_test.go new file mode 100644 index 0000000..459112d --- /dev/null +++ b/internal/api/gmessages_reauth_test.go @@ -0,0 +1,38 @@ +package api + +import ( + "net/http" + "strings" + "testing" + + "github.com/pocketbase/pocketbase/tests" +) + +func TestGoogleMessagesReauthEndpointRequiresDashboardAuth(t *testing.T) { + scenarios := []tests.ApiScenario{ + { + Name: "reauth requires dashboard auth", Method: http.MethodPost, + URL: "/api/connector/gmessages/reauth/google", + Headers: map[string]string{"Content-Type": "application/json"}, + Body: strings.NewReader(`{"cookieData":"SID=missing-rest"}`), + TestAppFactory: func(t testing.TB) *tests.TestApp { return apiTestFactoryWithGMessages(t) }, + ExpectedStatus: http.StatusUnauthorized, + ExpectedContent: []string{"Dashboard authentication is required."}, + }, + { + Name: "payment API key cannot reauthenticate Google Messages", Method: http.MethodPost, + URL: "/api/connector/gmessages/reauth/google", + Headers: map[string]string{ + "Authorization": "Bearer api-secret", + "Content-Type": "application/json", + }, + Body: strings.NewReader(`{"cookieData":"SID=missing-rest"}`), + TestAppFactory: func(t testing.TB) *tests.TestApp { return apiTestFactoryWithGMessages(t) }, + ExpectedStatus: http.StatusUnauthorized, + ExpectedContent: []string{"Dashboard authentication is required."}, + }, + } + for i := range scenarios { + scenarios[i].Test(t) + } +} diff --git a/internal/gmessages/client_events.go b/internal/gmessages/client_events.go new file mode 100644 index 0000000..ea81600 --- /dev/null +++ b/internal/gmessages/client_events.go @@ -0,0 +1,43 @@ +package gmessages + +import ( + "sync" + + "go.mau.fi/mautrix-gmessages/pkg/libgm" +) + +type clientEventGate struct { + mu sync.RWMutex + active bool +} + +var clientEventGates sync.Map // map[*libgm.Client]*clientEventGate + +func (m *Manager) registerClientEventHandler(client *libgm.Client) { + gate := &clientEventGate{active: true} + clientEventGates.Store(client, gate) + client.SetEventHandler(func(raw any) { + gate.mu.RLock() + defer gate.mu.RUnlock() + if !gate.active { + return + } + m.handleClientEvent(client, raw) + }) +} + +// retireClient waits for any event handler that already started for this +// client, disables future events, then closes the libgm transport. It must not +// be called from inside that same client's event handler. +func (m *Manager) retireClient(client *libgm.Client) { + if client == nil { + return + } + if value, ok := clientEventGates.LoadAndDelete(client); ok { + gate := value.(*clientEventGate) + gate.mu.Lock() + gate.active = false + gate.mu.Unlock() + } + client.Disconnect() +} diff --git a/internal/gmessages/manager.go b/internal/gmessages/manager.go index b906680..dd29f93 100644 --- a/internal/gmessages/manager.go +++ b/internal/gmessages/manager.go @@ -64,17 +64,24 @@ func NewManager(cfg config.Config, logger zerolog.Logger, ingest IngestFunc) *Ma logger.Warn().Err(err).Msg("ignoring invalid Google Messages session") return m } - if !validSession(session) { + if !validPairingSession(session) { m.status = Status{Enabled: true, State: "unpaired"} return m } m.session = session + state := "disconnected" + lastError := "" + if session.IsGoogleAccount() && !googleSessionHasRequiredCookies(session) { + state = "reauth_required" + lastError = googleReauthRequiredMessage + } m.status = Status{ Enabled: true, - State: "disconnected", + State: state, Paired: true, PairingMethod: sessionPairingMethod(session), AccountEmail: sessionAccountEmail(session), + LastError: lastError, } return m } @@ -117,15 +124,17 @@ func (m *Manager) Stop() { if pairCancel != nil { pairCancel() } - if client != nil { - client.Disconnect() - } + m.retireClient(client) } func (m *Manager) Status() Status { m.mu.RLock() defer m.mu.RUnlock() status := m.status + if status.State == "reauth_required" || status.State == "reauthenticating" { + status.Connected = false + return status + } if m.client != nil && status.Paired { status.Connected = m.client.IsConnected() if status.Connected && status.State == "disconnected" { @@ -143,6 +152,10 @@ func (m *Manager) connectWithBackoff(ctx context.Context) { } m.mu.Lock() + if m.status.State == "reauth_required" || m.status.State == "reauthenticating" { + m.mu.Unlock() + return + } if !validSession(m.session) { m.status.State = "unpaired" m.status.Paired = false @@ -154,12 +167,18 @@ func (m *Manager) connectWithBackoff(ctx context.Context) { } client := m.client m.status.State = "connecting" + m.status.Connected = false m.mu.Unlock() + // libgm starts its long-poll listener asynchronously. A nil return here + // only means the listener was started, not that Google accepted it. + // ClientReady/ListenRecovered are the authoritative connected events. if err := client.Connect(); err == nil { - m.markConnected() return } else { + if m.handleGoogleAuthFailure(err) { + return + } m.setError("degraded", err) } @@ -181,10 +200,21 @@ func (m *Manager) connectWithBackoff(ctx context.Context) { func (m *Manager) newClient(session *libgm.AuthData) *libgm.Client { client := libgm.NewClient(session, nil, m.logger.With().Str("component", "libgm").Logger()) - client.SetEventHandler(m.handleEvent) + m.registerClientEventHandler(client) return client } +func (m *Manager) handleClientEvent(client *libgm.Client, raw any) { + m.mu.RLock() + current := m.client == client + m.mu.RUnlock() + if !current { + m.logger.Debug().Type("event_type", raw).Msg("ignoring Google Messages event from retired client") + return + } + m.handleEvent(raw) +} + func (m *Manager) handleEvent(raw any) { switch event := raw.(type) { case *events.ClientReady: @@ -198,6 +228,7 @@ func (m *Manager) handleEvent(raw any) { m.status.PairingEmoji = "" m.status.PairingMethod = sessionPairingMethod(m.session) m.status.AccountEmail = sessionAccountEmail(m.session) + m.status.LastError = "" m.mu.Unlock() if err := m.saveCurrentSession(); err != nil { m.logger.Error().Err(err).Msg("failed to persist Google Messages session") @@ -207,11 +238,17 @@ func (m *Manager) handleEvent(raw any) { m.logger.Error().Err(err).Msg("failed to persist refreshed Google Messages auth") } case *events.PhoneNotResponding: + if m.connectionEventsSuppressed() { + return + } m.mu.Lock() m.status.PhoneResponsive = false m.status.State = "degraded" m.mu.Unlock() case *events.PhoneRespondingAgain: + if m.connectionEventsSuppressed() { + return + } m.mu.Lock() m.status.PhoneResponsive = true m.status.State = "connected" @@ -222,11 +259,21 @@ func (m *Manager) handleEvent(raw any) { case *events.ListenRecovered: m.markConnected() case *events.ListenFatalError: + if m.handleGoogleAuthFailure(event.Error) { + return + } m.setError("degraded", event.Error) m.scheduleReconnect() case *events.PingFailed: + if m.handleGoogleAuthFailure(event.Error) { + return + } m.setError("degraded", event.Error) case *events.GaiaLoggedOut: + if m.googleAccountPairingExists() { + m.markGoogleReauthRequired() + return + } m.markLoggedOut() case *libgm.WrappedMessage: m.handleMessage(event) @@ -308,7 +355,7 @@ func normalizeTimestampMS(timestamp int64) int64 { func (m *Manager) scheduleReconnect() { m.mu.Lock() - if m.reconnecting || m.ctx == nil || !validSession(m.session) { + if m.reconnecting || m.ctx == nil || !validSession(m.session) || m.status.State == "reauth_required" || m.status.State == "reauthenticating" { m.mu.Unlock() return } @@ -323,9 +370,7 @@ func (m *Manager) scheduleReconnect() { m.reconnecting = false m.mu.Unlock() }() - if client != nil { - client.Disconnect() - } + m.retireClient(client) timer := time.NewTimer(2 * time.Second) select { case <-ctx.Done(): @@ -334,6 +379,10 @@ func (m *Manager) scheduleReconnect() { case <-timer.C: } m.mu.Lock() + if m.status.State == "reauth_required" || m.status.State == "reauthenticating" || m.client != client { + m.mu.Unlock() + return + } m.client = nil m.mu.Unlock() m.connectWithBackoff(ctx) @@ -346,20 +395,33 @@ func (m *Manager) Reconnect() error { } m.mu.RLock() client := m.client - paired := validSession(m.session) + pairingExists := validPairingSession(m.session) + authReady := validSession(m.session) + state := m.status.State m.mu.RUnlock() - if !paired { + if !pairingExists { return errors.New("google messages is not paired") } + if state == "reauth_required" || state == "reauthenticating" || !authReady { + return errors.New("google account authentication must be refreshed before reconnecting") + } if client == nil { m.scheduleReconnect() return nil } + m.mu.Lock() + if m.client == client { + m.status.State = "connecting" + m.status.Connected = false + } + m.mu.Unlock() if err := client.Reconnect(); err != nil { + if m.handleGoogleAuthFailure(err) { + return errors.New("google account authentication must be refreshed before reconnecting") + } m.setError("degraded", err) return err } - m.markConnected() return m.saveCurrentSession() } @@ -373,7 +435,7 @@ func (m *Manager) BeginGooglePair(cookieInput string) (string, string, error) { } m.mu.Lock() - if validSession(m.session) { + if validPairingSession(m.session) { m.mu.Unlock() return "", "", errors.New("google messages is already paired; unpair it before starting a new pairing") } @@ -400,9 +462,7 @@ func (m *Manager) BeginGooglePair(cookieInput string) (string, string, error) { if oldPairCancel != nil { oldPairCancel() } - if oldClient != nil { - oldClient.Disconnect() - } + m.retireClient(oldClient) if err := client.FetchConfig(pairCtx); err != nil { pairCancel() @@ -456,12 +516,9 @@ func (m *Manager) awaitGooglePairResult(client *libgm.Client, cancel context.Can } func (m *Manager) failPairing(client *libgm.Client, err error) { - if client != nil { - client.Disconnect() - } m.mu.Lock() - defer m.mu.Unlock() if m.client != client { + m.mu.Unlock() return } if m.pairCancel != nil { @@ -474,6 +531,8 @@ func (m *Manager) failPairing(client *libgm.Client, err error) { if err != nil { m.status.LastError = err.Error() } + m.mu.Unlock() + m.retireClient(client) } func googlePairingError(err error) error { @@ -501,7 +560,7 @@ func (m *Manager) BeginPair() (string, error) { return "", errors.New("google messages connector is disabled") } m.mu.Lock() - if validSession(m.session) { + if validPairingSession(m.session) { m.mu.Unlock() return "", errors.New("google messages is already paired; unpair it before starting a new pairing") } @@ -515,9 +574,7 @@ func (m *Manager) BeginPair() (string, error) { client := m.client m.status = Status{Enabled: true, State: "pairing", PairingMethod: "qr"} m.mu.Unlock() - if oldClient != nil { - oldClient.Disconnect() - } + m.retireClient(oldClient) qrURL, err := client.StartLogin() if err != nil { m.setError("degraded", err) @@ -542,7 +599,7 @@ func (m *Manager) Unpair() error { m.mu.Lock() client := m.client pairCancel := m.pairCancel - wasPaired := validSession(m.session) + wasPaired := validPairingSession(m.session) m.client = nil m.pairCancel = nil m.session = nil @@ -557,7 +614,7 @@ func (m *Manager) Unpair() error { m.logger.Warn().Err(err).Msg("remote Google Messages unpair failed; deleting local session anyway") } } - client.Disconnect() + m.retireClient(client) } if err := os.Remove(m.cfg.GMessagesSessionPath); err != nil && !errors.Is(err, os.ErrNotExist) { return err @@ -568,6 +625,10 @@ func (m *Manager) Unpair() error { func (m *Manager) markConnected() { now := time.Now().UTC() m.mu.Lock() + if m.status.State == "reauth_required" || m.status.State == "reauthenticating" { + m.mu.Unlock() + return + } m.status.State = "connected" m.status.Paired = true m.status.Connected = true @@ -591,12 +652,17 @@ func (m *Manager) markLoggedOut() { } if client != nil { client.Disconnect() + go m.retireClient(client) } _ = os.Remove(m.cfg.GMessagesSessionPath) } func (m *Manager) setError(state string, err error) { m.mu.Lock() + if m.status.State == "reauth_required" || m.status.State == "reauthenticating" { + m.mu.Unlock() + return + } m.status.State = state m.status.Connected = false if err != nil { @@ -617,18 +683,7 @@ func (m *Manager) saveCurrentSession() error { } func validSession(session *libgm.AuthData) bool { - return session != nil && - googleSessionHasRequiredCookies(session) && - session.Browser != nil && - session.Mobile != nil && - session.RequestCrypto != nil && - len(session.RequestCrypto.AESKey) == 32 && - len(session.RequestCrypto.HMACKey) > 0 && - session.RefreshKey != nil && - len(session.RefreshKey.D) > 0 && - len(session.RefreshKey.X) > 0 && - len(session.RefreshKey.Y) > 0 && - len(session.TachyonAuthToken) > 0 + return validPairingSession(session) && googleSessionHasRequiredCookies(session) } func googleSessionHasRequiredCookies(session *libgm.AuthData) bool { diff --git a/internal/gmessages/reauth.go b/internal/gmessages/reauth.go new file mode 100644 index 0000000..ef0317a --- /dev/null +++ b/internal/gmessages/reauth.go @@ -0,0 +1,277 @@ +package gmessages + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "strings" + "time" + + "go.mau.fi/mautrix-gmessages/pkg/libgm" + "go.mau.fi/mautrix-gmessages/pkg/libgm/events" +) + +const ( + googleReauthRequiredMessage = "Google account authentication expired; refresh the Google login to reconnect" + googleAuthFailedMessage = "google account authentication failed; refresh the browser cookies and try again" + googleAuthVerifyMessage = "google account authentication could not be verified; try again" + googleWrongAccountMessage = "google login belongs to a different account; use the account already paired with this phone" +) + +func isGoogleAuthError(err error) bool { + if err == nil { + return false + } + if errors.Is(err, events.ErrInvalidCredentials) { + return true + } + var httpErr events.HTTPError + if !errors.As(err, &httpErr) || httpErr.Resp == nil { + return false + } + return httpErr.Resp.StatusCode == http.StatusUnauthorized || httpErr.Resp.StatusCode == http.StatusForbidden +} + +func (m *Manager) connectionEventsSuppressed() bool { + m.mu.RLock() + defer m.mu.RUnlock() + return m.status.State == "reauth_required" || m.status.State == "reauthenticating" +} + +func (m *Manager) googleAccountPairingExists() bool { + m.mu.RLock() + defer m.mu.RUnlock() + return validPairingSession(m.session) && m.session.IsGoogleAccount() +} + +func (m *Manager) handleGoogleAuthFailure(err error) bool { + m.mu.RLock() + googleSession := validPairingSession(m.session) && m.session.IsGoogleAccount() + m.mu.RUnlock() + if !googleSession || !isGoogleAuthError(err) { + return false + } + m.logger.Warn().Err(err).Msg("Google Messages account authentication requires refresh") + m.markGoogleReauthRequired() + return true +} + +func (m *Manager) markGoogleReauthRequired() { + m.mu.Lock() + client := m.client + m.status.State = "reauth_required" + m.status.Paired = validPairingSession(m.session) + m.status.Connected = false + m.status.PairingMethod = sessionPairingMethod(m.session) + m.status.AccountEmail = sessionAccountEmail(m.session) + m.status.LastError = googleReauthRequiredMessage + m.mu.Unlock() + if client != nil { + // Keep the client pointer until ReauthenticateGoogle retires its event + // gate. Status() already forces connected=false in reauth_required. + client.Disconnect() + } +} + +func cloneAuthData(session *libgm.AuthData) (*libgm.AuthData, error) { + if session == nil { + return nil, errors.New("cannot clone empty Google Messages session") + } + session.CookiesLock.RLock() + data, err := json.Marshal(session) + session.CookiesLock.RUnlock() + if err != nil { + return nil, err + } + var cloned libgm.AuthData + if err := json.Unmarshal(data, &cloned); err != nil { + return nil, err + } + return &cloned, nil +} + +func googleConfigAccount(client *libgm.Client) string { + if client == nil || client.Config == nil { + return "" + } + return strings.TrimSpace(client.Config.GetDeviceInfo().GetEmail()) +} + +// ReauthenticateGoogle replaces only the Google browser authentication on an +// existing Gaia pairing. The phone pairing, crypto keys and relay identity are +// preserved. Fresh cookies are committed only after Google confirms that they +// belong to the same account that originally paired the phone. +func (m *Manager) ReauthenticateGoogle(cookieInput string) error { + if !m.cfg.GMessagesEnabled { + return errors.New("google messages connector is disabled") + } + cookies, err := parseGoogleCookieInput(cookieInput) + if err != nil { + return err + } + + m.mu.Lock() + original := m.session + if !validPairingSession(original) || !original.IsGoogleAccount() { + m.mu.Unlock() + return errors.New("google account pairing is not available to reauthenticate") + } + if m.status.State == "pairing" || m.status.State == "reauthenticating" { + m.mu.Unlock() + return errors.New("google messages authentication is already being changed") + } + expectedAccount := sessionAccountEmail(original) + if expectedAccount == "" { + m.mu.Unlock() + return errors.New("paired Google account identity is missing; unpair and pair again") + } + oldClient := m.client + m.client = nil + m.status.State = "reauthenticating" + m.status.Paired = true + m.status.Connected = false + m.status.LastError = "" + baseCtx := m.ctx + m.mu.Unlock() + + // Drain any handler that already started on the retired client before the + // replacement can be installed. This prevents a delayed old ClientReady or + // auth failure from mutating the refreshed connection's state. + m.retireClient(oldClient) + + candidate, err := cloneAuthData(original) + if err != nil { + m.logger.Error().Err(err).Msg("failed to prepare Google Messages session for reauthentication") + m.finishGoogleReauthFailure(original, "Google login refresh could not be prepared; try again") + return errors.New("google login refresh could not be prepared; try again") + } + candidate.SetCookies(cookies) + client := m.newClient(candidate) + + if baseCtx == nil { + baseCtx = context.Background() + } + probeCtx, cancel := context.WithTimeout(baseCtx, 20*time.Second) + err = client.FetchConfig(probeCtx) + cancel() + if err != nil { + m.retireClient(client) + m.logger.Warn().Err(err).Msg("failed to verify refreshed Google Messages cookies") + message := googleAuthVerifyMessage + if isGoogleAuthError(err) { + message = googleAuthFailedMessage + } + m.finishGoogleReauthFailure(original, message) + return errors.New(message) + } + + account := googleConfigAccount(client) + if account == "" { + m.retireClient(client) + m.finishGoogleReauthFailure(original, googleAuthFailedMessage) + return errors.New(googleAuthFailedMessage) + } + if !strings.EqualFold(account, expectedAccount) { + m.retireClient(client) + m.finishGoogleReauthFailure(original, googleWrongAccountMessage) + return errors.New(googleWrongAccountMessage) + } + + // The live failure that motivated this flow occurred while libgm's stored + // Tachyon expiry still claimed the token was valid for many more hours. + // Clearing only the expiry forces libgm.Connect to use the existing signed + // refresh key to obtain fresh relay auth, while preserving the phone pairing. + candidate.TachyonExpiry = time.Time{} + + m.mu.Lock() + if m.session != original || m.status.State != "reauthenticating" { + m.mu.Unlock() + m.retireClient(client) + return errors.New("google messages pairing changed while authentication was being refreshed; try again") + } + if err := saveSession(m.cfg.GMessagesSessionPath, candidate); err != nil { + m.status.State = "reauth_required" + m.status.Connected = false + m.status.LastError = "Refreshed Google authentication could not be persisted" + m.mu.Unlock() + m.retireClient(client) + m.logger.Error().Err(err).Msg("failed to persist reauthenticated Google Messages session") + return errors.New("refreshed Google authentication could not be persisted") + } + m.session = candidate + m.client = client + m.status.State = "connecting" + m.status.Paired = true + m.status.Connected = false + m.status.PairingMethod = "google" + m.status.AccountEmail = expectedAccount + m.status.LastError = "" + connectCtx := m.ctx + m.mu.Unlock() + + if connectCtx != nil { + go m.connectReauthenticatedClient(connectCtx, client) + } else { + m.mu.Lock() + if m.client == client && m.status.State == "connecting" { + m.status.State = "disconnected" + } + m.mu.Unlock() + } + return nil +} + +func (m *Manager) connectReauthenticatedClient(ctx context.Context, client *libgm.Client) { + backoff := time.Second + for { + if err := ctx.Err(); err != nil { + return + } + m.mu.RLock() + current := m.client == client && m.status.State != "reauth_required" && m.status.State != "reauthenticating" + m.mu.RUnlock() + if !current { + return + } + + err := client.Connect() + if err == nil { + // A nil return only starts libgm's asynchronous listener. ClientReady + // or ListenRecovered will move the manager to connected. + return + } + if m.handleGoogleAuthFailure(err) { + return + } + m.setError("degraded", err) + + timer := time.NewTimer(backoff) + select { + case <-ctx.Done(): + timer.Stop() + return + case <-timer.C: + } + if backoff < time.Minute { + backoff *= 2 + if backoff > time.Minute { + backoff = time.Minute + } + } + } +} + +func (m *Manager) finishGoogleReauthFailure(original *libgm.AuthData, message string) { + m.mu.Lock() + defer m.mu.Unlock() + if m.session != original { + return + } + m.status.State = "reauth_required" + m.status.Paired = validPairingSession(original) + m.status.Connected = false + m.status.PairingMethod = "google" + m.status.AccountEmail = sessionAccountEmail(original) + m.status.LastError = message +} diff --git a/internal/gmessages/reauth_test.go b/internal/gmessages/reauth_test.go new file mode 100644 index 0000000..0277c73 --- /dev/null +++ b/internal/gmessages/reauth_test.go @@ -0,0 +1,178 @@ +package gmessages + +import ( + "context" + "errors" + "net/http" + "testing" + "time" + + "github.com/Phloraxx/payment-api/internal/config" + "github.com/google/uuid" + "github.com/rs/zerolog" + "go.mau.fi/mautrix-gmessages/pkg/libgm" + "go.mau.fi/mautrix-gmessages/pkg/libgm/events" + "go.mau.fi/mautrix-gmessages/pkg/libgm/gmproto" +) + +func googleTestSession(t *testing.T) *libgm.AuthData { + t.Helper() + session := libgm.NewAuthData() + session.Browser = &gmproto.Device{} + session.Mobile = &gmproto.Device{SourceID: "user@example.com"} + session.DestRegID = uuid.MustParse("00000000-0000-0000-0000-000000000001") + session.TachyonAuthToken = []byte{1, 2, 3} + cookies, err := parseGoogleCookieInput(testCookieHeader) + if err != nil { + t.Fatal(err) + } + session.SetCookies(cookies) + return session +} + +func TestGoogleAuthErrorDetection(t *testing.T) { + unauthorized := events.HTTPError{ + Action: "polling", + Resp: &http.Response{StatusCode: http.StatusUnauthorized}, + } + for name, err := range map[string]error{ + "direct 401": unauthorized, + "wrapped 401": errors.Join(errors.New("listen failed"), unauthorized), + "invalid credentials": events.ErrInvalidCredentials, + } { + t.Run(name, func(t *testing.T) { + if !isGoogleAuthError(err) { + t.Fatalf("%v was not classified as Google auth failure", err) + } + }) + } + if isGoogleAuthError(errors.New("temporary network error")) { + t.Fatal("unrelated error was classified as Google auth failure") + } +} + +func TestMarkGoogleReauthRequiredPreservesPairing(t *testing.T) { + session := googleTestSession(t) + manager := &Manager{ + cfg: config.Config{GMessagesEnabled: true}, + logger: zerolog.Nop(), + session: session, + status: Status{ + Enabled: true, State: "connected", Paired: true, Connected: true, + PairingMethod: "google", AccountEmail: "user@example.com", + }, + } + manager.markGoogleReauthRequired() + status := manager.Status() + if manager.session != session { + t.Fatal("reauth requirement replaced the existing phone pairing") + } + if status.State != "reauth_required" || !status.Paired || status.Connected { + t.Fatalf("status = %#v; want paired reauth_required and disconnected", status) + } + if status.AccountEmail != "user@example.com" || status.LastError != googleReauthRequiredMessage { + t.Fatalf("unexpected reauth status: %#v", status) + } +} + +func TestCloneAuthDataDoesNotShareCookies(t *testing.T) { + original := googleTestSession(t) + cloned, err := cloneAuthData(original) + if err != nil { + t.Fatal(err) + } + cloned.CookiesLock.Lock() + cloned.Cookies["SID"] = "changed" + cloned.CookiesLock.Unlock() + original.CookiesLock.RLock() + originalSID := original.Cookies["SID"] + original.CookiesLock.RUnlock() + if originalSID == "changed" { + t.Fatal("cloned session shares the original cookie map") + } + if sessionAccountEmail(cloned) != sessionAccountEmail(original) || cloned.PairingID != original.PairingID { + t.Fatal("cloned session did not preserve the existing pairing identity") + } +} + +func TestGoogleConfigAccount(t *testing.T) { + client := libgm.NewClient(libgm.NewAuthData(), nil, zerolog.Nop()) + client.Config = &gmproto.Config{DeviceInfo: &gmproto.Config_DeviceInfo{Email: " user@example.com "}} + if got := googleConfigAccount(client); got != "user@example.com" { + t.Fatalf("googleConfigAccount() = %q", got) + } +} + +func TestReauthenticateGoogleRequiresExistingGooglePairing(t *testing.T) { + manager := &Manager{cfg: config.Config{GMessagesEnabled: true}, logger: zerolog.Nop()} + if err := manager.ReauthenticateGoogle(testCookieHeader); err == nil { + t.Fatal("reauthentication without an existing Google pairing was accepted") + } +} + +func TestStaleReconnectTimerCannotReplaceNewClient(t *testing.T) { + session := googleTestSession(t) + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + manager := &Manager{ + cfg: config.Config{GMessagesEnabled: true}, + logger: zerolog.Nop(), + session: session, + ctx: ctx, + cancel: cancel, + status: Status{Enabled: true, State: "degraded", Paired: true, PairingMethod: "google"}, + } + oldClient := manager.newClient(session) + manager.client = oldClient + manager.scheduleReconnect() + + replacementSession, err := cloneAuthData(session) + if err != nil { + t.Fatal(err) + } + replacementClient := manager.newClient(replacementSession) + manager.mu.Lock() + manager.session = replacementSession + manager.client = replacementClient + manager.status.State = "connecting" + manager.mu.Unlock() + + time.Sleep(2300 * time.Millisecond) + manager.mu.RLock() + current := manager.client + manager.mu.RUnlock() + if current != replacementClient { + t.Fatal("stale reconnect timer replaced the newly installed client") + } +} + +func TestRetiredClientEventsAreIgnored(t *testing.T) { + session := googleTestSession(t) + manager := &Manager{ + cfg: config.Config{GMessagesEnabled: true}, + logger: zerolog.Nop(), + session: session, + status: Status{Enabled: true, State: "connecting", Paired: true, PairingMethod: "google"}, + } + retired := manager.newClient(session) + current := manager.newClient(session) + manager.client = current + + manager.handleClientEvent(retired, &events.ClientReady{}) + manager.mu.RLock() + retiredState := manager.status.State + retiredConnected := manager.status.Connected + manager.mu.RUnlock() + if retiredState != "connecting" || retiredConnected { + t.Fatalf("retired client event changed manager state: state=%s connected=%t", retiredState, retiredConnected) + } + + manager.handleClientEvent(current, &events.ClientReady{}) + manager.mu.RLock() + currentState := manager.status.State + currentConnected := manager.status.Connected + manager.mu.RUnlock() + if currentState != "connected" || !currentConnected { + t.Fatalf("current client event was ignored: state=%s connected=%t", currentState, currentConnected) + } +} diff --git a/internal/gmessages/session_state.go b/internal/gmessages/session_state.go new file mode 100644 index 0000000..ea2ac2f --- /dev/null +++ b/internal/gmessages/session_state.go @@ -0,0 +1,20 @@ +package gmessages + +import "go.mau.fi/mautrix-gmessages/pkg/libgm" + +// validPairingSession verifies the durable phone/crypto pairing independently +// from Google browser cookies. Google account cookies can expire while the +// underlying phone pairing remains reusable for reauthentication. +func validPairingSession(session *libgm.AuthData) bool { + return session != nil && + session.Browser != nil && + session.Mobile != nil && + session.RequestCrypto != nil && + len(session.RequestCrypto.AESKey) == 32 && + len(session.RequestCrypto.HMACKey) > 0 && + session.RefreshKey != nil && + len(session.RefreshKey.D) > 0 && + len(session.RefreshKey.X) > 0 && + len(session.RefreshKey.Y) > 0 && + len(session.TachyonAuthToken) > 0 +} diff --git a/web/src/pages/Settings.tsx b/web/src/pages/Settings.tsx index 2375981..b2743f0 100644 --- a/web/src/pages/Settings.tsx +++ b/web/src/pages/Settings.tsx @@ -42,7 +42,8 @@ export function Settings({ notify }: { notify: (value: string) => void }) { setPairingEmoji(status.pairingEmoji ?? ""); setPairingAccount(status.accountEmail ?? ""); } - if (status.paired) { + const refreshingGoogleAuth = status.state === "reauth_required" || status.state === "reauthenticating"; + if (status.paired && !refreshingGoogleAuth) { setCookieData(""); setPairingEmoji(""); setQrUrl(""); @@ -101,6 +102,27 @@ export function Settings({ notify }: { notify: (value: string) => void }) { } } + async function refreshGoogleLogin() { + if (!cookieData.trim()) { + notify("Paste a fresh Google Messages Copy-as-cURL request first."); + return; + } + setBusy(true); + try { + const status = await api("/api/connector/gmessages/reauth/google", { + method: "POST", + body: JSON.stringify({ cookieData }), + }); + setCookieData(""); + setConnector(status); + notify("Google login refreshed. Reconnecting with the existing phone pairing."); + } catch (err) { + notify(err instanceof Error ? err.message : "Google login could not be refreshed."); + } finally { + setBusy(false); + } + } + async function startQRPairing() { setBusy(true); try { @@ -140,10 +162,14 @@ export function Settings({ notify }: { notify: (value: string) => void }) { const enabled = connector?.enabled ?? false; const pairing = connector?.state === "pairing"; + const googleReauth = connector?.paired && connector.pairingMethod === "google" && + (connector.state === "reauth_required" || connector.state === "reauthenticating"); + const displayState = connector?.state?.replaceAll("_", " ") ?? "loading"; + return <>
-

GOOGLE MESSAGES

{enabled ? connector?.state ?? "loading" : "disabled"}

+

GOOGLE MESSAGES

{enabled ? displayState : "disabled"}

{connector?.lastError || "Read-only SMS connector. Google account + emoji pairing is preferred; QR remains available as a fallback."}

@@ -175,6 +201,25 @@ export function Settings({ notify }: { notify: (value: string) => void }) { } + {googleReauth &&
+

Refresh Google login

+

The phone pairing and encryption keys are still saved. Only the Google browser login expired, so no emoji or new device pairing is required.

+

Open Google Messages Web with {connector?.accountEmail || "the already paired Google account"}, then DevTools → Network → reload → configCopy as cURL. Paste the fresh request below.

+

Open Google Messages account/config ↗

+