PhoenixKitWeb.Users.MultiSession (phoenix_kit v2.23.0)

Copy Markdown View Source

Multi-account session switching.

The Plug session holds an ordered stack of raw session tokens under :pk_session_accounts. hd/1 of the stack is the ROOT account (the original login). The currently active token stays in :user_token, so all existing auth resolution (fetch_phoenix_kit_current_*, on_mount) is untouched.

Read helpers (gate_allowed?/1, list_accounts/1) take the string-keyed session map (works from both the plug and the LiveView on_mount). Conn-mutating ops (add_account/3, add_authenticated_user/3, switch_to/2, remove_account/2, logout helpers) take and return a Plug.Conn.

Surviving a lost session

The Plug session is the only place the stack lives, and on most hosts that is a browser-session cookie: mix phx.new ships @session_options with no max_age, so it is gone on browser restart. The remembered identity survives that — PhoenixKitWeb.Users.Auth.ensure_user_token/1 rebuilds the session from the remember-me cookie — but that cookie holds exactly ONE token, so every account the user had added silently disappeared and the switcher came back holding a single row.

So the added accounts are mirrored into a second persistent cookie (persist_account/2), and restore_persisted_accounts/2 rebuilds the whole stack next to the remembered root. Three rules keep that mirror honest:

  • It never outlives the remembered identity. Nothing is written unless this browser already holds a remember-me cookie, so a deliberately session-only login stays session-only for every account in it, and remember_me_enabled: false blocks the mirror exactly as it blocks remember-me.
  • It holds only what the user asked for. An impersonation is support access, not an account of the operator's, so impersonate/2 appends to the session stack and writes nothing here: "sign in as this user" ends with the browser session, as it did before any of this existed.
  • It is bound to the identity that built it. The cookie names the root token it belongs to, and restore_persisted_accounts/2 refuses a mirror naming any other. A fresh login clears the cookie too, but that is hygiene, not the control: a browser is free to ignore a deletion, and the tokens in a stale mirror stay valid until they expire — so on a shared computer the next person to sign in would otherwise inherit the previous user's accounts.

The restored account becomes the root; the account that happened to be active when the browser closed is not remembered. Coming back as the identity you logged in as is the predictable outcome — and the safe one, since the alternative is resuming inside a borrowed account.

Summary

Functions

Validates credentials and appends a real session for that user to the stack, making it the active account. The new account may be any role; the gate is enforced by the caller (controller) against the root account.

Appends an already-authenticated (active) user to the session stack and makes them the active account. Shares all invariants with add_account/3

Deletes every stack token from the DB (used by 'Log out all').

Drops token from the persistent cookie — the counterpart of persist_account/2 for an account being removed or logged out.

Drops the persistent cookie entirely.

True when the root session belongs to ANY authenticated user AND the multi_session_enabled setting is on. Evaluated against the root so the switcher stays visible even when a secondary account is active.

True when actor may borrow target's account.

The subset of users the actor may sign in as, as a MapSet of uuids.

Adds target to the session stack on an administrator's authority, without their password — "log in as this user".

The account an impersonation would be judged against — the session's ROOT, never the account currently active.

Resolves each stack token to a render struct: %{ref, user, email, role, active?, root?}. Tokens that no longer resolve to a user (expired/deleted) are dropped. role labels the roles in effect for that account's session (PhoenixKit.Users.ActiveRole).

Records an impersonation attempt refused before a target was resolved, so the controller's authority-first ordering does not cost the feed an entry.

Logs out the active account. When a non-root account is active, deletes it and switches back to root ({:switched, conn, root_user}). When the root account is active, signals a full logout ({:full, conn}) for the caller to run.

Maximum number of accounts allowed in one stack.

True when the session's ROOT account holds the authority impersonate/2 requires before it will look at a target at all.

Mirrors token into the persistent cookie, so the account survives a lost session cookie.

Removes a non-root token from the stack and deletes it from the DB.

Rebuilds the session stack around root_token from the persistent cookie.

Returns the user's most descriptive display role name.

role_label/1 for callers that already hold the role names.

Resolves the two transient Scope fields {multi_session_allowed?, multi_session_accounts} for a session in one call.

The list of raw session tokens in the stack. Falls back to the single active token when no explicit stack is stored, and [] when there is no active token.

Activates a token already present in the stack, identified by ref.

Functions

add_account(conn, email_or_username, password)

Validates credentials and appends a real session for that user to the stack, making it the active account. The new account may be any role; the gate is enforced by the caller (controller) against the root account.

Returns {:error, :already_in_stack} if the user is already present.

add_authenticated_user(conn, user, opts \\ [])

Appends an already-authenticated (active) user to the session stack and makes them the active account. Shares all invariants with add_account/3:

  • Stack-limit check (:stack_full)
  • Dedup check — returns {:error, :already_in_stack} if the user is already present
  • Session-fixation protection via renew_and_put_active_token/2

Used by the OAuth add-account callback so the same logic applies whether the user was authenticated via password or via OAuth.

