Packages

WebSocket-first Elixir framework with auto-generated HTTP MCP APIs

Current section

Files

Jump to
dialup lib auth.ex
Raw

lib/auth.ex

defmodule Dialup.Auth do
@moduledoc """
Authentication helpers for Dialup applications.
Dialup separates two session concepts:
- `dialup_session` — connection ID for `UserSessionProcess` (anonymous until enriched)
- `_dialup_user_token` — opaque auth token resolved to `current_user`
Configure an accounts module on the app or plug:
use Dialup,
auth_accounts: MyApp.Accounts
plugs: [{Dialup.Auth.Plug, accounts: MyApp.Accounts}]
"""
@user_token_cookie "_dialup_user_token"
@csrf_cookie "_dialup_csrf"
@doc "Cookie name for the opaque user session token."
def user_token_cookie, do: @user_token_cookie
@doc "Cookie name for CSRF double-submit."
def csrf_cookie, do: @csrf_cookie
@doc "Returns the configured accounts module for an app, if any."
@spec accounts_module(atom()) :: module() | nil
def accounts_module(app_module) when is_atom(app_module) do
if function_exported?(app_module, :__auth_accounts__, 0) do
app_module.__auth_accounts__()
end
end
@doc "Resolves the current user from conn assigns (set by Dialup.Auth.Plug)."
@spec current_user(Plug.Conn.t()) :: map() | struct() | nil
def current_user(%Plug.Conn{assigns: %{dialup_current_user: user}}), do: user
def current_user(%Plug.Conn{}), do: nil
@doc "Fetches the current user from assigns or cookies."
@spec fetch_current_user(Plug.Conn.t(), module()) :: {Plug.Conn.t(), map() | struct() | nil}
def fetch_current_user(conn, accounts_module) do
conn = Plug.Conn.fetch_cookies(conn)
user =
current_user(conn) ||
user_from_cookies(conn.cookies, accounts_module)
{Plug.Conn.assign(conn, :dialup_current_user, user), user}
end
@doc "Returns the opaque auth session token from cookies, if present."
@spec session_token_from_cookies(map()) :: binary() | nil
def session_token_from_cookies(cookies) when is_map(cookies) do
Map.get(cookies, @user_token_cookie)
end
@doc "Resolves a user from request cookies using the accounts module."
@spec user_from_cookies(map(), module()) :: map() | struct() | nil
def user_from_cookies(cookies, accounts_module) when is_map(cookies) do
case session_token_from_cookies(cookies) do
nil -> nil
token -> accounts_module.get_user_by_session_token(token)
end
end
@doc """
Resolves the current user from plug assigns or cookies.
Matches HTTP page rendering: prefers `dialup_current_user` from `Dialup.Auth.Plug`,
then falls back to cookie resolution when `auth_accounts` is configured.
"""
@spec resolve_conn_user(Plug.Conn.t(), atom()) :: map() | struct() | nil
def resolve_conn_user(conn, app_module) do
case current_user(conn) do
nil ->
conn = Plug.Conn.fetch_cookies(conn)
case accounts_module(app_module) do
nil -> nil
accounts -> user_from_cookies(conn.cookies, accounts)
end
user ->
user
end
end
@doc "Returns the opaque auth session token from a connection's cookies."
@spec session_token(Plug.Conn.t()) :: binary() | nil
def session_token(conn) do
conn = Plug.Conn.fetch_cookies(conn)
session_token_from_cookies(conn.cookies)
end
@doc "Seeds layout session with `current_user` before layout.mount runs."
@spec initial_session(map() | struct() | nil) :: map()
def initial_session(nil), do: %{}
def initial_session(user), do: %{current_user: user}
@doc "Merges auth context into an existing session map."
@spec build_layout_session(map(), map()) :: map()
def build_layout_session(session, %{current_user: user}) when not is_nil(user) do
Map.put(session, :current_user, user)
end
def build_layout_session(session, _auth_context), do: session
@doc "Projects a user for Dialup session / agent_state (no secrets)."
@spec public_user(map() | struct() | nil) :: map() | nil
def public_user(nil), do: nil
def public_user(user) do
id = Map.get(user, :id) || Map.get(user, "id")
email = Map.get(user, :email) || Map.get(user, "email")
role = Map.get(user, :role) || Map.get(user, "role") || :member
%{id: id, email: email, role: role}
end
@doc "Returns cookie options aligned with the app's `:secure_cookies` setting."
@spec cookie_opts(Plug.Conn.t()) :: keyword()
def cookie_opts(conn) do
app = conn.private[:dialup_app]
[http_only: true, same_site: "Lax", secure: secure_cookie?(conn, app)]
end
@doc "Returns a CSRF token for forms, setting the cookie when needed."
@spec csrf_token(Plug.Conn.t()) :: {Plug.Conn.t(), binary()}
def csrf_token(conn) do
conn = Plug.Conn.fetch_cookies(conn)
case conn.cookies[@csrf_cookie] do
token when is_binary(token) and token != "" ->
{conn, token}
_ ->
token = Base.url_encode64(:crypto.strong_rand_bytes(32), padding: false)
conn = Plug.Conn.put_resp_cookie(conn, @csrf_cookie, token, cookie_opts(conn))
{conn, token}
end
end
@doc "Returns a same-origin relative path or `/` when the target is unsafe."
@spec safe_redirect_path(term()) :: binary()
def safe_redirect_path(path) when is_binary(path) do
if safe_local_redirect?(path), do: path, else: "/"
end
def safe_redirect_path(_path), do: "/"
@doc "Validates a CSRF token from form params against the cookie."
@spec valid_csrf?(Plug.Conn.t(), map()) :: boolean()
def valid_csrf?(conn, params) when is_map(params) do
conn = Plug.Conn.fetch_cookies(conn)
cookie = conn.cookies[@csrf_cookie]
param = Map.get(params, "csrf_token") || Map.get(params, :csrf_token)
is_binary(cookie) and is_binary(param) and cookie != "" and
Plug.Crypto.secure_compare(cookie, param)
end
@doc "Logs a user in by issuing the auth cookie and rotating dialup_session."
@spec log_in_user(Plug.Conn.t(), map() | struct(), module()) :: Plug.Conn.t()
def log_in_user(conn, user, accounts_module) do
token = accounts_module.generate_user_session_token(user)
opts = Keyword.merge(cookie_opts(conn), max_age: Dialup.Auth.SessionToken.ttl(:session))
conn
|> Plug.Conn.put_resp_cookie(@user_token_cookie, token, opts)
|> Plug.Conn.assign(:dialup_current_user, user)
|> sync_connection_sessions(user, auth_session_token: token)
end
@doc "Logs a user out, clearing the auth cookie and rotating dialup_session."
@spec log_out_user(Plug.Conn.t(), module()) :: Plug.Conn.t()
def log_out_user(conn, accounts_module) do
conn = Plug.Conn.fetch_cookies(conn)
opts = cookie_opts(conn)
old_session_id = conn.cookies["dialup_session"]
tokens_to_revoke =
[conn.cookies[@user_token_cookie]]
|> then(fn tokens ->
if is_binary(old_session_id) and old_session_id != "" do
tokens ++ Dialup.UserSessionProcess.live_auth_session_tokens(old_session_id)
else
tokens
end
end)
|> Enum.reject(&is_nil/1)
|> Enum.uniq()
|> Enum.each(&accounts_module.delete_session_token/1)
conn
|> Plug.Conn.delete_resp_cookie(@user_token_cookie, opts)
|> Plug.Conn.assign(:dialup_current_user, nil)
|> sync_connection_sessions(nil)
end
@doc "Halts with a redirect when no user is present."
@spec require_authenticated_user(Plug.Conn.t(), binary()) :: Plug.Conn.t()
def require_authenticated_user(conn, login_path \\ "/log_in") do
if current_user(conn) do
conn
else
conn
|> Plug.Conn.put_resp_header("location", safe_redirect_path(login_path))
|> Plug.Conn.send_resp(302, "")
|> Plug.Conn.halt()
end
end
@doc "Rotates the anonymous connection session cookie (session fixation mitigation)."
@spec rotate_dialup_session(Plug.Conn.t()) :: Plug.Conn.t()
def rotate_dialup_session(conn) do
Plug.Conn.put_resp_cookie(
conn,
"dialup_session",
new_connection_session_id(),
cookie_opts(conn)
)
end
@doc false
def sync_connection_sessions(conn, auth_user, opts \\ []) do
conn = Plug.Conn.fetch_cookies(conn)
old_session_id = conn.cookies["dialup_session"]
if is_binary(old_session_id) and old_session_id != "" do
broadcast_opts =
case Keyword.fetch(opts, :auth_session_token) do
{:ok, token} -> [auth_session_token: token]
:error -> []
end
Dialup.UserSessionProcess.broadcast_auth_user(old_session_id, auth_user, broadcast_opts)
end
new_session_id = new_connection_session_id()
conn =
Plug.Conn.put_resp_cookie(conn, "dialup_session", new_session_id, cookie_opts(conn))
if is_binary(old_session_id) and old_session_id != "" do
Dialup.UserSessionProcess.rotate_connection_session_id(old_session_id, new_session_id)
end
conn
end
defp new_connection_session_id do
:crypto.strong_rand_bytes(16) |> Base.url_encode64(padding: false)
end
defp secure_cookie?(conn, app_module) do
case secure_cookies_mode(app_module) do
true -> true
false -> false
:auto -> https_request?(conn)
_ -> https_request?(conn)
end
end
defp secure_cookies_mode(nil), do: :auto
defp secure_cookies_mode(app_module) do
if function_exported?(app_module, :__secure_cookies__, 0),
do: app_module.__secure_cookies__(),
else: :auto
end
defp https_request?(conn) do
conn.scheme == :https or forwarded_proto(conn) == "https"
end
defp safe_local_redirect?(path) do
local_path?(path) and local_path?(fully_decode(path))
rescue
ArgumentError -> false
end
defp fully_decode(path, remaining \\ 5)
defp fully_decode(path, 0), do: path
defp fully_decode(path, remaining) do
decoded = URI.decode(path)
if decoded == path do
path
else
fully_decode(decoded, remaining - 1)
end
end
defp local_path?(path) do
String.starts_with?(path, "/") and
not String.starts_with?(path, "//") and
not String.contains?(path, "\\") and
not String.contains?(path, "://") and
not String.match?(path, ~r/[\r\n\x00]/) and
not dot_dot_segment?(path)
end
defp dot_dot_segment?(path) do
path
|> Path.split()
|> Enum.any?(&(&1 == ".."))
end
defp forwarded_proto(conn) do
conn
|> Plug.Conn.get_req_header("x-forwarded-proto")
|> List.first()
|> case do
nil -> nil
value -> value |> String.split(",") |> List.first() |> String.trim() |> String.downcase()
end
end
end