JourneyBay

Двойная авторизация: Appwrite JWT → Supabase JWT через Edge Function

Что такое двойная аутентификация (dual-auth)?

Двойная аутентификация (dual-auth) — это архитектурный паттерн, при котором приложение одновременно использует двух независимых провайдеров идентификации: как правило, региональный для соблюдения требований data residency и глобальный для прикладных данных. Серверный мост конвертирует токены между системами, позволяя клиенту пройти аутентификацию один раз и получить доступ к обоим бэкендам.

TL;DR

  • -Требования data residency заставляют держать персональные данные на региональном сервере, прикладные — в глобальном облаке
  • -Appwrite JWT (15 мин) конвертируется в Supabase JWT (1 час) через серверный Edge Function — клиент не знает секрета
  • -AuthBridge-синглтон синхронизирует 6 подсистем одним событием; Completer-lock исключает дублирующие refresh-запросы
  • -Cold start занимает 1–2 секунды; основная задержка — прогрев Deno-изолята
  • -SyncRecovery обрабатывает 3 уровня сбоев: retry сети, частичная ресинхронизация, полная ре-авторизация

Один auth-провайдер в приложении — стандарт. Два — необходимость, когда персональные данные пользователей должны оставаться на территории одной страны, а приложение работает глобально.

В JourneyBay Appwrite отвечает за логин и регистрацию на региональном сервере. Supabase — за данные, real-time подписки и серверную логику в глобальном облаке. Между ними — Edge Function, которая конвертирует один JWT в другой. На клиенте — AuthBridge, синглтон, который одним событием синхронизирует шесть подсистем.

Расскажу, как это устроено, зачем пришлось строить и чего это стоит.

Зачем два провайдера вместо одного

Причина — data residency. В России (и ряде других стран) закон требует хранить персональные данные пользователей на серверах внутри страны. Учётные записи, пароли, email, данные сессий — всё это должно лежать на локальном сервере. При этом приложение работает глобально: маршруты, рекомендации, AI-генерация — эти данные не привязаны к конкретной юрисдикции.

Одна платформа не закрывает обе задачи. Нужны две: региональная для персональных данных и глобальная для всего остального.

Appwrite развёрнут на собственном VPS в нужном регионе. Self-hosted, полный контроль над данными. Он хранит учётные записи, управляет сессиями, обрабатывает OAuth-потоки. Персональные данные не покидают территорию страны.

Supabase работает в глобальном облаке. PostgreSQL со всеми возможностями: join’ы, оконные функции, полнотекстовый поиск, JSONB. Плюс Row-Level Security (RLS), которая проверяет права доступа на уровне базы данных — не в коде приложения, а в самом PostgreSQL. И Edge Functions на Deno для серверной логики. Здесь лежат маршруты, POI, чеклисты, AI-генерации — всё, что не является персональными данными.

Можно ли использовать один провайдер для всего? В теории — да. На практике: либо self-hosted база теряет облачные преимущества (managed hosting, CDN, Edge Functions), либо глобальное облако нарушает локальное законодательство. Мост между двумя системами — компромисс, который сохраняет плюсы обеих платформ.

Принцип: персональные данные — на региональном сервере, приложенческие данные — в глобальном облаке, а между ними надёжная связка.

JWT-цепочка: от логина до запроса в базу

Весь dual-auth сводится к одной операции: конвертации токена. Пользователь авторизуется через Appwrite, получает Appwrite JWT. Этот токен обменивается на Supabase JWT через серверную функцию. Supabase JWT используется для всех запросов к данным.

Вот как выглядит поток:

Пользователь

    │  email + password

┌──────────┐
│ Appwrite │ ── создаёт сессию, возвращает Appwrite JWT (TTL 15 мин)
└──────────┘

    │  Appwrite JWT

┌──────────────────┐
│  Edge Function   │ ── проверяет Appwrite JWT → находит/создаёт пользователя
│  (Token Bridge)  │    в связующей таблице → подписывает Supabase JWT (TTL 1 час)
└──────────────────┘

    │  Supabase JWT

┌──────────────┐
│   Supabase   │ ── RLS проверяет auth.uid() из JWT → запрос к данным
│  (PostgREST) │
└──────────────┘

