UnifiApi.Auth.Session (UnifiApi v0.4.0)

Copy Markdown View Source

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/1 has 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-token header from the response and update the GenServer state and the :persistent_term snapshot 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

option()

@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 — base Req.Request.t() from UnifiApi.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 :relogin instead. When given, the session builds an internal :relogin closure that calls UnifiApi.Auth.Cookie.login/4 with them.
  • :style:udm (default) or :cloud_key.
  • :name — process name (any GenServer name).
  • :remember — passed through to UnifiApi.Auth.Cookie.login/4.

Functions

client(session)

@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.

csrf_token(session)

@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.

refresh(session, timeout \\ 60000)

@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.

relogin(session, timeout \\ 60000)

@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.

start_link(opts)

@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.