mirror of
https://github.com/team-alembic/ash_authentication.git
synced 2024-09-19 04:43:04 +12:00
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:
parent
a432bd5477
commit
8797005175
34 changed files with 809 additions and 167 deletions
|
@ -1,5 +1,10 @@
|
||||||
%Doctor.Config{
|
%Doctor.Config{
|
||||||
ignore_modules: [~r/^Inspect\./, ~r/.Plug$/, AshAuthentication.InfoGenerator],
|
ignore_modules: [
|
||||||
|
~r/^Inspect\./,
|
||||||
|
~r/.Plug$/,
|
||||||
|
AshAuthentication.InfoGenerator,
|
||||||
|
AshAuthentication.Plug.Macros
|
||||||
|
],
|
||||||
ignore_paths: [],
|
ignore_paths: [],
|
||||||
min_module_doc_coverage: 40,
|
min_module_doc_coverage: 40,
|
||||||
min_module_spec_coverage: 0,
|
min_module_spec_coverage: 0,
|
||||||
|
|
|
@ -16,7 +16,7 @@ spark_locals_without_parens = [
|
||||||
import_deps: [:ash, :spark],
|
import_deps: [:ash, :spark],
|
||||||
inputs: [
|
inputs: [
|
||||||
"*.{ex,exs}",
|
"*.{ex,exs}",
|
||||||
"{config,lib,test}/**/*.{ex,exs}"
|
"{dev,config,lib,test}/**/*.{ex,exs}"
|
||||||
],
|
],
|
||||||
plugins: [Spark.Formatter],
|
plugins: [Spark.Formatter],
|
||||||
export: [
|
export: [
|
||||||
|
|
|
@ -1,3 +1,7 @@
|
||||||
import Config
|
import Config
|
||||||
|
|
||||||
|
config :mime, :types, %{
|
||||||
|
"application/vnd.api+json" => ["json"]
|
||||||
|
}
|
||||||
|
|
||||||
import_config "#{config_env()}.exs"
|
import_config "#{config_env()}.exs"
|
||||||
|
|
|
@ -17,7 +17,7 @@ defmodule DevServer do
|
||||||
|
|
||||||
[
|
[
|
||||||
{DevServer.Session, []},
|
{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)
|
|> Supervisor.init(strategy: :one_for_all)
|
||||||
end
|
end
|
||||||
|
|
14
dev/dev_server/api_router.ex
Normal file
14
dev/dev_server/api_router.ex
Normal 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
|
|
@ -1,6 +1,6 @@
|
||||||
defmodule DevServer.ClearSession do
|
defmodule DevServer.ClearSession do
|
||||||
@moduledoc """
|
@moduledoc """
|
||||||
Resets the session storage, to 'log out" all actors.
|
Resets the session storage, to 'log out" all users.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@behaviour Plug
|
@behaviour Plug
|
||||||
|
|
26
dev/dev_server/gql_router.ex
Normal file
26
dev/dev_server/gql_router.ex
Normal 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
|
4
dev/dev_server/json_api_router.ex
Normal file
4
dev/dev_server/json_api_router.ex
Normal file
|
@ -0,0 +1,4 @@
|
||||||
|
defmodule DevServer.JsonApiRouter do
|
||||||
|
@moduledoc false
|
||||||
|
use AshJsonApi.Api.Router, api: Example, registry: Example.Registry
|
||||||
|
end
|
|
@ -1,24 +1,19 @@
|
||||||
defmodule DevServer.Plug do
|
defmodule DevServer.Router do
|
||||||
@moduledoc false
|
@moduledoc false
|
||||||
use Plug.Router
|
use Plug.Router
|
||||||
alias DevServer
|
|
||||||
import Example.AuthPlug
|
|
||||||
|
|
||||||
plug(Plug.Parsers, parsers: [:urlencoded, :multipart, :json], json_decoder: Jason)
|
plug(Plug.Parsers, parsers: [:urlencoded, :multipart, :json], json_decoder: Jason)
|
||||||
plug(Plug.Session, store: :ets, key: "_ash_authentication_session", table: DevServer.Session)
|
plug(Plug.Session, store: :ets, key: "_ash_authentication_session", table: DevServer.Session)
|
||||||
plug(:fetch_query_params)
|
|
||||||
plug(:fetch_session)
|
plug(:fetch_session)
|
||||||
|
plug(:fetch_query_params)
|
||||||
plug(Plug.Logger)
|
plug(Plug.Logger)
|
||||||
plug(:load_from_session)
|
|
||||||
plug(:match)
|
plug(:match)
|
||||||
plug(:dispatch)
|
plug(:dispatch)
|
||||||
|
|
||||||
forward("/auth", to: Example.AuthPlug.Router)
|
forward("/auth", to: Example.AuthPlug)
|
||||||
get("/clear_session", to: DevServer.ClearSession)
|
get("/clear_session", to: DevServer.ClearSession)
|
||||||
post("/token_check", to: DevServer.TokenCheck)
|
post("/token_check", to: DevServer.TokenCheck)
|
||||||
get("/", to: DevServer.TestPage)
|
forward("/api", to: DevServer.ApiRouter)
|
||||||
|
forward("/gql", to: DevServer.GqlRouter)
|
||||||
match _ do
|
forward("/", to: DevServer.WebRouter)
|
||||||
send_resp(conn, 404, "NOT FOUND")
|
|
||||||
end
|
|
||||||
end
|
end
|
|
@ -22,7 +22,7 @@ defmodule DevServer.TestPage do
|
||||||
def call(conn, _opts) do
|
def call(conn, _opts) do
|
||||||
resources = AshAuthentication.authenticated_resources(:ash_authentication)
|
resources = AshAuthentication.authenticated_resources(:ash_authentication)
|
||||||
|
|
||||||
current_actors =
|
current_users =
|
||||||
conn.assigns
|
conn.assigns
|
||||||
|> Stream.filter(fn {key, _value} ->
|
|> Stream.filter(fn {key, _value} ->
|
||||||
key
|
key
|
||||||
|
@ -31,7 +31,7 @@ defmodule DevServer.TestPage do
|
||||||
end)
|
end)
|
||||||
|> Map.new()
|
|> Map.new()
|
||||||
|
|
||||||
payload = render(resources: resources, current_actors: current_actors)
|
payload = render(resources: resources, current_users: current_users)
|
||||||
Conn.send_resp(conn, 200, payload)
|
Conn.send_resp(conn, 200, payload)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -32,18 +32,18 @@
|
||||||
</p>
|
</p>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
|
||||||
<%= if Enum.any?(@current_actors) do %>
|
<%= if Enum.any?(@current_users) do %>
|
||||||
<h2>Current actors:</h2>
|
<h2>Current users:</h2>
|
||||||
<a href="/clear_session">Clear session</a>
|
<a href="/clear_session">Clear session</a>
|
||||||
<table>
|
<table>
|
||||||
<tr>
|
<tr>
|
||||||
<th>Name</th>
|
<th>Name</th>
|
||||||
<th>Value</th>
|
<th>Value</th>
|
||||||
</tr>
|
</tr>
|
||||||
<%= for {name, actor} <- @current_actors do %>
|
<%= for {name, user} <- @current_users do %>
|
||||||
<tr>
|
<tr>
|
||||||
<td><code><pre>@<%= name %></pre></code></td>
|
<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>
|
</tr>
|
||||||
<% end %>
|
<% end %>
|
||||||
</table>
|
</table>
|
||||||
|
|
17
dev/dev_server/web_router.ex
Normal file
17
dev/dev_server/web_router.ex
Normal 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
|
|
@ -235,7 +235,7 @@ defmodule AshAuthentication do
|
||||||
|> Query.filter(^primary_key)
|
|> Query.filter(^primary_key)
|
||||||
|> config.api.read()
|
|> config.api.read()
|
||||||
|> case do
|
|> case do
|
||||||
{:ok, [actor]} -> {:ok, actor}
|
{:ok, [user]} -> {:ok, user}
|
||||||
_ -> {:error, "Invalid subject"}
|
_ -> {:error, "Invalid subject"}
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -8,14 +8,14 @@ defmodule AshAuthentication.PasswordAuthentication do
|
||||||
identity_field: [
|
identity_field: [
|
||||||
type: :atom,
|
type: :atom,
|
||||||
doc: """
|
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
|
default: :username
|
||||||
],
|
],
|
||||||
hashed_password_field: [
|
hashed_password_field: [
|
||||||
type: :atom,
|
type: :atom,
|
||||||
doc: """
|
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
|
default: :hashed_password
|
||||||
],
|
],
|
||||||
|
@ -115,7 +115,7 @@ defmodule AshAuthentication.PasswordAuthentication do
|
||||||
alias Plug.Conn
|
alias Plug.Conn
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
Attempt to sign in an actor of the provided resource type.
|
Attempt to sign in an user of the provided resource type.
|
||||||
|
|
||||||
## Example
|
## Example
|
||||||
|
|
||||||
|
@ -129,7 +129,7 @@ defmodule AshAuthentication.PasswordAuthentication do
|
||||||
as: :sign_in
|
as: :sign_in
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
Attempt to register an actor of the provided resource type.
|
Attempt to register an user of the provided resource type.
|
||||||
|
|
||||||
## Example
|
## Example
|
||||||
|
|
||||||
|
|
|
@ -11,7 +11,7 @@ defmodule AshAuthentication.PasswordAuthentication.Actions do
|
||||||
alias AshAuthentication.PasswordAuthentication
|
alias AshAuthentication.PasswordAuthentication
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
Attempt to sign in an actor of the provided resource type.
|
Attempt to sign in an user of the provided resource type.
|
||||||
|
|
||||||
## Example
|
## Example
|
||||||
|
|
||||||
|
@ -27,14 +27,14 @@ defmodule AshAuthentication.PasswordAuthentication.Actions do
|
||||||
|> Query.for_read(action, attributes)
|
|> Query.for_read(action, attributes)
|
||||||
|> api.read()
|
|> api.read()
|
||||||
|> case do
|
|> case do
|
||||||
{:ok, [actor]} -> {:ok, actor}
|
{:ok, [user]} -> {:ok, user}
|
||||||
{:ok, []} -> {:error, "Invalid username or password"}
|
{:ok, []} -> {:error, "Invalid username or password"}
|
||||||
{:error, reason} -> {:error, reason}
|
{:error, reason} -> {:error, reason}
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
Attempt to register an actor of the provided resource type.
|
Attempt to register an user of the provided resource type.
|
||||||
|
|
||||||
## Example
|
## Example
|
||||||
|
|
||||||
|
|
|
@ -3,7 +3,7 @@ defmodule AshAuthentication.PasswordAuthentication.Plug do
|
||||||
Handlers for incoming request and callback HTTP requests.
|
Handlers for incoming request and callback HTTP requests.
|
||||||
|
|
||||||
AshAuthentication is written with an eye towards OAuth which uses a two-phase
|
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
|
a single flow. This doesn't really work that well with `PasswordAuthentication` which has
|
||||||
seperate "registration" and "sign-in" actions.
|
seperate "registration" and "sign-in" actions.
|
||||||
|
|
||||||
|
@ -37,8 +37,8 @@ defmodule AshAuthentication.PasswordAuthentication.Plug do
|
||||||
|> Map.get(to_string(config.subject_name), %{})
|
|> Map.get(to_string(config.subject_name), %{})
|
||||||
|> do_action(config.resource)
|
|> do_action(config.resource)
|
||||||
|> case do
|
|> case do
|
||||||
{:ok, actor} when is_struct(actor, config.resource) ->
|
{:ok, user} when is_struct(user, config.resource) ->
|
||||||
private_store(conn, {:success, actor})
|
private_store(conn, {:success, user})
|
||||||
|
|
||||||
{:error, changeset} ->
|
{:error, changeset} ->
|
||||||
private_store(conn, {:failure, changeset})
|
private_store(conn, {:failure, changeset})
|
||||||
|
|
|
@ -32,7 +32,7 @@ defmodule AshAuthentication.Plug do
|
||||||
end
|
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
|
```elixir
|
||||||
import MyAppWeb.AuthPlug
|
import MyAppWeb.AuthPlug
|
||||||
|
@ -69,10 +69,10 @@ defmodule AshAuthentication.Plug do
|
||||||
do useful things like session and query param fetching.
|
do useful things like session and query param fetching.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
alias Ash.{Api, Changeset, Resource}
|
alias Ash.{Changeset, Error, Resource}
|
||||||
alias AshAuthentication.Plug.Helpers
|
alias AshAuthentication.Plug.{Defaults, Helpers, Macros}
|
||||||
alias Plug.Conn
|
alias Plug.Conn
|
||||||
alias Spark.Dsl.Extension
|
require Macros
|
||||||
|
|
||||||
@type authenticator_config :: %{
|
@type authenticator_config :: %{
|
||||||
api: module,
|
api: module,
|
||||||
|
@ -103,7 +103,7 @@ defmodule AshAuthentication.Plug do
|
||||||
The default implementation simply returns a 401 status with the message
|
The default implementation simply returns a 401 status with the message
|
||||||
"Access denied". You almost definitely want to override this.
|
"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
|
defmacro __using__(opts) do
|
||||||
otp_app =
|
otp_app =
|
||||||
|
@ -112,58 +112,15 @@ defmodule AshAuthentication.Plug do
|
||||||
|> Macro.expand_once(__CALLER__)
|
|> Macro.expand_once(__CALLER__)
|
||||||
|
|
||||||
quote do
|
quote do
|
||||||
require Ash.Api.Info
|
require Macros
|
||||||
|
Macros.validate_subject_name_uniqueness(unquote(otp_app))
|
||||||
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
|
|
||||||
|
|
||||||
@behaviour AshAuthentication.Plug
|
@behaviour AshAuthentication.Plug
|
||||||
|
@behaviour Plug
|
||||||
import Plug.Conn
|
import Plug.Conn
|
||||||
|
|
||||||
defmodule Router do
|
defmodule Router do
|
||||||
@moduledoc """
|
@moduledoc false
|
||||||
The Authentication Router.
|
|
||||||
|
|
||||||
Plug this into your app's router using:
|
|
||||||
|
|
||||||
```elixir
|
|
||||||
forward "/auth", to: #{__MODULE__}
|
|
||||||
```
|
|
||||||
|
|
||||||
This router is generated using `AshAuthentication.Plug.Router`.
|
|
||||||
"""
|
|
||||||
use AshAuthentication.Plug.Router,
|
use AshAuthentication.Plug.Router,
|
||||||
otp_app: unquote(otp_app),
|
otp_app: unquote(otp_app),
|
||||||
return_to:
|
return_to:
|
||||||
|
@ -173,58 +130,26 @@ defmodule AshAuthentication.Plug do
|
||||||
|> Module.concat()
|
|> Module.concat()
|
||||||
end
|
end
|
||||||
|
|
||||||
@doc """
|
Macros.define_load_from_session(unquote(otp_app))
|
||||||
The default implementation of `handle_success/3`.
|
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
|
@impl true
|
||||||
basic 200 response.
|
defdelegate handle_success(conn, user, token), to: Defaults
|
||||||
"""
|
|
||||||
@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
|
|
||||||
|
|
||||||
@doc """
|
@impl true
|
||||||
The default implementation of `handle_failure/1`.
|
defdelegate handle_failure(conn, error), to: Defaults
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
defoverridable handle_success: 3, handle_failure: 2
|
defoverridable handle_success: 3, handle_failure: 2
|
||||||
|
|
||||||
@doc """
|
@impl true
|
||||||
Store an actor in the session.
|
defdelegate init(opts), to: Router
|
||||||
"""
|
|
||||||
@spec store_in_session(Conn.t(), Resource.record()) :: Conn.t()
|
|
||||||
def store_in_session(conn, actor),
|
|
||||||
do: Helpers.store_in_session(conn, actor)
|
|
||||||
|
|
||||||
@doc """
|
@impl true
|
||||||
Attempt to retrieve all actors from the connections' session.
|
defdelegate call(conn, opts), to: Router
|
||||||
|
|
||||||
A wrapper around `AshAuthentication.Plug.Helpers.retrieve_from_session/2`
|
defdelegate set_actor(conn, subject_name), to: Helpers
|
||||||
with the `otp_app` already present.
|
defdelegate store_in_session(conn, user), to: Helpers
|
||||||
"""
|
|
||||||
@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))
|
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
36
lib/ash_authentication/plug/defaults.ex
Normal file
36
lib/ash_authentication/plug/defaults.ex
Normal 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
|
|
@ -41,8 +41,8 @@ defmodule AshAuthentication.Plug.Dispatcher do
|
||||||
%{state: :sent} ->
|
%{state: :sent} ->
|
||||||
conn
|
conn
|
||||||
|
|
||||||
%{private: %{authentication_result: {:success, actor}}} ->
|
%{private: %{authentication_result: {:success, user}}} ->
|
||||||
return_to.handle_success(conn, actor, Map.get(actor.__metadata__, :token))
|
return_to.handle_success(conn, user, Map.get(user.__metadata__, :token))
|
||||||
|
|
||||||
%{private: %{authentication_result: {:failure, reason}}} ->
|
%{private: %{authentication_result: {:failure, reason}}} ->
|
||||||
return_to.handle_failure(conn, reason)
|
return_to.handle_failure(conn, reason)
|
||||||
|
|
|
@ -2,23 +2,24 @@ defmodule AshAuthentication.Plug.Helpers do
|
||||||
@moduledoc """
|
@moduledoc """
|
||||||
Authentication helpers for use in your router, etc.
|
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 AshAuthentication.{Info, Jwt, TokenRevocation}
|
||||||
alias Plug.Conn
|
alias Plug.Conn
|
||||||
|
|
||||||
@doc """
|
@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()
|
@spec store_in_session(Conn.t(), Resource.record()) :: Conn.t()
|
||||||
def store_in_session(conn, actor) do
|
def store_in_session(conn, user) do
|
||||||
subject_name = AshAuthentication.Info.authentication_subject_name!(actor.__struct__)
|
subject_name = AshAuthentication.Info.authentication_subject_name!(user.__struct__)
|
||||||
subject = AshAuthentication.resource_to_subject(actor)
|
subject = AshAuthentication.resource_to_subject(user)
|
||||||
|
|
||||||
Conn.put_session(conn, subject_name, subject)
|
Conn.put_session(conn, subject_name, subject)
|
||||||
end
|
end
|
||||||
|
|
||||||
@doc """
|
@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
|
@spec load_subjects([AshAuthentication.subject()], module) :: map
|
||||||
def load_subjects(subjects, otp_app) when is_list(subjects) do
|
def load_subjects(subjects, otp_app) when is_list(subjects) do
|
||||||
|
@ -26,15 +27,17 @@ defmodule AshAuthentication.Plug.Helpers do
|
||||||
otp_app
|
otp_app
|
||||||
|> AshAuthentication.authenticated_resources()
|
|> AshAuthentication.authenticated_resources()
|
||||||
|> Stream.map(&{to_string(&1.subject_name), &1})
|
|> Stream.map(&{to_string(&1.subject_name), &1})
|
||||||
|
|> Map.new()
|
||||||
|
|
||||||
subjects
|
subjects
|
||||||
|> Enum.reduce(%{}, fn subject, result ->
|
|> Enum.reduce(%{}, fn subject, result ->
|
||||||
subject = URI.parse(subject)
|
subject = URI.parse(subject)
|
||||||
|
|
||||||
with {:ok, config} <- Map.fetch(configurations, subject.path),
|
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)
|
current_subject_name = current_subject_name(config.subject_name)
|
||||||
Map.put(result, current_subject_name, actor)
|
|
||||||
|
Map.put(result, current_subject_name, user)
|
||||||
else
|
else
|
||||||
_ -> result
|
_ -> result
|
||||||
end
|
end
|
||||||
|
@ -42,13 +45,13 @@ defmodule AshAuthentication.Plug.Helpers do
|
||||||
end
|
end
|
||||||
|
|
||||||
@doc """
|
@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
|
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_`).
|
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()
|
@spec retrieve_from_session(Conn.t(), module) :: Conn.t()
|
||||||
def retrieve_from_session(conn, otp_app) do
|
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)
|
current_subject_name = current_subject_name(config.subject_name)
|
||||||
|
|
||||||
with subject when is_binary(subject) <- Conn.get_session(conn, 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
|
{:ok, user} <- AshAuthentication.subject_to_resource(subject, config) do
|
||||||
Conn.assign(conn, current_subject_name, actor)
|
Conn.assign(conn, current_subject_name, user)
|
||||||
else
|
else
|
||||||
_ ->
|
_ ->
|
||||||
Conn.assign(conn, current_subject_name, nil)
|
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
|
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
|
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()
|
@spec retrieve_from_bearer(Conn.t(), module) :: Conn.t()
|
||||||
def retrieve_from_bearer(conn, otp_app) do
|
def retrieve_from_bearer(conn, otp_app) do
|
||||||
|
@ -82,9 +87,10 @@ defmodule AshAuthentication.Plug.Helpers do
|
||||||
|> Stream.map(&String.replace_leading(&1, "Bearer ", ""))
|
|> Stream.map(&String.replace_leading(&1, "Bearer ", ""))
|
||||||
|> Enum.reduce(conn, fn token, conn ->
|
|> Enum.reduce(conn, fn token, conn ->
|
||||||
with {:ok, %{"sub" => subject}, config} <- Jwt.verify(token, otp_app),
|
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
|
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
|
else
|
||||||
_ -> conn
|
_ -> conn
|
||||||
end
|
end
|
||||||
|
@ -113,10 +119,47 @@ defmodule AshAuthentication.Plug.Helpers do
|
||||||
end)
|
end)
|
||||||
end
|
end
|
||||||
|
|
||||||
# Dyanamically generated atoms are generally frowned upon, but in this case
|
@doc """
|
||||||
# the `subject_name` is a statically configured atom, so should be fine.
|
Set a subject as the request actor.
|
||||||
defp current_subject_name(subject_name) when is_atom(subject_name),
|
|
||||||
do: String.to_atom("current_#{subject_name}")
|
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 """
|
@doc """
|
||||||
Store result in private.
|
Store result in private.
|
||||||
|
@ -136,4 +179,9 @@ defmodule AshAuthentication.Plug.Helpers do
|
||||||
def private_store(conn, {:failure, reason})
|
def private_store(conn, {:failure, reason})
|
||||||
when is_nil(reason) or is_map(reason),
|
when is_nil(reason) or is_map(reason),
|
||||||
do: Conn.put_private(conn, :authentication_result, {:failure, 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
|
end
|
||||||
|
|
108
lib/ash_authentication/plug/macros.ex
Normal file
108
lib/ash_authentication/plug/macros.ex
Normal 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
|
6
mix.exs
6
mix.exs
|
@ -77,11 +77,15 @@ defmodule AshAuthentication.MixProject do
|
||||||
# Run "mix help deps" to learn about dependencies.
|
# Run "mix help deps" to learn about dependencies.
|
||||||
defp deps do
|
defp deps do
|
||||||
[
|
[
|
||||||
{:ash, "~> 2.2"},
|
{:ash, "~> 2.3"},
|
||||||
{:bcrypt_elixir, "~> 3.0", optional: true},
|
{:bcrypt_elixir, "~> 3.0", optional: true},
|
||||||
{:jason, "~> 1.4"},
|
{:jason, "~> 1.4"},
|
||||||
{:joken, "~> 2.5"},
|
{:joken, "~> 2.5"},
|
||||||
{:plug, "~> 1.13"},
|
{: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]},
|
{:ash_postgres, "~> 1.1", only: [:dev, :test]},
|
||||||
{:credo, "~> 1.6", only: [:dev, :test], runtime: false},
|
{:credo, "~> 1.6", only: [:dev, :test], runtime: false},
|
||||||
{:dialyxir, "~> 1.2", only: [:dev, :test], runtime: false},
|
{:dialyxir, "~> 1.2", only: [:dev, :test], runtime: false},
|
||||||
|
|
12
mix.lock
12
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"},
|
"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"},
|
"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"},
|
"bunt": {:hex, :bunt, "0.2.1", "e2d4792f7bc0ced7583ab54922808919518d0e57ee162901a16a1b6664ef3b14", [:mix], [], "hexpm", "a330bfb4245239787b15005e66ae6845c9cd524a288f0d141c148b02603777a5"},
|
||||||
"comeonin": {:hex, :comeonin, "5.3.3", "2c564dac95a35650e9b6acfe6d2952083d8a08e4a89b93a481acb552b325892e", [:mix], [], "hexpm", "3e38c9c2cb080828116597ca8807bb482618a315bfafd98c90bc22a821cc84df"},
|
"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"},
|
"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"},
|
"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": {: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"},
|
"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"},
|
"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"},
|
"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"},
|
"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"},
|
"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"},
|
"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"},
|
"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"},
|
"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"},
|
"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": {: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_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"},
|
"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"},
|
"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"},
|
"ranch": {:hex, :ranch, "1.8.0", "8c7a100a139fd57f17327b6413e4167ac559fbc04ca7448e9be9057311597a1d", [:make, :rebar3], [], "hexpm", "49fbcfd3682fab1f5d109351b61257676da1a2fdbe295904176d5e521a2ddfe5"},
|
||||||
"sourceror": {:hex, :sourceror, "0.11.2", "549ce48be666421ac60cfb7f59c8752e0d393baa0b14d06271d3f6a8c1b027ab", [:mix], [], "hexpm", "9ab659118896a36be6eec68ff7b0674cba372fc8e210b1e9dc8cf2b55bb70dfb"},
|
"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"},
|
"stream_data": {:hex, :stream_data, "0.5.0", "b27641e58941685c75b353577dc602c9d2c12292dd84babf506c2033cd97893e", [:mix], [], "hexpm", "012bd2eec069ada4db3411f9115ccafa38540a3c78c4c0349f151fc761b9e271"},
|
||||||
"telemetry": {:hex, :telemetry, "1.1.0", "a589817034a27eab11144ad24d5c0f9fab1f58173274b1e9bae7074af9cbee51", [:rebar3], [], "hexpm", "b727b2a1f75614774cff2d7565b64d0dfa5bd52ba517f16543e6fc7efcc0df48"},
|
"telemetry": {:hex, :telemetry, "1.1.0", "a589817034a27eab11144ad24d5c0f9fab1f58173274b1e9bae7074af9cbee51", [:rebar3], [], "hexpm", "b727b2a1f75614774cff2d7565b64d0dfa5bd52ba517f16543e6fc7efcc0df48"},
|
||||||
"typable": {:hex, :typable, "0.3.0", "0431e121d124cd26f312123e313d2689b9a5322b15add65d424c07779eaa3ca1", [:mix], [], "hexpm", "880a0797752da1a4c508ac48f94711e04c86156f498065a83d160eef945858f8"},
|
"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"},
|
||||||
}
|
}
|
||||||
|
|
39
test/ash_authentication/plug/defaults_test.exs
Normal file
39
test/ash_authentication/plug/defaults_test.exs
Normal 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
|
119
test/ash_authentication/plug/helpers_test.exs
Normal file
119
test/ash_authentication/plug/helpers_test.exs
Normal 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
|
174
test/ash_authentication/plug_test.exs
Normal file
174
test/ash_authentication/plug_test.exs
Normal 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
|
|
@ -60,11 +60,12 @@ defmodule AshAuthentication.DataCase do
|
||||||
def username, do: Faker.Internet.user_name()
|
def username, do: Faker.Internet.user_name()
|
||||||
def password, do: Faker.Lorem.words(4) |> Enum.join(" ")
|
def password, do: Faker.Lorem.words(4) |> Enum.join(" ")
|
||||||
|
|
||||||
def build_user(attrs \\ %{}) do
|
def build_user(attrs \\ []) do
|
||||||
password = password()
|
password = password()
|
||||||
|
|
||||||
attrs =
|
attrs =
|
||||||
attrs
|
attrs
|
||||||
|
|> Map.new()
|
||||||
|> Map.put_new(:username, username())
|
|> Map.put_new(:username, username())
|
||||||
|> Map.put_new(:password, password)
|
|> Map.put_new(:password, password)
|
||||||
|> Map.put_new(:password_confirmation, password)
|
|> Map.put_new(:password_confirmation, password)
|
||||||
|
|
|
@ -1,8 +1,12 @@
|
||||||
defmodule Example do
|
defmodule Example do
|
||||||
@moduledoc false
|
@moduledoc false
|
||||||
use Ash.Api, otp_app: :ash_authentication
|
use Ash.Api, otp_app: :ash_authentication, extensions: [AshGraphql.Api, AshJsonApi.Api]
|
||||||
|
|
||||||
resources do
|
resources do
|
||||||
registry Example.Registry
|
registry Example.Registry
|
||||||
end
|
end
|
||||||
|
|
||||||
|
json_api do
|
||||||
|
prefix "/api"
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
|
@ -3,19 +3,32 @@ defmodule Example.AuthPlug do
|
||||||
use AshAuthentication.Plug, otp_app: :ash_authentication
|
use AshAuthentication.Plug, otp_app: :ash_authentication
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def handle_success(conn, actor, token) do
|
def handle_success(conn, user, token) do
|
||||||
conn
|
conn
|
||||||
|> store_in_session(actor)
|
|> store_in_session(user)
|
||||||
|> send_resp(200, """
|
|> put_resp_header("content-type", "application/json")
|
||||||
Token: #{token}
|
|> send_resp(
|
||||||
|
200,
|
||||||
Actor: #{inspect(actor)}
|
Jason.encode!(%{
|
||||||
""")
|
token: token,
|
||||||
|
user: %{
|
||||||
|
id: user.id,
|
||||||
|
username: user.username
|
||||||
|
}
|
||||||
|
})
|
||||||
|
)
|
||||||
end
|
end
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def handle_failure(conn, _) do
|
def handle_failure(conn, reason) do
|
||||||
conn
|
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
|
||||||
end
|
end
|
||||||
|
|
17
test/support/example/current_user_read.ex
Normal file
17
test/support/example/current_user_read.ex
Normal 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
|
16
test/support/example/schema.ex
Normal file
16
test/support/example/schema.ex
Normal 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
|
|
@ -2,7 +2,12 @@ defmodule Example.UserWithUsername do
|
||||||
@moduledoc false
|
@moduledoc false
|
||||||
use Ash.Resource,
|
use Ash.Resource,
|
||||||
data_layer: AshPostgres.DataLayer,
|
data_layer: AshPostgres.DataLayer,
|
||||||
extensions: [AshAuthentication, AshAuthentication.PasswordAuthentication]
|
extensions: [
|
||||||
|
AshAuthentication,
|
||||||
|
AshAuthentication.PasswordAuthentication,
|
||||||
|
AshGraphql.Resource,
|
||||||
|
AshJsonApi.Resource
|
||||||
|
]
|
||||||
|
|
||||||
@type t :: %__MODULE__{
|
@type t :: %__MODULE__{
|
||||||
id: Ecto.UUID.t(),
|
id: Ecto.UUID.t(),
|
||||||
|
@ -16,22 +21,57 @@ defmodule Example.UserWithUsername do
|
||||||
uuid_primary_key(:id)
|
uuid_primary_key(:id)
|
||||||
|
|
||||||
attribute(:username, :ci_string, allow_nil?: false)
|
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)
|
create_timestamp(:created_at)
|
||||||
update_timestamp(:updated_at)
|
update_timestamp(:updated_at)
|
||||||
end
|
end
|
||||||
|
|
||||||
actions do
|
actions do
|
||||||
|
read :read do
|
||||||
|
primary? true
|
||||||
|
end
|
||||||
|
|
||||||
destroy :destroy do
|
destroy :destroy do
|
||||||
primary? true
|
primary? true
|
||||||
end
|
end
|
||||||
|
|
||||||
|
read :current_user do
|
||||||
|
get? true
|
||||||
|
manual Example.CurrentUserRead
|
||||||
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
code_interface do
|
code_interface do
|
||||||
define_for(Example)
|
define_for(Example)
|
||||||
end
|
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
|
postgres do
|
||||||
table("user_with_username")
|
table("user_with_username")
|
||||||
repo(Example.Repo)
|
repo(Example.Repo)
|
||||||
|
|
23
test/support/session_pipeline.ex
Normal file
23
test/support/session_pipeline.ex
Normal 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
|
|
@ -1,2 +1,4 @@
|
||||||
|
Mimic.copy(AshAuthentication.Plug.Defaults)
|
||||||
|
Mimic.copy(AshAuthentication.Plug.Helpers)
|
||||||
Mimic.copy(AshAuthentication.TokenRevocation)
|
Mimic.copy(AshAuthentication.TokenRevocation)
|
||||||
ExUnit.start(capture_log: true)
|
ExUnit.start(capture_log: true)
|
||||||
|
|
Loading…
Reference in a new issue