Перейти к содержанию
Me2eM

SSO без базы пользователей: seed-фраза как аккаунт платформы

Паттерн Multi-App SSO на Me2eM: один инстанс, много приложений, ноль пользовательских таблиц. Per-app персоны, аттестационные цепочки вместо IdP, HttpOnly-сессии и бан без базы данных — по корню идентичности.

M

Me2eM Organization

8 мин. чтения

Классический SSO — это история о централизации. Платформа с набором приложений ставит Keycloak или Auth0, заводит базу пользователей, строит redirect-танцы между приложениями и IdP, синхронизирует сессии. База пользователей — центр вселенной: в ней регистрации, профили, парольные хэши, связи «кто к чему имеет доступ». И она же — главная мишень: один дамп этой базы стоит всех приложений вместе взятых.

Паттерн Multi-App SSO на Me2eM собирает тот же результат — один вход открывает все приложения платформы — с противоположной централизацией: аккаунт пользователя — это его seed-фраза, и база пользователей не существует вовсе. Не «обезличена» и «зашифрована» — не существует. Платформа хранит только ревокации и hashed refresh-токены SSO-сессий; всё остальное предъявляется в каждом запросе в виде подписанных доказательств.

Уровень: концепт-паттерн. Механика сессий и верификации разобрана в stateless-сессиях и аттестационных цепочках; серверная обвязка — в stateless-бэкенде. Здесь — как эти блоки складываются в платформу с множеством приложений.

Модель: кто есть кто

СущностьЧто этоЧто хранит
Инстанс платформыОдно развёртывание, N приложений (портал, дашборд, админка, отчёты…)Shared-хранилище ревокаций + hashed refresh-токены SSO-сессий. Всё
ПорталТочка ввода seed-фразыНичего. Это страница, а не сервис аккаунтов
ПриложениеОтдельный продукт платформыТолько свои ревокационные решения (общие через shared store)
Per-app persona (Handle)Хэндл пользователя, выведенный для конкретного приложенияНе хранится нигде — вычисляется из seed
Identity пользователяКорень дерева пользователя, seed на его бумагеПлатформа не знает и не хранит даже его публичный ключ

Последняя строка — самая непривычная. В классическом SSO платформа владеет учётными записями. Здесь платформа не знает своих пользователей даже на уровне публичных ключей: корень приходит с каждым запросом, подпись проверяется математически, а «кто это» платформе знать не положено по дизайну.

Классический SSO против Me2eM SSO

АспектКлассический SSO (OIDC/Keycloak)Me2eM Multi-App
РегистрацияОбязательна: email/телефон, профили в БДОтсутствует: seed = аккаунт
IdPОтдельный сервис с БД и его собственной моделью угрозНе существует; роль IdP выполняет криптография
База пользователейЦентральная, главная мишеньНулевая — нечего украсть
Сессия приложенияВыдана IdP, opaque или JWTПодписана хэндлом самого пользователя
Хранение сессииCookie + server-side state у IdPHttpOnly cookie, верификация без state
Отзыв пользователяDELETE в БДЗапись корня в shared block-list
Компрометация платформыДамп всех аккаунтовДамп списка отозванных ключей
Изоляция приложенийScope-модель IdPКриптографическая: у каждого приложения свой ключ
ОнбордингПисьма, подтверждения, сбросы паролейВвод 12 слов

Поток логина

sequenceDiagram
    participant U as User
    participant P as Portal
    participant D as Dashboard app

    U->>P: вводит seed-фразу
    P->>P: Identity из seed, деривация per-app хэндлов
    U->>P: открывает Dashboard
    P->>P: attestHandle для аудитории dashboard
    P->>P: Session.create от хэндла dashboard
    P-->>U: redirect, токен во фрагменте URL
    U->>D: открывает приложение
    D->>D: клиентский bridge ставит HttpOnly cookie
    U->>D: работает, cookie на каждом запросе
    D->>D: verifyAttested с публичным корнем из запроса

На портале: сессия на каждое приложение

Ключевое решение паттерна — per-app персона: для каждого приложения пользователь выводит отдельный хэндл и отдельно его аттестует. Сессии создаются по одной на приложение — при первом входе в него:

import { Identity, Session } from '@me2em/core';

// На портале, после ввода seed-фразы:
const identity = await Identity.fromSeed(seedBytes);

async function openApp(appName: string, appDomain: string) {
  // 1. Персона приложения: изолированный ключ из дерева пользователя
  const handle = await identity.deriveHandle(appName);

  // 2. Аттестация хэндла — минимальная цепочка Mode 2:
  //    корень пользователя подтверждает, что этот ключ — его persona
  //    для этого приложения, с этими границами
  const A = await identity.attestHandle(appName, {
    audiences: [appDomain],
    scopes: ['read', 'write'],
    maxSessionTtl: 3600,
  });

  // 3. Сессия приложения, подписанная per-app хэндлом
  const session = await Session.create(handle, {
    audience: appDomain,
    scopes: ['read', 'write'],
    ttl: 1800,
  });

  // 4. Передача — конвенция платформы, не протокол:
  //    токен + цепочка + публичный корень пользователя уходят в приложение
  return {
    token: session.token,
    chain: [A.token],
    rootPub: toBase64(identity.getPublicKey()),
  };
}