Два токена, два TTL, одна серверная функция между ними. Token Bridge принимает короткоживущий Appwrite-токен и возвращает долгоживущий Supabase-токен.

Почему именно Edge Function

Token Bridge работает как серверная функция на стороне Supabase. Почему не в клиенте?

Потому что подпись JWT требует серверного секрета. Supabase JWT подписывается ключом, который знает только сервер. Клиент физически не может создать валидный Supabase-токен — и не должен. Token Bridge — единственная точка, где Appwrite-аутентификация превращается в Supabase-авторизацию. Скомпрометировать один токен без доступа к серверному секрету недостаточно для доступа к данным другой системы.

Жизненный цикл токенов

Два токена — два TTL:

ТокенTTLОбновление
Appwrite JWT15 минутAppwrite SDK автоматически через сессию
Supabase JWT1 часКлиент обновляет за 5 минут до истечения

Клиент отслеживает время жизни Supabase-токена. За пять минут до истечения запрашивает новый Appwrite JWT, отправляет его в Token Bridge и получает свежий Supabase JWT. Пользователь этого не замечает.

Что происходит на холодном старте: приложение открывается → Appwrite SDK восстанавливает сессию → запрашивается Appwrite JWT → отправляется в Token Bridge → Supabase JWT получен → все запросы к данным начинают работать. Весь процесс занимает 1–2 секунды. Если Token Bridge отвечает медленно (cold start Deno-изолята) — клиент показывает экран загрузки.

Связка пользователей

Token Bridge при первом обращении нового пользователя создаёт запись в связующей таблице. Эта таблица маппит идентификатор пользователя в Appwrite на идентификатор в Supabase. Все RLS-политики в Supabase работают с единым внутренним ID — одинаковым в обеих системах.

При первом логине Token Bridge дополнительно активирует trial-период через серверную SQL-функцию. Пользователь получает доступ к платным функциям без явной регистрации в платёжной системе.

AuthBridge: один listener на шесть систем

Когда состояние авторизации меняется — пользователь залогинился, вышел, токен обновился — это должно отразиться не только в UI. Шесть подсистем должны узнать об этом изменении и среагировать.

AuthBridge — синглтон в DI-контейнере, который подписывается на изменения auth-состояния через Riverpod-провайдер и рассылает обновления всем зависимым системам.

authProvider (Riverpod)

    │  state change

┌──────────────┐
│  AuthBridge  │──► CustomAuthManager (legacy FlutterFlow, хранит токен)
│  (singleton) │──► UserStateProvider  (глобальный user state, userId, isLoggedIn)
│              │──► SupabaseClient     (инициализация JWT, HTTP-клиент)
│              │──► Sentry             (привязка user context для ошибок)
│              │──► PostHog            (identify для аналитики)
│              │──► SyncRecovery       (reset при успехе / enqueue при провале)
└──────────────┘

При логине AuthBridge выполняет шесть операций последовательно:

  1. CustomAuthManager получает токен и идентификатор — это legacy-интеграция с FlutterFlow, которая всё ещё используется частью кодовой базы.
  2. UserStateProvider обновляет глобальное состояние: userId, isLoggedIn, данные профиля. Виджеты, подписанные на этот провайдер, перерисовываются.
  3. SupabaseClientService запускает обмен токенов через Token Bridge. Это самый долгий шаг — сетевой запрос к Edge Function.
  4. Sentry получает userId — теперь все ошибки привязаны к конкретному пользователю.
  5. PostHog вызывает identify — аналитические события привязываются к пользователю.
  6. SyncRecoveryService сбрасывается при успехе. Если обмен токенов на шаге 3 провалился — SyncRecovery ставит восстановление в очередь.

При логауте — обратный процесс. Каждая система очищает своё состояние. Sentry и PostHog сбрасывают user context. SupabaseClient аннулирует токен.

Зачем синглтон, а не отдельные подписки? Потому что порядок важен. Нельзя отправлять аналитику до инициализации Supabase. Нельзя показывать UI залогиненного пользователя до установки UserState. AuthBridge гарантирует последовательность.

