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
Name |
Value |
- <%= for {name, actor} <- @current_actors do %>
+ <%= for {name, user} <- @current_users do %>
@<%= name %>
|
- <%= inspect actor, pretty: true %>
|
+ <%= inspect user, pretty: true %>
|
<% end %>
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)