Зачем аттестация, если хэндл и так выведен из того же дерева? Потому что верификатор — не пользователь: приложение не может пересчитать ключ хэндла (это потребовало бы seed пользователя). Ему нужна цепочка, восходящая к публичному корню, — иначе Mode 2 не работает вовсе. Аттестация здесь не «делегирование» в строгом смысле, а удостоверение персоны: «этот ключ — моя persona для этого приложения, действуй в этих границах».

Протокол не фиксирует транспорт; рабочая конвенция платформы:

  1. Портал делает redirect на приложение, токен и цепочка едут во фрагменте URL (#...) — фрагменты не попадают в серверные логи и заголовки Referer.
  2. Клиентский скрипт приложения читает фрагмент и отправляет одноразовый POST на собственный backend (/sso/bridge).
  3. Backend валидирует форму запроса и ставит HttpOnly cookie (Secure, SameSite=Lax), после чего клиент затирает фрагмент через history.replaceState.
// Backend приложения:
app.post('/sso/bridge', (req, res) => {
  const { token, chain, rootPub } = req.body;
  res.cookie('sso_session', JSON.stringify({ token, chain, rootPub }), {
    httpOnly: true,
    secure: true,
    sameSite: 'lax',
    maxAge: 30 * 60 * 1000,
  });
  res.sendStatus(204);
});

Почему HttpOnly, если Me2eM-токен подписан и самодостаточен? По той же причине, что и в классике: JS-доступная cookie — первая жертва XSS. Токен всё равно предъявляется с каждым запросом, так что пусть это делает браузер, а не скрипт, который может быть скомпрометирован.

SSO-сессия инстанса: вход один раз на всю платформу

Cookie из схемы выше — сессионная, для одного приложения. Чтобы пользователь не вводил seed при входе в каждое приложение платформы, инстанс при первом логине ставит собственную HttpOnly cookie (на домен инстанса или поддомен) — SSO-сессию. При открытии следующего приложения браузер предъявляет её инстансу, тот подтверждает сессию и выдаёт приложению свежий per-app токен: деривация и аттестация происходят так же, как при первом входе, но без повторного ввода фразы.

За этой cookie стоит минимальное платформенное состояние: hashed refresh-токены. Оно же даёт «выйти на всех устройствах»: отзыв refresh-токена плюс ревокация jti гасят сессии везде, не храня ничего, что восстанавливает личность или содержимое.

На приложении: верификация без БД

Приложение проверяет каждую сессию через аттестационную цепочку — публичный корень пользователя приезжает в составе cookie:

import { Session, AttestationError } from '@me2em/core';

async function requireSession(req): Promise<Session> {
  const { token, chain, rootPub } = readSsoCookie(req);

  // 0. Бан-лист платформы: отозван ли этот корень вообще
  if (await rootBlocklist.has(rootPub)) throw new SessionRevokedError();

  // 1. Полная верификация: подписи, время, гранты, аудитория
  return Session.verifyAttested(
    token,
    fromBase64(rootPub),
    chain,
    'dashboard.platform.example',
    sharedRevocationChecker,   // общий на все приложения платформы
  );
}

Заметьте, что приложение знает о пользователе ровно то, что подписано в токене и аттестации: публичный ключ персоны, имя хэндла, scopes, TTL. Плюс публичный корень — но не как «запись пользователя», а как одноразовый аргумент проверки.

Изоляция приложений: зачем persona per app

Соблазн упростить — один хэндл на все приложения платформы — разрушает главное свойство паттерна. С per-app персонами:

  • Утечка cookie одного приложения не открывает другие. Сессия дашборда подписана ключом dashboard-хэндла; админка проверяет подпись ключа admin-хэндла и отвергает чужой токен по аудитории и подписи. В классическом SSO украденная SSO-cookie часто означает «всё сразу».
  • Гранты независимы. Аттестация каждой персоны задаёт свои scopes и потолки TTL: отчётному приложению — только чтение, админке — шире. Вложенность грантов проверяется верификатором на каждом запросе.
  • Отзыв точечный. Пользователь может отозвать доступ одного приложения, не трогая остальные, — отозвав аттестацию конкретной персоны.

И честная оговорка: приложения одной платформы коррелируют пользователя между собой на уровне оператора — все они консультируют общий shared-хранилище и видят одни и те же корни. Изоляция здесь — криптографическая (компрометация и права), а не метаданная. Если нужна несвязуемость между сервисами — это не SSO-платформа, а отдельные инстансы, и хэндлы для них выводятся из разных веток дерева.

Бан без базы данных: по корню, не по хэндлу

Самый тонкий вопрос паттерна: как платформа блокирует пользователя, если у неё нет его записи? Худший ответ — бан по хэндлу: детерминизм позволяет вывести admin2, admin3… бесконечно. Единственный устойчивый уровень — корень идентичности: хэндлы пользователь сменить может, корень — нет (детерминизм не ротируется; это то же свойство, что делает seed неотзываемым, — см. почему мы не храним seed).

Shared-хранилище ревокаций платформы ведёт три типа записей:

ЗаписьГаситКто заносит
Session jtiОдну сессиюСистема (подозрительная активность)
Attestation jtiПерсону одного приложения в одной веткеПользователь или оператор
Root public keyПользователя во всех приложениях сразуМодерация платформы

Block-list корней — платформенная конвенция поверх протокола: интерфейс RevocationChecker принимает только jti, поэтому проверка корня выполняется до verifyAttested, как в коде выше. По объёму это не «база пользователей» — список забаненных публичных ключей, растущий только при инцидентах.

Цена модели честная: бан — единственная власть платформы над пользователем, и она грубая. Нет «сбросить пароль», нет «изменить email», нет работы с профилем — нечего изменять. Это фича и ограничение одновременно, и в следующем разделе — о цене целиком.

Партнёрские приложения: аттестация как делегирование

До сих пор речь шла о приложениях платформы. Паттерн расширяется на внешние приложения без изменения механики: partner-app получает от пользователя аттестацию с более узким грантом — и верифицирует её независимо, зная только публичный корень пользователя.

// Пользователь делегирует партнёрскому аналитическому сервису
// доступ к своим отчётам — без передачи каких-либо секретов:
const A = await identity.attestHandle('analytics-partner', {
  audiences: ['partner-analytics.example'],
  scopes: ['reports:read'],
  maxSessionTtl: 1800,
}, { ttlSeconds: 30 * 24 * 3600 });   // делегация умирает через месяц сама

Партнёр верифицирует сессии сам — платформа не участвует в его трафике и не становится единой точкой доверия между ним и пользователем. Платформенный block-list корней при этом не действует на партнёра автоматически: у него своя политика доверия. Вот почему в описании паттерна аттестации помечены «optional» — для приложений платформы достаточно минимальных персон-аттестаций; партнёрская делегация — опциональное расширение той же механики.

Эксплуатация и trade-offs

Инфраструктура паттерна — минимальная: статические приложения, shared-хранилище ревокаций (Redis, O(1)-чтение), верификация в каждом приложении. Масштабирование — как во всём протоколе: stateless-проверки, инстансы за балансировщиком без sticky-сессий. Подробная серверная обвязка (Guard, маршрутизация 400/401/403, multi-root) — в статье о stateless-бэкенде.

ПолучаемПлатим
ХранениеНоль пользовательских данных; дамп платформы — список отозванных ключейSeed-фраза — единственный вход; потеря = потеря всех приложений
БезопасностьИзоляция per-app, гранты на верификации, бан по корнюОткрытая модель: любой seed получает аккаунт — модерация только постфактум
ОперацииНет регистраций, писем, сбросов паролей, миграций профилейНет канала коммуникации с пользователем — платформа «немая»
СоответствиеНет персональных данных — минимальная поверхность GDPRНет и способов идентифицировать пользователя для billing/KYC

Кому подходит: внутренние экосистемы инструментов и дашбордов; self-hosted-наборы приложений; developer-платформы; приватные облака, где пользователи способны хранить фразу. Кому не подходит: публичным consumer-продуктам с биллингом и KYC; системам, обязанным знать своих пользователей; аудиториям, которые потеряют seed (а это большинство массовой).

Заключение

Multi-App SSO — предельный случай философии протокола: платформа, у которой пользователей «нет» — в том смысле, в каком их нет у телефона: есть люди с ключами. Один вход открывает все приложения, изоляция между ними криптографическая, а вся память платформы о своих пользователях умещается в список отозванных ключей. Классический SSO оптимизирует удобство центра; этот паттерн оптимизирует отсутствие центра.

Что читать дальше

Поделиться:

Похожие статьи

Почему мы не храним ваш seed — и почему это фича, а не недоработка

Seed-фраза — единственный носитель вашей идентичности. Мы не храним её. Почему это осознанное решение, какую цену вы за него платите, и почему эта цена честнее альтернативы.

M Me2eM Organization
7 мин. чтения
#security #identity #architecture

Me2eM: идентичность из seed-фразы — как работает детерминированная деривация

Полный разбор детерминированного дерева ключей: как 12 слов превращаются в иерархию идентичностей — Identity, Handle, SubHandle — и почему один и тот же seed всегда даёт одни и те же ключи.

M Me2eM Organization
4 мин. чтения
#identity #cryptography #architecture

Готовы начать?

Установите пакет, пройдите quick start и за считанные минуты запустите свой первый сценарий.