Options

  • :event — the activity-feed action written on success (default "session.account_added"). It exists so impersonate/2 can record what actually happened instead of a second row saying session.account_added — in the feed those two are the same sentence, and one of them is a user adding an account of their own.
  • :persist — whether the account joins this browser's durable stack (default true). impersonate/2 passes false; see its docstring.

delete_all_stack_tokens(conn)

Deletes every stack token from the DB (used by 'Log out all').

forget_account(conn, token)

@spec forget_account(Plug.Conn.t(), binary()) :: Plug.Conn.t()

Drops token from the persistent cookie — the counterpart of persist_account/2 for an account being removed or logged out.

Reads the remembered identity directly rather than through remembered_root/1: a browser must be able to stop persisting an account it already holds even after an operator turns remember_me_enabled off.

forget_persisted_accounts(conn)

@spec forget_persisted_accounts(Plug.Conn.t()) :: Plug.Conn.t()

Drops the persistent cookie entirely.

Called wherever the session it mirrors ends or is replaced: full logout, a fresh login (the stack belongs to whoever was signed in before), and the plug's own recovery path when there is no remembered identity to hang a stack on.

gate_allowed?(session)

True when the root session belongs to ANY authenticated user AND the multi_session_enabled setting is on. Evaluated against the root so the switcher stays visible even when a secondary account is active.

Anonymous (no root token / no valid user) always returns false.

impersonable?(actor, target)

@spec impersonable?(
  PhoenixKit.Users.Auth.User.t() | nil,
  PhoenixKit.Users.Auth.User.t()
) :: boolean()

True when actor may borrow target's account.

Answers with the same rules impersonate/2 enforces — it calls the very same private predicate — so a menu built on this cannot offer an action the request would then refuse, and cannot hide one it would have allowed.

A deactivated target answers false. That refusal (:inactive) is raised by add_authenticated_user/2 rather than by the authority rules, so asking the rules alone would put the offer on every deactivated row in the admin list — where the status is displayed next to it — and every click would come back "That account is deactivated."

The remaining reasons impersonate/2 may still decline (the stack being full, or the target already sitting in it) depend on session state at request time, are recoverable, and report themselves through the controller's flash rather than by silently removing the option.

Target roles come from the :roles preload when the caller has one — the user detail page loads its user through get_user_with_roles/1 — and from a lookup otherwise.

impersonable_uuids(actor, users)

@spec impersonable_uuids(PhoenixKit.Users.Auth.User.t() | nil, [
  PhoenixKit.Users.Auth.User.t()
]) ::
  MapSet.t()

The subset of users the actor may sign in as, as a MapSet of uuids.

impersonable?/2 reads roles from the database — three lookups per call, once the staff?/1 check is counted — which is fine for one user but is an N+1 per row in a list. This reads the actor's roles once and each target's from the :roles preload the caller already has, falling back to a lookup only for a row that arrives without one. Decisions come from the same private predicate impersonate/2 uses, so the two cannot diverge.

Deactivated rows are left out for the reason given on impersonable?/2.

assign(socket, :impersonable_uuids, MultiSession.impersonable_uuids(actor, users))

and in the template :if={user.uuid in @impersonable_uuids}.

impersonate(conn, target)

@spec impersonate(Plug.Conn.t(), PhoenixKit.Users.Auth.User.t()) ::
  {:ok, Plug.Conn.t()}
  | {:error,
     :not_allowed
     | :target_is_owner
     | :target_is_staff
     | :stack_full
     | :already_in_stack
     | :inactive
     | :self}

Adds target to the session stack on an administrator's authority, without their password — "log in as this user".

Shares every invariant of add_authenticated_user/2 and adds the authority checks that separate support access from account takeover:

  • the root account decides, never the active one. Otherwise an administrator could impersonate a user and, from inside that session, impersonate someone the user could never reach;
  • the root must hold the Owner or Admin role. Deliberately not can_access_admin_area?/1: that is true for any permission holder, so a customer granted one self-service permission would qualify — and could then borrow another customer's account;
  • an Owner is never a target. The one account that can undo anything must not be reachable by borrowing it;
  • an Admin root cannot take another Admin either — support access is for the people being supported, not sideways between staff. An Owner root may, because there is nothing above it to escalate to.

A success is logged as session.impersonated — one row, written in place of the session.account_added the stack append would otherwise have written. A refusal is logged as session.impersonation_refused with the deciding rule in metadata["reason"]: an impersonation nobody can see afterwards is the thing that makes this feature dangerous, and a rejected attempt to borrow the owner's account is the entry whoever watches the feed most wants to find.

Refusal rows carry no target_uuid on purpose. Activity.log/1 fans a row with one out to that user's notification inbox, and a refused attempt is a signal for the feed, not a message to the person it named.

impersonation_actor(session)

@spec impersonation_actor(map()) :: PhoenixKit.Users.Auth.User.t() | nil

The account an impersonation would be judged against — the session's ROOT, never the account currently active.

