From 87970051754a50e8d031bc5abee4edb8a4ab267c Mon Sep 17 00:00:00 2001 From: James Harton <59449+jimsynz@users.noreply.github.com> Date: Mon, 31 Oct 2022 16:43:00 +1300 Subject: [PATCH] feat(Ash.PlugHelpers): Support standard actor configuration. (#16) * improvement(docs): change all references to `actor` to `user`. The word "actor" has special meaning in the Ash ecosystem. * chore: format `dev` directory also. * feat(Ash.PlugHelpers): Support standard actor configuration. * Adds the `:set_actor` plug which will set the actor to a resource based on the subject name. * Also includes GraphQL and JSON:API interfaces in the devserver for testing. --- .doctor.exs | 7 +- .formatter.exs | 2 +- config/config.exs | 4 + dev/dev_server.ex | 2 +- dev/dev_server/api_router.ex | 14 ++ dev/dev_server/clear_session.ex | 2 +- dev/dev_server/gql_router.ex | 26 +++ dev/dev_server/json_api_router.ex | 4 + dev/dev_server/{plug.ex => router.ex} | 17 +- dev/dev_server/test_page.ex | 4 +- dev/dev_server/test_page.html.eex | 8 +- dev/dev_server/web_router.ex | 17 ++ lib/ash_authentication.ex | 2 +- .../password_authentication.ex | 8 +- .../password_authentication/actions.ex | 6 +- .../password_authentication/plug.ex | 6 +- lib/ash_authentication/plug.ex | 119 +++--------- lib/ash_authentication/plug/defaults.ex | 36 ++++ lib/ash_authentication/plug/dispatcher.ex | 4 +- lib/ash_authentication/plug/helpers.ex | 88 +++++++-- lib/ash_authentication/plug/macros.ex | 108 +++++++++++ mix.exs | 6 +- mix.lock | 12 +- .../ash_authentication/plug/defaults_test.exs | 39 ++++ test/ash_authentication/plug/helpers_test.exs | 119 ++++++++++++ test/ash_authentication/plug_test.exs | 174 ++++++++++++++++++ test/support/data_case.ex | 3 +- test/support/example.ex | 6 +- test/support/example/auth_plug.ex | 31 +++- test/support/example/current_user_read.ex | 17 ++ test/support/example/schema.ex | 16 ++ test/support/example/user_with_username.ex | 44 ++++- test/support/session_pipeline.ex | 23 +++ test/test_helper.exs | 2 + 34 files changed, 809 insertions(+), 167 deletions(-) create mode 100644 dev/dev_server/api_router.ex create mode 100644 dev/dev_server/gql_router.ex create mode 100644 dev/dev_server/json_api_router.ex rename dev/dev_server/{plug.ex => router.ex} (64%) create mode 100644 dev/dev_server/web_router.ex create mode 100644 lib/ash_authentication/plug/defaults.ex create mode 100644 lib/ash_authentication/plug/macros.ex create mode 100644 test/ash_authentication/plug/defaults_test.exs create mode 100644 test/ash_authentication/plug/helpers_test.exs create mode 100644 test/ash_authentication/plug_test.exs create mode 100644 test/support/example/current_user_read.ex create mode 100644 test/support/example/schema.ex create mode 100644 test/support/session_pipeline.ex diff --git a/.doctor.exs b/.doctor.exs index e5edb7d..fed208c 100644 --- a/.doctor.exs +++ b/.doctor.exs @@ -1,5 +1,10 @@ %Doctor.Config{ - ignore_modules: [~r/^Inspect\./, ~r/.Plug$/, AshAuthentication.InfoGenerator], + ignore_modules: [ + ~r/^Inspect\./, + ~r/.Plug$/, + AshAuthentication.InfoGenerator, + AshAuthentication.Plug.Macros + ], ignore_paths: [], min_module_doc_coverage: 40, min_module_spec_coverage: 0, diff --git a/.formatter.exs b/.formatter.exs index 29f5856..f7e3f63 100644 --- a/.formatter.exs +++ b/.formatter.exs @@ -16,7 +16,7 @@ spark_locals_without_parens = [ import_deps: [:ash, :spark], inputs: [ "*.{ex,exs}", - "{config,lib,test}/**/*.{ex,exs}" + "{dev,config,lib,test}/**/*.{ex,exs}" ], plugins: [Spark.Formatter], export: [ diff --git a/config/config.exs b/config/config.exs index d1186fe..3d69282 100644 --- a/config/config.exs +++ b/config/config.exs @@ -1,3 +1,7 @@ import Config +config :mime, :types, %{ + "application/vnd.api+json" => ["json"] +} + import_config "#{config_env()}.exs" diff --git a/dev/dev_server.ex b/dev/dev_server.ex index b62bc77..2d55ff8 100644 --- a/dev/dev_server.ex +++ b/dev/dev_server.ex @@ -17,7 +17,7 @@ defmodule DevServer do [ {DevServer.Session, []}, - {Plug.Cowboy, scheme: :http, plug: DevServer.Plug, options: opts} + {Plug.Cowboy, scheme: :http, plug: DevServer.Router, options: opts} ] |> Supervisor.init(strategy: :one_for_all) end diff --git a/dev/dev_server/api_router.ex b/dev/dev_server/api_router.ex new file mode 100644 index 0000000..98cd633 --- /dev/null +++ b/dev/dev_server/api_router.ex @@ -0,0 +1,14 @@ +defmodule DevServer.ApiRouter do + @moduledoc """ + Router for API Requests. + """ + use Plug.Router + import Example.AuthPlug + + plug(:load_from_bearer) + plug(:set_actor, :user_with_username) + plug(:match) + plug(:dispatch) + + forward("/", to: DevServer.JsonApiRouter) +end diff --git a/dev/dev_server/clear_session.ex b/dev/dev_server/clear_session.ex index 35a707e..d0c8191 100644 --- a/dev/dev_server/clear_session.ex +++ b/dev/dev_server/clear_session.ex @@ -1,6 +1,6 @@ defmodule DevServer.ClearSession do @moduledoc """ - Resets the session storage, to 'log out" all actors. + Resets the session storage, to 'log out" all users. """ @behaviour Plug diff --git a/dev/dev_server/gql_router.ex b/dev/dev_server/gql_router.ex new file mode 100644 index 0000000..72f28ca --- /dev/null +++ b/dev/dev_server/gql_router.ex @@ -0,0 +1,26 @@ +defmodule DevServer.GqlRouter do + @moduledoc """ + Router for GraphQL requests. + """ + use Plug.Router + import Example.AuthPlug + + plug(:load_from_bearer) + plug(:set_actor, :user_with_username) + plug(AshGraphql.Plug) + plug(:match) + plug(:dispatch) + + forward("/playground", + to: Absinthe.Plug.GraphiQL, + init_opts: [ + schema: Example.Schema, + interface: :playground + ] + ) + + forward("/", + to: Absinthe.Plug, + init_opts: [schema: Example.Schema] + ) +end diff --git a/dev/dev_server/json_api_router.ex b/dev/dev_server/json_api_router.ex new file mode 100644 index 0000000..287aafa --- /dev/null +++ b/dev/dev_server/json_api_router.ex @@ -0,0 +1,4 @@ +defmodule DevServer.JsonApiRouter do + @moduledoc false + use AshJsonApi.Api.Router, api: Example, registry: Example.Registry +end diff --git a/dev/dev_server/plug.ex b/dev/dev_server/router.ex similarity index 64% rename from dev/dev_server/plug.ex rename to dev/dev_server/router.ex index b36f454..2a43c19 100644 --- a/dev/dev_server/plug.ex +++ b/dev/dev_server/router.ex @@ -1,24 +1,19 @@ -defmodule DevServer.Plug do +defmodule DevServer.Router do @moduledoc false use Plug.Router - alias DevServer - import Example.AuthPlug plug(Plug.Parsers, parsers: [:urlencoded, :multipart, :json], json_decoder: Jason) plug(Plug.Session, store: :ets, key: "_ash_authentication_session", table: DevServer.Session) - plug(:fetch_query_params) plug(:fetch_session) + plug(:fetch_query_params) plug(Plug.Logger) - plug(:load_from_session) plug(:match) plug(:dispatch) - forward("/auth", to: Example.AuthPlug.Router) + forward("/auth", to: Example.AuthPlug) get("/clear_session", to: DevServer.ClearSession) post("/token_check", to: DevServer.TokenCheck) - get("/", to: DevServer.TestPage) - - match _ do - send_resp(conn, 404, "NOT FOUND") - end + forward("/api", to: DevServer.ApiRouter) + forward("/gql", to: DevServer.GqlRouter) + forward("/", to: DevServer.WebRouter) end diff --git a/dev/dev_server/test_page.ex b/dev/dev_server/test_page.ex index e4a916f..15a4356 100644 --- a/dev/dev_server/test_page.ex +++ b/dev/dev_server/test_page.ex @@ -22,7 +22,7 @@ defmodule DevServer.TestPage do def call(conn, _opts) do resources = AshAuthentication.authenticated_resources(:ash_authentication) - current_actors = + current_users = conn.assigns |> Stream.filter(fn {key, _value} -> key @@ -31,7 +31,7 @@ defmodule DevServer.TestPage do end) |> Map.new() - payload = render(resources: resources, current_actors: current_actors) + payload = render(resources: resources, current_users: current_users) Conn.send_resp(conn, 200, payload) end end diff --git a/dev/dev_server/test_page.html.eex b/dev/dev_server/test_page.html.eex index 5ed8c2c..fa0492d 100644 --- a/dev/dev_server/test_page.html.eex +++ b/dev/dev_server/test_page.html.eex @@ -32,18 +32,18 @@

<% end %> - <%= if Enum.any?(@current_actors) do %> -

Current actors:

+ <%= if Enum.any?(@current_users) do %> +

Current users:

Clear session - <%= for {name, actor} <- @current_actors do %> + <%= for {name, user} <- @current_users do %> - + <% end %>
Name Value
@<%= name %>
<%= inspect actor, pretty: true %>
<%= inspect user, pretty: true %>
diff --git a/dev/dev_server/web_router.ex b/dev/dev_server/web_router.ex new file mode 100644 index 0000000..879f776 --- /dev/null +++ b/dev/dev_server/web_router.ex @@ -0,0 +1,17 @@ +defmodule DevServer.WebRouter do + @moduledoc """ + Router for web (browser) requests. + """ + use Plug.Router + import Example.AuthPlug + + plug(:load_from_session) + plug(:match) + plug(:dispatch) + + get("/", to: DevServer.TestPage) + + match _ do + send_resp(conn, 404, "NOT FOUND") + end +end diff --git a/lib/ash_authentication.ex b/lib/ash_authentication.ex index 2990c3e..3534f0a 100644 --- a/lib/ash_authentication.ex +++ b/lib/ash_authentication.ex @@ -235,7 +235,7 @@ defmodule AshAuthentication do |> Query.filter(^primary_key) |> config.api.read() |> case do - {:ok, [actor]} -> {:ok, actor} + {:ok, [user]} -> {:ok, user} _ -> {:error, "Invalid subject"} end end diff --git a/lib/ash_authentication/password_authentication.ex b/lib/ash_authentication/password_authentication.ex index ecc3170..12cb64c 100644 --- a/lib/ash_authentication/password_authentication.ex +++ b/lib/ash_authentication/password_authentication.ex @@ -8,14 +8,14 @@ defmodule AshAuthentication.PasswordAuthentication do identity_field: [ type: :atom, doc: """ - The name of the attribute which uniquely identifies the actor. Usually something like `username` or `email_address`. + The name of the attribute which uniquely identifies the user. Usually something like `username` or `email_address`. """, default: :username ], hashed_password_field: [ type: :atom, doc: """ - The name of the attribute within which to store the actor's password once it has been hashed. + The name of the attribute within which to store the user's password once it has been hashed. """, default: :hashed_password ], @@ -115,7 +115,7 @@ defmodule AshAuthentication.PasswordAuthentication do alias Plug.Conn @doc """ - Attempt to sign in an actor of the provided resource type. + Attempt to sign in an user of the provided resource type. ## Example @@ -129,7 +129,7 @@ defmodule AshAuthentication.PasswordAuthentication do as: :sign_in @doc """ - Attempt to register an actor of the provided resource type. + Attempt to register an user of the provided resource type. ## Example diff --git a/lib/ash_authentication/password_authentication/actions.ex b/lib/ash_authentication/password_authentication/actions.ex index 03638fc..acd9abd 100644 --- a/lib/ash_authentication/password_authentication/actions.ex +++ b/lib/ash_authentication/password_authentication/actions.ex @@ -11,7 +11,7 @@ defmodule AshAuthentication.PasswordAuthentication.Actions do alias AshAuthentication.PasswordAuthentication @doc """ - Attempt to sign in an actor of the provided resource type. + Attempt to sign in an user of the provided resource type. ## Example @@ -27,14 +27,14 @@ defmodule AshAuthentication.PasswordAuthentication.Actions do |> Query.for_read(action, attributes) |> api.read() |> case do - {:ok, [actor]} -> {:ok, actor} + {:ok, [user]} -> {:ok, user} {:ok, []} -> {:error, "Invalid username or password"} {:error, reason} -> {:error, reason} end end @doc """ - Attempt to register an actor of the provided resource type. + Attempt to register an user of the provided resource type. ## Example diff --git a/lib/ash_authentication/password_authentication/plug.ex b/lib/ash_authentication/password_authentication/plug.ex index 128be86..e7e438e 100644 --- a/lib/ash_authentication/password_authentication/plug.ex +++ b/lib/ash_authentication/password_authentication/plug.ex @@ -3,7 +3,7 @@ defmodule AshAuthentication.PasswordAuthentication.Plug do Handlers for incoming request and callback HTTP requests. AshAuthentication is written with an eye towards OAuth which uses a two-phase - request/callback process which can be used to register and sign in an actor in + request/callback process which can be used to register and sign in an user in a single flow. This doesn't really work that well with `PasswordAuthentication` which has seperate "registration" and "sign-in" actions. @@ -37,8 +37,8 @@ defmodule AshAuthentication.PasswordAuthentication.Plug do |> Map.get(to_string(config.subject_name), %{}) |> do_action(config.resource) |> case do - {:ok, actor} when is_struct(actor, config.resource) -> - private_store(conn, {:success, actor}) + {:ok, user} when is_struct(user, config.resource) -> + private_store(conn, {:success, user}) {:error, changeset} -> private_store(conn, {:failure, changeset}) diff --git a/lib/ash_authentication/plug.ex b/lib/ash_authentication/plug.ex index 6d56efd..374b221 100644 --- a/lib/ash_authentication/plug.ex +++ b/lib/ash_authentication/plug.ex @@ -32,7 +32,7 @@ defmodule AshAuthentication.Plug do end ``` - In order to load any authenticated actors for either web or API users you can add the following to your router: + In order to load any authenticated users for either web or API users you can add the following to your router: ```elixir import MyAppWeb.AuthPlug @@ -69,10 +69,10 @@ defmodule AshAuthentication.Plug do do useful things like session and query param fetching. """ - alias Ash.{Api, Changeset, Resource} - alias AshAuthentication.Plug.Helpers + alias Ash.{Changeset, Error, Resource} + alias AshAuthentication.Plug.{Defaults, Helpers, Macros} alias Plug.Conn - alias Spark.Dsl.Extension + require Macros @type authenticator_config :: %{ api: module, @@ -103,7 +103,7 @@ defmodule AshAuthentication.Plug do The default implementation simply returns a 401 status with the message "Access denied". You almost definitely want to override this. """ - @callback handle_failure(Conn.t(), nil | Changeset.t()) :: Conn.t() + @callback handle_failure(Conn.t(), nil | Changeset.t() | Error.t()) :: Conn.t() defmacro __using__(opts) do otp_app = @@ -112,58 +112,15 @@ defmodule AshAuthentication.Plug do |> Macro.expand_once(__CALLER__) quote do - require Ash.Api.Info - - unquote(otp_app) - |> Application.compile_env(:ash_apis, []) - |> Stream.flat_map(&Api.Info.depend_on_resources(&1)) - |> Stream.map(&{&1, Extension.get_persisted(&1, :authentication)}) - |> Stream.reject(&(elem(&1, 1) == nil)) - |> Stream.map(&{elem(&1, 0), elem(&1, 1).subject_name}) - |> Enum.group_by(&elem(&1, 1), &elem(&1, 0)) - |> Enum.reject(&(length(elem(&1, 1)) < 2)) - |> case do - [] -> - nil - - duplicates -> - import AshAuthentication.Utils, only: [to_sentence: 2] - - duplicates = - duplicates - |> Enum.map(fn {subject_name, resources} -> - resources = - resources - |> Enum.map(&"`#{inspect(&1)}`") - |> to_sentence(final: "and") - - " `#{subject_name}`: #{resources}\n" - end) - - raise """ - Error: There are multiple resources configured with the same subject name. - - This is bad because we will be unable to correctly convert between subjects and resources. - - #{duplicates} - """ - end + require Macros + Macros.validate_subject_name_uniqueness(unquote(otp_app)) @behaviour AshAuthentication.Plug + @behaviour Plug import Plug.Conn defmodule Router do - @moduledoc """ - The Authentication Router. - - Plug this into your app's router using: - - ```elixir - forward "/auth", to: #{__MODULE__} - ``` - - This router is generated using `AshAuthentication.Plug.Router`. - """ + @moduledoc false use AshAuthentication.Plug.Router, otp_app: unquote(otp_app), return_to: @@ -173,58 +130,26 @@ defmodule AshAuthentication.Plug do |> Module.concat() end - @doc """ - The default implementation of `handle_success/3`. + Macros.define_load_from_session(unquote(otp_app)) + Macros.define_load_from_bearer(unquote(otp_app)) + Macros.define_revoke_bearer_tokens(unquote(otp_app)) - Calls `AshAuthentication.Plug.Helpers.store_in_session/2` then sends a - basic 200 response. - """ - @spec handle_success(Conn.t(), Resource.record(), token :: String.t()) :: - Conn.t() - def handle_success(conn, actor, _token) do - conn - |> store_in_session(actor) - |> send_resp(200, "Access granted") - end + @impl true + defdelegate handle_success(conn, user, token), to: Defaults - @doc """ - The default implementation of `handle_failure/1`. - - Sends a very basic 401 response. - """ - @spec handle_failure(Conn.t(), nil | Changeset.t()) :: Conn.t() - def handle_failure(conn, _) do - conn - |> send_resp(401, "Access denied") - end + @impl true + defdelegate handle_failure(conn, error), to: Defaults defoverridable handle_success: 3, handle_failure: 2 - @doc """ - Store an actor in the session. - """ - @spec store_in_session(Conn.t(), Resource.record()) :: Conn.t() - def store_in_session(conn, actor), - do: Helpers.store_in_session(conn, actor) + @impl true + defdelegate init(opts), to: Router - @doc """ - Attempt to retrieve all actors from the connections' session. + @impl true + defdelegate call(conn, opts), to: Router - A wrapper around `AshAuthentication.Plug.Helpers.retrieve_from_session/2` - with the `otp_app` already present. - """ - @spec load_from_session(Conn.t(), any) :: Conn.t() - def load_from_session(conn, _opts), - do: Helpers.retrieve_from_session(conn, unquote(otp_app)) - - @doc """ - Attempt to retrieve actors from the `Authorization` header(s). - - A wrapper around `AshAuthentication.Plug.Helpers.retrieve_from_bearer/2` with the `otp_app` already present. - """ - @spec load_from_bearer(Conn.t(), any) :: Conn.t() - def load_from_bearer(conn, _opts), - do: Helpers.retrieve_from_bearer(conn, unquote(otp_app)) + defdelegate set_actor(conn, subject_name), to: Helpers + defdelegate store_in_session(conn, user), to: Helpers end end end diff --git a/lib/ash_authentication/plug/defaults.ex b/lib/ash_authentication/plug/defaults.ex new file mode 100644 index 0000000..63acae5 --- /dev/null +++ b/lib/ash_authentication/plug/defaults.ex @@ -0,0 +1,36 @@ +defmodule AshAuthentication.Plug.Defaults do + @moduledoc """ + Provides the default implementations of `handle_success/3` and + `handle_failure/2` used in generated authentication plugs. + """ + + alias Ash.{Changeset, Error, Resource} + alias Plug.Conn + import AshAuthentication.Plug.Helpers + import Plug.Conn + + @doc """ + The default implementation of `handle_success/3`. + + Calls `AshAuthentication.Plug.Helpers.store_in_session/2` then sends a + basic 200 response. + """ + @spec handle_success(Conn.t(), Resource.record(), token :: String.t()) :: + Conn.t() + def handle_success(conn, user, _token) do + conn + |> store_in_session(user) + |> send_resp(200, "Access granted") + end + + @doc """ + The default implementation of `handle_failure/1`. + + Sends a very basic 401 response. + """ + @spec handle_failure(Conn.t(), nil | Changeset.t() | Error.t()) :: Conn.t() + def handle_failure(conn, _) do + conn + |> send_resp(401, "Access denied") + end +end diff --git a/lib/ash_authentication/plug/dispatcher.ex b/lib/ash_authentication/plug/dispatcher.ex index cb8f03c..cedf8f7 100644 --- a/lib/ash_authentication/plug/dispatcher.ex +++ b/lib/ash_authentication/plug/dispatcher.ex @@ -41,8 +41,8 @@ defmodule AshAuthentication.Plug.Dispatcher do %{state: :sent} -> conn - %{private: %{authentication_result: {:success, actor}}} -> - return_to.handle_success(conn, actor, Map.get(actor.__metadata__, :token)) + %{private: %{authentication_result: {:success, user}}} -> + return_to.handle_success(conn, user, Map.get(user.__metadata__, :token)) %{private: %{authentication_result: {:failure, reason}}} -> return_to.handle_failure(conn, reason) diff --git a/lib/ash_authentication/plug/helpers.ex b/lib/ash_authentication/plug/helpers.ex index b4c4017..386d1ae 100644 --- a/lib/ash_authentication/plug/helpers.ex +++ b/lib/ash_authentication/plug/helpers.ex @@ -2,23 +2,24 @@ defmodule AshAuthentication.Plug.Helpers do @moduledoc """ Authentication helpers for use in your router, etc. """ - alias Ash.{Changeset, Error, Resource} + + alias Ash.{Changeset, Error, PlugHelpers, Resource} alias AshAuthentication.{Info, Jwt, TokenRevocation} alias Plug.Conn @doc """ - Store the actor in the connections' session. + Store the user in the connections' session. """ @spec store_in_session(Conn.t(), Resource.record()) :: Conn.t() - def store_in_session(conn, actor) do - subject_name = AshAuthentication.Info.authentication_subject_name!(actor.__struct__) - subject = AshAuthentication.resource_to_subject(actor) + def store_in_session(conn, user) do + subject_name = AshAuthentication.Info.authentication_subject_name!(user.__struct__) + subject = AshAuthentication.resource_to_subject(user) Conn.put_session(conn, subject_name, subject) end @doc """ - Given a list of subjects, turn as many as possible into actors. + Given a list of subjects, turn as many as possible into users. """ @spec load_subjects([AshAuthentication.subject()], module) :: map def load_subjects(subjects, otp_app) when is_list(subjects) do @@ -26,15 +27,17 @@ defmodule AshAuthentication.Plug.Helpers do otp_app |> AshAuthentication.authenticated_resources() |> Stream.map(&{to_string(&1.subject_name), &1}) + |> Map.new() subjects |> Enum.reduce(%{}, fn subject, result -> subject = URI.parse(subject) with {:ok, config} <- Map.fetch(configurations, subject.path), - {:ok, actor} <- AshAuthentication.subject_to_resource(subject, config) do + {:ok, user} <- AshAuthentication.subject_to_resource(subject, config) do current_subject_name = current_subject_name(config.subject_name) - Map.put(result, current_subject_name, actor) + + Map.put(result, current_subject_name, user) else _ -> result end @@ -42,13 +45,13 @@ defmodule AshAuthentication.Plug.Helpers do end @doc """ - Attempt to retrieve all actors from the connections' session. + Attempt to retrieve all users from the connections' session. Iterates through all configured authentication resources for `otp_app` and - retrieves any actors stored in the session, loads them and stores them in the + retrieves any users stored in the session, loads them and stores them in the assigns under their subject name (with the prefix `current_`). - If there is no actor present for a resource then the assign is set to `nil`. + If there is no user present for a resource then the assign is set to `nil`. """ @spec retrieve_from_session(Conn.t(), module) :: Conn.t() def retrieve_from_session(conn, otp_app) do @@ -58,8 +61,8 @@ defmodule AshAuthentication.Plug.Helpers do current_subject_name = current_subject_name(config.subject_name) with subject when is_binary(subject) <- Conn.get_session(conn, config.subject_name), - {:ok, actor} <- AshAuthentication.subject_to_resource(subject, config) do - Conn.assign(conn, current_subject_name, actor) + {:ok, user} <- AshAuthentication.subject_to_resource(subject, config) do + Conn.assign(conn, current_subject_name, user) else _ -> Conn.assign(conn, current_subject_name, nil) @@ -72,7 +75,9 @@ defmodule AshAuthentication.Plug.Helpers do Assumes that your clients are sending a bearer-style authorization header with your request. If a valid bearer token is present then the subject is loaded - into the assigns. + into the assigns under their subject name (with the prefix `current_`). + + If there is no user present for a resource then the assign is set to `nil`. """ @spec retrieve_from_bearer(Conn.t(), module) :: Conn.t() def retrieve_from_bearer(conn, otp_app) do @@ -82,9 +87,10 @@ defmodule AshAuthentication.Plug.Helpers do |> Stream.map(&String.replace_leading(&1, "Bearer ", "")) |> Enum.reduce(conn, fn token, conn -> with {:ok, %{"sub" => subject}, config} <- Jwt.verify(token, otp_app), - {:ok, actor} <- AshAuthentication.subject_to_resource(subject, config), + {:ok, user} <- AshAuthentication.subject_to_resource(subject, config), current_subject_name <- current_subject_name(config.subject_name) do - Conn.assign(conn, current_subject_name, actor) + conn + |> Conn.assign(current_subject_name, user) else _ -> conn end @@ -113,10 +119,47 @@ defmodule AshAuthentication.Plug.Helpers do end) end - # Dyanamically generated atoms are generally frowned upon, but in this case - # the `subject_name` is a statically configured atom, so should be fine. - defp current_subject_name(subject_name) when is_atom(subject_name), - do: String.to_atom("current_#{subject_name}") + @doc """ + Set a subject as the request actor. + + Presumes that you have already loaded your user resource(s) into the + connection's assigns. + + Uses `Ash.PlugHelpers` to streamline integration with `AshGraphql` and + `AshJsonApi`. + + ## Examples + + Setting the actor for a AshGraphql API using `Plug.Router`. + + ```elixir + defmodule MyApp.ApiRouter do + use Plug.Router + import MyApp.AuthPlug + + plug :retrieve_from_bearer + plug :set_actor, :user + + forward "/gql", + to: Absinthe.Plug, + init_opts: [schema: MyApp.Schema] + end + ``` + """ + @spec set_actor(Conn.t(), subject_name :: atom) :: Conn.t() + def set_actor(conn, subject_name) do + current_subject_name = + subject_name + |> current_subject_name() + + actor = + conn + |> Map.get(:assigns, %{}) + |> Map.get(current_subject_name) + + conn + |> PlugHelpers.set_actor(actor) + end @doc """ Store result in private. @@ -136,4 +179,9 @@ defmodule AshAuthentication.Plug.Helpers do def private_store(conn, {:failure, reason}) when is_nil(reason) or is_map(reason), do: Conn.put_private(conn, :authentication_result, {:failure, reason}) + + # Dyanamically generated atoms are generally frowned upon, but in this case + # the `subject_name` is a statically configured atom, so should be fine. + defp current_subject_name(subject_name) when is_atom(subject_name), + do: String.to_atom("current_#{subject_name}") end diff --git a/lib/ash_authentication/plug/macros.ex b/lib/ash_authentication/plug/macros.ex new file mode 100644 index 0000000..0e1bff6 --- /dev/null +++ b/lib/ash_authentication/plug/macros.ex @@ -0,0 +1,108 @@ +defmodule AshAuthentication.Plug.Macros do + @moduledoc """ + Generators used within `AshAuthentication.Plug.__using_/1`. + """ + + alias Ash.Api + alias AshAuthentication.Plug.Helpers + alias Plug.Conn + alias Spark.Dsl.Extension + + @doc """ + Generates the subject name validation code for the auth plug. + """ + @spec validate_subject_name_uniqueness(atom) :: Macro.t() + defmacro validate_subject_name_uniqueness(otp_app) do + quote do + require Ash.Api.Info + + unquote(otp_app) + |> Application.compile_env(:ash_apis, []) + |> Stream.flat_map(&Api.Info.depend_on_resources(&1)) + |> Stream.map(&{&1, Extension.get_persisted(&1, :authentication)}) + |> Stream.reject(&(elem(&1, 1) == nil)) + |> Stream.map(&{elem(&1, 0), elem(&1, 1).subject_name}) + |> Enum.group_by(&elem(&1, 1), &elem(&1, 0)) + |> Enum.reject(&(length(elem(&1, 1)) < 2)) + |> case do + [] -> + nil + + duplicates -> + import AshAuthentication.Utils, only: [to_sentence: 2] + + duplicates = + duplicates + |> Enum.map(fn {subject_name, resources} -> + resources = + resources + |> Enum.map(&"`#{inspect(&1)}`") + |> to_sentence(final: "and") + + " `#{subject_name}`: #{resources}\n" + end) + + raise """ + Error: There are multiple resources configured with the same subject name. + + This is bad because we will be unable to correctly convert between subjects and resources. + + #{duplicates} + """ + end + end + end + + @doc """ + Generates the `load_from_session/2` plug with the `otp_app` prefilled. + """ + @spec define_load_from_session(atom) :: Macro.t() + defmacro define_load_from_session(otp_app) do + quote do + @doc """ + Attempt to retrieve all users from the connections' session. + + A wrapper around `AshAuthentication.Plug.Helpers.retrieve_from_session/2` + with the `otp_app` already present. + """ + @spec load_from_session(Conn.t(), any) :: Conn.t() + def load_from_session(conn, _opts), + do: Helpers.retrieve_from_session(conn, unquote(otp_app)) + end + end + + @doc """ + Generates the `load_from_bearer/2` plug with the `otp_app` prefilled. + """ + @spec define_load_from_bearer(atom) :: Macro.t() + defmacro define_load_from_bearer(otp_app) do + quote do + @doc """ + Attempt to retrieve users from the `Authorization` header(s). + + A wrapper around `AshAuthentication.Plug.Helpers.retrieve_from_bearer/2` with the `otp_app` already present. + """ + @spec load_from_bearer(Conn.t(), any) :: Conn.t() + def load_from_bearer(conn, _opts), + do: Helpers.retrieve_from_bearer(conn, unquote(otp_app)) + end + end + + @doc """ + Generates the `revoke_bearer_tokens/2` plug with the `otp_app` prefilled. + """ + @spec define_revoke_bearer_tokens(atom) :: Macro.t() + defmacro define_revoke_bearer_tokens(otp_app) do + quote do + @doc """ + Revoke all authorization header(s). + + Any bearer-style authorization headers will have their tokens revoked. + A wrapper around `AshAuthentication.Plug.Helpers.revoke_bearer_tokens/2` with the `otp_app` already present. + """ + @spec revoke_bearer_tokens(Conn.t(), any) :: Conn.t() + def revoke_bearer_tokens(conn, _opts), + do: Helpers.revoke_bearer_tokens(conn, unquote(otp_app)) + end + end +end diff --git a/mix.exs b/mix.exs index f73a4fa..3f605a3 100644 --- a/mix.exs +++ b/mix.exs @@ -77,11 +77,15 @@ defmodule AshAuthentication.MixProject do # Run "mix help deps" to learn about dependencies. defp deps do [ - {:ash, "~> 2.2"}, + {:ash, "~> 2.3"}, {:bcrypt_elixir, "~> 3.0", optional: true}, {:jason, "~> 1.4"}, {:joken, "~> 2.5"}, {:plug, "~> 1.13"}, + {:absinthe_plug, "~> 1.5", only: [:dev, :test]}, + # These two can be changed back to hex once the next release goes out. + {:ash_graphql, github: "ash-project/ash_graphql", only: [:dev, :test]}, + {:ash_json_api, github: "ash-project/ash_json_api", only: [:dev, :test]}, {:ash_postgres, "~> 1.1", only: [:dev, :test]}, {:credo, "~> 1.6", only: [:dev, :test], runtime: false}, {:dialyxir, "~> 1.2", only: [:dev, :test], runtime: false}, diff --git a/mix.lock b/mix.lock index fde4ba0..d9d2736 100644 --- a/mix.lock +++ b/mix.lock @@ -1,15 +1,21 @@ %{ - "ash": {:hex, :ash, "2.2.0", "4fdc0fef5afb3f5045b1ca4e1ccb139b9f703cbc7c21dc645e32ac9582b11f63", [:mix], [{:comparable, "~> 1.0", [hex: :comparable, repo: "hexpm", optional: false]}, {:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:earmark, "~> 1.4", [hex: :earmark, repo: "hexpm", optional: true]}, {:ecto, "~> 3.7", [hex: :ecto, repo: "hexpm", optional: false]}, {:ets, "~> 0.8.0", [hex: :ets, repo: "hexpm", optional: false]}, {:jason, ">= 1.0.0", [hex: :jason, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:picosat_elixir, "~> 0.2", [hex: :picosat_elixir, repo: "hexpm", optional: false]}, {:spark, "~> 0.1 and >= 0.1.28", [hex: :spark, repo: "hexpm", optional: false]}, {:stream_data, "~> 0.5.0", [hex: :stream_data, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.1", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "48eca587e7076fe4f8547e919c0712f081ce85e66c316f6f51dd2535ad046013"}, + "absinthe": {:hex, :absinthe, "1.7.0", "36819e7b1fd5046c9c734f27fe7e564aed3bda59f0354c37cd2df88fd32dd014", [:mix], [{:dataloader, "~> 1.0.0", [hex: :dataloader, repo: "hexpm", optional: true]}, {:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}, {:nimble_parsec, "~> 0.5 or ~> 1.0", [hex: :nimble_parsec, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.0 or ~> 0.4", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "566a5b5519afc9b29c4d367f0c6768162de3ec03e9bf9916f9dc2bcbe7c09643"}, + "absinthe_plug": {:hex, :absinthe_plug, "1.5.8", "38d230641ba9dca8f72f1fed2dfc8abd53b3907d1996363da32434ab6ee5d6ab", [:mix], [{:absinthe, "~> 1.5", [hex: :absinthe, repo: "hexpm", optional: false]}, {:plug, "~> 1.4", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "bbb04176647b735828861e7b2705465e53e2cf54ccf5a73ddd1ebd855f996e5a"}, + "ash": {:hex, :ash, "2.3.0", "3f47a8f1f273a8fce66ac48ef146f4f7a51a6e50d26f50c2f650fbb976e6f5a8", [:mix], [{:comparable, "~> 1.0", [hex: :comparable, repo: "hexpm", optional: false]}, {:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:earmark, "~> 1.4", [hex: :earmark, repo: "hexpm", optional: true]}, {:ecto, "~> 3.7", [hex: :ecto, repo: "hexpm", optional: false]}, {:ets, "~> 0.8.0", [hex: :ets, repo: "hexpm", optional: false]}, {:jason, ">= 1.0.0", [hex: :jason, repo: "hexpm", optional: false]}, {:nimble_options, "~> 0.4.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:picosat_elixir, "~> 0.2", [hex: :picosat_elixir, repo: "hexpm", optional: false]}, {:spark, "~> 0.2", [hex: :spark, repo: "hexpm", optional: false]}, {:stream_data, "~> 0.5.0", [hex: :stream_data, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.1", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "1540d43533b2c9caa9602209035f33ec2e32240df53d289fc196766dc0e3b510"}, + "ash_graphql": {:git, "https://github.com/ash-project/ash_graphql.git", "57e42cac6b7c58f96ee469c70be53b14d7135aa3", []}, + "ash_json_api": {:git, "https://github.com/ash-project/ash_json_api.git", "50b2785f31e9e8071b12942387e08b9f24a8602a", []}, "ash_postgres": {:hex, :ash_postgres, "1.1.1", "2bbc2b39d9e387f89b964b29b042f88dd352b71e486d9aea7f9390ab1db3ced4", [:mix], [{:ash, "~> 2.1", [hex: :ash, repo: "hexpm", optional: false]}, {:ecto, "~> 3.9", [hex: :ecto, repo: "hexpm", optional: false]}, {:ecto_sql, "~> 3.9", [hex: :ecto_sql, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}, {:postgrex, ">= 0.0.0", [hex: :postgrex, repo: "hexpm", optional: false]}], "hexpm", "fe47a6e629b6b23ce17c1d70b1bd4b3fd732df513b67126514fb88be86a6439e"}, "bcrypt_elixir": {:hex, :bcrypt_elixir, "3.0.1", "9be815469e6bfefec40fa74658ecbbe6897acfb57614df1416eeccd4903f602c", [:make, :mix], [{:comeonin, "~> 5.3", [hex: :comeonin, repo: "hexpm", optional: false]}, {:elixir_make, "~> 0.6", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "486bb95efb645d1efc6794c1ddd776a186a9a713abf06f45708a6ce324fb96cf"}, "bunt": {:hex, :bunt, "0.2.1", "e2d4792f7bc0ced7583ab54922808919518d0e57ee162901a16a1b6664ef3b14", [:mix], [], "hexpm", "a330bfb4245239787b15005e66ae6845c9cd524a288f0d141c148b02603777a5"}, "comeonin": {:hex, :comeonin, "5.3.3", "2c564dac95a35650e9b6acfe6d2952083d8a08e4a89b93a481acb552b325892e", [:mix], [], "hexpm", "3e38c9c2cb080828116597ca8807bb482618a315bfafd98c90bc22a821cc84df"}, "comparable": {:hex, :comparable, "1.0.0", "bb669e91cedd14ae9937053e5bcbc3c52bb2f22422611f43b6e38367d94a495f", [:mix], [{:typable, "~> 0.1", [hex: :typable, repo: "hexpm", optional: false]}], "hexpm", "277c11eeb1cd726e7cd41c6c199e7e52fa16ee6830b45ad4cdc62e51f62eb60c"}, "connection": {:hex, :connection, "1.1.0", "ff2a49c4b75b6fb3e674bfc5536451607270aac754ffd1bdfe175abe4a6d7a68", [:mix], [], "hexpm", "722c1eb0a418fbe91ba7bd59a47e28008a189d47e37e0e7bb85585a016b2869c"}, + "conv_case": {:hex, :conv_case, "0.2.3", "c1455c27d3c1ffcdd5f17f1e91f40b8a0bc0a337805a6e8302f441af17118ed8", [:mix], [], "hexpm", "88f29a3d97d1742f9865f7e394ed3da011abb7c5e8cc104e676fdef6270d4b4a"}, "cowboy": {:hex, :cowboy, "2.9.0", "865dd8b6607e14cf03282e10e934023a1bd8be6f6bacf921a7e2a96d800cd452", [:make, :rebar3], [{:cowlib, "2.11.0", [hex: :cowlib, repo: "hexpm", optional: false]}, {:ranch, "1.8.0", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "2c729f934b4e1aa149aff882f57c6372c15399a20d54f65c8d67bef583021bde"}, "cowboy_telemetry": {:hex, :cowboy_telemetry, "0.4.0", "f239f68b588efa7707abce16a84d0d2acf3a0f50571f8bb7f56a15865aae820c", [:rebar3], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "7d98bac1ee4565d31b62d59f8823dfd8356a169e7fcbb83831b8a5397404c9de"}, "cowlib": {:hex, :cowlib, "2.11.0", "0b9ff9c346629256c42ebe1eeb769a83c6cb771a6ee5960bd110ab0b9b872063", [:make, :rebar3], [], "hexpm", "2b3e9da0b21c4565751a6d4901c20d1b4cc25cbb7fd50d91d2ab6dd287bc86a9"}, "credo": {:hex, :credo, "1.6.7", "323f5734350fd23a456f2688b9430e7d517afb313fbd38671b8a4449798a7854", [:mix], [{:bunt, "~> 0.2.1", [hex: :bunt, repo: "hexpm", optional: false]}, {:file_system, "~> 0.2.8", [hex: :file_system, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: false]}], "hexpm", "41e110bfb007f7eda7f897c10bf019ceab9a0b269ce79f015d54b0dcf4fc7dd3"}, + "dataloader": {:hex, :dataloader, "1.0.10", "a42f07641b1a0572e0b21a2a5ae1be11da486a6790f3d0d14512d96ff3e3bbe9", [:mix], [{:ecto, ">= 3.4.3 and < 4.0.0", [hex: :ecto, repo: "hexpm", optional: true]}, {:telemetry, "~> 1.0 or ~> 0.4", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "54cd70cec09addf4b2ace14cc186a283a149fd4d3ec5475b155951bf33cd963f"}, "db_connection": {:hex, :db_connection, "2.4.2", "f92e79aff2375299a16bcb069a14ee8615c3414863a6fef93156aee8e86c2ff3", [:mix], [{:connection, "~> 1.0", [hex: :connection, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "4fe53ca91b99f55ea249693a0229356a08f4d1a7931d8ffa79289b145fe83668"}, "decimal": {:hex, :decimal, "2.0.0", "a78296e617b0f5dd4c6caf57c714431347912ffb1d0842e998e9792b5642d697", [:mix], [], "hexpm", "34666e9c55dea81013e77d9d87370fe6cb6291d1ef32f46a1600230b1d44f577"}, "dialyxir": {:hex, :dialyxir, "1.2.0", "58344b3e87c2e7095304c81a9ae65cb68b613e28340690dfe1a5597fd08dec37", [:mix], [{:erlex, ">= 0.2.6", [hex: :erlex, repo: "hexpm", optional: false]}], "hexpm", "61072136427a851674cab81762be4dbeae7679f85b1272b6d25c3a839aff8463"}, @@ -31,6 +37,7 @@ "jason": {:hex, :jason, "1.4.0", "e855647bc964a44e2f67df589ccf49105ae039d4179db7f6271dfd3843dc27e6", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "79a3791085b2a0f743ca04cec0f7be26443738779d09302e01318f97bdb82121"}, "joken": {:hex, :joken, "2.5.0", "09be497d804b8115eb6f07615cef2e60c2a1008fb89dc0aef0d4c4b4609b99aa", [:mix], [{:jose, "~> 1.11.2", [hex: :jose, repo: "hexpm", optional: false]}], "hexpm", "22b25c89617c5ed8ca7b31026340a25ea0f9ca7160f9706b79be9ed81fdf74e7"}, "jose": {:hex, :jose, "1.11.2", "f4c018ccf4fdce22c71e44d471f15f723cb3efab5d909ab2ba202b5bf35557b3", [:mix, :rebar3], [], "hexpm", "98143fbc48d55f3a18daba82d34fe48959d44538e9697c08f34200fa5f0947d2"}, + "json_xema": {:hex, :json_xema, "0.4.2", "85de190f597a98ce9da436b8a59c97ef561a6ab6017255df8b494babefd6fb10", [:mix], [{:conv_case, "~> 0.2", [hex: :conv_case, repo: "hexpm", optional: false]}, {:xema, "~> 0.11", [hex: :xema, repo: "hexpm", optional: false]}], "hexpm", "5516213758667d21669e0d63ea287238d277519527bac6c02140a5e34c1fda80"}, "makeup": {:hex, :makeup, "1.1.0", "6b67c8bc2882a6b6a445859952a602afc1a41c2e08379ca057c0f525366fc3ca", [:mix], [{:nimble_parsec, "~> 1.2.2 or ~> 1.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "0a45ed501f4a8897f580eabf99a2e5234ea3e75a4373c8a52824f6e873be57a6"}, "makeup_elixir": {:hex, :makeup_elixir, "0.16.0", "f8c570a0d33f8039513fbccaf7108c5d750f47d8defd44088371191b76492b0b", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.2.3", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "28b2cbdc13960a46ae9a8858c4bebdec3c9a6d7b4b9e7f4ed1502f8159f338e7"}, "makeup_erlang": {:hex, :makeup_erlang, "0.1.1", "3fcb7f09eb9d98dc4d208f49cc955a34218fc41ff6b84df7c75b3e6e533cc65f", [:mix], [{:makeup, "~> 1.0", [hex: :makeup, repo: "hexpm", optional: false]}], "hexpm", "174d0809e98a4ef0b3309256cbf97101c6ec01c4ab0b23e926a9e17df2077cbb"}, @@ -46,8 +53,9 @@ "providers": {:hex, :providers, "1.8.1", "70b4197869514344a8a60e2b2a4ef41ca03def43cfb1712ecf076a0f3c62f083", [:rebar3], [{:getopt, "1.0.1", [hex: :getopt, repo: "hexpm", optional: false]}], "hexpm", "e45745ade9c476a9a469ea0840e418ab19360dc44f01a233304e118a44486ba0"}, "ranch": {:hex, :ranch, "1.8.0", "8c7a100a139fd57f17327b6413e4167ac559fbc04ca7448e9be9057311597a1d", [:make, :rebar3], [], "hexpm", "49fbcfd3682fab1f5d109351b61257676da1a2fdbe295904176d5e521a2ddfe5"}, "sourceror": {:hex, :sourceror, "0.11.2", "549ce48be666421ac60cfb7f59c8752e0d393baa0b14d06271d3f6a8c1b027ab", [:mix], [], "hexpm", "9ab659118896a36be6eec68ff7b0674cba372fc8e210b1e9dc8cf2b55bb70dfb"}, - "spark": {:hex, :spark, "0.2.0", "501ce2c3fe46876fcfea4831168ef7f826c7cbbd8ede8e2c131ec27af5f07d75", [:mix], [{:nimble_options, "~> 0.4.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:sourceror, "~> 0.1", [hex: :sourceror, repo: "hexpm", optional: false]}], "hexpm", "d1dfdbce04e674bae337fa439e0e75087e7006cc8227567f035b9be0013a23c3"}, + "spark": {:hex, :spark, "0.2.1", "4f76234fce4bf48a6236e2268fba4d33c441ed8e30944785852c483a7aed231c", [:mix], [{:nimble_options, "~> 0.4.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:sourceror, "~> 0.1", [hex: :sourceror, repo: "hexpm", optional: false]}], "hexpm", "29033cb2ebecfff5ceff5209cca06c8e1e7ce8c1da189676de19cdc07d146b43"}, "stream_data": {:hex, :stream_data, "0.5.0", "b27641e58941685c75b353577dc602c9d2c12292dd84babf506c2033cd97893e", [:mix], [], "hexpm", "012bd2eec069ada4db3411f9115ccafa38540a3c78c4c0349f151fc761b9e271"}, "telemetry": {:hex, :telemetry, "1.1.0", "a589817034a27eab11144ad24d5c0f9fab1f58173274b1e9bae7074af9cbee51", [:rebar3], [], "hexpm", "b727b2a1f75614774cff2d7565b64d0dfa5bd52ba517f16543e6fc7efcc0df48"}, "typable": {:hex, :typable, "0.3.0", "0431e121d124cd26f312123e313d2689b9a5322b15add65d424c07779eaa3ca1", [:mix], [], "hexpm", "880a0797752da1a4c508ac48f94711e04c86156f498065a83d160eef945858f8"}, + "xema": {:hex, :xema, "0.17.0", "982e397ce0af55cdf1c6bf9c5ee6e20c5ea4a24e58e5266339cfff0dadbfa01e", [:mix], [{:conv_case, "~> 0.2.2", [hex: :conv_case, repo: "hexpm", optional: false]}, {:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "9020afc75c5b9fba1c5875fd735a19c3c544db058cd97ef4c4675e479fc8bcbe"}, } diff --git a/test/ash_authentication/plug/defaults_test.exs b/test/ash_authentication/plug/defaults_test.exs new file mode 100644 index 0000000..cb62de5 --- /dev/null +++ b/test/ash_authentication/plug/defaults_test.exs @@ -0,0 +1,39 @@ +defmodule AshAuthentication.Plug.DefaultsTest do + @moduledoc false + use AshAuthentication.DataCase, async: true + alias AshAuthentication.{Plug.Defaults, SessionPipeline} + import Plug.Test, only: [conn: 3] + + setup do + conn = + :get + |> conn("/", %{}) + |> SessionPipeline.call([]) + + {:ok, conn: conn} + end + + describe "handle_success/3" do + test "it returns 200 and a basic message", %{conn: conn} do + user = build_user() + + conn = + conn + |> Defaults.handle_success(user, user.__metadata__.token) + + assert conn.status == 200 + assert conn.resp_body =~ ~r/access granted/i + end + end + + describe "handle_failure/2" do + test "it returns 401 and a basic message", %{conn: conn} do + conn = + conn + |> Defaults.handle_failure(:arbitrary_reason) + + assert conn.status == 401 + assert conn.resp_body =~ ~r/access denied/i + end + end +end diff --git a/test/ash_authentication/plug/helpers_test.exs b/test/ash_authentication/plug/helpers_test.exs new file mode 100644 index 0000000..35eef3f --- /dev/null +++ b/test/ash_authentication/plug/helpers_test.exs @@ -0,0 +1,119 @@ +defmodule AshAuthentication.Plug.HelpersTest do + @moduledoc false + use AshAuthentication.DataCase, async: true + alias AshAuthentication.{Plug.Helpers, SessionPipeline} + import Plug.Test, only: [conn: 3] + alias Plug.Conn + + setup do + conn = + :get + |> conn("/", %{}) + |> SessionPipeline.call([]) + + {:ok, conn: conn} + end + + describe "store_in_session/2" do + test "it stores the user in the session", %{conn: conn} do + user = build_user() + subject = AshAuthentication.resource_to_subject(user) + + conn = + conn + |> Helpers.store_in_session(user) + + assert conn.private.plug_session["user_with_username"] == subject + end + end + + describe "load_subjects/2" do + test "it loads the subjects listed" do + user = build_user() + subject = AshAuthentication.resource_to_subject(user) + + rx_users = Helpers.load_subjects([subject], :ash_authentication) + + assert rx_users[:current_user_with_username].id == user.id + end + end + + describe "retrieve_from_session/2" do + test "it loads any subjects stored in the session", %{conn: conn} do + user = build_user() + subject = AshAuthentication.resource_to_subject(user) + + conn = + conn + |> Conn.put_session("user_with_username", subject) + |> Helpers.retrieve_from_session(:ash_authentication) + + assert conn.assigns.current_user_with_username.id == user.id + end + end + + describe "retrieve_from_bearer/2" do + test "it loads any subjects from authorization headers", %{conn: conn} do + user = build_user() + + conn = + conn + |> Conn.put_req_header("authorization", "Bearer #{user.__metadata__.token}") + |> Helpers.retrieve_from_bearer(:ash_authentication) + + assert conn.assigns.current_user_with_username.id == user.id + end + end + + describe "revoke_bearer_tokens/2" do + test "it revokes any tokens in the authorization headers", %{conn: conn} do + user = build_user() + + {:ok, %{"jti" => jti}} = + user.__metadata__.token + |> Joken.peek_claims() + + conn + |> Conn.put_req_header("authorization", "Bearer #{user.__metadata__.token}") + |> Helpers.revoke_bearer_tokens(:ash_authentication) + + assert AshAuthentication.TokenRevocation.revoked?(user.__struct__, jti) + end + end + + describe "set_actor/2" do + alias Ash.PlugHelpers + + test "it sets the actor when there is a `current_` resource in the assigns", %{conn: conn} do + user = build_user() + + conn = + conn + |> Conn.assign(:current_user_with_username, user) + |> Helpers.set_actor(:user_with_username) + + assert PlugHelpers.get_actor(conn) == user + end + + test "it sets the actor to `nil` otherwise", %{conn: conn} do + conn = + conn + |> Helpers.set_actor(:user_with_username) + + refute PlugHelpers.get_actor(conn) + end + end + + describe "private_store/2" do + test "it stores the authentication result in the conn's private", %{conn: conn} do + user = build_user() + + conn = + conn + |> Conn.put_private(:authenticator, %{resource: user.__struct__}) + |> Helpers.private_store({:success, user}) + + assert conn.private.authentication_result == {:success, user} + end + end +end diff --git a/test/ash_authentication/plug_test.exs b/test/ash_authentication/plug_test.exs new file mode 100644 index 0000000..51ab70b --- /dev/null +++ b/test/ash_authentication/plug_test.exs @@ -0,0 +1,174 @@ +defmodule AshAuthentication.PlugTest do + @moduledoc false + use AshAuthentication.DataCase, async: true + use Mimic + alias AshAuthentication.Plug.{Defaults, Helpers} + alias AshAuthentication.SessionPipeline + alias Example.AuthPlug + import Plug.Test, only: [conn: 3] + + describe "handle_success/3" do + test "it is called when authentication is successful" do + password = password() + user = build_user(password: password, password_confirmation: password) + + opts = AuthPlug.init([]) + + %{status: status, resp_body: resp} = + :post + |> conn("/user_with_username/password/callback", %{ + "user_with_username" => %{ + "username" => to_string(user.username), + "password" => password, + "action" => "sign_in" + } + }) + |> SessionPipeline.call([]) + |> AuthPlug.call(opts) + + resp = Jason.decode!(resp) + + assert status == 200 + assert resp["user"]["id"] == user.id + assert resp["user"]["username"] == to_string(user.username) + end + end + + describe "handle_failure/2" do + test "it is called when authentication is unsuccessful" do + opts = AuthPlug.init([]) + + %{status: status, resp_body: resp} = + :post + |> conn("/user_with_username/password/callback", %{ + "user_with_username" => %{ + "username" => username(), + "password" => password(), + "action" => "sign_in" + } + }) + |> SessionPipeline.call([]) + |> AuthPlug.call(opts) + + resp = Jason.decode!(resp) + + assert status == 401 + assert resp["status"] == "failed" + assert resp["reason"] =~ ~r/Forbidden/ + end + end + + describe "load_from_session/2" do + test "it delegates to Helpers.retrieve_from_session/2" do + conn = conn(:get, "/", %{}) + + Helpers + |> expect(:retrieve_from_session, fn rx_conn, otp_app -> + assert otp_app == :ash_authentication + assert conn == rx_conn + end) + + conn + |> AuthPlug.load_from_session([]) + end + end + + describe "load_from_bearer/2" do + test "it delegates to Helpers.retrieve_from_bearer/2" do + conn = conn(:get, "/", %{}) + + Helpers + |> expect(:retrieve_from_bearer, fn rx_conn, otp_app -> + assert otp_app == :ash_authentication + assert conn == rx_conn + end) + + conn + |> AuthPlug.load_from_bearer([]) + end + end + + describe "revoke_bearer_tokens/2" do + test "it delegates to Helpers.revoke_bearer_tokens/2" do + conn = conn(:get, "/", %{}) + + Helpers + |> expect(:revoke_bearer_tokens, fn rx_conn, otp_app -> + assert otp_app == :ash_authentication + assert conn == rx_conn + end) + + conn + |> AuthPlug.revoke_bearer_tokens([]) + end + end + + describe "set_actor/2" do + test "it delegates to Helpers.set_actor/2" do + conn = conn(:get, "/", %{}) + + Helpers + |> expect(:set_actor, fn rx_conn, subject_name -> + assert subject_name == :user_with_username + assert conn == rx_conn + end) + + conn + |> AuthPlug.set_actor(:user_with_username) + end + end + + describe "store_in_session/2" do + test "it delegates to Helpers.store_in_session/2" do + user = build_user() + + conn = conn(:get, "/", %{}) + + Helpers + |> expect(:store_in_session, fn rx_conn, rx_user -> + assert rx_user == user + assert conn == rx_conn + end) + + conn + |> AuthPlug.store_in_session(user) + end + end + + describe "__using__/1" do + defmodule WithDefaults do + @moduledoc false + use AshAuthentication.Plug, otp_app: :ash_authentication + end + + test "it uses the default handle_success/3" do + conn = conn(:get, "/", %{}) + user = build_user() + token = Ecto.UUID.generate() + + Defaults + |> expect(:handle_success, fn rx_conn, rx_user, rx_token -> + assert rx_conn == conn + assert rx_user == user + assert rx_token == token + end) + + conn + |> WithDefaults.handle_success(user, token) + end + + test "it uses the default handle_failure/2" do + conn = conn(:get, "/", %{}) + reason = Ecto.UUID.generate() + + Defaults + |> expect(:handle_failure, fn rx_conn, rx_reason -> + assert rx_conn == conn + assert rx_reason == reason + end) + + conn + |> WithDefaults.handle_failure(reason) + end + end +end diff --git a/test/support/data_case.ex b/test/support/data_case.ex index 952a6f9..a19e134 100644 --- a/test/support/data_case.ex +++ b/test/support/data_case.ex @@ -60,11 +60,12 @@ defmodule AshAuthentication.DataCase do def username, do: Faker.Internet.user_name() def password, do: Faker.Lorem.words(4) |> Enum.join(" ") - def build_user(attrs \\ %{}) do + def build_user(attrs \\ []) do password = password() attrs = attrs + |> Map.new() |> Map.put_new(:username, username()) |> Map.put_new(:password, password) |> Map.put_new(:password_confirmation, password) diff --git a/test/support/example.ex b/test/support/example.ex index cca718d..712e7f0 100644 --- a/test/support/example.ex +++ b/test/support/example.ex @@ -1,8 +1,12 @@ defmodule Example do @moduledoc false - use Ash.Api, otp_app: :ash_authentication + use Ash.Api, otp_app: :ash_authentication, extensions: [AshGraphql.Api, AshJsonApi.Api] resources do registry Example.Registry end + + json_api do + prefix "/api" + end end diff --git a/test/support/example/auth_plug.ex b/test/support/example/auth_plug.ex index 128dd13..efc7c80 100644 --- a/test/support/example/auth_plug.ex +++ b/test/support/example/auth_plug.ex @@ -3,19 +3,32 @@ defmodule Example.AuthPlug do use AshAuthentication.Plug, otp_app: :ash_authentication @impl true - def handle_success(conn, actor, token) do + def handle_success(conn, user, token) do conn - |> store_in_session(actor) - |> send_resp(200, """ - Token: #{token} - - Actor: #{inspect(actor)} - """) + |> store_in_session(user) + |> put_resp_header("content-type", "application/json") + |> send_resp( + 200, + Jason.encode!(%{ + token: token, + user: %{ + id: user.id, + username: user.username + } + }) + ) end @impl true - def handle_failure(conn, _) do + def handle_failure(conn, reason) do conn - |> send_resp(401, "Sorry mate") + |> put_resp_header("content-type", "application/json") + |> send_resp( + 401, + Jason.encode!(%{ + status: "failed", + reason: inspect(reason) + }) + ) end end diff --git a/test/support/example/current_user_read.ex b/test/support/example/current_user_read.ex new file mode 100644 index 0000000..d6f8023 --- /dev/null +++ b/test/support/example/current_user_read.ex @@ -0,0 +1,17 @@ +defmodule Example.CurrentUserRead do + @moduledoc """ + There's no need to actually go to the database to get the current user, when + we know it will already be in the context. + + Here we just check that the actor is the same type of resource as is being + asked for. + """ + use Ash.Resource.ManualRead + + @doc false + @impl true + def read(%{resource: resource}, _, _, %{actor: actor}) when is_struct(actor, resource), + do: {:ok, [actor]} + + def read(_, _, _, _), do: {:ok, []} +end diff --git a/test/support/example/schema.ex b/test/support/example/schema.ex new file mode 100644 index 0000000..0a440aa --- /dev/null +++ b/test/support/example/schema.ex @@ -0,0 +1,16 @@ +defmodule Example.Schema do + @moduledoc false + use Absinthe.Schema + + @apis [Example] + + use AshGraphql, apis: @apis + + def context(ctx) do + AshGraphql.add_context(ctx, @apis) + end + + def plugins do + [Absinthe.Middleware.Dataloader | Absinthe.Plugin.defaults()] + end +end diff --git a/test/support/example/user_with_username.ex b/test/support/example/user_with_username.ex index 00a8dbc..5fcdf8e 100644 --- a/test/support/example/user_with_username.ex +++ b/test/support/example/user_with_username.ex @@ -2,7 +2,12 @@ defmodule Example.UserWithUsername do @moduledoc false use Ash.Resource, data_layer: AshPostgres.DataLayer, - extensions: [AshAuthentication, AshAuthentication.PasswordAuthentication] + extensions: [ + AshAuthentication, + AshAuthentication.PasswordAuthentication, + AshGraphql.Resource, + AshJsonApi.Resource + ] @type t :: %__MODULE__{ id: Ecto.UUID.t(), @@ -16,22 +21,57 @@ defmodule Example.UserWithUsername do uuid_primary_key(:id) attribute(:username, :ci_string, allow_nil?: false) - attribute(:hashed_password, :string, allow_nil?: false, sensitive?: true) + attribute(:hashed_password, :string, allow_nil?: false, sensitive?: true, private?: true) create_timestamp(:created_at) update_timestamp(:updated_at) end actions do + read :read do + primary? true + end + destroy :destroy do primary? true end + + read :current_user do + get? true + manual Example.CurrentUserRead + end end code_interface do define_for(Example) end + graphql do + type :user + + queries do + get(:get_user, :read) + list(:list_users, :read) + read_one(:current_user, :current_user) + end + + mutations do + create :register, :register + end + end + + json_api do + type "user" + + routes do + base("/users") + get(:read) + get(:current_user, route: "/me") + index(:read) + post(:register) + end + end + postgres do table("user_with_username") repo(Example.Repo) diff --git a/test/support/session_pipeline.ex b/test/support/session_pipeline.ex new file mode 100644 index 0000000..a68331d --- /dev/null +++ b/test/support/session_pipeline.ex @@ -0,0 +1,23 @@ +defmodule AshAuthentication.SessionPipeline do + @moduledoc """ + A simple plug pipeline that ensures that the session is set up ready to be consumed. + """ + use Plug.Builder + import Ecto.UUID, only: [generate: 0] + + plug(:set_secret) + + plug(Plug.Session, + store: :cookie, + key: inspect(__MODULE__), + encryption_salt: generate(), + signing_salt: generate() + ) + + plug(:fetch_session) + + @doc false + def set_secret(conn, _) do + put_in(conn.secret_key_base, generate() <> generate()) + end +end diff --git a/test/test_helper.exs b/test/test_helper.exs index 3be0501..cbc84df 100644 --- a/test/test_helper.exs +++ b/test/test_helper.exs @@ -1,2 +1,4 @@ +Mimic.copy(AshAuthentication.Plug.Defaults) +Mimic.copy(AshAuthentication.Plug.Helpers) Mimic.copy(AshAuthentication.TokenRevocation) ExUnit.start(capture_log: true)