Completer-lock: три точки дедупликации

В мобильном приложении одно и то же действие может быть вызвано несколькими источниками одновременно. Приложение открывается — initState и lifecycle-listener одновременно проверяют авторизацию. Два параллельных запроса получают 401 — оба пытаются обновить токен.

Без защиты это приведёт к дублированию сетевых запросов, race condition’ам и непредсказуемому состоянию.

Решение — паттерн Completer-lock. Работает так: первый вызов создаёт Completer и начинает операцию. Все последующие вызовы обнаруживают, что Completer уже существует, и ждут его результат. Когда операция завершается — все ожидающие получают один и тот же результат.

В auth-системе JourneyBay этот паттерн применяется в трёх точках:

1. Проверка авторизации при запуске

Когда приложение открывается, проверка текущей сессии может быть вызвана из нескольких мест: инициализация виджета, lifecycle callback, deeplink handler. Completer гарантирует, что сетевой запрос к Appwrite (getAccount) выполнится только один раз. Остальные вызовы дождутся результата.

2. Обмен токенов через Token Bridge

SupabaseClientService использует отдельный Completer для вызова Token Bridge. Если AuthBridge инициирует обмен токенов, а параллельно приходит API-запрос, который тоже обнаруживает протухший токен — второй вызов не пойдёт в Token Bridge, а дождётся результата первого.

Важная деталь: при логауте текущий Completer принудительно завершается с ошибкой. Это предотвращает ситуацию, когда запрос к Token Bridge завис в сети, пользователь нажал “Выйти”, а через 30 секунд пришёл ответ и перезаписал очищенное состояние.

3. Обработка 401 в рантайме

Когда API-запрос возвращает 401, AuthRetryHandler пытается обновить токен и повторить запрос. Если пять параллельных запросов одновременно получили 401 — только один вызовет обновление токена. Остальные четыре дождутся результата и повторят свои запросы с новым токеном.

Если повторный запрос снова вернул 401 — это permanent failure. AuthRetryHandler вызывает callback, который через AuthBridge инициирует полный логаут. Пользователь видит экран авторизации.

Три Completer’а, три уровня защиты. В тестировании ни один обмен токенов не продублировался.

SyncRecovery: что делать, когда всё сломалось

Обмен токенов — сетевая операция. Сеть ненадёжна. Token Bridge может быть недоступен из-за cold start Deno-изолята, ошибки на сервере или отсутствия интернета у пользователя.

SyncRecoveryService — state machine, которая обрабатывает провал синхронизации с exponential backoff.

                    успешный логин


                    ┌─────────┐
          reset()──►│  idle   │◄──────────── reset() (успешная синхронизация)
                    └─────────┘

                         │ провал обмена токенов

                    ┌──────────────┐
                    │ pendingRetry │──── таймер с exponential backoff
                    └──────────────┘

                         │ таймер сработал

                    ┌──────────┐
                    │ syncing  │──── повторная попытка обмена токенов
                    └──────────┘
                       │     │
                  успех│     │провал
                       │     │
                       ▼     ▼
                    idle   попытка < 5?
                              │     │
                           да │     │ нет
                              ▼     ▼
                        pendingRetry  ┌────────┐
                                      │ failed │
                                      └────────┘

Задержки между попытками растут: 5 секунд, 15, 30, 60, 120. Пять попыток — после чего состояние переходит в failed и пользователь видит баннер “Не удалось синхронизировать. Попробуйте войти заново.”

SyncRecovery транслирует своё состояние через StreamProvider. UI подписывается и показывает:

  • syncing — ненавязчивый индикатор “Синхронизация…”
  • pendingRetry — “Повторная попытка через N сек”
  • failed — баннер с кнопкой “Войти заново”

Отдельная логика для checkPendingSync при возвращении приложения из фона: если синхронизация была в состоянии pendingRetry — сервис сбрасывает счётчик попыток до предпоследнего значения и немедленно делает одну финальную попытку. Смысл: пользователь вернулся в приложение, вероятно сеть восстановилась — стоит попробовать ещё раз.

WidgetRef в DI-синглтоне: нестандартный паттерн

