defmodule OpenAgentsWeb.UserAuth do
@moduledoc "Session and LiveView authorization for active OpenAgents users."
use OpenAgentsWeb, :verified_routes
import Plug.Conn
alias OpenAgents.Accounts
alias OpenAgents.DeviceAuthorizations
@session_key "user_id"
# Read back by `OpenAgentsWeb.AuthController.callback/2`. Both ends go
# through `DeviceAuthorizations.cast_user_code/1`, so nothing but a code this
# application mints ever reaches it or leaves it.
@device_session_key "device_user_code"
# The path is written out because a route pattern cannot be a `~p` sigil.
# `OpenAgentsWeb.RouteAuthority` classifies the same literal.
@device_path "/device"
@doc "The session key carrying a device authorization across the sign-in."
def device_session_key, do: @device_session_key
def put_no_store(conn, _options), do: put_resp_header(conn, "cache-control", "no-store")
def fetch_current_user(conn, _options) do
with user_id when is_binary(user_id) <- get_session(conn, @session_key),
{:ok, user} <- Accounts.get_active_user(user_id) do
Plug.Conn.assign(conn, :current_user, user)
else
_missing_or_inactive ->
conn
|> delete_session(@session_key)
|> Plug.Conn.assign(:current_user, nil)
end
end
def require_authenticated_user(
%{assigns: %{current_user: %{status: "active"}}} = conn,
_options
),
do: conn
def require_authenticated_user(conn, _options) do
{conn, sign_in_path} = remember_device_authorization(conn)
conn
|> put_resp_header("cache-control", "no-store")
|> Phoenix.Controller.redirect(to: sign_in_path)
|> halt()
end
# `/device` is the one authenticated route a reader reaches before they have
# a session here: a terminal sent them, and the code it printed is in the
# link. Bouncing them to the public root and forgetting the code turns one
# intent into two errands — sign in, then go and find the code again — which
# is what issue #129 is about.
#
# So the code is remembered in the session on the way out.
# `OpenAgentsWeb.AuthController.callback/2` reads it back after the OAuth
# round trip and returns the reader to the approval with the code already in
# hand, so approving is the next click. Carrying it in the session rather
# than in the sign-in form means every sign-in control returns them —
# the landing page's and the command bar's alike — instead of only the one
# that was threaded with the code.
#
# It rides in the URL as well, but only so the landing page can say what the
# sign-in is for. The session is the authority; the parameter is display.
#
# Only a code travels, never a path. `cast_user_code/1` admits exactly the
# shape this application mints, so a crafted `?user_code=` carrying a host, a
# path, a newline, or markup is refused here and the reader lands on the
# public root exactly as they did before. This is the only place in the
# sign-out path that builds a redirect from anything the browser sent, and it
# builds one of two constants either way.
defp remember_device_authorization(%{request_path: @device_path} = conn) do
conn = Plug.Conn.fetch_query_params(conn)
case DeviceAuthorizations.cast_user_code(conn.query_params["user_code"]) do
{:ok, code} ->
{put_session(conn, @device_session_key, code), ~p"/?user_code=#{code}"}
# Reaching the device page without a usable code says the reader is not
# mid-flow with the one we may have remembered earlier. Drop it rather
# than let it decide where a later sign-in lands.
:error ->
{delete_session(conn, @device_session_key), ~p"/"}
end
end
defp remember_device_authorization(conn), do: {conn, ~p"/"}
def require_admin_user(conn, _options) do
if Accounts.admin?(conn.assigns[:current_user]) do
conn
else
conn
|> put_resp_header("cache-control", "no-store")
|> Phoenix.Controller.redirect(to: ~p"/")
|> halt()
end
end
# Which sections the reader has collapsed, so the first paint already agrees
# with them. See `OpenAgentsWeb.Plugs.SidebarSections`.
defp assign_sidebar_sections(socket, session) do
Phoenix.Component.assign(
socket,
:sidebar_sections,
OpenAgentsWeb.Plugs.SidebarSections.from_session(session)
)
end
# The scope is the user, plus the answers the layout needs on every render
# and must not re-ask for. Resolved once here, at mount.
#
# It deliberately does not depend on the page's params. It used to carry the
# repository the sidebar's Issues and Projects rows pointed at, read from
# `:owner`/`:repo` when the route had them, so a global nav row silently
# retargeted as you browsed. Those rows now address `/issues` and
# `/projects`, which mean the same thing on every page.
defp scope(user) do
%{
user
| agent_surfaces?: OpenAgents.Conversations.user_has_messages?(user),
unread_notifications: OpenAgents.Notifications.unread_count(user)
}
end
# The unread count is the one thing on the scope that goes stale while the
# reader sits still: a comment lands, or they mark something read, and the
# sidebar badge is wrong until the next navigation. So the count is refreshed
# in place rather than only at mount.
#
# It lives here rather than in each LiveView because the sidebar renders
# inside `Layouts.app/1` on every page, and threading a second assign through
# every call site would put the badge's correctness in the hands of whoever
# writes the next surface. One subscription per connected session, one
# aggregate per event addressed to that account, and nothing on the render
# path asks the database at all.
#
# The topic is keyed by the session's own user id, never by anything the
# browser sent, so this widens what the scope carries without widening whose
# data it can reach.
defp watch_unread_notifications(socket, user) do
if Phoenix.LiveView.connected?(socket) do
:ok = OpenAgents.Notifications.subscribe_unread(user)
Phoenix.LiveView.attach_hook(
socket,
:unread_notifications,
:handle_info,
&recount_unread/2
)
else
socket
end
end
defp recount_unread({:unread_notifications_changed, _user_id}, socket) do
case socket.assigns[:current_user] do
%Accounts.User{} = user ->
{:halt,
Phoenix.Component.assign(
socket,
:current_scope,
%{
socket.assigns.current_scope
| unread_notifications: OpenAgents.Notifications.unread_count(user)
}
)}
_signed_out ->
{:halt, socket}
end
end
defp recount_unread(_message, socket), do: {:cont, socket}
def on_mount(:mount_current_user, _params, session, socket) do
socket = assign_sidebar_sections(socket, session)
with user_id when is_binary(user_id) <- session[@session_key],
{:ok, user} <- Accounts.get_active_user(user_id) do
{:cont,
socket
|> Phoenix.Component.assign(:current_user, user)
|> Phoenix.Component.assign(:current_scope, scope(user))
|> watch_unread_notifications(user)}
else
_missing_or_inactive ->
{:cont,
socket
|> Phoenix.Component.assign(:current_user, nil)
|> Phoenix.Component.assign(:current_scope, nil)}
end
end
def on_mount(:ensure_authenticated, _params, session, socket) do
socket = assign_sidebar_sections(socket, session)
with user_id when is_binary(user_id) <- session[@session_key],
{:ok, user} <- Accounts.get_active_user(user_id) do
{:cont,
socket
|> Phoenix.Component.assign(:current_user, user)
|> Phoenix.Component.assign(:current_scope, scope(user))
|> watch_unread_notifications(user)
|> Phoenix.LiveView.attach_hook(
:active_user_guard,
:handle_event,
&ensure_active_event/3
)}
else
_missing_or_inactive ->
{:halt,
socket
|> Phoenix.LiveView.put_flash(:error, "Log in with GitHub to continue.")
|> Phoenix.LiveView.redirect(to: ~p"/")}
end
end
def on_mount(:ensure_admin, _params, _session, socket) do
with %{id: user_id} <- socket.assigns[:current_user],
{:ok, user} <- Accounts.get_active_user(user_id),
true <- Accounts.admin?(user) do
{:cont,
socket
|> Phoenix.Component.assign(:current_user, user)
|> Phoenix.LiveView.attach_hook(:admin_guard, :handle_event, &ensure_admin_event/3)}
else
_not_an_operator ->
{:halt, Phoenix.LiveView.redirect(socket, to: ~p"/")}
end
end
defp ensure_active_event(_event, _params, socket) do
case Accounts.get_active_user(socket.assigns.current_user.id) do
{:ok, user} ->
{:cont, Phoenix.Component.assign(socket, :current_user, user)}
{:error, _inactive_or_missing} ->
{:halt,
socket
|> Phoenix.LiveView.put_flash(:error, "This session is no longer active.")
|> Phoenix.LiveView.redirect(to: ~p"/")}
end
end
defp ensure_admin_event(_event, _params, socket) do
with {:ok, user} <- Accounts.get_active_user(socket.assigns.current_user.id),
true <- Accounts.admin?(user) do
{:cont, Phoenix.Component.assign(socket, :current_user, user)}
else
_not_an_operator -> {:halt, Phoenix.LiveView.redirect(socket, to: ~p"/")}
end
end
def require_authenticated_api_user(
%{assigns: %{current_user: %{status: "active"}}} = conn,
_options
),
do: conn
def require_authenticated_api_user(conn, _options) do
conn
|> put_status(:unauthorized)
|> put_resp_header("cache-control", "no-store")
|> Phoenix.Controller.json(%{error: "authentication_required"})
|> halt()
end
def require_operator_api_user(conn, _options) do
if Accounts.admin?(conn.assigns[:current_user]) do
conn
else
conn
|> put_status(:forbidden)
|> put_resp_header("cache-control", "no-store")
|> Phoenix.Controller.json(%{error: "operator_required"})
|> halt()
end
end
end