Packages

Identity provider and authentication layer

Current section

Files

Jump to
nym lib nym.ex
Raw

lib/nym.ex

defmodule Nym do
@moduledoc """
Identity provider and authentication layer.
Nym is a minimal IndieAuth implementation providing an authorization endpoint, which serves two purposes:
1. decentralised authentication using personal domain names as identity, following the [IndieAuth specification](https://indieauth.spec.indieweb.org/).
2. centralised authentication layer for in-house applications (similar to OAuth or SSO).
This library provides a Nym client for easily protecting Phoenix-based applications with centralised identity management.
## Usage
import Nym
pipeline :browser do
...
plug :fetch_session
plug :fetch_current_user
end
scope "/protected" do
pipe_through [:browser, :require_auth]
get "/", PageController, :index
live_session :default, on_mount: Nym do
live "/hello", WorldLive, :hello
end
end
"""
import Plug.Conn
import Phoenix.Controller
require Logger
@type opts :: [
on: String.t(),
scopes: [String.t()]
]
@doc false
def on_mount(_, _params, session, socket) do
user = Map.get(session, "current_user")
{:cont, socket
|> Phoenix.Component.assign(:current_user, user)
|> Phoenix.Component.assign(:authenticated?, not is_nil(user))}
end
@spec fetch_current_user(Plug.Conn.t(), opts()) :: Plug.Conn.t()
def fetch_current_user(conn, _opts) do
user = get_session(conn, :current_user)
conn
|> assign(:current_user, user)
|> assign(:authenticated?, not is_nil(user))
end
@spec require_auth(Plug.Conn.t(), opts()) :: Plug.Conn.t()
def require_auth(%{params: %{"code" => _}} = conn, opts) do
opts = Application.get_all_env(:nym) |> Keyword.merge(opts)
endpoint = Keyword.fetch!(opts, :on)
client_id = Phoenix.Controller.endpoint_module(conn).url()
redirect_uri = get_session(conn, :redirect_uri, "/")
with {:ok, state} <- validate_param(conn.params["state"], "state"),
{:ok, me} <- validate_param(conn.params["me"], "me"),
{:ok, code} <- validate_param(conn.params["code"], "code"),
{:ok, iss} <- validate_param(conn.params["iss"], "iss"),
{:ok, session_state} <- validate_session(conn, :state),
{:ok, code_verifier} <- validate_session(conn, :code_verifier),
:ok <- validate_state_match(state, session_state),
:ok <- validate_issuer(iss, endpoint) do
token_params = %{
grant_type: "authorization_code",
me: me,
code: code,
client_id: client_id,
redirect_uri: redirect_uri,
code_verifier: code_verifier
}
case verify_code(endpoint, token_params) do
{:ok, %{"me" => _, "meta" => meta}} ->
conn
|> renew_session()
|> put_session(:current_user, meta)
|> redirect(to: URI.parse(redirect_uri).path)
|> halt()
{:ok, %{"meta" => _}} ->
handle_error(conn, "Missing 'me' value")
{:ok, %{"me" => _}} ->
handle_error(conn, "Missing 'meta' value")
{:error, reason} ->
handle_error(conn, format_error(reason))
end
else
{:error, message} ->
handle_error(conn, message)
end
end
@spec require_auth(Plug.Conn.t(), opts()) :: Plug.Conn.t()
def require_auth(conn, opts) do
opts = Application.get_all_env(:nym) |> Keyword.merge(opts)
endpoint = Keyword.fetch!(opts, :on)
client_id = Phoenix.Controller.endpoint_module(conn).url()
redirect_uri = Phoenix.Controller.current_url(conn)
scopes = Keyword.get(opts, :scopes, [])
if conn.assigns.current_user do
conn
else
state = Phoenix.Token.sign(conn, "nym_state", System.system_time())
code_verifier = Base.url_encode64(:crypto.strong_rand_bytes(32), padding: false)
code_challenge = Base.url_encode64(:crypto.hash(:sha256, code_verifier), padding: false)
auth_params = %{
client_id: client_id,
redirect_uri: redirect_uri,
state: state,
code_challenge: code_challenge,
code_challenge_method: "S256",
scope: Enum.join(scopes, " ")
}
conn
|> renew_session()
|> put_session(:state, state)
|> put_session(:code_verifier, code_verifier)
|> put_session(:redirect_uri, redirect_uri)
|> redirect(external: construct_url(endpoint, auth_params))
|> halt()
end
end
defp construct_url(endpoint, params) do
query = URI.encode_query(params)
endpoint
|> URI.parse()
|> Map.put(:query, query)
|> URI.to_string()
end
defp verify_code(endpoint, params) do
opts = [
url: endpoint,
form: params,
headers: [{"Accept", "application/json"}],
retry: :transient
]
case Req.post(opts) do
{:ok, %Req.Response{status: 200, body: %{"error" => _} = body}} ->
{:error, {:token_error, body}}
{:ok, %Req.Response{status: 200, body: body}} ->
{:ok, body}
{:ok, %Req.Response{status: status, body: body}} ->
{:error, {:http_error, status, body}}
{:error, reason} ->
{:error, {:net_error, reason}}
end
end
defp validate_param(nil, name), do: {:error, "Missing '#{name}' parameter"}
defp validate_param(value, _name), do: {:ok, value}
defp validate_session(conn, key) do
case get_session(conn, key) do
nil -> {:error, "Missing #{key} in session"}
value -> {:ok, value}
end
end
defp validate_state_match(state, session_state) when state == session_state, do: :ok
defp validate_state_match(_, _), do: {:error, "Invalid state parameter"}
defp validate_issuer(iss, expected) when iss == expected, do: :ok
defp validate_issuer(_, _), do: {:error, "Invalid issuer"}
defp handle_error(conn, message) do
Logger.warning("Nym crashed: #{message}")
conn
|> put_status(:unauthorized)
|> put_flash(:error, message)
|> json(%{error: message})
|> halt()
end
defp format_error({:http_error, status, body}) do
case body do
%{"error" => error} -> error
e when is_binary(e) -> e
_ -> "Token endpoint returned HTTP #{status}: #{inspect(body)}"
end
end
defp format_error({:token_error, reason}) do
case reason do
%{"error_description" => error} -> error
%{"error" => error} -> error
e when is_binary(e) -> e
_ -> "Token endpoint refused request with: #{inspect(reason)}"
end
end
defp format_error({:net_error, reason}) do
"Connection to token endpoint could not be established: #{inspect(reason)}"
end
defp format_error(other) do
"Something went wrong: #{inspect(other)}"
end
# This function renews the session ID and erases the whole
# session to avoid fixation attacks. If there is any data
# in the session you may want to preserve after log in/log out,
# you must explicitly fetch the session data before clearing
# and then immediately set it after clearing.
defp renew_session(conn) do
delete_csrf_token()
conn
|> configure_session(renew: true)
|> clear_session()
end
end