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.
This commit is contained in:
James Harton 2022-10-31 16:43:00 +13:00 committed by GitHub
parent a432bd5477
commit 8797005175
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
34 changed files with 809 additions and 167 deletions

View file

@ -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,

View file

@ -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: [

View file

@ -1,3 +1,7 @@
import Config
config :mime, :types, %{
"application/vnd.api+json" => ["json"]
}
import_config "#{config_env()}.exs"

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -0,0 +1,4 @@
defmodule DevServer.JsonApiRouter do
@moduledoc false
use AshJsonApi.Api.Router, api: Example, registry: Example.Registry
end

View file

@ -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

View file

@ -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

View file

@ -32,18 +32,18 @@
</p>
<% end %>
<%= if Enum.any?(@current_actors) do %>
<h2>Current actors:</h2>
<%= if Enum.any?(@current_users) do %>
<h2>Current users:</h2>
<a href="/clear_session">Clear session</a>
<table>
<tr>
<th>Name</th>
<th>Value</th>
</tr>
<%= for {name, actor} <- @current_actors do %>
<%= for {name, user} <- @current_users do %>
<tr>
<td><code><pre>@<%= name %></pre></code></td>
<td><code><pre><%= inspect actor, pretty: true %></pre></code></td>
<td><code><pre><%= inspect user, pretty: true %></pre></code></td>
</tr>
<% end %>
</table>

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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})

View file

@ -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

View file

@ -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

View file

@ -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)

View file

@ -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

View file

@ -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

View file

@ -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},

View file

@ -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"},
}

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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)

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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

View file

@ -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)

View file

@ -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

View file

@ -1,2 +1,4 @@
Mimic.copy(AshAuthentication.Plug.Defaults)
Mimic.copy(AshAuthentication.Plug.Helpers)
Mimic.copy(AshAuthentication.TokenRevocation)
ExUnit.start(capture_log: true)