Supervised, auto-refreshing cookie + CSRF session.
UnifiApi.Auth.Cookie.login/4 returns a static Req.Request.t() —
fine for one-shot scripts, but when the controller rotates the CSRF
token mid-session, callers have to either notice the 403, manually
call refresh_csrf/1, and retry, or just re-login.
UnifiApi.Auth.Session wraps the auth state in a GenServer:
- The request struct returned by
client/1has request steps that pull the current cookies + CSRF from:persistent_term(keyed by a per-session ref) at send time — concurrent reads, no mailbox serialization. - Response steps capture any rotated
x-csrf-tokenheader from the response and update the GenServer state and the:persistent_termsnapshot for the next call.
Use this in long-running pollers that mix reads and writes against the v1 / v2 endpoints.
Why a GenServer
A GenServer is justified here per the
Iron Law: the CSRF token
mutates across calls and the rotation write needs to be serialised.
Reads, however, do not — they hit :persistent_term directly,
so concurrent consumers of one session no longer block on a single
mailbox. Read-only stateless usage should stick with
UnifiApi.Auth.Cookie.login/4.
Credentials
The username/password are not stored as separate fields in the
GenServer state (CWE-522). Supply a :relogin callback instead —
the lib calls it only when a fresh login is needed and never holds
the plaintext beyond the closure's own capture.
format_status/1 redacts the state, so :sys.get_status/1 and the
State: line of :gen_server's abnormal-termination report carry only
style/remember/pid.
One exposure remains, and it is a property of OTP's error reporting
rather than of this module. If a crash inside a callback is a
FunctionClauseError (or another error that captures its arguments), the
stacktrace prints those arguments — and the argument may be the authed
Req.Request, whose cookie and x-csrf-token headers Req's Inspect
implementation renders verbatim. format_status/1 cannot intercept a
stacktrace. If you forward crash reports to a third party, scrub
cookie and x-csrf-token at your Logger backend or error-tracker
boundary.
Quick start
children = [
{UnifiApi.Auth.Session,
name: MyApp.UnifiSession,
client: UnifiApi.new(base_url: "https://192.168.1.1",
cert_fingerprints: System.get_env("UNIFI_CERT_FINGERPRINTS", "")
|> String.split(",", trim: true)),
relogin: fn ->
UnifiApi.Auth.Cookie.login(
UnifiApi.new(base_url: "https://192.168.1.1"),
System.fetch_env!("UNIFI_USERNAME"),
System.fetch_env!("UNIFI_PASSWORD"),
style: :udm
)
end,
style: :udm}
]
Supervisor.start_link(children, strategy: :one_for_one)
# Anywhere in your app:
authed = UnifiApi.Auth.Session.client(MyApp.UnifiSession)
UnifiApi.Network.Events.list(authed, "default")Legacy username/password (deprecated, v0.5 removal)
For backward compatibility, :username + :password are still
accepted — the session synthesises a :relogin closure from them.
This keeps the plaintext alive for the process lifetime via the
closure, so prefer the explicit :relogin callback for new code.
Summary
Types
Options accepted by start_link/1.
Functions
Returns a Req.Request.t() configured to pull the current cookies + CSRF
from this session on every request and to capture rotated tokens.
Returns the current CSRF token. Like client/1, a direct
:persistent_term read with no process hop.
Forces a CSRF refresh by issuing a lightweight GET against the controller. Useful after a 403 to recover without a full re-login.
Forces a full re-login. Use this when the session has fully expired (typically a 401 on a request that worked previously).
Starts the session and logs in synchronously during init/1.
Types
@type option() :: {:client, Req.Request.t()} | {:relogin, (-> {:ok, Req.Request.t()} | {:error, term()})} | {:username, String.t()} | {:password, String.t()} | {:style, :udm | :cloud_key} | {:name, GenServer.name()} | {:remember, boolean()}
Options accepted by start_link/1.
:client— baseReq.Request.t()fromUnifiApi.new/1. Required.:relogin— 0-arity callback returning{:ok, Req.Request.t()} | {:error, term()}. Preferred over:username/:password(CWE-522).:username/:password— controller credentials. Deprecated (v0.5 removal): supply:relogininstead. When given, the session builds an internal:reloginclosure that callsUnifiApi.Auth.Cookie.login/4with them.:style—:udm(default) or:cloud_key.:name— process name (any GenServer name).:remember— passed through toUnifiApi.Auth.Cookie.login/4.
Functions
@spec client(GenServer.server()) :: Req.Request.t()
Returns a Req.Request.t() configured to pull the current cookies + CSRF
from this session on every request and to capture rotated tokens.
This is a plain :persistent_term read — no message is sent to the
session process. That matters: handle_call(:relogin, ...) performs a
blocking HTTP round trip, so while a re-login is in flight a
GenServer.call-based client/1 would park every caller in the session's
mailbox behind a full login.
Raises if the session is not running, matching what a GenServer.call/2
to a dead process did.
@spec csrf_token(GenServer.server()) :: String.t() | nil
Returns the current CSRF token. Like client/1, a direct
:persistent_term read with no process hop.
@spec refresh(GenServer.server(), timeout()) :: :ok | {:error, term()}
Forces a CSRF refresh by issuing a lightweight GET against the controller. Useful after a 403 to recover without a full re-login.
timeout must exceed the HTTP budget of the client this session was built
with. The default is 60000ms; the previous 5s default reliably
raised exit(:timeout) while the session process carried on working, and
took every queued caller down with it.
@spec relogin(GenServer.server(), timeout()) :: :ok | {:error, term()}
Forces a full re-login. Use this when the session has fully expired (typically a 401 on a request that worked previously).
Concurrent callers are coalesced: the request carries the monotonic
timestamp at which it was made, and if a login has already succeeded
after that instant the session replies :ok immediately instead of
logging in again. On expiry every consumer sees a 401 at once, and without
this the requests serialise into N sequential full logins hammering the
controller.
@spec start_link([option()]) :: GenServer.on_start()
Starts the session and logs in synchronously during init/1.
Returns {:error, %UnifiApi.AuthError{}} (or other error term) if
login fails — the supervisor will see this and apply its restart
policy.