jonnify·codemojex /codemojex/the-live-surface/the-auth-floor

Codemojex · C7.1 · a dive of The Live Surface on Phoenix

Identity is verified, never trusted.

Telegram hands the Mini App a signed initData string. The floor verifies that signature with one pure function, exchanges it for a SES-branded session in the shared Valkey through one handshake — the sole mint — and gates every player-acting route on the :auth plug. The old unauthenticated mint (POST /api/players) is retired: a player exists only behind a verified handshake.

Grounding — echo/…/codemojex/init_data.ex (verify/3 · check_hash/3) · codemojex_web/controllers/auth_controller.ex (handshake/2) · session.ex (mint/3 · resolve/1 · revoke/1) · tables.ex (:cm_sessions · :tracking) · codemojex_web/auth.ex · cm.4 §9.

The six-step verify — select a step
1 · parse URI.decode_query(init_data) else {:error, :missing} 2 · data-check-string drop hash + signature · sort join key=value with \n 3 · secret_key HMAC("WebAppData", token) nil token: {:error, :no_token} 4 · compare :crypto.hash_equals(exp, hash) else {:error, :bad_hash} 5 · freshness now − auth_date ≤ 86 400 else {:error, :stale} 6 · extract tg_user_id = user["id"] else {:error, :malformed}
3 · secret_key → secret_key = :crypto.mac(:hmac, :sha256, "WebAppData", token) — the WebApp derivation, which DIFFERS from the older Login-Widget SHA256(token). Fail-closed: a nil token is {:error, :no_token} before any HMAC runs.
§1

The pure verifier

init_data.ex

Codemojex.InitData.verify/3 is a function over a string and a token: no HTTP, no Repo, no session store. The same verify/3 is provable offline with a test-signed fixture, so the HMAC path runs with no live Telegram. Every rejection lands in the closed error set — :missing | :malformed | :no_token | :bad_hash | :stale — and the verifier is fail-closed: a nil token (no bot token configured) returns {:error, :no_token} rather than authenticating.

echo/…/codemojex/init_data.ex · check_hash/3 — the HMAC core source
# The data-check-string excludes BOTH "hash" and "signature" (pinned footgun),
# sorts the remaining keys, joins `key=value` with newlines. The compare is
# constant-time over the lower-case hex digest.
defp check_hash(raw, hash, token) do
  data_check_string =
    raw
    |> Map.drop(["hash", "signature"])
    |> Enum.sort_by(fn {k, _v} -> k end)
    |> Enum.map_join("\n", fn {k, v} -> k <> "=" <> v end)

  secret_key = :crypto.mac(:hmac, :sha256, "WebAppData", token)

  expected =
    :crypto.mac(:hmac, :sha256, secret_key, data_check_string)
    |> Base.encode16(case: :lower)

  if :crypto.hash_equals(expected, hash), do: :ok, else: {:error, :bad_hash}
end

The algorithm is pinned against the live Telegram WebApp docs, not memory, and two of its steps are the ones naive ports get wrong. First, the data-check-string excludes both hash and signature: signature is the newer Ed25519 third-party field, and a present signature left in the string corrupts the check. Second, the secret-key derivation is the WebApp one — HMAC-SHA256(key: "WebAppData", msg: token) — not the older Login-Widget SHA256(token).

The compare is :crypto.hash_equals(expected, hash) — constant-time, never ==: a non-constant-time compare is a timing oracle. Freshness rejects when now - auth_date > max_age_seconds (@default_max_age_seconds 86_400; :infinity disables the window), and opts[:now] injects the clock so a fixture is deterministic.

cm.4 §9 A4 pins the posture: a fixture signed with a known test token accepts; one flipped field is {:error, :bad_hash}; a present signature field is excluded (accept still holds); and a mutation flipping :crypto.hash_equals to a truthy constant must make the suite fail — the net-zero spot-check.

§2

The handshake — the sole mint

auth_controller.ex · session.ex

POST /api/auth/:platform (the AuthController.handshake/2 action) is the SOLE SES mint — the “issue here, verify everywhere else” point and the single writer the read-only-edge model depends on. The route is open: it issues the bearer, so there is no SES to present yet; the trust is in the initData signature, not a session.

echo/…/codemojex_web/controllers/auth_controller.ex · handshake/2 source
def handshake(conn, %{"platform" => platform} = params) do
  init_data = params["initData"] || get_init_data_header(conn)

  with {:ok, %{tg_user_id: uid} = claims} <-
         Codemojex.InitData.verify(init_data, Codemojex.Bot.token(), max_age_seconds: @max_age_seconds),
       {:ok, plr} <- Codemojex.resolve_player_by_tg(uid, name: display_name(claims)),
       {:ok, ses} <- Codemojex.Session.mint(plr, platform, %{"tg_user_id" => uid}) do
    json(conn, %{session: ses, player: plr})
  else
    # A verify / resolve / mint failure is unauthenticated, not a server error.
    # Render 401 here (the FallbackController's default for an unknown reason is
    # 400, which would mis-signal a verify failure).
    {:error, _reason} -> unauthorized(conn)
    _ -> unauthorized(conn)
  end
