asobi_console_session (asobi v0.84.0)
View SourceBrowser 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:
| Model | What XSS yields | Persistence |
|---|---|---|
HttpOnly cookie + derived CSRF | acting from the page while it is open | none - the cookie is unreadable |
| bearer in JS memory | the token, exfiltrated anywhere | until it expires |
bearer in localStorage | the same, and it survives reload | worse |
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
-type reason() :: unknown | expired | bad_csrf.
-type session() :: #{id := binary(), csrf := binary(), label := binary(), caps := [asobi_ops_caps:class()], expires_at := integer()}.
Functions
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.
-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.
The CSRF token for Id. Derived, so it is the same on every call.
-spec delete(binary()) -> ok.
End a session. Unknown ids are ok - logging out twice is not an error.
-spec handle_call(term(), gen_server:from(), #{}) -> {reply, term(), #{}}.
-spec handle_cast(term(), #{}) -> {noreply, #{}}.
-spec handle_info(term(), #{}) -> {noreply, #{}}.
-spec init([]) -> {ok, #{}}.
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.
-spec start_link() -> gen_server:start_ret().
-spec ttl_seconds() -> pos_integer().
Session lifetime in seconds. console_session_ttl, clamped to 1 minute - 1 day.