Двойная авторизация: 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 JWT | 15 минут | Appwrite SDK автоматически через сессию |
| Supabase JWT | 1 час | Клиент обновляет за 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 выполняет шесть операций последовательно:
- CustomAuthManager получает токен и идентификатор — это legacy-интеграция с FlutterFlow, которая всё ещё используется частью кодовой базы.
- UserStateProvider обновляет глобальное состояние: userId, isLoggedIn, данные профиля. Виджеты, подписанные на этот провайдер, перерисовываются.
- SupabaseClientService запускает обмен токенов через Token Bridge. Это самый долгий шаг — сетевой запрос к Edge Function.
- Sentry получает userId — теперь все ошибки привязаны к конкретному пользователю.
- PostHog вызывает identify — аналитические события привязываются к пользователю.
- 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 мс.