end

The chain is three verified steps. Codemojex.resolve_player_by_tg/2 is resolve-or-create: two handshakes for the same Telegram user resolve the same PLR — one players row, raced first-touch included (cm.4 §9 A2). Codemojex.Session.mint/3 generates EchoData.BrandedId.generate!("SES") — a 14-byte branded id, the 3-letter namespace SES + 11 Base62 over a ts|node|seq snowflake. The durable identity is the PLR (in Postgres); the SES references it and is ephemeral — Valkey-TTL + re-handshake on loss.

Any verify / resolve / mint failure renders a deterministic 401, never a 500, so a forged, stale, or unconfigured-token request is refused at the door. And the free-money gap is closed: POST /api/players is retired (cm.4 §9 A5 — the route is removed; a request is 404). The session value itself is JSON (Jason), the cross-language contract: EchoStore.Table.put frames it as SET ecc:{sessions}:<SES> (version<>json) PX ttl_ms — never :erlang.term_to_binary.

Forward — cm.4 §11, not the floor. The documented Go-edge read contract (strip the leading 14-byte version, then json.Unmarshal — read-only), bitmapist marking by the durable PLR, and SES Graft-durability are forward rungs; every one reads the same documented SES shape, and the floor forecloses none of them.

The handshake reads initData from the initData body param, or the x-telegram-init-data header as a fallback; @max_age_seconds 86_400 is the freshness window it passes to the verifier. The browser pipeline runs the same exchange through CodemojexWeb.MiniAppAuth, landing the SES in the Plug session for LiveView — the only SES writer on the browser path, so the single-writer model holds on both surfaces.

§3

The session and its revocation

tables.ex · auth.ex

:cm_sessions is the first MUTABLE EchoStore.Table — kind "SES", coherence: :tracking. The two existing caches (:cm_games, :cm_emojisets) run coherence: :none only because each holds an entity that is immutable for its life. A session is mutable and revocable, so :none here would be a security defect: a revoked SES surviving in L1 would keep authenticating.

The SES lifecycle across L1 and L2 — select a move
move operation across the layers next request mint/3 SET ecc:{sessions}:<SES> … PX writes L1 + L2 · one writer {:ok, ses} resolve/1 fetch → decode → re-put a hit slides the PX deadline {:ok, %{plr:, …}} revoke/1 invalidate → DEL + push every L1 evicted at once 401 — :unknown
revoke/1 → EchoStore.Table.invalidate drops both layers; the :tracking push (RESP3 CLIENT TRACKING) evicts the row from every BEAM holder’s L1 immediately — the next resolve/1 is {:error, :unknown}, the next request 401s. Revocation is a guarantee, not best-effort.
echo/…/codemojex/tables.ex · the :cm_sessions registration source
# The auth sessions — the FIRST mutable table (cm.4). `:tracking` so a
# revoke (a DEL on ecc:{sessions}:<SES>) is pushed by Valkey and evicts the
# row from every BEAM holder's L1 immediately. The loader is a clean miss
# (a SES has no relational system of record — it lives only in Valkey), so
# an unknown/expired/revoked SES is a fetch miss → the :auth plug 401s.
Supervisor.child_spec(
  {EchoStore.Table,
   name: @sessions,
   kind: "SES",
   loader: &load_session/1,
   coherence: :tracking,
   ttl_ms: sessions_ttl,
   max_size: 100_000,
   connector: connector},
  id: :cm_sessions_table
)

The loader is the quiet half of the design: a SES has no relational system of record — it lives only in Valkey — so load_session/1 answers a clean miss ({:error, :not_found}). An unknown, expired, or revoked SES is therefore a fetch miss, Codemojex.Session.resolve/1 maps it to {:error, :unknown}, and the request 401s. On a hit, resolve/1 re-puts the row unchanged — the sliding-TTL move re-stamps the version and the PX deadline, so an active player never re-handshakes mid-play.

The read side is the :auth plug (CodemojexWeb.Auth): it reads Authorization: Bearer <SES>, resolves through Codemojex.Session.resolve/1, and assigns conn.assigns.player — the ONLY identity a player-acting action reads. The router pipes the player-acting JSON routes through [:api, :auth]. The plug carries NO dev/test bypass (F2, D-2): a leaked auth-skip minting access into the shared Valkey would fool every service, so the suite mints a real SES via a test/support helper rather than a trust flag.

cm.4 §9 A3 names THE REVOCATION INVARIANT: mint a SES, prove an action authenticates with it, revoke/1 — and the NEXT request with that SES is 401, because :tracking evicted it from L1 and the DEL cleared L2. A10 holds the chapter’s privacy line: the game secret never crosses the wire (the privacy story scenarios stay green; the auth cutover does not touch the views).

§4

References

sources · related

Sources

Related