asobi_console_session (asobi v0.75.1)

View Source

Browser sessions for the operator console: an opaque cookie, and a CSRF token derived from it.

The console does not hold the operator secret, and it does not hold a bearer token either. It exchanges the secret once for a session, and every later request carries an HttpOnly cookie the page cannot read plus an x-csrf-token header the page holds in memory. The trade this makes is the one the console design settles on:

ModelWhat XSS yieldsPersistence
HttpOnly cookie + derived CSRFacting from the page while it is opennone - the cookie is unreadable
bearer in JS memorythe token, exfiltrated anywhereuntil it expires
bearer in localStoragethe same, and it survives reloadworse

The CSRF token is HMAC(node_secret, "csrf:" ++ cookie), so it is derived, not stored, and it exists only for a cookie that resolves to a live session. That is what separates it from a plain double-submit token: an attacker who can set a cookie in the victim's browser still cannot produce a value that verifies, because producing one needs the node secret.

It travels in a second, script-readable cookie so the page can send it back as a header after a reload. Holding it only in memory would end the session every time the operator refreshed, and a cross-origin page cannot read either cookie anyway - both are SameSite=Strict.

The node secret is per boot. Sessions live in an ETS table this process owns, so a restart takes both the table and the secret with it and every session in flight ends - which is the correct coupling, not a gap.

Expiry is absolute and resolve/2 does not extend it. A sliding window would mean a write from every reading process, and an ops session that never ends while a tab sits open is the wrong default for a surface that bans players.

Summary

Functions

Open a session for an operator who has already proved the secret.

Open a session carrying Caps and expiring no later than NotAfter.

The CSRF token for Id. Derived, so it is the same on every call.

End a session. Unknown ids are ok - logging out twice is not an error.

Resolve a cookie and its CSRF token to a live session.

Session lifetime in seconds. console_session_ttl, clamped to 1 minute - 1 day.

Types

reason()

-type reason() :: unknown | expired | bad_csrf.

session()

-type session() ::
          #{id := binary(),
            csrf := binary(),
            label := binary(),
            caps := [asobi_ops_caps:class()],
            expires_at := integer()}.

Functions

create(Label)

-spec create(binary()) -> {ok, session()}.

Open a session for an operator who has already proved the secret.

Label is the display name the audit trail carries. It is self-asserted - the credential is a shared secret, so nothing here attests who typed it - and it is held to the same shape asobi_ops_auth:display/1 holds the x-asobi-operator header to.

Every capability class but erasure. The secret proves all of them, so this is not about what was proved; it is about the medium the credential arrived on. A bearer secret in a config file is a script an operator wrote. A session cookie is a browser, which can be XSS'd or clickjacked into posting somewhere the operator never meant to - and an erasure is the one ops action no follow-up call can undo. Same secret, different blast radius, different default. Set console_erasure to true to erase from the console anyway.

create(Label, Caps, NotAfter)

-spec create(binary(), [asobi_ops_caps:class()], integer()) -> {ok, session()}.

Open a session carrying Caps and expiring no later than NotAfter.

A minted token proves only the classes it carries and expires in minutes, so exchanging one for a session must not widen either: the session inherits the token's capabilities, and its expiry is clamped to the token's. Otherwise a fifteen-minute credential with two classes would buy a twelve-hour session with three, which is the whole point of the token undone by the exchange.

Nothing is subtracted here, unlike create/1: a minted token carries exactly the classes the control plane decided to mint, and second-guessing that would put the decision in two places.

csrf(Id)

-spec csrf(binary()) -> binary().

The CSRF token for Id. Derived, so it is the same on every call.

delete/1

-spec delete(binary()) -> ok.

End a session. Unknown ids are ok - logging out twice is not an error.

handle_call/3

-spec handle_call(term(), gen_server:from(), #{}) -> {reply, term(), #{}}.

handle_cast(Message, State)

-spec handle_cast(term(), #{}) -> {noreply, #{}}.

handle_info/2

-spec handle_info(term(), #{}) -> {noreply, #{}}.

init/1

-spec init([]) -> {ok, #{}}.

resolve/2

-spec resolve(binary(), binary()) -> {ok, session()} | {error, reason()}.

Resolve a cookie and its CSRF token to a live session.

Both halves are required. A cookie alone is not a credential here: that is the whole point of the second layer, and it is why a cross-origin form post carrying the browser's cookie gets 403 rather than a ban.

start_link()

-spec start_link() -> gen_server:start_ret().

ttl_seconds()

-spec ttl_seconds() -> pos_integer().

Session lifetime in seconds. console_session_ttl, clamped to 1 minute - 1 day.