Returns nil when gate_allowed?/1 is false, which makes impersonable?/2 answer false for every target and takes the offer off the menu. That check belongs here rather than at the call sites: the controller opens with the same gate (with_gate), so without it a menu could offer impersonation while multi_session_enabled is off and the POST would bounce to the home page with "Multi-account switching is not available." The authority rules in authorize_impersonation/2 never see the setting, so asking them alone is not enough to predict the outcome.

Pair with impersonable?/2 to offer the action only where it would succeed. A LiveView can hold the result across a mount safely: it is a User struct, so nothing keeps a session token in the socket.

list_accounts(session)

Resolves each stack token to a render struct: %{ref, user, email, role, active?, root?}. Tokens that no longer resolve to a user (expired/deleted) are dropped. role labels the roles in effect for that account's session (PhoenixKit.Users.ActiveRole).

log_impersonation_refusal(conn, target_uuid)

@spec log_impersonation_refusal(Plug.Conn.t(), String.t()) :: :ok

Records an impersonation attempt refused before a target was resolved, so the controller's authority-first ordering does not cost the feed an entry.

log_out_active(conn)

Logs out the active account. When a non-root account is active, deletes it and switches back to root ({:switched, conn, root_user}). When the root account is active, signals a full logout ({:full, conn}) for the caller to run.

max_accounts()

Maximum number of accounts allowed in one stack.

may_impersonate?(session)

@spec may_impersonate?(map()) :: boolean()

True when the session's ROOT account holds the authority impersonate/2 requires before it will look at a target at all.

Exposed so the controller can refuse an unauthorized actor before it resolves the uuid: resolving first answers "does this account exist?" with a distinct message, and this endpoint is reachable by every signed-in user, not only by staff. Shares staff?/1 with authorize_impersonation/2 so the two rules cannot drift apart.

persist_account(conn, token)

@spec persist_account(Plug.Conn.t(), binary()) :: Plug.Conn.t()

Mirrors token into the persistent cookie, so the account survives a lost session cookie.

A no-op unless this browser already holds a remember-me cookie: the mirror must not give an account more persistence than the login it was added from.

remove_account(conn, ref)

Removes a non-root token from the stack and deletes it from the DB.

restore_persisted_accounts(conn, root_token)

@spec restore_persisted_accounts(Plug.Conn.t(), binary()) :: Plug.Conn.t()

Rebuilds the session stack around root_token from the persistent cookie.

Called from the plug's remember-me recovery, the one moment a real session is reconstructed from cookies alone.

The mirror is accepted only if it names root_token as the identity it was built under. Clearing the cookie at login is a request the browser is free to ignore, and the tokens in a stale mirror stay valid until they expire — so without this check the next person to sign in on a shared browser inherits the previous user's accounts, live, in their own switcher. Binding makes that unforgeable rather than merely unlikely: a login mints a new session token, so a mirror written before it can never name the new root.

Tokens that no longer resolve to an active user are dropped and pruned from the cookie: this is the only pass that ever looks at them, so without it a revoked account would be re-offered for the cookie's full life.

Restores nothing while multi_session_enabled is off, and takes the cookie with it — turning the feature off should not leave a browser quietly holding other people's sessions until someone turns it back on.

role_label(user)

Returns the user's most descriptive display role name.

Priority: Owner > Admin > first custom (non-"User") role > "User". This correctly labels custom roles (e.g. "Manager", "Client") instead of bucketing all permission-holders as "Admin".

Use this for any "what is this account?" label. In particular do not derive one from Scope.can_access_admin_area?/1: that gate is true for Owner, Admin or any single permission holder, so a Client — who holds client_portal — reads back as "Admin".

Labels the roles IN EFFECT — for a user acting as one role (PhoenixKit.Users.ActiveRole), that role — read through ActiveRole.effective_role_names/1 rather than by building a full Scope: the scope carries an opaque MapSet of permissions we don't need here (and constructing it tripped a Dialyzer opaqueness warning).

role_label_from_roles(roles)

@spec role_label_from_roles([String.t()]) :: String.t()

role_label/1 for callers that already hold the role names.

Auth.User.get_roles/1 queries, so a render path with the names in hand — Scope's cached_roles, loaded once at Scope.for_user/1 — should pass them here instead of handing over the user and paying for the lookup again.

scope_fields(session)

Resolves the two transient Scope fields {multi_session_allowed?, multi_session_accounts} for a session in one call.

Crucially, the (DB-heavy) account stack is resolved ONLY when the setting is on. When multi_session_enabled is off — the default — this short-circuits to {false, []} without touching the DB, so the hot auth path (plug + every LiveView mount) pays nothing for a feature that is disabled.

When it IS on, allowed? is derived from the resolved stack (a surviving root account) rather than a separate gate_allowed?/1 call, which would re-resolve the root token in its own query on top of the list_accounts/1 walk.

stack_tokens(session)

The list of raw session tokens in the stack. Falls back to the single active token when no explicit stack is stored, and [] when there is no active token.

switch_to(conn, ref)

Activates a token already present in the stack, identified by ref.