Authentication & rate limiting
How Asobi authenticates clients, validates purchases, and bounds the brute-force surface. For the higher-level trust assumptions see the threat model.
Session bearer tokens
Every authenticated route is gated by asobi_auth_plugin:verify/1, which expects an Authorization: Bearer <token> header. Tokens are issued by nova_auth_session:generate_session_token/2 after a successful register/login/refresh/OAuth flow. The plugin attaches auth_data => #{player_id => Id, ...} to the request map — controllers should pattern-match on that rather than parsing the header themselves.
Tokens are stored in asobi_player_token and revocable via nova_auth_session:delete_session_token/2.
Apple StoreKit 2 JWS verification
asobi_iap:verify_apple/1 parses an Apple-signed JWS receipt and verifies it end-to-end:
- Header
algis required to beES256. Other algorithms are rejected. - The
x5cchain is decoded (DER-encoded certificates, base64'd in JWS order: leaf → intermediate → root). - The chain is validated against a configured Apple Root CA via
public_key:pkix_path_validation/3. Operators ship the root inpriv/apple_root_ca.pem(or override the path viaapplication:get_env(asobi, apple_root_ca_path, ...)). - The signature on
<header>.<payload>is verified with the leaf cert's public key. A bit-flipped signature, swapped signature, or any chain mismatch fails the verification.
Failures return {error, Reason} with a sanitised reason atom; the controller (asobi_iap_controller) maps them to 400/401 responses without leaking JWS internals to the client.
Steam ticket validation
asobi_steam:validate_ticket/1 validates a hex-encoded Steam session ticket against the Steam Web API:
- The ticket character class is enforced (
[0-9a-fA-F]+, max 4096 bytes). Anything else is rejected before any HTTP call. - All dynamic URL components (key, app id, ticket, steam id) are passed through
uri_string:quote/1so an&or=in user input cannot inject query parameters into the Steam call.
Per-route rate limits
asobi_rate_limit_plugin is wired as a pre_request plugin. It selects a Seki limiter group based on the request path:
Path prefix | Limiter | Default limit (req/sec/IP)
-------------------|---------------------|-----------------------------
/api/v1/auth/* | asobi_auth_limiter | 5
/api/v1/iap/* | asobi_iap_limiter | 10
everything else | asobi_api_limiter | 300The auth limiter is the brute-force gate: a 5/sec cap plus the bcrypt cost on nova_auth_accounts:authenticate/3 makes online password guessing infeasible at internet scale.
Operators can override limits via the asobi, rate_limits env in their sys config:
{rate_limits, #{
auth => #{limit => 10, window => 1000},
iap => #{limit => 20, window => 1000},
api => #{limit => 600, window => 1000}
}}The dev/test sys config bumps all three to 1000 because CT bursts register/login calls against 127.0.0.1 and the production-default auth cap would fail the suites.
DDoS / DoS surface notes
Deliberate per-call upper bounds in the runtime that exist purely to bound the cost of a single hostile request:
- Cloud saves (
/saves/:slot) — body capped at 256 KB; per-player slot count capped at 10. - Storage (
/storage/:collection/:key) —read_perm/write_permwhitelisted to["public", "owner"]; arbitrary strings rejected with 400. - Inventory consume — quantity range
[1, 1_000_000]. - Leaderboard
top/around—?limitclamped to 100,?rangeto 50 (mitigates an O(N) ETS scan attack). - Chat history —
?limitclamped to[1, 200]; channel membership is enforced (DM participants, world joiners, group members). - DM send — content capped at 2000 bytes; non-binary or empty content rejected.
- Group chat / WS chat.join — channel id namespaced (
dm:,world:,zone:,prox:,room:); per-connection cap of 32 simultaneously joined channels; idle channels stop after 60s with no live members. - Per-player world creation — capped via pg group; default 5 worlds per player, 1000 globally. Tunable via
world_max_per_player/world_maxenv. - Matchmaker — party entries that don't match the requester are silently dropped; ticket reads / cancellations require ownership.
Test coverage
Regressions for the items above live under test/:
asobi_iap_SUITE.erl— Apple JWS happy path + 14 negative cases.asobi_world_lobby_SUITE.erl— per-player + global world caps.asobi_matchmaker_api_SUITE.erl— party consent + ticket ownership.asobi_social_api_SUITE.erl— chat history membership.asobi_dm_tests.erl— DM length cap + empty-content rejection.
Run with rebar3 ct,eunit.