PhoenixKitWeb.Users.MultiSession (phoenix_kit v1.7.227)

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/2, switch_to/2, remove_account/2, logout helpers) take and return a Plug.Conn.

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').

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.

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

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.

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.

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

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, event \\ "session.account_added")

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.

event names the activity-feed action written on success. 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.

delete_all_stack_tokens(conn)

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

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.

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.

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.

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.

remove_account(conn, ref)

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

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.