diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 9dcc2a9..a39c140 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -229,7 +229,7 @@ Responsibilities: - process old/catch-up events through the same idempotent ingestion path; - expose operator pairing/reconnect/unpair controls. -Pairing refuses to replace an already-valid session. The operator must unpair first. +Google-account/emoji (Gaia) pairing is the primary flow. The operator supplies the same cookie material required by upstream mautrix-gmessages (cookie JSON, Cookie header, or DevTools Copy-as-cURL); values are not logged or echoed. QR pairing remains a fallback. Pairing refuses to replace an already-valid session or another in-progress pairing; the operator must cancel/unpair first. The connector is optional. Payment/SMS records remain valid if Google's private protocol changes. @@ -247,8 +247,11 @@ GET /api/paygate/health GET /api/config # authenticated operator GET /api/dashboard # authenticated operator GET /api/connector/gmessages/status # authenticated operator -POST /api/connector/gmessages/pair -POST /api/connector/gmessages/pair/refresh +POST /api/connector/gmessages/pair/google +POST /api/connector/gmessages/pair/qr +POST /api/connector/gmessages/pair/qr/refresh +POST /api/connector/gmessages/pair # backward-compatible QR alias +POST /api/connector/gmessages/pair/refresh # backward-compatible QR refresh alias POST /api/connector/gmessages/reconnect DELETE /api/connector/gmessages/pair ``` diff --git a/IMPLEMENTATION_SPEC.md b/IMPLEMENTATION_SPEC.md index d8ab596..b40f071 100644 --- a/IMPLEMENTATION_SPEC.md +++ b/IMPLEMENTATION_SPEC.md @@ -67,7 +67,7 @@ Use non-empty uniqueness for RRN, idempotency key and `(source, source_event_id) - `GET /api/paygate/health`: PayGate DB/readiness and redacted connector summary. - `GET /api/config`: authenticated safe configuration/connector status. - `GET /api/dashboard`: authenticated summary. -- connector status/pair/refresh/reconnect/unpair routes are authenticated. +- connector status, Google-account/emoji pair, QR fallback/refresh, reconnect and unpair routes are dashboard-authenticated. The SPA must never swallow unknown `/api/*` or `/_/*` routes. @@ -101,14 +101,16 @@ UI pages: Login, Dashboard, Payments/create/details, SMS Events, Webhook Deliver libgm is optional infrastructure, not the payment model. - Persist AuthData under `pb_data/gmessages/session.json` with restrictive permissions. -- Console/API QR pairing refreshes short-lived QR tokens. -- Refuse accidental replacement of an existing valid pairing; unpair first. +- Prefer Google-account/Gaia pairing: validate the upstream-required Google cookie set, fetch config, display the derived emoji, wait for phone confirmation, then persist the completed session. +- Cookie input may be cookie JSON, a raw Cookie header, or a DevTools Copy-as-cURL request; values must never be logged or echoed. +- Keep QR pairing as a fallback and refresh short-lived QR tokens. +- Refuse accidental replacement of an existing valid pairing or another pairing already in progress; cancel/unpair first. - When enabled/paired, connect on serve, persist token refreshes, process incoming text `WrappedMessage` events into the same SMS service, and reconnect/back off on failure. - Keep provider message ID and original timestamp, including catch-up/old events. - Report paired/connected/phone-responsive/timestamp/error state to authenticated operator views. - Application remains healthy when unpaired/offline. - Connector is read-only; do not send SMS/RCS. -- Real phone QR scanning is intentionally deferred until the operator performs the device test. +- Real phone Google-account/emoji pairing and reconnect persistence remain a device acceptance test; QR is fallback only. ## Expiry/background work diff --git a/PLAN.md b/PLAN.md index a371626..d45e832 100644 --- a/PLAN.md +++ b/PLAN.md @@ -58,10 +58,11 @@ This document records what was implemented and what must be proven before/after - [x] Privacy-prefilter bank-credit-like messages before ingestion. - [x] Handle connected/degraded/phone-response events. - [x] Back off reconnect attempts. -- [x] Add QR pairing command/API. -- [x] Auto-refresh short-lived QR data. -- [x] Refuse accidental re-pair over an existing session. -- [ ] Scan/complete a real phone QR pairing — intentionally deferred by operator request. +- [x] Add Google-account/Gaia emoji pairing API and UI. +- [x] Accept/validate upstream-required cookie JSON, raw Cookie headers and DevTools Copy-as-cURL input without logging values. +- [x] Keep QR pairing as a fallback and auto-refresh short-lived QR data. +- [x] Refuse accidental re-pair over an existing session or another pairing in progress. +- [ ] Complete a real phone Google-account/emoji pairing and verify reconnect/session persistence. ### API/security @@ -100,7 +101,7 @@ This document records what was implemented and what must be proven before/after - [x] SMS evidence view. - [x] Outgoing webhook-delivery view. - [x] Connector health/settings. -- [x] QR rendering/refresh. +- [x] Google-account/emoji pairing UI plus QR fallback rendering/refresh. - [x] Periodic auth refresh and 401 sign-out. - [x] UI create retries preserve idempotency key. @@ -163,7 +164,7 @@ Before replacing `main`, use a **new temporary Docker volume** with the final im 13. database survives container stop/removal/recreation on the same volume; 14. Docker health transitions to healthy after recreation. -QR scanning is excluded from this acceptance pass by explicit operator request. +Live Google Messages device pairing is excluded from the generic container acceptance pass and is validated separately with the real phone. ## Production cutover checklist @@ -182,7 +183,7 @@ QR scanning is excluded from this acceptance pass by explicit operator request. - [ ] Generate a new strong `SMS_WEBHOOK_SECRET`. - [ ] Keep the old `WEBHOOK_SECRET` only if the current Android relay must survive the first cutover. - [ ] Set `LEGACY_SMS_WEBHOOK_ENABLED=true` only for that transition. -- [ ] Keep Google Messages disabled until real QR testing if not paired yet. +- [x] Enable Google Messages only after production core cutover; live account/emoji pairing remains pending. - [ ] Confirm no secret is printed into logs/history during the change. ### Branch/cutover @@ -204,7 +205,7 @@ Once the new Android endpoint or Google Messages path is confirmed: - rotate/update the Android relay to `/api/events/sms` with `SMS_WEBHOOK_SECRET` and timestamps/provider IDs; - set `LEGACY_SMS_WEBHOOK_ENABLED=false`; - remove the old weak `WEBHOOK_SECRET` from Dokploy; -- pair/test Google Messages with the real phone; +- pair/test Google Messages with the real phone using Google-account/emoji pairing (QR only as fallback); - measure ingestion/matching latency and missed-event rate before treating libgm as the primary source. ## Definition of v1 done diff --git a/README.md b/README.md index 5bf164e..51a68e5 100644 --- a/README.md +++ b/README.md @@ -200,7 +200,7 @@ The React UI at `/` provides: - cancellation; - SMS evidence records; - outgoing webhook delivery records; -- Google Messages connector status and QR pairing controls; +- Google Messages connector status, Google-account/emoji pairing and QR fallback controls; - safe non-secret configuration status. Operator accounts use the PocketBase `users` auth collection. Domain collections are read-only through PocketBase APIs for authenticated `users`; state-changing payment operations go through custom Go handlers. Direct domain writes are locked. @@ -226,11 +226,15 @@ The connector: - records the Google message ID and original timestamp; - reconnects/backoffs on connection failures; - reports paired/connected/phone-responsive state; -- supports QR pairing and automatic QR refresh. +- supports the current Google-account + emoji (Gaia) pairing flow; +- accepts the upstream-required browser cookie set as cookie JSON, a raw Cookie header, or a DevTools Copy-as-cURL request; +- keeps QR pairing as a fallback and automatically refreshes short-lived QR data. -Starting a new pairing is refused while a valid session is already paired; explicitly unpair first. +Google-account pairing is the primary path. Browser cookie input is never logged or echoed; it remains transient until pairing succeeds, after which libgm's AuthData (including the cookies required for account reauthentication) is stored in `pb_data/gmessages/session.json` with restrictive permissions. -**Live phone QR scanning is the one intentionally deferred acceptance test.** The connector code is integrated and unit-tested, but the private Google Messages protocol can change and must be validated with the actual phone before relying on it as the only ingestion source. +Starting a new pairing is refused while another pairing is active or a valid session is already paired; explicitly cancel/unpair first. + +**Live phone Google-account/emoji pairing is the remaining connector acceptance test.** The connector code is integrated and unit-tested, but the private Google Messages protocol can change and must be validated with the actual phone before relying on it as the only ingestion source. ## Configuration diff --git a/RESEARCH.md b/RESEARCH.md index 09ba6ac..2bd34bd 100644 --- a/RESEARCH.md +++ b/RESEARCH.md @@ -148,7 +148,7 @@ This is why: - an old event cannot confirm a payment created later; - connector health is visible to the operator. -Real p50/p95/p99 latency must be measured with the actual bank/phone/network after live QR pairing. +Real p50/p95/p99 latency must be measured with the actual bank/phone/network after live Google-account/emoji pairing (or QR fallback if required). ## 8. Why browser automation was rejected diff --git a/internal/api/api.go b/internal/api/api.go index 5f57988..e1a4c20 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -24,6 +24,7 @@ import ( const ( maxPaymentRequestBytes int64 = (1 << 20) + (64 << 10) maxSMSRequestBytes int64 = 128 << 10 + maxGMessagesPairBytes int64 = 128 << 10 ) type API struct { @@ -48,6 +49,10 @@ func (a *API) Register(app core.App) { e.Router.GET("/api/config", a.getConfig) 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/pair/qr", a.gmessagesPair) + e.Router.POST("/api/connector/gmessages/pair/qr/refresh", a.gmessagesPairRefresh) + // Backward-compatible QR aliases from the first PayGate rebuild. e.Router.POST("/api/connector/gmessages/pair", a.gmessagesPair) e.Router.POST("/api/connector/gmessages/pair/refresh", a.gmessagesPairRefresh) e.Router.POST("/api/connector/gmessages/reconnect", a.gmessagesReconnect) @@ -258,6 +263,32 @@ func (a *API) gmessagesStatus(e *core.RequestEvent) error { return e.JSON(http.StatusOK, a.connectorStatus()) } +type googleMessagesPairBody struct { + CookieData string `json:"cookieData"` +} + +func (a *API) gmessagesGooglePair(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) + } + emoji, accountEmail, err := a.GMessages.BeginGooglePair(strings.TrimSpace(body.CookieData)) + if err != nil { + return e.BadRequestError(err.Error(), nil) + } + return e.JSON(http.StatusOK, map[string]any{ + "emoji": emoji, + "accountEmail": accountEmail, + "status": 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/api_test.go b/internal/api/api_test.go index 4595f17..cf46d3e 100644 --- a/internal/api/api_test.go +++ b/internal/api/api_test.go @@ -4,17 +4,20 @@ import ( "io" "net/http" "net/http/httptest" + "path/filepath" "strings" "testing" "time" "github.com/Phloraxx/payment-api/internal/config" + "github.com/Phloraxx/payment-api/internal/gmessages" "github.com/Phloraxx/payment-api/internal/payments" "github.com/Phloraxx/payment-api/internal/sms" _ "github.com/Phloraxx/payment-api/migrations" "github.com/pocketbase/pocketbase/apis" "github.com/pocketbase/pocketbase/core" "github.com/pocketbase/pocketbase/tests" + "github.com/rs/zerolog" ) func apiTestFactory(t testing.TB, before func(*tests.TestApp, *payments.Service)) *tests.TestApp { @@ -44,6 +47,24 @@ func apiTestFactoryWithConfig(t testing.TB, configure func(*config.Config), befo return app } +func apiTestFactoryWithGMessages(t testing.TB) *tests.TestApp { + app, err := tests.NewTestApp() + if err != nil { + t.Fatalf("create PocketBase test app: %v", err) + } + cfg := config.Config{ + UPIID: "operator@bank", UPIPayeeName: "PayGate", + APIKey: "api-secret", SMSWebhookSecret: "sms-secret", + PaymentTTL: 5 * time.Minute, AmountQuarantine: 24 * time.Hour, + GMessagesEnabled: true, GMessagesSessionPath: filepath.Join(t.TempDir(), "session.json"), + } + paymentService := payments.NewService(app, cfg, nil) + smsService := sms.NewService(app, paymentService) + manager := gmessages.NewManager(cfg, zerolog.Nop(), nil) + New(cfg, paymentService, smsService, manager).Register(app) + return app +} + func TestPaymentAPIAuthenticationAndAmountValidation(t *testing.T) { scenarios := []tests.ApiScenario{ { @@ -174,6 +195,28 @@ func TestPublicPaymentStatusRedactsSensitiveEvidence(t *testing.T) { scenario.Test(t) } +func TestGoogleMessagesAccountPairEndpointValidation(t *testing.T) { + scenarios := []tests.ApiScenario{ + { + Name: "Google pair requires dashboard auth", Method: http.MethodPost, URL: "/api/connector/gmessages/pair/google", + 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 control Google pairing", Method: http.MethodPost, URL: "/api/connector/gmessages/pair/google", + Headers: map[string]string{"Authorization": "Bearer api-secret"}, + 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."}, + }, + } + // API keys intentionally do not grant dashboard-only connector access. + for i := range scenarios { + scenarios[i].Test(t) + } +} + func TestLegacySMSWebhookMatchesPayment(t *testing.T) { const paymentID = "smstest00000001" scenario := tests.ApiScenario{ diff --git a/internal/gmessages/cookies.go b/internal/gmessages/cookies.go new file mode 100644 index 0000000..a05dbcc --- /dev/null +++ b/internal/gmessages/cookies.go @@ -0,0 +1,102 @@ +package gmessages + +import ( + "encoding/json" + "errors" + "fmt" + "regexp" + "sort" + "strings" +) + +var requiredGoogleCookies = []string{"SID", "HSID", "OSID", "SSID", "APISID", "SAPISID"} +var allowedGoogleCookies = map[string]struct{}{ + "SID": {}, "HSID": {}, "OSID": {}, "SSID": {}, "APISID": {}, "SAPISID": {}, "__Secure-1PSIDTS": {}, +} + +var ( + curlHeaderCookieRE = regexp.MustCompile(`(?is)(?:-H|--header)\s+(?:'([^']*cookie\s*:[^']*)'|"([^"]*cookie\s*:[^"]*)")`) + curlCookieRE = regexp.MustCompile(`(?is)(?:-b|--cookie)\s+(?:'([^']*)'|"([^"]*)")`) +) + +func parseGoogleCookieInput(input string) (map[string]string, error) { + input = strings.TrimSpace(input) + if input == "" { + return nil, errors.New("google cookie data is required") + } + + var cookies map[string]string + if strings.HasPrefix(input, "{") { + if err := json.Unmarshal([]byte(input), &cookies); err != nil { + return nil, errors.New("cookie JSON is invalid") + } + } else { + header := extractCookieHeader(input) + cookies = parseCookieHeader(header) + } + if err := validateGoogleCookies(cookies); err != nil { + return nil, err + } + filtered := make(map[string]string, len(allowedGoogleCookies)) + for name := range allowedGoogleCookies { + if value := cookies[name]; value != "" { + filtered[name] = value + } + } + return filtered, nil +} + +func extractCookieHeader(input string) string { + if match := curlHeaderCookieRE.FindStringSubmatch(input); len(match) > 0 { + header := firstNonEmpty(match[1], match[2]) + if idx := strings.Index(strings.ToLower(header), "cookie:"); idx >= 0 { + return strings.TrimSpace(header[idx+len("cookie:"):]) + } + } + if match := curlCookieRE.FindStringSubmatch(input); len(match) > 0 { + return strings.TrimSpace(firstNonEmpty(match[1], match[2])) + } + if idx := strings.Index(strings.ToLower(input), "cookie:"); idx >= 0 { + line := input[idx+len("cookie:"):] + if end := strings.IndexAny(line, "\r\n"); end >= 0 { + line = line[:end] + } + return strings.Trim(strings.TrimSpace(line), "'\"") + } + return input +} + +func parseCookieHeader(header string) map[string]string { + cookies := make(map[string]string) + for _, part := range strings.Split(header, ";") { + name, value, ok := strings.Cut(strings.TrimSpace(part), "=") + if !ok || name == "" { + continue + } + cookies[name] = strings.TrimSpace(value) + } + return cookies +} + +func validateGoogleCookies(cookies map[string]string) error { + var missing []string + for _, name := range requiredGoogleCookies { + if strings.TrimSpace(cookies[name]) == "" { + missing = append(missing, name) + } + } + if len(missing) > 0 { + sort.Strings(missing) + return fmt.Errorf("missing required Google cookies: %s", strings.Join(missing, ", ")) + } + return nil +} + +func firstNonEmpty(values ...string) string { + for _, value := range values { + if value != "" { + return value + } + } + return "" +} diff --git a/internal/gmessages/cookies_test.go b/internal/gmessages/cookies_test.go new file mode 100644 index 0000000..661aecd --- /dev/null +++ b/internal/gmessages/cookies_test.go @@ -0,0 +1,35 @@ +package gmessages + +import "testing" + +const testCookieHeader = "SID=sid-value; HSID=hsid-value; OSID=osid-value; SSID=ssid-value; APISID=apisid-value; SAPISID=sapisid-value; __Secure-1PSIDTS=optional" + +func TestParseGoogleCookieInputFormats(t *testing.T) { + cases := map[string]string{ + "raw header": testCookieHeader, + "cookie header": "Cookie: " + testCookieHeader, + "curl header": "curl 'https://messages.google.com/web/config' -H 'accept: */*' -H 'cookie: " + testCookieHeader + "'", + "curl cookie flag": "curl 'https://messages.google.com/web/config' --cookie '" + testCookieHeader + "'", + "json": `{"SID":"sid-value","HSID":"hsid-value","OSID":"osid-value","SSID":"ssid-value","APISID":"apisid-value","SAPISID":"sapisid-value"}`, + } + for name, input := range cases { + t.Run(name, func(t *testing.T) { + cookies, err := parseGoogleCookieInput(input) + if err != nil { + t.Fatal(err) + } + if got := cookies["SAPISID"]; got != "sapisid-value" { + t.Fatalf("SAPISID = %q", got) + } + if _, exists := cookies["NID"]; exists { + t.Fatal("unrelated Google cookie was retained") + } + }) + } +} + +func TestParseGoogleCookieInputRejectsMissingRequired(t *testing.T) { + if _, err := parseGoogleCookieInput("SID=only-one-cookie"); err == nil { + t.Fatal("incomplete cookie set was accepted") + } +} diff --git a/internal/gmessages/manager.go b/internal/gmessages/manager.go index 0757922..b906680 100644 --- a/internal/gmessages/manager.go +++ b/internal/gmessages/manager.go @@ -26,6 +26,9 @@ type Status struct { Paired bool `json:"paired"` Connected bool `json:"connected"` PhoneResponsive bool `json:"phoneResponsive"` + PairingMethod string `json:"pairingMethod,omitempty"` + PairingEmoji string `json:"pairingEmoji,omitempty"` + AccountEmail string `json:"accountEmail,omitempty"` LastConnectedAt *time.Time `json:"lastConnectedAt,omitempty"` LastMessageAt *time.Time `json:"lastMessageAt,omitempty"` LastError string `json:"lastError,omitempty"` @@ -45,6 +48,7 @@ type Manager struct { ctx context.Context cancel context.CancelFunc reconnecting bool + pairCancel context.CancelFunc } func NewManager(cfg config.Config, logger zerolog.Logger, ingest IngestFunc) *Manager { @@ -65,7 +69,13 @@ func NewManager(cfg config.Config, logger zerolog.Logger, ingest IngestFunc) *Ma return m } m.session = session - m.status = Status{Enabled: true, State: "disconnected", Paired: true} + m.status = Status{ + Enabled: true, + State: "disconnected", + Paired: true, + PairingMethod: sessionPairingMethod(session), + AccountEmail: sessionAccountEmail(session), + } return m } @@ -90,8 +100,10 @@ func (m *Manager) Start(parent context.Context) { func (m *Manager) Stop() { m.mu.Lock() cancel := m.cancel + pairCancel := m.pairCancel client := m.client m.cancel = nil + m.pairCancel = nil m.ctx = nil m.client = nil m.status.Connected = false @@ -102,6 +114,9 @@ func (m *Manager) Stop() { if cancel != nil { cancel() } + if pairCancel != nil { + pairCancel() + } if client != nil { client.Disconnect() } @@ -180,6 +195,9 @@ func (m *Manager) handleEvent(raw any) { m.status.State = "connected" m.status.Connected = true m.status.PhoneResponsive = true + m.status.PairingEmoji = "" + m.status.PairingMethod = sessionPairingMethod(m.session) + m.status.AccountEmail = sessionAccountEmail(m.session) m.mu.Unlock() if err := m.saveCurrentSession(); err != nil { m.logger.Error().Err(err).Msg("failed to persist Google Messages session") @@ -345,22 +363,157 @@ func (m *Manager) Reconnect() error { return m.saveCurrentSession() } +func (m *Manager) BeginGooglePair(cookieInput string) (string, 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() + if validSession(m.session) { + m.mu.Unlock() + return "", "", errors.New("google messages is already paired; unpair it before starting a new pairing") + } + if m.status.State == "pairing" { + m.mu.Unlock() + return "", "", errors.New("google messages pairing is already in progress") + } + oldClient := m.client + oldPairCancel := m.pairCancel + session := libgm.NewAuthData() + session.SetCookies(cookies) + client := m.newClient(session) + parent := m.ctx + if parent == nil { + parent = context.Background() + } + pairCtx, pairCancel := context.WithTimeout(parent, 2*time.Minute) + m.session = session + m.client = client + m.pairCancel = pairCancel + m.status = Status{Enabled: true, State: "pairing", PairingMethod: "google"} + m.mu.Unlock() + + if oldPairCancel != nil { + oldPairCancel() + } + if oldClient != nil { + oldClient.Disconnect() + } + + if err := client.FetchConfig(pairCtx); err != nil { + pairCancel() + m.failPairing(client, fmt.Errorf("google account authentication failed: %w", err)) + return "", "", errors.New("google account authentication failed; refresh the browser cookies and try again") + } + accountEmail := strings.TrimSpace(client.Config.GetDeviceInfo().GetEmail()) + + emojiCh := make(chan string, 1) + resultCh := make(chan error, 1) + go func() { + resultCh <- client.DoGaiaPairing(pairCtx, func(emoji string) { + emojiCh <- emoji + }) + }() + + select { + case emoji := <-emojiCh: + m.mu.Lock() + if m.client == client { + m.status.PairingEmoji = emoji + m.status.AccountEmail = accountEmail + } + m.mu.Unlock() + go m.awaitGooglePairResult(client, pairCancel, resultCh) + return emoji, accountEmail, nil + case err := <-resultCh: + pairCancel() + m.failPairing(client, googlePairingError(err)) + return "", accountEmail, googlePairingError(err) + case <-pairCtx.Done(): + err := googlePairingError(pairCtx.Err()) + m.failPairing(client, err) + return "", accountEmail, err + } +} + +func (m *Manager) awaitGooglePairResult(client *libgm.Client, cancel context.CancelFunc, resultCh <-chan error) { + err := <-resultCh + cancel() + if err != nil { + m.failPairing(client, googlePairingError(err)) + return + } + m.mu.Lock() + if m.client == client { + m.pairCancel = nil + m.status.PairingEmoji = "" + } + m.mu.Unlock() +} + +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 { + return + } + if m.pairCancel != nil { + m.pairCancel() + } + m.pairCancel = nil + m.client = nil + m.session = nil + m.status = Status{Enabled: m.cfg.GMessagesEnabled, State: "unpaired"} + if err != nil { + m.status.LastError = err.Error() + } +} + +func googlePairingError(err error) error { + switch { + case err == nil: + return nil + case errors.Is(err, libgm.ErrNoDevicesFound): + return errors.New("no Google Messages phone was found for this account; enable account pairing on the phone and keep Messages online") + case errors.Is(err, libgm.ErrPairingInitTimeout): + return errors.New("the phone did not respond to the pairing request; keep Google Messages open and disable battery optimisation temporarily") + case errors.Is(err, libgm.ErrIncorrectEmoji): + return errors.New("the wrong emoji was selected on the phone") + case errors.Is(err, libgm.ErrPairingCancelled): + return errors.New("pairing was cancelled on the phone") + case errors.Is(err, libgm.ErrPairingTimeout), errors.Is(err, context.DeadlineExceeded): + return errors.New("google messages pairing timed out") + default: + return errors.New("google messages pairing failed; check the connector logs and try again") + } +} + +// BeginPair starts the legacy QR flow. Google-account/emoji pairing is preferred. func (m *Manager) BeginPair() (string, error) { if !m.cfg.GMessagesEnabled { return "", errors.New("google messages connector is disabled") } - m.mu.RLock() - alreadyPaired := validSession(m.session) - m.mu.RUnlock() - if alreadyPaired { + m.mu.Lock() + if validSession(m.session) { + m.mu.Unlock() return "", errors.New("google messages is already paired; unpair it before starting a new pairing") } - m.mu.Lock() + if m.status.State == "pairing" { + m.mu.Unlock() + return "", errors.New("google messages pairing is already in progress") + } oldClient := m.client m.session = libgm.NewAuthData() m.client = m.newClient(m.session) client := m.client - m.status = Status{Enabled: true, State: "pairing"} + m.status = Status{Enabled: true, State: "pairing", PairingMethod: "qr"} m.mu.Unlock() if oldClient != nil { oldClient.Disconnect() @@ -377,9 +530,10 @@ func (m *Manager) RefreshPair() (string, error) { m.mu.RLock() client := m.client state := m.status.State + method := m.status.PairingMethod m.mu.RUnlock() - if client == nil || state != "pairing" { - return "", errors.New("pairing has not started") + if client == nil || state != "pairing" || method != "qr" { + return "", errors.New("QR pairing has not started") } return client.RefreshPhoneRelay() } @@ -387,13 +541,21 @@ func (m *Manager) RefreshPair() (string, error) { func (m *Manager) Unpair() error { m.mu.Lock() client := m.client + pairCancel := m.pairCancel + wasPaired := validSession(m.session) m.client = nil + m.pairCancel = nil m.session = nil m.status = Status{Enabled: m.cfg.GMessagesEnabled, State: "unpaired"} m.mu.Unlock() + if pairCancel != nil { + pairCancel() + } if client != nil { - if _, err := client.UnpairBugle(); err != nil { - m.logger.Warn().Err(err).Msg("remote Google Messages unpair failed; deleting local session anyway") + if wasPaired { + if _, err := client.UnpairBugle(); err != nil { + m.logger.Warn().Err(err).Msg("remote Google Messages unpair failed; deleting local session anyway") + } } client.Disconnect() } @@ -418,10 +580,15 @@ func (m *Manager) markConnected() { func (m *Manager) markLoggedOut() { m.mu.Lock() client := m.client + pairCancel := m.pairCancel m.client = nil + m.pairCancel = nil m.session = nil m.status = Status{Enabled: m.cfg.GMessagesEnabled, State: "unpaired", LastError: "Google Messages session was logged out"} m.mu.Unlock() + if pairCancel != nil { + pairCancel() + } if client != nil { client.Disconnect() } @@ -451,6 +618,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 && @@ -463,6 +631,32 @@ func validSession(session *libgm.AuthData) bool { len(session.TachyonAuthToken) > 0 } +func googleSessionHasRequiredCookies(session *libgm.AuthData) bool { + if session == nil || !session.IsGoogleAccount() { + return true + } + session.CookiesLock.RLock() + defer session.CookiesLock.RUnlock() + return validateGoogleCookies(session.Cookies) == nil +} + +func sessionPairingMethod(session *libgm.AuthData) string { + if session == nil { + return "" + } + if session.IsGoogleAccount() { + return "google" + } + return "qr" +} + +func sessionAccountEmail(session *libgm.AuthData) string { + if session == nil || !session.IsGoogleAccount() || session.Mobile == nil { + return "" + } + return strings.TrimSpace(session.Mobile.GetSourceID()) +} + func loadSession(path string) (*libgm.AuthData, error) { file, err := os.Open(path) if errors.Is(err, os.ErrNotExist) { diff --git a/internal/gmessages/manager_test.go b/internal/gmessages/manager_test.go index 00440a1..aa0e673 100644 --- a/internal/gmessages/manager_test.go +++ b/internal/gmessages/manager_test.go @@ -6,6 +6,8 @@ import ( "strings" "testing" + "github.com/google/uuid" + "github.com/Phloraxx/payment-api/internal/config" "go.mau.fi/mautrix-gmessages/pkg/libgm" @@ -86,6 +88,38 @@ func TestBeginPairRefusesToReplaceExistingSession(t *testing.T) { } } +func TestGoogleSessionRequiresCookies(t *testing.T) { + session := libgm.NewAuthData() + session.Browser = &gmproto.Device{} + session.Mobile = &gmproto.Device{SourceID: "user@example.com"} + session.TachyonAuthToken = []byte{1} + session.DestRegID = uuid.MustParse("00000000-0000-0000-0000-000000000001") + if validSession(session) { + t.Fatal("Google-account session without cookies was accepted") + } + cookies, err := parseGoogleCookieInput(testCookieHeader) + if err != nil { + t.Fatal(err) + } + session.SetCookies(cookies) + if !validSession(session) { + t.Fatal("complete Google-account session with required cookies was rejected") + } + if got := sessionPairingMethod(session); got != "google" { + t.Fatalf("pairing method = %q", got) + } +} + +func TestQRPairRefusesDuringGooglePairing(t *testing.T) { + manager := &Manager{ + cfg: config.Config{GMessagesEnabled: true}, + status: Status{Enabled: true, State: "pairing", PairingMethod: "google"}, + } + if _, err := manager.BeginPair(); err == nil || !strings.Contains(err.Error(), "already in progress") { + t.Fatalf("BeginPair() error = %v; want in-progress guard", err) + } +} + func TestValidSessionRejectsPartialCredentialState(t *testing.T) { partial := &libgm.AuthData{Browser: &gmproto.Device{}, TachyonAuthToken: []byte{1}} if validSession(partial) { diff --git a/web/src/pages/Settings.tsx b/web/src/pages/Settings.tsx index 3098571..2375981 100644 --- a/web/src/pages/Settings.tsx +++ b/web/src/pages/Settings.tsx @@ -15,11 +15,17 @@ type SafeConfig = { connector: Connector; }; -type PairResponse = { qrUrl: string; status: Connector }; +type QRPairResponse = { qrUrl: string; status: Connector }; +type GooglePairResponse = { emoji: string; accountEmail: string; status: Connector }; + +const googleMessagesConfigURL = "https://accounts.google.com/AccountChooser?continue=https://messages.google.com/web/config"; export function Settings({ notify }: { notify: (value: string) => void }) { const [config, setConfig] = useState(null); const [connector, setConnector] = useState(null); + const [cookieData, setCookieData] = useState(""); + const [pairingEmoji, setPairingEmoji] = useState(""); + const [pairingAccount, setPairingAccount] = useState(""); const [qrUrl, setQrUrl] = useState(""); const [qrImage, setQrImage] = useState(""); const [busy, setBusy] = useState(false); @@ -32,7 +38,13 @@ export function Settings({ notify }: { notify: (value: string) => void }) { ]); setConfig(cfg); setConnector(status); - if (status.state !== "pairing" && status.paired) { + if (status.pairingMethod === "google" && status.state === "pairing") { + setPairingEmoji(status.pairingEmoji ?? ""); + setPairingAccount(status.accountEmail ?? ""); + } + if (status.paired) { + setCookieData(""); + setPairingEmoji(""); setQrUrl(""); setQrImage(""); } @@ -43,7 +55,7 @@ export function Settings({ notify }: { notify: (value: string) => void }) { useEffect(() => { void refresh(); - const timer = window.setInterval(() => void refresh(), 10_000); + const timer = window.setInterval(() => void refresh(), 3_000); return () => window.clearInterval(timer); }, [refresh]); @@ -53,10 +65,10 @@ export function Settings({ notify }: { notify: (value: string) => void }) { }, [qrUrl]); useEffect(() => { - if (!qrUrl || connector?.state !== "pairing") return; + if (!qrUrl || connector?.state !== "pairing" || connector.pairingMethod !== "qr") return; const timer = window.setInterval(async () => { try { - const result = await api("/api/connector/gmessages/pair/refresh", { method: "POST" }); + const result = await api("/api/connector/gmessages/pair/qr/refresh", { method: "POST" }); setQrUrl(result.qrUrl); setConnector(result.status); } catch (err) { @@ -64,17 +76,40 @@ export function Settings({ notify }: { notify: (value: string) => void }) { } }, 20_000); return () => window.clearInterval(timer); - }, [qrUrl, connector?.state, notify]); + }, [qrUrl, connector?.state, connector?.pairingMethod, notify]); + + async function startGooglePairing() { + if (!cookieData.trim()) { + notify("Paste the Google Messages cookie data or Copy-as-cURL request first."); + return; + } + setBusy(true); + try { + const result = await api("/api/connector/gmessages/pair/google", { + method: "POST", + body: JSON.stringify({ cookieData }), + }); + setCookieData(""); + setPairingEmoji(result.emoji); + setPairingAccount(result.accountEmail); + setConnector(result.status); + notify("Pairing request sent. Tap the matching emoji in Google Messages on your phone."); + } catch (err) { + notify(err instanceof Error ? err.message : "Google account pairing could not start."); + } finally { + setBusy(false); + } + } - async function startPairing() { + async function startQRPairing() { setBusy(true); try { - const result = await api("/api/connector/gmessages/pair", { method: "POST" }); + const result = await api("/api/connector/gmessages/pair/qr", { method: "POST" }); setQrUrl(result.qrUrl); setConnector(result.status); - notify("Pairing started. Scan the QR from Google Messages."); + notify("QR fallback pairing started."); } catch (err) { - notify(err instanceof Error ? err.message : "Pairing could not start."); + notify(err instanceof Error ? err.message : "QR pairing could not start."); } finally { setBusy(false); } @@ -91,37 +126,69 @@ export function Settings({ notify }: { notify: (value: string) => void }) { } async function unpair() { - if (!window.confirm("Remove the stored Google Messages pairing from PayGate?")) return; + const pairing = connector?.state === "pairing"; + if (!window.confirm(pairing ? "Cancel the current Google Messages pairing?" : "Remove the stored Google Messages pairing from PayGate?")) return; setBusy(true); try { setConnector(await api("/api/connector/gmessages/pair", { method: "DELETE" })); - setQrUrl(""); setQrImage(""); - notify("Google Messages unpaired."); + setCookieData(""); setPairingEmoji(""); setPairingAccount(""); setQrUrl(""); setQrImage(""); + notify(pairing ? "Pairing cancelled." : "Google Messages unpaired."); } catch (err) { notify(err instanceof Error ? err.message : "Unpair failed."); } finally { setBusy(false); } } const enabled = connector?.enabled ?? false; + const pairing = connector?.state === "pairing"; return <>

GOOGLE MESSAGES

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

-

{connector?.lastError || "Read-only SMS connector. The authenticated Android SMS webhook remains available as fallback."}

+

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

Paired
{connector?.paired ? "Yes" : "No"}
+
Pairing method
{connector?.pairingMethod || "—"}
+
Google account
{connector?.accountEmail || pairingAccount || "—"}
Phone responsive
{connector?.phoneResponsive ? "Yes" : "No / unknown"}
Last connected
{formatDate(connector?.lastConnectedAt)}
Last bank SMS
{formatDate(connector?.lastMessageAt)}
+ + {enabled && !connector?.paired && !pairing &&
+

Google account pairing

+

Open Google Messages Web in the same Google account, then in browser DevTools → Network reload the page, select the config request, and use Copy as cURL. Paste it below. A raw Cookie header or JSON cookie object also works.

+

Open Google Messages account/config ↗

+