SMeetMini Apps

SMeet Mini Apps

Вход

Человек уже вошёл в SMeet. Страница просит у SMeet одноразовый код входа, её сервер обменивает код на сессию мини-приложения, и дальше страница говорит со своим сервером уже собственной сессией. Cookie в этом не участвуют.

Как это работает#

  1. Страница просит у своего сервера попытку входа {attempt_id, attempt_secret}: два случайных значения, одноразовых, действующих около двух минут. Страница держит их в памяти.
  2. Страница вызывает app.authorize(). SDK создаёт PKCE verifier из 32 случайных байт и отправляет SMeet только его challenge по S256.
  3. SMeet проверяет, что окном ещё можно пользоваться, и отвечает кодом входа smlc_..., который действует 60 секунд. SDK возвращает {code, codeVerifier, expiresAt}.
  4. Страница отправляет своему серверу {attempt_id, attempt_secret, code, code_verifier}.
  5. Сервер гасит попытку и вызывает exchangeLaunchCode своим ключом и с Idempotency-Key, привязанным к попытке. SMeet сверяет verifier с challenge и отвечает сессией мини-приложения smas1_... и сведениями о том, кто вошёл.
  6. Сервер оставляет сессию SMeet у себя и отдаёт странице собственную сессию в теле ответа. Страница держит её в памяти и шлёт как Authorization: Bearer с каждым запросом к серверу.

Сессия SMeet (smas1_...) и ключ сервера (smac1_...) на страницу не попадают никогда. Код и verifier ходят только между страницей и её сервером.

Попытка входа#

Попытка входа хранит состояние одного входа на вашем сервере вместо cookie. Она привязывает код, который приносит страница, к той странице, которая начала вход: код, полученный от SMeet чужой страницей, без секрета этой страницы не превратится в её сессию.

  • Делайте оба значения случайными, не меньше 128 бит каждое, и храните только хеш секрета.
  • Используйте попытку один раз: атомарно забирайте её перед обменом при любом исходе и сравнивайте секрет за постоянное время.
  • Пусть она истекает примерно через две минуты.

Минимальный пример на Python держит попытки в памяти:

Pythondocs/mini-apps/examples/python-minimal/server.py
def new_attempt(self) -> Dict[str, Any]:
    attempt_id, attempt_secret = secrets.token_urlsafe(18), secrets.token_urlsafe(32)
    with self.lock:
        now = self.clock()
        self.attempts = {k: v for k, v in self.attempts.items() if v[1] > now}
        self.attempts[attempt_id] = (hashlib.sha256(attempt_secret.encode()).digest(), now + ATTEMPT_TTL)
    return {"attempt_id": attempt_id, "attempt_secret": attempt_secret}
Pythondocs/mini-apps/examples/python-minimal/server.py
def consume_attempt(self, attempt_id: str, attempt_secret: str) -> bool:
    with self.lock:
        item = self.attempts.pop(attempt_id, None)
    if item is None:
        return False
    same = hmac.compare_digest(item[0], hashlib.sha256(attempt_secret.encode()).digest())
    return same and item[1] > self.clock()

Сервер из нескольких процессов хранит попытки в базе данных, например с UPDATE ... SET used_at = now() WHERE id = $1 AND used_at IS NULL AND expires_at > now().

authorize()#

JavaScript
const attempt = await api("login/attempt");             // ваш сервер
const { code, codeVerifier, expiresAt } = await app.authorize();
const session = await api("login/exchange", {
  attempt_id: attempt.attempt_id, attempt_secret: attempt.attempt_secret, code, code_verifier: codeVerifier,
});
  • Сначала получите попытку, потом вызывайте authorize(): код живёт 60 секунд, и попытка, полученная после, отнимает у них время.
  • SMeet отдаёт код только по каналу той страницы, которая его попросила, то есть документа, который SMeet загрузил и проверил. Запрос прежнего или другого документа окна кода не получит. Страница, перешедшая на другую страницу того же приложения, входит там заново.
  • Verifier живёт только в памяти страницы; SDK его не сохраняет.
  • Вызывайте authorize() в стартовом коде страницы, например в window.onload после connect() и ready(). В этот момент SMeet ни о чём не спрашивает человека: согласие уже дано до открытия окна.

Обмен#

Ваш сервер гасит попытку, затем обменивает код. Ключ запроса привязан к попытке, поэтому повтор после потерянного ответа получает ту же сессию, а ничто другое её не получит:

Pythondocs/mini-apps/examples/python-minimal/server.py
def sign_in(self, body: Dict[str, Any]) -> Dict[str, Any]:
    attempt_id, attempt_secret = body.get("attempt_id"), body.get("attempt_secret")
    code, verifier = body.get("code"), body.get("code_verifier")
    if not (isinstance(attempt_id, str) and ATTEMPT_RE.match(attempt_id) and isinstance(attempt_secret, str)
            and isinstance(code, str) and 0 < len(code) <= 512 and isinstance(verifier, str) and VERIFIER_RE.match(verifier)):
        raise PageError(400, "INVALID_REQUEST")
    if not self.consume_attempt(attempt_id, attempt_secret):
        raise PageError(400, "LOGIN_RETRY")
    # The key is bound to the attempt: a repeat of this exchange gets the same session (6.4), nothing else does.
    key = f"login:{attempt_id}"
    for round_ in range(3):
        try:
            exchanged = self.author("exchangeLaunchCode", {"code": code, "code_verifier": verifier}, key)
            break
        except SMeetError as error:
            if error.code in ("NETWORK", "TEMPORARILY_UNAVAILABLE", "RATE_LIMITED") and round_ < 2:
                time.sleep(min(5, error.retry_after or 1))
                continue
            raise page_error(error)
    if exchanged.get("environment") != self.settings.environment:
        raise PageError(500, "SERVER_MISCONFIGURED")
    token = secrets.token_urlsafe(32)
    with self.lock:
        self.sessions[token] = {"smeet": exchanged["session_token"], "user": exchanged.get("user") or {},
                                "expires": min(self.clock() + SESSION_TTL, parse_time(exchanged.get("expires_at")))}
    user = exchanged.get("user") or {}
    return {"session_token": token, "environment": exchanged.get("environment"),
            "expires_at": exchanged.get("expires_at"),
            "user": {"id": user.get("id"), "display_name": user.get("display_name")}}
  • Сравните environment из ответа со средой вашего сервера; ключ тестовой версии никогда не обменяет рабочий код (ENVIRONMENT_MISMATCH), а эта проверка защищает от ключа, по ошибке поставленного не на тот сервер.
  • user.id это идентификатор человека в Bot API, тот же, что from.id в событиях вашего бота. chat.id это личный чат человека с вашим ботом.
  • start_param это параметр кнопки или ссылки, которой открыто окно, или null.

Ответ и каждое поле описаны в справочнике API.

Собственная сессия приложения#

Не отдавайте странице сессию SMeet. Дайте странице сессию вашего сервера в теле ответа и держите её в памяти страницы:

  • шлите её как Authorization: Bearer <token>, никогда не cookie и никогда не в адресе;
  • пусть она заканчивается не позже expires_at сессии SMeet (не больше 15 минут);
  • перед каждой защищённой операцией проверяйте стоящую за ней сессию SMeet через checkSession.

localStorage и sessionStorage страницы могут хранить черновик, но не сессию: они переживают окно и общие для всего на вашем origin.

Продление#

Сессия мини-приложения живёт не больше 15 минут. Пока окно открыто, страница продлевает свою сессию примерно за две минуты до конца: новая попытка, новый authorize(), новый обмен. Отдельного токена обновления нет.

JavaScript
function scheduleRenewal(session) {
  const wait = Math.max(10_000, Date.parse(session.expires_at) - Date.now() - 120_000);
  setTimeout(() => signIn().catch(showSignInError), wait);
}

Если запуск закрыт (человек закрыл окно на другом устройстве, отозвал доступ, вышел), SMeet сам закрывает окно; authorize() тогда отказывает с LAUNCH_CLOSED.

Ошибки#

authorize() отказывает с кодом моста:

КодЧто делать
CODE_EXPIREDСнова вызвать authorize(), с новой попыткой
NETWORKСнова вызвать authorize()
INTERNALОшибка SMeet; снова вызвать authorize(), не больше нескольких раз
RATE_LIMITEDПодождать: 60 кодов в минуту на человека
LAUNCH_CLOSEDОстановиться: SMeet сам объяснит человеку причину и закроет окно
MAINTENANCEМини-приложения на паузе; сказать об этом и сохранить черновик человека
NOT_READYОкно показывает экран ошибки; на странице делать нечего

Обмен на вашем сервере отказывает с кодом API автора:

КодЧто делать
CODE_NOT_FOUND, CODE_EXPIRED, CODE_REVOKED, CODE_ALREADY_USED, CODE_VERIFIER_MISMATCHСтраница входит заново: новая попытка и новый authorize()
ACCESS_DENIEDЧеловек сейчас не может пользоваться приложением; reason говорит почему (Причины)
TEMPORARILY_UNAVAILABLE, RATE_LIMITED, сетевая ошибка, 502, 503 или 504Повторить тот же запрос с тем же ключом через retry_after, в пределах 60 секунд после обмена
MINI_APPS_MAINTENANCEМини-приложения на паузе; страница сообщает об этом
INVALID_CREDENTIALS, APP_MISMATCH, ENVIRONMENT_MISMATCHКлюч сервера неверен или принадлежит другому приложению или среде: исправить настройки

Почему без cookie#

Внутри окна SMeet ваша страница является фреймом другого сайта. Браузеры считают cookie в нём сторонними, а Safari их блокирует. Вход, зависящий от cookie, работает в одном браузере и ломается в другом. Попытка входа заменяет cookie как состояние входа, а сессия в памяти страницы заменяет cookie сессии.