AuthBridge — синглтон в get_it (DI-контейнер). Riverpod-провайдеры живут в widget tree. Это два разных мира: DI-контейнер создаётся при запуске приложения, а WidgetRef появляется только после первого build().

Проблема: AuthBridge должен слушать authProvider (Riverpod), но у него нет WidgetRef.

Решение: отложенная инъекция. AuthBridge создаётся get_it при старте приложения с минимальными зависимостями (только CustomAuthManager). WidgetRef передаётся позже, через метод startSync(ref), который вызывается из initState() корневого виджета.

App запускается


get_it создаёт AuthBridge (без WidgetRef)


MyApp.build() → ConsumerStatefulWidget


MyApp.initState()

    │  ref (WidgetRef)

authBridge.startSync(ref)


AuthBridge подписывается на authProvider через ref.listenManual()

Почему это не антипаттерн? Потому что альтернативы хуже:

  • Сделать AuthBridge провайдером Riverpod — тогда он теряет доступ к get_it-зависимостям. Половина приложения на get_it (legacy FlutterFlow), половина на Riverpod. AuthBridge — мост между ними.
  • Передавать WidgetRef в каждый метод — AuthBridge вызывается из разных мест (retry handler, lifecycle callback). Протаскивать ref через все эти точки — хрупко и многословно.
  • Использовать глобальный ProviderContainer — работает, но нарушает scope Riverpod и делает тестирование сложнее.

Отложенная инъекция через метод — прагматичный компромисс. AuthBridge получает WidgetRef один раз, при старте приложения, и использует его до конца жизни процесса. Тестируется через AuthBridgeTestBed, который создаёт реальный widget tree с ConsumerWidget и передаёт ref в startSync.

Чего это стоит: trade-offs

Dual-auth — не бесплатная архитектура. Вот реальная цена:

Удвоение точек отказа. Два провайдера — два набора rate limits, два набора инцидентов, два dashboard’а мониторинга. Обновление Appwrite SDK может сломать формат JWT — и авторизация ляжет, хотя Supabase работает безупречно.

Сложность отладки. “Пользователь не может загрузить данные” — это проблема в Appwrite-сессии? В Token Bridge? В Supabase JWT? В RLS-политике? Четыре слоя вместо одного. Нужны структурированные логи на каждом уровне: Appwrite auth event → Token Bridge request/response → Supabase JWT validation → RLS query. Без них — слепой дебаг.

Onboarding разработчиков. Новому разработчику нужно объяснить не только “как работает авторизация”, а “как работают две авторизации и мост между ними”. Документация AuthBridge — 4 страницы. Документация single-auth провайдера — полстраницы.

Два набора секретов. Appwrite API key, Appwrite project ID, Supabase URL, Supabase anon key, Supabase JWT secret, Token Bridge endpoint. Шесть значений вместо трёх. Каждое нужно хранить в CI, в .env, в секретах деплоя.

Задержка при холодном старте. Цепочка: Appwrite SDK init → JWT request → Token Bridge (возможен cold start Deno) → Supabase JWT → первый запрос данных. На медленном соединении это 3–4 секунды. С warm Deno-изолятом — 1–2 секунды. Решение — WarmupService, который прогревает Edge Functions при открытии приложения.

Каждый из этих trade-offs решаем. Но решаем дополнительным кодом, дополнительным мониторингом, дополнительной документацией. Dual-auth — не для каждого проекта. Оправдан, когда плюсы двух платформ перевешивают расходы на интеграцию.

Шире, чем один проект: data residency через dual-auth

Dual-auth в JourneyBay вырос из конкретной задачи — соблюдение российского закона о персональных данных. Но сама схема применима шире.

В любой стране с жёстким регулированием данных (Россия, Китай, ряд стран ЕС с локальными требованиями, Индия с проектом DPDP Act) возникает одна и та же проблема: чувствительные данные должны лежать на локальных серверах, а приложение работает глобально. Один облачный провайдер этого не закрывает.

Dual-auth решает эту проблему архитектурно:

┌─────────────────────────────┐     ┌──────────────────────────────┐
│   Региональный сервер       │     │   Глобальное облако          │
│   (self-hosted)             │     │   (managed)                  │
│                             │     │                              │
│   • Учётные записи          │     │   • Маршруты, POI            │
│   • Пароли, сессии          │ JWT │   • AI-генерации             │
│   • OAuth-токены            │────►│   • Чеклисты, рекомендации   │
│   • Email, телефон          │     │   • Real-time подписки       │
│   • Персональные данные     │     │   • Edge Functions           │
│                             │     │                              │
│   Appwrite / Firebase /     │     │   Supabase / любой облачный  │
│   любой self-hosted auth    │     │   PostgreSQL-провайдер       │
└─────────────────────────────┘     └──────────────────────────────┘

Региональный auth-сервер хранит только то, что требует закон. Глобальная база работает с приложенческими данными без юридических ограничений. Token Bridge — единственная точка пересечения, и через неё проходят только токены, а не персональные данные.

Схема не привязана к конкретным платформам. Appwrite можно заменить на Firebase Auth, Keycloak, Auth0 с локальным деплоем. Supabase — на любой облачный PostgreSQL с RLS. Token Bridge останется тем же: принять JWT от регионального провайдера, выдать JWT для глобального.

Итог

AuthBridge, Completer-lock и SyncRecovery превращают потенциально хрупкую связку двух BaaS-платформ в предсказуемую систему. Каждый сценарий отказа — обработан. Каждый race condition — закрыт Completer’ом. Каждый сетевой сбой — подхвачен SyncRecovery с exponential backoff.

Dual-auth — не для каждого проекта. Но если вы работаете с data residency, строите мультирегиональное приложение или используете два BaaS-провайдера по любой другой причине — три вещи стоит спроектировать до начала: единый идентификатор пользователя между системами, серверный Token Bridge, и клиентский recovery-механизм. Всё остальное — детали реализации.

FAQ

Почему Supabase JWT имеет TTL 1 час, когда Appwrite JWT живёт лишь 15 минут — и что происходит при компрометации Supabase-токена в этом окне?

Асимметрия намеренна: Appwrite управляет identity-сессией и может мгновенно её отозвать, тогда как Supabase JWT — это stateless bearer-токены, которые нельзя отозвать поштучно после выдачи. TTL в 1 час балансирует пользовательский опыт (меньше refresh-туров через Token Bridge) и окно безопасности. Если Supabase JWT скомпрометирован, атакующий получает доступ максимум на 55 минут к прикладным данным — но не к персональным данным, которые остаются на региональном сервере Appwrite. Для сценариев с повышенным риском сокращение TTL до 15 минут синхронизирует его с Appwrite-каденцией ценой вызова Token Bridge при каждом Appwrite-обновлении.

Как паттерн Completer-lock обрабатывает случай, когда refresh токена прошёл успешно, но ответ на исходный 401-запрос пришёл не в том порядке?

AuthRetryHandler сохраняет опции исходного запроса и воспроизводит его после разрешения Completer’а с новым токеном — он не переиспользует исходный объект Response. Если исходный запрос каким-то образом разрешается до Completer’а (крайний случай на очень быстрых сетях с гонкой между 401-ответом и refresh’ом), AuthRetryHandler определяет, что новый токен уже доступен, и немедленно делает повтор без входа в очередь блокировки. Принудительное завершение Completer’а при логауте покрывает обратный сценарий: запросы в процессе выполнения получают немедленную ошибку вместо повтора с инвалидированным токеном.

Какова реальная разбивка латентности cold start для цепочки Appwrite → Token Bridge → Supabase и как WarmupService её снижает?

На холодном Deno-изоляте цепочка занимает 3–4 секунды: восстановление сессии Appwrite SDK (~300 мс), запрос Appwrite JWT (~200 мс), cold start Token Bridge (~1500–2000 мс на раскрутку Deno-изолята), подпись JWT и ответ (~100 мс), инициализация Supabase-клиента (~200 мс). WarmupService отправляет лёгкий ping в Token Bridge при запуске приложения — до того как пользователь нажмёт на любой экран, зависящий от данных — превращая штраф cold start в фоновую операцию. С тёплым изолятом вся цепочка сокращается до 600–900 мс.