SMeet Mini Apps
Вход
Человек уже вошёл в SMeet. Страница просит у SMeet одноразовый код входа, её сервер обменивает код на сессию мини-приложения, и дальше страница говорит со своим сервером уже собственной сессией. Cookie в этом не участвуют.
Как это работает#
- Страница просит у своего сервера попытку входа
{attempt_id, attempt_secret}: два случайных значения, одноразовых, действующих около двух минут. Страница держит их в памяти. - Страница вызывает
app.authorize(). SDK создаёт PKCE verifier из 32 случайных байт и отправляет SMeet только его challenge по S256. - SMeet проверяет, что окном ещё можно пользоваться, и отвечает кодом входа
smlc_..., который действует 60 секунд. SDK возвращает{code, codeVerifier, expiresAt}. - Страница отправляет своему серверу
{attempt_id, attempt_secret, code, code_verifier}. - Сервер гасит попытку и вызывает exchangeLaunchCode своим ключом и с
Idempotency-Key, привязанным к попытке. SMeet сверяет verifier с challenge и отвечает сессией мини-приложенияsmas1_...и сведениями о том, кто вошёл. - Сервер оставляет сессию SMeet у себя и отдаёт странице собственную сессию в теле ответа. Страница держит её в памяти и шлёт как
Authorization: Bearerс каждым запросом к серверу.
Сессия SMeet (smas1_...) и ключ сервера (smac1_...) на страницу не попадают никогда. Код и verifier ходят только между страницей и её сервером.
Попытка входа#
Попытка входа хранит состояние одного входа на вашем сервере вместо cookie. Она привязывает код, который приносит страница, к той странице, которая начала вход: код, полученный от SMeet чужой страницей, без секрета этой страницы не превратится в её сессию.
- Делайте оба значения случайными, не меньше 128 бит каждое, и храните только хеш секрета.
- Используйте попытку один раз: атомарно забирайте её перед обменом при любом исходе и сравнивайте секрет за постоянное время.
- Пусть она истекает примерно через две минуты.
Минимальный пример на Python держит попытки в памяти:
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}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()#
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 ни о чём не спрашивает человека: согласие уже дано до открытия окна.
Обмен#
Ваш сервер гасит попытку, затем обменивает код. Ключ запроса привязан к попытке, поэтому повтор после потерянного ответа получает ту же сессию, а ничто другое её не получит:
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(), новый обмен. Отдельного токена обновления нет.
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 сессии.