SMeet Mini Apps
Sign-in
The person is already signed in to SMeet. The page asks SMeet for a one-time login code, its server exchanges the code for a Mini App session, and from then on the page talks to its server with a session of its own. No cookie takes part.
How it works#
- The page asks its own server for a login attempt,
{attempt_id, attempt_secret}: two random values, used once, valid for about two minutes. The page keeps them in memory. - The page calls
app.authorize(). The SDK makes a PKCE verifier of 32 random bytes and sends SMeet only its S256 challenge. - SMeet checks that the window may still be used and answers with a login code,
smlc_..., valid for 60 seconds. The SDK returns{code, codeVerifier, expiresAt}. - The page sends
{attempt_id, attempt_secret, code, code_verifier}to its server. - The server uses up the attempt and calls exchangeLaunchCode with its key and an
Idempotency-Keybound to the attempt. SMeet checks the verifier against the challenge and answers with a Mini App session,smas1_..., and who signed in. - The server keeps the SMeet session and gives the page a session of its own in the response body. The page keeps it in memory and sends it as
Authorization: Bearerwith every request to the server.
The SMeet session (smas1_...) and the key of the server (smac1_...) never reach the page. The code and the verifier travel only between the page and its server.
The login attempt#
The attempt is the state of one sign-in, kept by your server instead of a cookie. It binds the code the page brings back to the page that started the sign-in: a code that a foreign page got from SMeet cannot be exchanged into a session of this page without this page's secret.
- Make both values random, at least 128 bits each, and keep only a hash of the secret.
- Use an attempt once: take it out atomically before the exchange, whatever the outcome, and compare the secret in constant time.
- Let it expire after about two minutes.
The minimal Python example keeps attempts in memory:
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()A server with more than one process keeps attempts in its database, for example with UPDATE ... SET used_at = now() WHERE id = $1 AND used_at IS NULL AND expires_at > now().
authorize()#
const attempt = await api("login/attempt"); // your server
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,
});- Ask for the attempt first, then call
authorize(): the code lives 60 seconds, and an attempt fetched afterwards eats into them. - SMeet answers the code only over the channel of the page that asked, the document SMeet loaded and checked. A request of an earlier or another document of the window never gets it. A page that navigates to another page of the same app signs in again there.
- The verifier lives only in the memory of the page; the SDK does not store it.
- Call
authorize()in your page's start code, for example inwindow.onloadafterconnect()andready(). SMeet asks the person nothing at this point: the consent was given before the window opened.
The exchange#
Your server takes up the attempt, then exchanges the code. The key of the request is bound to the attempt, so a repeat after a lost answer gets the same session and nothing else does:
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")}}- Compare
environmentof the answer with the environment of your server; a key of the test version never exchanges a production code (ENVIRONMENT_MISMATCH), and the check guards against a key put into the wrong server. user.idis the person's id in the Bot API, the same asfrom.idin your bot's updates.chat.idis the private chat of the person with your bot.start_paramis the parameter of the button or link that opened the window, or null.
The answer and every field are described in the API reference.
Your app's own session#
Do not hand the SMeet session to the page. Give the page a session of your server, in the body of the answer, and keep it in the page's memory:
- send it as
Authorization: Bearer <token>, never as a cookie and never in an address; - let it end no later than
expires_atof the SMeet session (15 minutes at most); - before every protected operation, check the SMeet session behind it with checkSession.
localStorage and sessionStorage of the page may hold a draft, but not a session: they outlive the window and are shared by everything on your origin.
Renewal#
A Mini App session lives 15 minutes at most. While the window is open, the page renews its session about two minutes before it ends: a new attempt, a new authorize(), a new exchange. There is no refresh token.
function scheduleRenewal(session) {
const wait = Math.max(10_000, Date.parse(session.expires_at) - Date.now() - 120_000);
setTimeout(() => signIn().catch(showSignInError), wait);
}When the launch is closed (the person closed the window on another device, revoked access, signed out), SMeet closes the window itself; authorize() then fails with LAUNCH_CLOSED.
Errors#
authorize() fails with a bridge code:
| Code | What to do |
|---|---|
CODE_EXPIRED | Call authorize() again, with a new attempt |
NETWORK | Call authorize() again |
INTERNAL | A fault of SMeet; call authorize() again, at most a few times |
RATE_LIMITED | Wait: 60 codes a minute per person |
LAUNCH_CLOSED | Stop: SMeet tells the person why and closes the window |
MAINTENANCE | Mini Apps are paused; say so and keep the person's draft |
NOT_READY | The window shows an error screen; nothing to do in the page |
The exchange on your server fails with a code of the author API:
| Code | What to do |
|---|---|
CODE_NOT_FOUND, CODE_EXPIRED, CODE_REVOKED, CODE_ALREADY_USED, CODE_VERIFIER_MISMATCH | The page signs in again: a new attempt and a new authorize() |
ACCESS_DENIED | The person may not use the app now; reason says why (Reasons) |
TEMPORARILY_UNAVAILABLE, RATE_LIMITED, a network error, a 502, 503 or 504 | Repeat the same request with the same key after retry_after, within 60 seconds of the exchange |
MINI_APPS_MAINTENANCE | Mini Apps are paused; the page says so |
INVALID_CREDENTIALS, APP_MISMATCH, ENVIRONMENT_MISMATCH | The server's key is wrong or belongs to another app or environment: fix the configuration |
Why there are no cookies#
Inside the SMeet window your page is a frame of another site. Browsers treat cookies there as third-party cookies, and Safari blocks them. A sign-in that depends on a cookie works in one browser and fails in another. The login attempt replaces the cookie as the state of the sign-in, and the session in the page's memory replaces the session cookie.