From a939dde9b917c072cdf10c4b0913a9886a4b0231 Mon Sep 17 00:00:00 2001
From: James Harton <59449+jimsynz@users.noreply.github.com>
Date: Tue, 25 Oct 2022 11:07:07 +1300
Subject: [PATCH] feat(PasswordAuthentication): Registration and authentication
with local credentials (#4)
This is missing a bunch of features that you probably want to use (eg confirmation, password resets), but it's a pretty good place to put a stake in the sand and say it works.
---
.devcontainer/docker-compose.yml | 1 +
.doctor.exs | 4 +-
.formatter.exs | 27 ++-
config/dev.exs | 21 ++
config/test.exs | 23 ++
dev/dev_server.ex | 24 ++
dev/dev_server/clear_session.ex | 23 ++
dev/dev_server/plug.ex | 24 ++
dev/dev_server/session.ex | 17 ++
dev/dev_server/test_page.ex | 37 +++
dev/dev_server/test_page.html.eex | 53 +++++
dev/dev_server/token_check.ex | 26 +++
lib/ash_authentication.ex | 174 +++++++++++++-
lib/ash_authentication/application.ex | 26 ++-
lib/ash_authentication/bcrypt_provider.ex | 29 +++
.../errors/authentication_failed.ex | 14 ++
lib/ash_authentication/hash_provider.ex | 22 ++
lib/ash_authentication/info.ex | 10 +
lib/ash_authentication/info_generator.ex | 170 ++++++++++++++
lib/ash_authentication/jwt.ex | 131 +++++++++++
lib/ash_authentication/jwt/config.ex | 144 ++++++++++++
.../password_authentication.ex | 175 +++++++++++++++
.../password_authentication/actions.ex | 52 +++++
.../generate_token_change.ex | 24 ++
.../hash_password_change.ex | 32 +++
.../password_authentication/html.ex | 96 ++++++++
.../password_authentication/info.ex | 9 +
.../password_confirmation_validation.ex | 30 +++
.../password_authentication/plug.ex | 55 +++++
.../sign_in_preparation.ex | 55 +++++
.../password_authentication/transformer.ex | 198 ++++++++++++++++
.../user_validations.ex | 191 ++++++++++++++++
lib/ash_authentication/plug.ex | 194 ++++++++++++++++
lib/ash_authentication/plug/dispatcher.ex | 56 +++++
lib/ash_authentication/plug/helpers.ex | 139 ++++++++++++
lib/ash_authentication/plug/router.ex | 65 ++++++
lib/ash_authentication/provider.ex | 40 ++++
lib/ash_authentication/token_revocation.ex | 172 ++++++++++++++
.../token_revocation/expunger.ex | 53 +++++
.../token_revocation/info.ex | 9 +
.../token_revocation/revoke_token_change.ex | 32 +++
.../token_revocation/transformer.ex | 212 ++++++++++++++++++
lib/ash_authentication/transformer.ex | 155 +++++++++++++
lib/ash_authentication/utils.ex | 80 +++++++
lib/ash_authentication/validations.ex | 109 +++++++++
lib/ash_authentication/validations/action.ex | 161 +++++++++++++
.../validations/attribute.ex | 79 +++++++
mix.exs | 32 ++-
mix.lock | 37 ++-
.../20221002235524_install_2_extensions.exs | 21 ++
.../20221002235526_migrate_resources1.exs | 23 ++
...21020042559_add_token_revocation_table.exs | 28 +++
priv/resource_snapshots/extensions.json | 6 +
.../token_revocations/20221020042559.json | 39 ++++
.../user_with_username/20221002235526.json | 69 ++++++
.../user_with_username/20221020042559.json | 78 +++++++
test/ash_authentication/jwt/config_test.exs | 73 ++++++
test/ash_authentication/jwt_test.exs | 77 +++++++
.../password_authentication/action_test.exs | 166 ++++++++++++++
.../password_authentication/identity_test.exs | 49 ++++
.../token_revocation_test.exs | 24 ++
test/ash_authentication_test.exs | 14 +-
test/support/data_case.ex | 76 +++++++
test/support/example.ex | 8 +
test/support/example/auth_plug.ex | 21 ++
test/support/example/registry.ex | 9 +
test/support/example/repo.ex | 7 +
test/support/example/token_revocation.ex | 24 ++
test/support/example/user_with_username.ex | 57 +++++
test/test_helper.exs | 3 +-
70 files changed, 4381 insertions(+), 33 deletions(-)
create mode 100644 dev/dev_server.ex
create mode 100644 dev/dev_server/clear_session.ex
create mode 100644 dev/dev_server/plug.ex
create mode 100644 dev/dev_server/session.ex
create mode 100644 dev/dev_server/test_page.ex
create mode 100644 dev/dev_server/test_page.html.eex
create mode 100644 dev/dev_server/token_check.ex
create mode 100644 lib/ash_authentication/bcrypt_provider.ex
create mode 100644 lib/ash_authentication/errors/authentication_failed.ex
create mode 100644 lib/ash_authentication/hash_provider.ex
create mode 100644 lib/ash_authentication/info.ex
create mode 100644 lib/ash_authentication/info_generator.ex
create mode 100644 lib/ash_authentication/jwt.ex
create mode 100644 lib/ash_authentication/jwt/config.ex
create mode 100644 lib/ash_authentication/password_authentication.ex
create mode 100644 lib/ash_authentication/password_authentication/actions.ex
create mode 100644 lib/ash_authentication/password_authentication/generate_token_change.ex
create mode 100644 lib/ash_authentication/password_authentication/hash_password_change.ex
create mode 100644 lib/ash_authentication/password_authentication/html.ex
create mode 100644 lib/ash_authentication/password_authentication/info.ex
create mode 100644 lib/ash_authentication/password_authentication/password_confirmation_validation.ex
create mode 100644 lib/ash_authentication/password_authentication/plug.ex
create mode 100644 lib/ash_authentication/password_authentication/sign_in_preparation.ex
create mode 100644 lib/ash_authentication/password_authentication/transformer.ex
create mode 100644 lib/ash_authentication/password_authentication/user_validations.ex
create mode 100644 lib/ash_authentication/plug.ex
create mode 100644 lib/ash_authentication/plug/dispatcher.ex
create mode 100644 lib/ash_authentication/plug/helpers.ex
create mode 100644 lib/ash_authentication/plug/router.ex
create mode 100644 lib/ash_authentication/provider.ex
create mode 100644 lib/ash_authentication/token_revocation.ex
create mode 100644 lib/ash_authentication/token_revocation/expunger.ex
create mode 100644 lib/ash_authentication/token_revocation/info.ex
create mode 100644 lib/ash_authentication/token_revocation/revoke_token_change.ex
create mode 100644 lib/ash_authentication/token_revocation/transformer.ex
create mode 100644 lib/ash_authentication/transformer.ex
create mode 100644 lib/ash_authentication/utils.ex
create mode 100644 lib/ash_authentication/validations.ex
create mode 100644 lib/ash_authentication/validations/action.ex
create mode 100644 lib/ash_authentication/validations/attribute.ex
create mode 100644 priv/repo/migrations/20221002235524_install_2_extensions.exs
create mode 100644 priv/repo/migrations/20221002235526_migrate_resources1.exs
create mode 100644 priv/repo/migrations/20221020042559_add_token_revocation_table.exs
create mode 100644 priv/resource_snapshots/extensions.json
create mode 100644 priv/resource_snapshots/repo/token_revocations/20221020042559.json
create mode 100644 priv/resource_snapshots/repo/user_with_username/20221002235526.json
create mode 100644 priv/resource_snapshots/repo/user_with_username/20221020042559.json
create mode 100644 test/ash_authentication/jwt/config_test.exs
create mode 100644 test/ash_authentication/jwt_test.exs
create mode 100644 test/ash_authentication/password_authentication/action_test.exs
create mode 100644 test/ash_authentication/password_authentication/identity_test.exs
create mode 100644 test/ash_authentication/token_revocation_test.exs
create mode 100644 test/support/data_case.ex
create mode 100644 test/support/example.ex
create mode 100644 test/support/example/auth_plug.ex
create mode 100644 test/support/example/registry.ex
create mode 100644 test/support/example/repo.ex
create mode 100644 test/support/example/token_revocation.ex
create mode 100644 test/support/example/user_with_username.ex
diff --git a/.devcontainer/docker-compose.yml b/.devcontainer/docker-compose.yml
index 5484329..33483a8 100644
--- a/.devcontainer/docker-compose.yml
+++ b/.devcontainer/docker-compose.yml
@@ -1,4 +1,5 @@
version: "3.8"
+name: ash_authentication
volumes:
apt-cache: {}
diff --git a/.doctor.exs b/.doctor.exs
index 1c250f4..e5edb7d 100644
--- a/.doctor.exs
+++ b/.doctor.exs
@@ -1,11 +1,11 @@
%Doctor.Config{
- ignore_modules: [~r/^Inspect\./, ~r/.Plug$/],
+ ignore_modules: [~r/^Inspect\./, ~r/.Plug$/, AshAuthentication.InfoGenerator],
ignore_paths: [],
min_module_doc_coverage: 40,
min_module_spec_coverage: 0,
min_overall_doc_coverage: 50,
min_overall_spec_coverage: 0,
- moduledoc_required: true,
+ min_overall_moduledoc_coverage: 100,
exception_moduledoc_required: true,
raise: false,
reporter: Doctor.Reporters.Full,
diff --git a/.formatter.exs b/.formatter.exs
index d2cda26..29f5856 100644
--- a/.formatter.exs
+++ b/.formatter.exs
@@ -1,4 +1,25 @@
-# Used by "mix format"
-[
- inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"]
+spark_locals_without_parens = [
+ api: 1,
+ confirmation_required?: 1,
+ hash_provider: 1,
+ hashed_password_field: 1,
+ identity_field: 1,
+ password_confirmation_field: 1,
+ password_field: 1,
+ read_action_name: 1,
+ register_action_name: 1,
+ sign_in_action_name: 1,
+ subject_name: 1
+]
+
+[
+ import_deps: [:ash, :spark],
+ inputs: [
+ "*.{ex,exs}",
+ "{config,lib,test}/**/*.{ex,exs}"
+ ],
+ plugins: [Spark.Formatter],
+ export: [
+ locals_without_parens: spark_locals_without_parens
+ ]
]
diff --git a/config/dev.exs b/config/dev.exs
index 945c764..ba29688 100644
--- a/config/dev.exs
+++ b/config/dev.exs
@@ -7,3 +7,24 @@ config :git_ops,
manage_mix_version?: true,
manage_readme_version: "README.md",
version_tag_prefix: "v"
+
+config :ash_authentication, DevServer, start?: true, port: 4000
+
+config :ash_authentication, ecto_repos: [Example.Repo], ash_apis: [Example]
+
+config :ash_authentication, Example.Repo,
+ username: "postgres",
+ password: "postgres",
+ hostname: "localhost",
+ database: "ash_authentication_dev",
+ stacktrace: true,
+ show_sensitive_data_on_connection_error: true,
+ pool_size: 10
+
+config :ash_authentication, Example,
+ resources: [
+ registry: Example.Registry
+ ]
+
+config :ash_authentication, AshAuthentication.Jwt,
+ signing_secret: "Marty McFly in the past with the Delorean"
diff --git a/config/test.exs b/config/test.exs
index e69de29..5242761 100644
--- a/config/test.exs
+++ b/config/test.exs
@@ -0,0 +1,23 @@
+import Config
+
+config :ash_authentication, ecto_repos: [Example.Repo], ash_apis: [Example]
+
+config :ash_authentication, Example.Repo,
+ username: "postgres",
+ password: "postgres",
+ hostname: "localhost",
+ database: "ash_authentication_test#{System.get_env("MIX_TEST_PARTITION")}",
+ pool: Ecto.Adapters.SQL.Sandbox,
+ pool_size: 10
+
+config :ash_authentication, Example,
+ resources: [
+ registry: Example.Registry
+ ]
+
+config :bcrypt_elixir, :log_rounds, 4
+
+config :ash, :disable_async?, true
+
+config :ash_authentication, AshAuthentication.Jwt,
+ signing_secret: "Marty McFly in the past with the Delorean"
diff --git a/dev/dev_server.ex b/dev/dev_server.ex
new file mode 100644
index 0000000..b62bc77
--- /dev/null
+++ b/dev/dev_server.ex
@@ -0,0 +1,24 @@
+defmodule DevServer do
+ @moduledoc """
+ This module provides an extremely simplified authentication UI, mainly for
+ local development and testing.
+ """
+
+ use Supervisor
+
+ def start_link(init_arg), do: Supervisor.start_link(__MODULE__, init_arg, name: __MODULE__)
+
+ @impl true
+ def init(_init_arg) do
+ opts =
+ :ash_authentication
+ |> Application.get_env(DevServer, [])
+ |> Keyword.delete(:start?)
+
+ [
+ {DevServer.Session, []},
+ {Plug.Cowboy, scheme: :http, plug: DevServer.Plug, options: opts}
+ ]
+ |> Supervisor.init(strategy: :one_for_all)
+ end
+end
diff --git a/dev/dev_server/clear_session.ex b/dev/dev_server/clear_session.ex
new file mode 100644
index 0000000..35a707e
--- /dev/null
+++ b/dev/dev_server/clear_session.ex
@@ -0,0 +1,23 @@
+defmodule DevServer.ClearSession do
+ @moduledoc """
+ Resets the session storage, to 'log out" all actors.
+ """
+
+ @behaviour Plug
+ alias Plug.Conn
+
+ @doc false
+ @impl true
+ @spec init(keyword) :: keyword
+ def init(opts), do: opts
+
+ @doc false
+ @impl true
+ @spec call(Conn.t(), any) :: Conn.t()
+ def call(conn, _opts) do
+ conn
+ |> Conn.clear_session()
+ |> Conn.put_resp_header("location", "/")
+ |> Conn.send_resp(302, "Redirected")
+ end
+end
diff --git a/dev/dev_server/plug.ex b/dev/dev_server/plug.ex
new file mode 100644
index 0000000..b36f454
--- /dev/null
+++ b/dev/dev_server/plug.ex
@@ -0,0 +1,24 @@
+defmodule DevServer.Plug 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(Plug.Logger)
+ plug(:load_from_session)
+ plug(:match)
+ plug(:dispatch)
+
+ forward("/auth", to: Example.AuthPlug.Router)
+ 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
+end
diff --git a/dev/dev_server/session.ex b/dev/dev_server/session.ex
new file mode 100644
index 0000000..5a81a23
--- /dev/null
+++ b/dev/dev_server/session.ex
@@ -0,0 +1,17 @@
+defmodule DevServer.Session do
+ @moduledoc """
+ Does nothing but own an ETS table for the session to be stored in.
+ """
+
+ use GenServer
+
+ @doc false
+ def start_link(opts), do: GenServer.start_link(__MODULE__, opts, [])
+
+ @doc false
+ @impl true
+ def init(_) do
+ table_ref = :ets.new(__MODULE__, [:named_table, :public, read_concurrency: true])
+ {:ok, table_ref, :hibernate}
+ end
+end
diff --git a/dev/dev_server/test_page.ex b/dev/dev_server/test_page.ex
new file mode 100644
index 0000000..e4a916f
--- /dev/null
+++ b/dev/dev_server/test_page.ex
@@ -0,0 +1,37 @@
+defmodule DevServer.TestPage do
+ @moduledoc """
+ Displays a very basic login form according to the currently configured
+ Überauth providers.
+ """
+ @behaviour Plug
+ alias Plug.Conn
+ require EEx
+
+ EEx.function_from_file(:defp, :render, String.replace(__ENV__.file, ".ex", ".html.eex"), [
+ :assigns
+ ])
+
+ @doc false
+ @impl true
+ @spec init(keyword) :: keyword
+ def init(opts), do: opts
+
+ @doc false
+ @spec call(Conn.t(), any) :: Conn.t()
+ @impl true
+ def call(conn, _opts) do
+ resources = AshAuthentication.authenticated_resources(:ash_authentication)
+
+ current_actors =
+ conn.assigns
+ |> Stream.filter(fn {key, _value} ->
+ key
+ |> to_string()
+ |> String.starts_with?("current_")
+ end)
+ |> Map.new()
+
+ payload = render(resources: resources, current_actors: current_actors)
+ Conn.send_resp(conn, 200, payload)
+ end
+end
diff --git a/dev/dev_server/test_page.html.eex b/dev/dev_server/test_page.html.eex
new file mode 100644
index 0000000..5ed8c2c
--- /dev/null
+++ b/dev/dev_server/test_page.html.eex
@@ -0,0 +1,53 @@
+
+
+
+ Ash Authentication
+
+
+
+ Ash Authentication
+ <%= if Enum.any?(@resources) do %>
+ Resources:
+
+ <%= for config <- @resources do %>
+ <%= inspect(config.subject_name) %> - <%= Ash.Api.Info.short_name(config.api) %> / <%= Ash.Resource.Info.short_name(config.resource) %>
+
+ <%= for provider <- config.providers do %>
+ <%= Module.concat(provider, HTML).register(config.resource, action: "/auth/#{config.subject_name}/#{provider.provides()}/callback", legend: "Register") %>
+ <%= Module.concat(provider, HTML).sign_in(config.resource, action: "/auth/#{config.subject_name}/#{provider.provides()}/callback", legend: "Sign in") %>
+ <% end %>
+ <% end %>
+
+ Validate token
+
+ <% else %>
+
+ No resources configured
+
+ Please see the documentation for more information.
+
+ <% end %>
+
+ <%= if Enum.any?(@current_actors) do %>
+ Current actors:
+ Clear session
+
+
+ Name |
+ Value |
+
+ <%= for {name, actor} <- @current_actors do %>
+
+ @<%= name %>
|
+ <%= inspect actor, pretty: true %>
|
+
+ <% end %>
+
+ <% end %>
+
+
+
diff --git a/dev/dev_server/token_check.ex b/dev/dev_server/token_check.ex
new file mode 100644
index 0000000..e2efb9e
--- /dev/null
+++ b/dev/dev_server/token_check.ex
@@ -0,0 +1,26 @@
+defmodule DevServer.TokenCheck do
+ @moduledoc """
+ Verifies a submitted token and reports the contents.
+ """
+
+ @behaviour Plug
+ alias AshAuthentication.Jwt
+ alias Plug.Conn
+
+ @doc false
+ @impl true
+ @spec init(keyword) :: keyword
+ def init(opts), do: opts
+
+ @doc false
+ @impl true
+ @spec call(Conn.t(), any) :: Conn.t()
+ def call(%{params: %{"token" => token}} = conn, _opts) do
+ result = Jwt.verify(token, :ash_authentication)
+
+ conn
+ |> Conn.send_resp(200, inspect(result))
+ end
+
+ def call(conn, _opts), do: Conn.send_resp(conn, 200, "Invalid request")
+end
diff --git a/lib/ash_authentication.ex b/lib/ash_authentication.ex
index 55e1d72..56fe2e4 100644
--- a/lib/ash_authentication.ex
+++ b/lib/ash_authentication.ex
@@ -1,18 +1,176 @@
defmodule AshAuthentication do
+ import AshAuthentication.Utils, only: [to_sentence: 2]
+
+ @authentication %Spark.Dsl.Section{
+ name: :authentication,
+ describe: "Configure authentication for this resource",
+ schema: [
+ subject_name: [
+ type: :atom,
+ doc: """
+ The subject name is used in generating token claims and in generating authentication routes.
+ """
+ ],
+ api: [
+ type: {:behaviour, Ash.Api},
+ doc: """
+ The name of the Ash API to use to access this resource when registering/authenticating.
+ """
+ ],
+ get_by_subject_action_name: [
+ type: :atom,
+ doc: """
+ The name of the read action used to retrieve the access when calling `AshAuthentication.subject_to_resource/2`.
+ """,
+ default: :get_by_subject
+ ]
+ ]
+ }
+ @tokens %Spark.Dsl.Section{
+ name: :tokens,
+ describe: "Configure JWT settings for this resource",
+ schema: [
+ enabled?: [
+ type: :boolean,
+ doc: """
+ Should JWTs be generated by this resource?
+ """
+ ],
+ signing_algorithm: [
+ type: :string,
+ doc: """
+ The algorithm to use for token signing.
+
+ Available signing algorithms are; #{to_sentence(Joken.Signer.algorithms(), final: "and")}.
+ """
+ ],
+ token_lifetime: [
+ type: :pos_integer,
+ doc: """
+ How long a token should be valid, in hours.
+ """
+ ],
+ revocation_resource: [
+ type: {:behaviour, Ash.Resource},
+ doc: """
+ If token generation is enabled for this resource, we need a place to store revocation information.
+ This option is the name of an Ash Resource which has the `AshAuthentication.TokenRevocation` extension present.
+ """
+ ]
+ ]
+ }
+
@moduledoc """
- Documentation for `AshAuthentication`.
+ AshAuthentication
+
+ AshAuthentication provides a turn-key authentication solution for folks using
+ [Ash](https://www.ash-hq.org/).
+
+
+ ## Authentication DSL
+
+ ### Index
+
+ #{Spark.Dsl.Extension.doc_index([@authentication])}
+
+ ### Docs
+
+ #{Spark.Dsl.Extension.doc([@authentication])}
+
+ ## Token DSL
+
+ ### Index
+
+ #{Spark.Dsl.Extension.doc_index([@tokens])}
+
+ ### Docs
+
+ #{Spark.Dsl.Extension.doc([@tokens])}
+
"""
+ alias Ash.{Api, Query, Resource}
+ alias AshAuthentication.Info
+ alias Spark.Dsl.Extension
+
+ use Spark.Dsl.Extension,
+ sections: [@authentication, @tokens],
+ transformers: [AshAuthentication.Transformer]
+
+ require Ash.Query
+
+ @type resource_config :: %{
+ api: module,
+ providers: [module],
+ resource: module,
+ subject_name: atom
+ }
+
+ @type subject :: String.t()
@doc """
- Hello world.
+ Find all resources which support authentication for a given OTP application.
- ## Examples
-
- iex> AshAuthentication.hello()
- :world
+ Returns a map where the key is the authentication provider, and the values are
+ lists of api/resource pairs.
+ This is primarily useful for introspection, but also allows us to simplify
+ token lookup.
"""
- def hello do
- :world
+ @spec authenticated_resources(atom) :: [resource_config]
+ def authenticated_resources(otp_app) do
+ otp_app
+ |> Application.get_env(:ash_apis, [])
+ |> Stream.flat_map(&Api.Info.resources(&1))
+ |> Stream.map(&{&1, Extension.get_persisted(&1, :authentication)})
+ |> Stream.reject(&(elem(&1, 1) == nil))
+ |> Stream.map(fn {resource, config} ->
+ Map.put(config, :resource, resource)
+ end)
+ |> Enum.to_list()
+ end
+
+ @doc """
+ Return a subject string for an AshAuthentication resource.
+ """
+ @spec resource_to_subject(Resource.record()) :: subject
+ def resource_to_subject(record) do
+ subject_name =
+ record.__struct__
+ |> AshAuthentication.Info.authentication_subject_name!()
+
+ record.__struct__
+ |> Resource.Info.primary_key()
+ |> then(&Map.take(record, &1))
+ |> then(fn primary_key ->
+ "#{subject_name}?#{URI.encode_query(primary_key)}"
+ end)
+ end
+
+ @doc """
+ Given a subject string, attempt to retrieve a resource.
+ """
+ @spec subject_to_resource(subject | URI.t(), resource_config) ::
+ {:ok, Resource.record()} | {:error, any}
+ def subject_to_resource(subject, config) when is_binary(subject),
+ do: subject |> URI.parse() |> subject_to_resource(config)
+
+ def subject_to_resource(%URI{path: subject_name, query: primary_key} = _subject, config)
+ when is_map(config) do
+ with ^subject_name <- to_string(config.subject_name),
+ {:ok, action_name} <- Info.authentication_get_by_subject_action_name(config.resource) do
+ primary_key =
+ primary_key
+ |> URI.decode_query()
+ |> Enum.to_list()
+
+ config.resource
+ |> Query.for_read(action_name, %{})
+ |> Query.filter(^primary_key)
+ |> config.api.read()
+ |> case do
+ {:ok, [actor]} -> {:ok, actor}
+ _ -> {:error, "Invalid subject"}
+ end
+ end
end
end
diff --git a/lib/ash_authentication/application.ex b/lib/ash_authentication/application.ex
index c71737c..a0dd6c0 100644
--- a/lib/ash_authentication/application.ex
+++ b/lib/ash_authentication/application.ex
@@ -1,20 +1,26 @@
defmodule AshAuthentication.Application do
- # See https://hexdocs.pm/elixir/Application.html
- # for more information on OTP Applications
@moduledoc false
use Application
+ import AshAuthentication.Utils, only: [maybe_append: 3]
+ @doc false
@impl true
def start(_type, _args) do
- children = [
- # Starts a worker by calling: AshAuthentication.Worker.start_link(arg)
- # {AshAuthentication.Worker, arg}
- ]
+ [AshAuthentication.TokenRevocation.Expunger]
+ |> maybe_append(start_dev_server?(), {DevServer, []})
+ |> maybe_append(start_repo?(), {Example.Repo, []})
+ |> Supervisor.start_link(strategy: :one_for_one, name: AshAuthentication.Supervisor)
+ end
- # See https://hexdocs.pm/elixir/Supervisor.html
- # for other strategies and supported options
- opts = [strategy: :one_for_one, name: AshAuthentication.Supervisor]
- Supervisor.start_link(children, opts)
+ defp start_dev_server? do
+ :ash_authentication
+ |> Application.get_env(DevServer, [])
+ |> Keyword.get(:start?, false)
+ end
+
+ defp start_repo? do
+ repos = Application.get_env(:ash_authentication, :ecto_repos, [])
+ Example.Repo in repos
end
end
diff --git a/lib/ash_authentication/bcrypt_provider.ex b/lib/ash_authentication/bcrypt_provider.ex
new file mode 100644
index 0000000..9acdcdb
--- /dev/null
+++ b/lib/ash_authentication/bcrypt_provider.ex
@@ -0,0 +1,29 @@
+defmodule AshAuthentication.BcryptProvider do
+ @moduledoc """
+ Provides the default implementation of `AshAuthentication.HashProvider` using `Bcrypt`.
+ """
+ @behaviour AshAuthentication.HashProvider
+
+ @doc """
+ Given some user input as a string, convert it into it's hashed form using `Bcrypt`.
+ """
+ @impl true
+ @spec hash(String.t()) :: {:ok, String.t()} | :error
+ def hash(input) when is_binary(input), do: {:ok, Bcrypt.hash_pwd_salt(input)}
+ def hash(_), do: :error
+
+ @doc """
+ Check if the user input matches the hash.
+ """
+ @impl true
+ @spec valid?(input :: String.t(), hash :: String.t()) :: boolean
+ def valid?(input, hash) when is_binary(input) and is_binary(hash),
+ do: Bcrypt.verify_pass(input, hash)
+
+ @doc """
+ Simulate a password check to help avoid timing attacks.
+ """
+ @impl true
+ @spec simulate :: false
+ def simulate, do: Bcrypt.no_user_verify()
+end
diff --git a/lib/ash_authentication/errors/authentication_failed.ex b/lib/ash_authentication/errors/authentication_failed.ex
new file mode 100644
index 0000000..685e33d
--- /dev/null
+++ b/lib/ash_authentication/errors/authentication_failed.ex
@@ -0,0 +1,14 @@
+defmodule AshAuthentication.Errors.AuthenticationFailed do
+ @moduledoc """
+ A generic, authentication failed error.
+ """
+ use Ash.Error.Exception
+ def_ash_error([], class: :forbidden)
+
+ defimpl Ash.ErrorKind do
+ @moduledoc false
+ def id(_), do: Ecto.UUID.generate()
+ def code(_), do: "authentication_failed"
+ def message(_), do: "Authentication failed"
+ end
+end
diff --git a/lib/ash_authentication/hash_provider.ex b/lib/ash_authentication/hash_provider.ex
new file mode 100644
index 0000000..825b90f
--- /dev/null
+++ b/lib/ash_authentication/hash_provider.ex
@@ -0,0 +1,22 @@
+defmodule AshAuthentication.HashProvider do
+ @moduledoc """
+ A behaviour providing password hashing.
+ """
+
+ @doc """
+ Given some user input as a string, convert it into it's hashed form.
+ """
+ @callback hash(input :: String.t()) :: {:ok, hash :: String.t()} | :error
+
+ @doc """
+ Check if the user input matches the hash.
+ """
+ @callback valid?(input :: String.t(), hash :: String.t()) :: boolean()
+
+ @doc """
+ Attempt to defeat timing attacks by simulating a password hash check.
+
+ See [Bcrypt.no_user_verify/1](https://hexdocs.pm/bcrypt_elixir/Bcrypt.html#no_user_verify/1) for more information.
+ """
+ @callback simulate :: false
+end
diff --git a/lib/ash_authentication/info.ex b/lib/ash_authentication/info.ex
new file mode 100644
index 0000000..150c3c8
--- /dev/null
+++ b/lib/ash_authentication/info.ex
@@ -0,0 +1,10 @@
+defmodule AshAuthentication.Info do
+ @moduledoc """
+ Generated configuration functions based on a resource's DSL configuration.
+ """
+
+ use AshAuthentication.InfoGenerator,
+ extension: AshAuthentication,
+ sections: [:authentication, :tokens],
+ prefix?: true
+end
diff --git a/lib/ash_authentication/info_generator.ex b/lib/ash_authentication/info_generator.ex
new file mode 100644
index 0000000..56098a9
--- /dev/null
+++ b/lib/ash_authentication/info_generator.ex
@@ -0,0 +1,170 @@
+defmodule AshAuthentication.InfoGenerator do
+ @moduledoc """
+ Used to dynamically generate configuration functions for Spark extensions
+ based on their DSL.
+
+ ## Usage
+
+ ```elixir
+ defmodule MyConfig do
+ use AshAuthentication.InfoGenerator, extension: MyDslExtension, sections: [:my_section]
+ end
+ ```
+ """
+
+ @type options :: [{:extension, module} | {:sections, [atom]} | {:prefix?, boolean}]
+
+ @doc false
+ @spec __using__(options) :: Macro.t()
+ defmacro __using__(opts) do
+ extension = Keyword.fetch!(opts, :extension)
+ sections = Keyword.get(opts, :sections, [])
+ prefix? = Keyword.get(opts, :prefix?, false)
+
+ quote do
+ require unquote(extension)
+ end
+
+ for section <- sections do
+ quote do
+ AshAuthentication.InfoGenerator.generate_options_function(
+ unquote(extension),
+ unquote(section),
+ unquote(prefix?)
+ )
+
+ AshAuthentication.InfoGenerator.generate_config_functions(
+ unquote(extension),
+ unquote(section),
+ unquote(prefix?)
+ )
+ end
+ end
+ end
+
+ @doc false
+ @spec generate_config_functions(module, atom, boolean) :: Macro.t()
+ defmacro generate_config_functions(extension, section, prefix?) do
+ options =
+ extension
+ |> Macro.expand_literal(__ENV__)
+ |> apply(:sections, [])
+ |> Enum.find(&(&1.name == section))
+ |> Map.get(:schema, [])
+
+ for {name, opts} <- options do
+ pred? = name |> to_string() |> String.ends_with?("?")
+ function_name = if prefix?, do: :"#{section}_#{name}", else: name
+
+ if pred? do
+ generate_predicate_function(function_name, section, name, Keyword.get(opts, :doc, false))
+ else
+ spec = AshAuthentication.Utils.spec_for_option(opts)
+
+ quote generated: true do
+ unquote(
+ generate_config_function(
+ function_name,
+ section,
+ name,
+ Keyword.get(opts, :doc, false),
+ spec
+ )
+ )
+
+ unquote(
+ generate_config_bang_function(
+ function_name,
+ section,
+ name,
+ Keyword.get(opts, :doc, false),
+ spec
+ )
+ )
+ end
+ end
+ end
+ end
+
+ defp generate_predicate_function(function_name, section, name, doc) do
+ quote generated: true do
+ @doc unquote(doc)
+ @spec unquote(function_name)(dsl_or_resource :: module | map) :: boolean
+ def unquote(function_name)(dsl_or_resource) do
+ import Spark.Dsl.Extension, only: [get_opt: 4]
+ get_opt(dsl_or_resource, [unquote(section)], unquote(name), false)
+ end
+ end
+ end
+
+ defp generate_config_function(function_name, section, name, doc, spec) do
+ quote generated: true do
+ @doc unquote(doc)
+ @spec unquote(function_name)(dsl_or_resource :: module | map) ::
+ {:ok, unquote(spec)} | :error
+
+ def unquote(function_name)(dsl_or_resource) do
+ import Spark.Dsl.Extension, only: [get_opt: 4]
+
+ case get_opt(dsl_or_resource, [unquote(section)], unquote(name), :error) do
+ :error -> :error
+ value -> {:ok, value}
+ end
+ end
+ end
+ end
+
+ defp generate_config_bang_function(function_name, section, name, doc, spec) do
+ quote generated: true do
+ @doc unquote(doc)
+ @spec unquote(:"#{function_name}!")(dsl_or_resource :: module | map) ::
+ unquote(spec) | no_return
+
+ def unquote(:"#{function_name}!")(dsl_or_resource) do
+ import Spark.Dsl.Extension, only: [get_opt: 4]
+
+ case get_opt(dsl_or_resource, [unquote(section)], unquote(name), :error) do
+ :error ->
+ raise "No configuration for `#{unquote(name)}` present on `#{inspect(dsl_or_resource)}`."
+
+ value ->
+ value
+ end
+ end
+ end
+ end
+
+ @doc false
+ @spec generate_options_function(module, atom, boolean) :: Macro.t()
+ defmacro generate_options_function(extension, section, prefix?) do
+ options =
+ extension
+ |> Macro.expand_literal(__ENV__)
+ |> apply(:sections, [])
+ |> Enum.find(&(&1.name == section))
+ |> Map.get(:schema, [])
+
+ function_name = if prefix?, do: :"#{section}_options", else: :options
+
+ quote generated: true do
+ @doc """
+ The DSL options
+
+ Returns a map containing the schema and any configured or default values.
+ """
+ @spec unquote(function_name)(dsl_or_resource :: module | map) :: %{required(atom) => any}
+ def unquote(function_name)(dsl_or_resource) do
+ import Spark.Dsl.Extension, only: [get_opt: 3]
+
+ Enum.reduce(unquote(options), %{}, fn {name, opts}, result ->
+ with nil <- get_opt(dsl_or_resource, [unquote(section)], name),
+ nil <- Keyword.get(opts, :default) do
+ result
+ else
+ value -> Map.put(result, name, value)
+ end
+ end)
+ end
+ end
+ end
+end
diff --git a/lib/ash_authentication/jwt.ex b/lib/ash_authentication/jwt.ex
new file mode 100644
index 0000000..9570e69
--- /dev/null
+++ b/lib/ash_authentication/jwt.ex
@@ -0,0 +1,131 @@
+defmodule AshAuthentication.Jwt do
+ @default_algorithm "HS256"
+ @default_lifetime_hrs 7 * 24
+ @supported_algorithms Joken.Signer.algorithms()
+ import AshAuthentication.Utils, only: [to_sentence: 2]
+
+ @moduledoc """
+ Uses the excellent `joken` hex package to generate and sign Json Web Tokens.
+
+ ## Configuration
+
+ There are a few things we need to know in order to generate and sign a JWT:
+
+ * `signing_algorithm` - the crypographic algorithm used to to sign tokens.
+ Instance-wide configuration is configured by the application environment,
+ but can be overriden on a per-resource basis.
+ * `token_lifetime` - how long the token is valid for (in hours).
+ Instance-wide configuration is configured by the application environment,
+ but can be overriden on a per-resource basis.
+ * `signing_secret` - the secret key used to sign the tokens. Only
+ configurable via the application environment.
+
+ ```elixir
+ config :ash_authentication, #{inspect(__MODULE__)},
+ signing_algorithm: #{@default_algorithm}
+ signing_secret: "I finally invent something that works!",
+ token_lifetime: #{@default_lifetime_hrs} # #{@default_lifetime_hrs / 24.0} days
+ ```
+
+ Available signing algorithms are #{to_sentence(@supported_algorithms, final: "or")}. Defaults to #{@default_algorithm}.
+
+ We strongly advise against storing the signing secret in your mix config. We
+ instead suggest you make use of
+ [`runtime.exs`](https://elixir-lang.org/getting-started/mix-otp/config-and-releases.html#configuration)
+ and read it from the system environment or other secret store.
+
+ The default token lifetime is #{@default_lifetime_hrs} and should be specified
+ in integer positive hours.
+ """
+
+ alias Ash.Resource
+ alias AshAuthentication.Jwt.Config
+
+ @typedoc """
+ A string likely to contain a valid JWT.
+ """
+ @type token :: String.t()
+
+ @typedoc """
+ "claims" are the decoded contents of a JWT. A map of (short) string keys to
+ string values.
+ """
+ @type claims :: %{required(String.t()) => String.t()}
+
+ @doc "The default signing algorithm"
+ @spec default_algorithm :: String.t()
+ def default_algorithm, do: @default_algorithm
+
+ @doc "Supported signing algorithms"
+ @spec supported_algorithms :: [String.t()]
+ def supported_algorithms, do: @supported_algorithms
+
+ @doc "The default token lifetime"
+ @spec default_lifetime_hrs :: pos_integer
+ def default_lifetime_hrs, do: @default_lifetime_hrs
+
+ @doc """
+ Given a record, generate a signed JWT for use while authenticating.
+ """
+ @spec token_for_record(Resource.record()) :: {:ok, token, claims} | :error
+ def token_for_record(record) do
+ resource = record.__struct__
+
+ default_claims = Config.default_claims(resource)
+ signer = Config.token_signer(resource)
+
+ subject = AshAuthentication.resource_to_subject(record)
+ extra_claims = %{"sub" => subject}
+
+ extra_claims =
+ case Map.fetch(record.__metadata__, :tenant) do
+ {:ok, tenant} -> Map.put(extra_claims, "tenant", to_string(tenant))
+ :error -> extra_claims
+ end
+
+ Joken.generate_and_sign(default_claims, extra_claims, signer)
+ end
+
+ @doc """
+ Given a token, verify it's signature and validate it's claims.
+ """
+ @spec verify(token, module) ::
+ {:ok, claims, AshAuthentication.resource_config()} | :error
+ def verify(token, otp_app) do
+ with {:ok, config} <- token_to_resource(token, otp_app),
+ signer <- Config.token_signer(config.resource),
+ {:ok, claims} <- Joken.verify(token, signer),
+ defaults <- Config.default_claims(config.resource),
+ {:ok, claims} <- Joken.validate(defaults, claims, config) do
+ {:ok, claims, config}
+ else
+ _ -> :error
+ end
+ end
+
+ @doc """
+ Given a token, find a matching resource configuration.
+
+ ## Warning
+
+ This function *does not* validate the token, so don't rely on it for
+ authentication or authorisation.
+ """
+ @spec token_to_resource(token, module) :: {:ok, AshAuthentication.resource_config()} | :error
+ def token_to_resource(token, otp_app) do
+ with {:ok, %{"sub" => subject}} <- Joken.peek_claims(token),
+ %URI{path: subject_name} <- URI.parse(subject) do
+ config_for_subject_name(subject_name, otp_app)
+ else
+ _ -> :error
+ end
+ end
+
+ defp config_for_subject_name(subject_name, otp_app) do
+ otp_app
+ |> AshAuthentication.authenticated_resources()
+ |> Enum.find_value(:error, fn config ->
+ if to_string(config.subject_name) == subject_name, do: {:ok, config}
+ end)
+ end
+end
diff --git a/lib/ash_authentication/jwt/config.ex b/lib/ash_authentication/jwt/config.ex
new file mode 100644
index 0000000..6b88e65
--- /dev/null
+++ b/lib/ash_authentication/jwt/config.ex
@@ -0,0 +1,144 @@
+defmodule AshAuthentication.Jwt.Config do
+ @moduledoc """
+ Implementation details JWT generation and validation.
+
+ Provides functions to generate token configuration at runtime, based on the
+ resource being signed for and for verifying claims and checking for token
+ revocation.
+ """
+
+ alias Ash.Resource
+ alias AshAuthentication.{Info, Jwt, TokenRevocation}
+ alias Joken.{Config, Signer}
+
+ @doc """
+ Generate the default claims for a specified resource.
+ """
+ @spec default_claims(Resource.t()) :: Joken.token_config()
+ def default_claims(resource) do
+ config =
+ resource
+ |> config()
+
+ {:ok, vsn} = :application.get_key(:ash_authentication, :vsn)
+
+ vsn =
+ vsn
+ |> to_string()
+ |> Version.parse!()
+
+ Config.default_claims(default_exp: token_lifetime(config))
+ |> Config.add_claim(
+ "iss",
+ fn -> generate_issuer(vsn) end,
+ &validate_issuer/3
+ )
+ |> Config.add_claim(
+ "aud",
+ fn -> generate_audience(vsn) end,
+ &validate_audience(&1, &2, &3, vsn)
+ )
+ |> Config.add_claim(
+ "jti",
+ &Joken.generate_jti/0,
+ &validate_jti/3
+ )
+ end
+
+ @doc """
+ The generator function used to generate the "iss" claim.
+ """
+ @spec generate_issuer(Version.t()) :: String.t()
+ def generate_issuer(vsn) do
+ "AshAuthentication v#{vsn}"
+ end
+
+ @doc """
+ The validation function used to validate the "iss" claim.
+
+ It simply verifies that the claim starts with `"AshAuthentication"`
+ """
+ @spec validate_issuer(String.t(), any, any) :: boolean
+ def validate_issuer(claim, _, _), do: String.starts_with?(claim, "AshAuthentication")
+
+ @doc """
+ The generator function used to generate the "aud" claim.
+
+ It generates an Elixir-style `~>` version requirement against the current
+ major and minor version numbers of AshAuthentication.
+ """
+ @spec generate_audience(Version.t()) :: String.t()
+ def generate_audience(vsn) do
+ "~> #{vsn.major}.#{vsn.minor}"
+ end
+
+ @doc """
+ The validation function used to validate the "aud" claim.
+
+ Uses `Version.match?/2` to validate the provided claim against the current
+ version. The use of `~>` means that tokens generated by versions of
+ AshAuthentication with the the same major version and at least the same minor
+ version should be compatible.
+ """
+ @spec validate_audience(String.t(), any, any, Version.t()) :: boolean
+ def validate_audience(claim, _, _, vsn) do
+ Version.match?(vsn, Version.parse_requirement!(claim))
+ end
+
+ @doc """
+ The validation function used to the validate the "jti" claim.
+
+ This is done by checking that the token is valid with the token revocation
+ resource. Requires that the subject's resource configuration be passed as the
+ validation context. This is automatically done by calling `Jwt.verify/2`.
+ """
+ @spec validate_jti(String.t(), any, %{resource: module} | any) :: boolean
+ def validate_jti(jti, _claims, %{resource: resource}) do
+ case Info.tokens_revocation_resource(resource) do
+ {:ok, revocation_resource} ->
+ TokenRevocation.valid?(revocation_resource, jti)
+
+ _ ->
+ false
+ end
+ end
+
+ def validate_jti(_, _, _), do: false
+
+ @doc """
+ The signer used to sign the token on a per-resource basis.
+ """
+ @spec token_signer(Resource.t()) :: Signer.t()
+ def token_signer(resource) do
+ config = config(resource)
+
+ algorithm = Keyword.get_lazy(config, :signing_algorithm, &Jwt.default_algorithm/0)
+
+ case Keyword.fetch(config, :signing_secret) do
+ {:ok, secret} ->
+ Signer.create(algorithm, secret)
+
+ :error ->
+ raise "Missing JWT signing secret. Please see the documentation for `AshAuthentication.Jwt` for details"
+ end
+ end
+
+ defp token_lifetime(config) do
+ hours =
+ config
+ |> Keyword.get_lazy(:token_lifetime, &Jwt.default_lifetime_hrs/0)
+
+ hours * 60 * 60
+ end
+
+ defp config(resource) do
+ config =
+ resource
+ |> Info.tokens_options()
+ |> Enum.reject(&is_nil(elem(&1, 1)))
+
+ :ash_authentication
+ |> Application.get_env(Jwt, [])
+ |> Keyword.merge(config)
+ end
+end
diff --git a/lib/ash_authentication/password_authentication.ex b/lib/ash_authentication/password_authentication.ex
new file mode 100644
index 0000000..4199d2e
--- /dev/null
+++ b/lib/ash_authentication/password_authentication.ex
@@ -0,0 +1,175 @@
+defmodule AshAuthentication.PasswordAuthentication do
+ @password_authentication %Spark.Dsl.Section{
+ name: :password_authentication,
+ describe: """
+ Configure password authentication authentication for this resource.
+ """,
+ schema: [
+ identity_field: [
+ type: :atom,
+ doc: """
+ The name of the attribute which uniquely identifies the actor. 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.
+ """,
+ default: :hashed_password
+ ],
+ hash_provider: [
+ type: {:behaviour, AshAuthentication.HashProvider},
+ doc: """
+ A module which implements the `AshAuthentication.HashProvider` behaviour - which is used to provide cryptographic hashing of passwords.
+ """,
+ default: AshAuthentication.BcryptProvider
+ ],
+ confirmation_required?: [
+ type: :boolean,
+ required: false,
+ doc: """
+ Whether a password confirmation field is required when registering or changing passwords.
+ """,
+ default: true
+ ],
+ password_field: [
+ type: :atom,
+ doc: """
+ The name of the argument used to collect the user's password in plaintext when registering, checking or changing passwords.
+ """,
+ default: :password
+ ],
+ password_confirmation_field: [
+ type: :atom,
+ doc: """
+ The name of the argument used to confirm the user's password in plaintext when registering or changing passwords.
+ """,
+ default: :password_confirmation
+ ],
+ register_action_name: [
+ type: :atom,
+ doc: "The name to use for the register action",
+ default: :register
+ ],
+ sign_in_action_name: [
+ type: :atom,
+ doc: "The name to use for the sign in action",
+ default: :sign_in
+ ]
+ ]
+ }
+
+ @moduledoc """
+ Authentication using your application as the source of truth.
+
+ This extension provides an authentication mechanism for authenticating with a
+ username (or other unique identifier) and password.
+
+ ## Usage do
+
+ ```elixir
+ defmodule MyApp.Accounts.Users do
+ use Ash.Resource, extensions: [AshAuthentication.PasswordAuthentication]
+
+ attributes do
+ uuid_primary_key :id
+ attribute :username, :ci_string, allow_nil?: false
+ attribute :hashed_password, :string, allow_nil?: false
+ end
+
+ password_authentication do
+ identity_field :username
+ password_field :password
+ password_confirmation_field :password_confirmation
+ hashed_password_field :hashed_password
+ hash_provider AshAuthentication.BcryptProvider
+ confirmation_required? true
+ end
+
+ authentication do
+ api MyApp.Accounts
+ end
+ end
+ ```
+
+ ## DSL Documentation
+
+ ### Index
+
+ #{Spark.Dsl.Extension.doc_index([@password_authentication])}
+
+ ### Docs
+
+ #{Spark.Dsl.Extension.doc([@password_authentication])}
+ """
+
+ @behaviour AshAuthentication.Provider
+
+ use Spark.Dsl.Extension,
+ sections: [@password_authentication],
+ transformers: [AshAuthentication.PasswordAuthentication.Transformer]
+
+ alias AshAuthentication.PasswordAuthentication
+ alias Plug.Conn
+
+ @doc """
+ Attempt to sign in an actor of the provided resource type.
+
+ ## Example
+
+ iex> sign_in_action(MyApp.User, %{username: "marty", password: "its_1985"})
+ {:ok, #MyApp.User<>}
+ """
+ @impl true
+ @spec sign_in_action(module, map) :: {:ok, struct} | {:error, term}
+ defdelegate sign_in_action(resource, attributes),
+ to: PasswordAuthentication.Actions,
+ as: :sign_in
+
+ @doc """
+ Attempt to register an actor of the provided resource type.
+
+ ## Example
+
+ iex> register(MyApp.User, %{username: "marty", password: "its_1985", password_confirmation: "its_1985})
+ {:ok, #MyApp.User<>}
+ """
+ @impl true
+ @spec register_action(module, map) :: {:ok, struct} | {:error, term}
+ defdelegate register_action(resource, attributes),
+ to: PasswordAuthentication.Actions,
+ as: :register
+
+ @doc """
+ Handle the request phase.
+
+ The password authentication provider does nothing with the request phase, and just returns
+ the `conn` unmodified.
+ """
+ @impl true
+ @spec request_plug(Conn.t(), any) :: Conn.t()
+ defdelegate request_plug(conn, config), to: PasswordAuthentication.Plug, as: :request
+
+ @doc """
+ Handle the callback phase.
+
+ Handles both sign-in and registration actions via the same endpoint.
+ """
+ @impl true
+ @spec callback_plug(Conn.t(), any) :: Conn.t()
+ defdelegate callback_plug(conn, config), to: PasswordAuthentication.Plug, as: :callback
+
+ @doc """
+ Returns the name of the associated provider.
+ """
+ @impl true
+ @spec provides :: String.t()
+ def provides, do: "password"
+
+ @doc false
+ @impl true
+ @spec has_register_step?(any) :: boolean
+ def has_register_step?(_), do: true
+end
diff --git a/lib/ash_authentication/password_authentication/actions.ex b/lib/ash_authentication/password_authentication/actions.ex
new file mode 100644
index 0000000..f523469
--- /dev/null
+++ b/lib/ash_authentication/password_authentication/actions.ex
@@ -0,0 +1,52 @@
+defmodule AshAuthentication.PasswordAuthentication.Actions do
+ @moduledoc """
+ Code interface for password authentication.
+
+ Allows you to use the password authentication provider without needing to mess around with
+ changesets, apis, etc.
+ """
+
+ alias Ash.{Changeset, Query}
+ alias AshAuthentication.PasswordAuthentication
+
+ @doc """
+ Attempt to sign in an actor of the provided resource type.
+
+ ## Example
+
+ iex> sign_in(MyApp.User, %{username: "marty", password: "its_1985"})
+ {:ok, #MyApp.User<>}
+ """
+ @spec sign_in(module, map) :: {:ok, struct} | {:error, term}
+ def sign_in(resource, attributes) do
+ {:ok, action} = PasswordAuthentication.Info.sign_in_action_name(resource)
+ {:ok, api} = AshAuthentication.Info.authentication_api(resource)
+
+ resource
+ |> Query.for_read(action, attributes)
+ |> api.read()
+ |> case do
+ {:ok, [actor]} -> {:ok, actor}
+ {:ok, []} -> {:error, "Invalid username or password"}
+ {:error, reason} -> {:error, reason}
+ end
+ end
+
+ @doc """
+ Attempt to register an actor of the provided resource type.
+
+ ## Example
+
+ iex> register(MyApp.User, %{username: "marty", password: "its_1985", password_confirmation: "its_1985})
+ {:ok, #MyApp.User<>}
+ """
+ @spec register(module, map) :: {:ok, struct} | {:error, term}
+ def register(resource, attributes) do
+ {:ok, action} = PasswordAuthentication.Info.register_action_name(resource)
+ {:ok, api} = AshAuthentication.Info.authentication_api(resource)
+
+ resource
+ |> Changeset.for_create(action, attributes)
+ |> api.create()
+ end
+end
diff --git a/lib/ash_authentication/password_authentication/generate_token_change.ex b/lib/ash_authentication/password_authentication/generate_token_change.ex
new file mode 100644
index 0000000..75120b3
--- /dev/null
+++ b/lib/ash_authentication/password_authentication/generate_token_change.ex
@@ -0,0 +1,24 @@
+defmodule AshAuthentication.PasswordAuthentication.GenerateTokenChange do
+ @moduledoc """
+ Given a successful registration, generate a token.
+ """
+
+ use Ash.Resource.Change
+ alias Ash.{Changeset, Resource.Change}
+ alias AshAuthentication.{Info, Jwt}
+
+ @doc false
+ @impl true
+ @spec change(Changeset.t(), keyword, Change.context()) :: Changeset.t()
+ def change(changeset, _opts, _) do
+ changeset
+ |> Changeset.after_action(fn _changeset, result ->
+ if Info.tokens_enabled?(result.__struct__) do
+ {:ok, token, _claims} = Jwt.token_for_record(result)
+ {:ok, %{result | __metadata__: Map.put(result.__metadata__, :token, token)}}
+ else
+ {:ok, result}
+ end
+ end)
+ end
+end
diff --git a/lib/ash_authentication/password_authentication/hash_password_change.ex b/lib/ash_authentication/password_authentication/hash_password_change.ex
new file mode 100644
index 0000000..f0654b3
--- /dev/null
+++ b/lib/ash_authentication/password_authentication/hash_password_change.ex
@@ -0,0 +1,32 @@
+defmodule AshAuthentication.PasswordAuthentication.HashPasswordChange do
+ @moduledoc """
+ Set the hash based on the password input.
+
+ Uses the configured `AshAuthentication.HashProvider` to generate a hash of the
+ user's password input and store it in the changeset.
+ """
+
+ use Ash.Resource.Change
+ alias AshAuthentication.PasswordAuthentication.Info
+ alias Ash.{Changeset, Resource.Change}
+
+ @doc false
+ @impl true
+ @spec change(Changeset.t(), keyword, Change.context()) :: Changeset.t()
+ def change(changeset, _opts, _) do
+ changeset
+ |> Changeset.before_action(fn changeset ->
+ {:ok, password_field} = Info.password_field(changeset.resource)
+ {:ok, hash_field} = Info.hashed_password_field(changeset.resource)
+ {:ok, hasher} = Info.hash_provider(changeset.resource)
+
+ with value when is_binary(value) <- Changeset.get_argument(changeset, password_field),
+ {:ok, hash} <- hasher.hash(value) do
+ Changeset.change_attribute(changeset, hash_field, hash)
+ else
+ nil -> changeset
+ :error -> {:error, "Error hashing password"}
+ end
+ end)
+ end
+end
diff --git a/lib/ash_authentication/password_authentication/html.ex b/lib/ash_authentication/password_authentication/html.ex
new file mode 100644
index 0000000..584afe0
--- /dev/null
+++ b/lib/ash_authentication/password_authentication/html.ex
@@ -0,0 +1,96 @@
+defmodule AshAuthentication.PasswordAuthentication.HTML do
+ @moduledoc """
+ Renders a very basic forms for using password authentication.
+
+ These are mainly used for testing.
+ """
+
+ require EEx
+ alias AshAuthentication.PasswordAuthentication
+
+ EEx.function_from_string(
+ :defp,
+ :render_sign_in,
+ ~s"""
+
+ """,
+ [:assigns]
+ )
+
+ EEx.function_from_string(
+ :defp,
+ :render_register,
+ ~s"""
+
+ """,
+ [:assigns]
+ )
+
+ @defaults [method: "POST", legend: nil]
+
+ @type options :: [method_option | action_option]
+
+ @typedoc """
+ The HTTP method used to submit the form.
+
+ Defaults to `#{inspect(Keyword.get(@defaults, :method))}`.
+ """
+ @type method_option :: {:method, String.t()}
+
+ @typedoc """
+ The path/URL to which the form should be submitted.
+ """
+ @type action_option :: {:action, String.t()}
+
+ @doc """
+ Render a basic HTML sign-in form.
+ """
+ @spec sign_in(module, options) :: String.t()
+ def sign_in(resource, options) do
+ resource
+ |> build_assigns(options)
+ |> render_sign_in()
+ end
+
+ @doc """
+ Render a basic HTML registration form.
+ """
+ @spec register(module, options) :: String.t()
+ def register(resource, options) do
+ resource
+ |> build_assigns(options)
+ |> render_register()
+ end
+
+ defp build_assigns(resource, options) do
+ @defaults
+ |> Keyword.merge(options)
+ |> Map.new()
+ |> Map.merge(PasswordAuthentication.Info.options(resource))
+ |> Map.merge(AshAuthentication.Info.authentication_options(resource))
+ end
+end
diff --git a/lib/ash_authentication/password_authentication/info.ex b/lib/ash_authentication/password_authentication/info.ex
new file mode 100644
index 0000000..c307172
--- /dev/null
+++ b/lib/ash_authentication/password_authentication/info.ex
@@ -0,0 +1,9 @@
+defmodule AshAuthentication.PasswordAuthentication.Info do
+ @moduledoc """
+ Generated configuration functions based on a resource's DSL configuration.
+ """
+
+ use AshAuthentication.InfoGenerator,
+ extension: AshAuthentication.PasswordAuthentication,
+ sections: [:password_authentication]
+end
diff --git a/lib/ash_authentication/password_authentication/password_confirmation_validation.ex b/lib/ash_authentication/password_authentication/password_confirmation_validation.ex
new file mode 100644
index 0000000..3d5f199
--- /dev/null
+++ b/lib/ash_authentication/password_authentication/password_confirmation_validation.ex
@@ -0,0 +1,30 @@
+defmodule AshAuthentication.PasswordAuthentication.PasswordConfirmationValidation do
+ @moduledoc """
+ Validate that the password and password confirmation match.
+
+ This check is only performed when the `confirmation_required?` DSL option is set to `true`.
+ """
+
+ use Ash.Resource.Validation
+ alias Ash.{Changeset, Error.Changes.InvalidArgument}
+ alias AshAuthentication.PasswordAuthentication.Info
+
+ @doc """
+ Validates that the password and password confirmation fields contain
+ equivalent values - if confirmation is required.
+ """
+ @spec validate(Changeset.t(), keyword) :: :ok | {:error, String.t() | Exception.t()}
+ def validate(changeset, _) do
+ with true <- Info.confirmation_required?(changeset.resource),
+ {:ok, password_field} <- Info.password_field(changeset.resource),
+ {:ok, confirm_field} <- Info.password_confirmation_field(changeset.resource),
+ password <- Changeset.get_argument(changeset, password_field),
+ confirmation <- Changeset.get_argument(changeset, confirm_field),
+ false <- password == confirmation do
+ {:error, InvalidArgument.exception(field: confirm_field, message: "does not match")}
+ else
+ :error -> {:error, "Password confirmation required, but not configured"}
+ _ -> :ok
+ end
+ end
+end
diff --git a/lib/ash_authentication/password_authentication/plug.ex b/lib/ash_authentication/password_authentication/plug.ex
new file mode 100644
index 0000000..128be86
--- /dev/null
+++ b/lib/ash_authentication/password_authentication/plug.ex
@@ -0,0 +1,55 @@
+defmodule AshAuthentication.PasswordAuthentication.Plug do
+ @moduledoc """
+ 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
+ a single flow. This doesn't really work that well with `PasswordAuthentication` which has
+ seperate "registration" and "sign-in" actions.
+
+ Here we simply ignore the request phase, which will cause an error to be
+ returned to the remote user if they somehow find themselves there.
+
+ We use the "callback" phase to handle both registration and sign in by passing
+ an "action" parameter along with the form data.
+ """
+ import AshAuthentication.Plug.Helpers, only: [private_store: 2]
+ alias AshAuthentication.PasswordAuthentication
+ alias Plug.Conn
+
+ @doc """
+ Handle the request phase.
+
+ The password authentication provider does nothing with the request phase, and just returns
+ the `conn` unmodified.
+ """
+ @spec request(Conn.t(), any) :: Conn.t()
+ def request(conn, _opts), do: conn
+
+ @doc """
+ Handle the callback phase.
+
+ Handles both sign-in and registration actions via the same endpoint.
+ """
+ @spec callback(Conn.t(), any) :: Conn.t()
+ def callback(%{params: params, private: %{authenticator: config}} = conn, _opts) do
+ params
+ |> 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})
+
+ {:error, changeset} ->
+ private_store(conn, {:failure, changeset})
+ end
+ end
+
+ def callback(conn, _opts), do: conn
+
+ defp do_action(%{"action" => "sign_in"} = attrs, resource),
+ do: PasswordAuthentication.sign_in_action(resource, attrs)
+
+ defp do_action(%{"action" => "register"} = attrs, resource),
+ do: PasswordAuthentication.register_action(resource, attrs)
+end
diff --git a/lib/ash_authentication/password_authentication/sign_in_preparation.ex b/lib/ash_authentication/password_authentication/sign_in_preparation.ex
new file mode 100644
index 0000000..25845b2
--- /dev/null
+++ b/lib/ash_authentication/password_authentication/sign_in_preparation.ex
@@ -0,0 +1,55 @@
+defmodule AshAuthentication.PasswordAuthentication.SignInPreparation do
+ @moduledoc """
+ Prepare a query for sign in
+
+ This preparation performs two jobs, one before the query executes and one
+ after.
+
+ Firstly, it constrains the query to match the identity field pased on the
+ identity argument passed to the action.
+
+ Secondly, it validates the supplied password using the configured hash
+ provider, and if correct allows the record to be returned, otherwise
+ returns an empty result.
+ """
+ use Ash.Resource.Preparation
+ alias AshAuthentication.{Errors.AuthenticationFailed, Jwt, PasswordAuthentication.Info}
+ alias Ash.{Query, Resource.Preparation}
+ require Ash.Query
+
+ @impl true
+ @spec prepare(Query.t(), keyword, Preparation.context()) :: Query.t()
+ def prepare(query, _opts, _) do
+ {:ok, identity_field} = Info.identity_field(query.resource)
+ {:ok, password_field} = Info.password_field(query.resource)
+ {:ok, hasher} = Info.hash_provider(query.resource)
+
+ identity = Query.get_argument(query, identity_field)
+
+ query
+ |> Query.filter(ref(^identity_field) == ^identity)
+ |> Query.after_action(fn
+ query, [record] ->
+ password = Query.get_argument(query, password_field)
+
+ if hasher.valid?(password, record.hashed_password),
+ do: {:ok, [maybe_generate_token(record)]},
+ else: auth_failed(query)
+
+ _, _ ->
+ hasher.simulate()
+ auth_failed(query)
+ end)
+ end
+
+ defp auth_failed(query), do: {:error, AuthenticationFailed.exception(query: query)}
+
+ defp maybe_generate_token(record) do
+ if AshAuthentication.Info.tokens_enabled?(record.__struct__) do
+ {:ok, token, _claims} = Jwt.token_for_record(record)
+ %{record | __metadata__: Map.put(record.__metadata__, :token, token)}
+ else
+ record
+ end
+ end
+end
diff --git a/lib/ash_authentication/password_authentication/transformer.ex b/lib/ash_authentication/password_authentication/transformer.ex
new file mode 100644
index 0000000..80e21a5
--- /dev/null
+++ b/lib/ash_authentication/password_authentication/transformer.ex
@@ -0,0 +1,198 @@
+defmodule AshAuthentication.PasswordAuthentication.Transformer do
+ @moduledoc """
+ The PasswordAuthentication Authentication transformer
+
+ Scans the resource and checks that all the fields and actions needed are
+ present.
+
+ ## What it's looking for.
+
+ In order for password authentication to work we need a few basic things to be present on the
+ resource, but we _can_ generate almost everything we need, even if we do
+ generate some actions, etc, we still must validate them because we want to
+ allow the user to be able to overwrite as much as possible.
+
+ You can manually implement as much (or as little) of these as you wish.
+
+ Here's a (simplified) list of what it's validating:
+
+ * The main `AshAuthentication` extension is present on the resource.
+ * There is an identity field configured (either by the user or by default) and
+ that a writable attribute with the same name of the appropriate type exists.
+ * There is a hashed password field configured (either by the user or by
+ default) and that a writable attribute with the same name of the appropriate
+ type exists.
+ * That the configured hash provider actually implements the
+ `AshAuthentication.HashProvider` behaviour.
+ * That there is a read action called `sign_in` (or other name based on
+ configuration) and that it has the following properties:
+ - it takes an argument of the same name and type as the configured identity
+ field.
+ - it takes an argument of the same name and type as the configured password
+ field.
+ - it has the `PasswordAuthentication.SignInPreparation` preparation present.
+ * That there is a create action called `register` (or other name based on
+ configuration) and that it has the following properties:
+ - it takes an argument of the same name and type as the configured identity field.
+ - it takes an argument of the same name and type as the configured password field.
+ - it takes an argument of the same name and type as the configured password confirmation field if confirmation is enabled.
+ - it has the `PasswordAuthentication.HashPasswordChange` change present.
+ - it has the `PasswordAuthentication.GenerateTokenChange` change present.
+ - it has the `PasswordAuthentication.PasswordConfirmationValidation` validation present.
+
+ ## Future improvements.
+
+ * Allow default constraints on password fields to be configurable.
+ """
+
+ use Spark.Dsl.Transformer
+
+ alias AshAuthentication.PasswordAuthentication.{
+ GenerateTokenChange,
+ HashPasswordChange,
+ Info,
+ PasswordConfirmationValidation,
+ SignInPreparation
+ }
+
+ alias Ash.{Resource, Type}
+ alias Spark.Dsl.Transformer
+ import AshAuthentication.PasswordAuthentication.UserValidations
+ import AshAuthentication.Utils
+
+ @doc false
+ @impl true
+ @spec transform(map) ::
+ :ok
+ | {:ok, map()}
+ | {:error, term()}
+ | {:warn, map(), String.t() | [String.t()]}
+ | :halt
+ def transform(dsl_state) do
+ with :ok <- validate_authentication_extension(dsl_state),
+ {:ok, dsl_state} <- validate_identity_field(dsl_state),
+ {:ok, dsl_state} <- validate_hashed_password_field(dsl_state),
+ {:ok, dsl_state} <- maybe_build_action(dsl_state, :register, &build_register_action/1),
+ {:ok, dsl_state} <- validate_register_action(dsl_state),
+ {:ok, dsl_state} <- maybe_build_action(dsl_state, :sign_in, &build_sign_in_action/1),
+ {:ok, dsl_state} <- validate_sign_in_action(dsl_state),
+ :ok <- validate_hash_provider(dsl_state) do
+ authentication =
+ Transformer.get_persisted(dsl_state, :authentication)
+ |> Map.update(
+ :providers,
+ [AshAuthentication.PasswordAuthentication],
+ &[AshAuthentication.PasswordAuthentication | &1]
+ )
+
+ dsl_state =
+ dsl_state
+ |> Transformer.persist(:authentication, authentication)
+
+ {:ok, dsl_state}
+ end
+ end
+
+ @doc false
+ @impl true
+ @spec after?(module) :: boolean
+ def after?(AshAuthentication.Transformer), do: true
+ def after?(_), do: false
+
+ @doc false
+ @impl true
+ @spec before?(module) :: boolean
+ def before?(Resource.Transformers.DefaultAccept), do: true
+ def before?(_), do: false
+
+ defp build_register_action(dsl_state) do
+ with {:ok, hashed_password_field} <- Info.hashed_password_field(dsl_state),
+ {:ok, password_field} <- Info.password_field(dsl_state),
+ {:ok, confirm_field} <- Info.password_confirmation_field(dsl_state),
+ confirmation_required? <- Info.confirmation_required?(dsl_state) do
+ password_opts = [
+ type: Type.String,
+ allow_nil?: false,
+ constraints: [min_length: 8],
+ sensitive?: true
+ ]
+
+ arguments =
+ [
+ Transformer.build_entity!(
+ Resource.Dsl,
+ [:actions, :create],
+ :argument,
+ Keyword.put(password_opts, :name, password_field)
+ )
+ ]
+ |> maybe_append(
+ confirmation_required?,
+ Transformer.build_entity!(
+ Resource.Dsl,
+ [:actions, :create],
+ :argument,
+ Keyword.put(password_opts, :name, confirm_field)
+ )
+ )
+
+ changes =
+ []
+ |> maybe_append(
+ confirmation_required?,
+ Transformer.build_entity!(Resource.Dsl, [:actions, :create], :validate,
+ validation: PasswordConfirmationValidation
+ )
+ )
+ |> Enum.concat([
+ Transformer.build_entity!(Resource.Dsl, [:actions, :create], :change,
+ change: HashPasswordChange
+ ),
+ Transformer.build_entity!(Resource.Dsl, [:actions, :create], :change,
+ change: GenerateTokenChange
+ )
+ ])
+
+ Transformer.build_entity(Resource.Dsl, [:actions], :create,
+ name: :register,
+ arguments: arguments,
+ changes: changes,
+ allow_nil_input: [hashed_password_field]
+ )
+ end
+ end
+
+ def build_sign_in_action(dsl_state) do
+ with {:ok, identity_field} <- Info.identity_field(dsl_state),
+ {:ok, password_field} <- Info.password_field(dsl_state) do
+ identity_attribute = Resource.Info.attribute(dsl_state, identity_field)
+
+ arguments = [
+ Transformer.build_entity!(Resource.Dsl, [:actions, :read], :argument,
+ name: identity_field,
+ type: identity_attribute.type,
+ allow_nil?: false
+ ),
+ Transformer.build_entity!(Resource.Dsl, [:actions, :read], :argument,
+ name: password_field,
+ type: Type.String,
+ allow_nil?: false,
+ sensitive?: true
+ )
+ ]
+
+ preparations = [
+ Transformer.build_entity!(Resource.Dsl, [:actions, :read], :prepare,
+ preparation: SignInPreparation
+ )
+ ]
+
+ Transformer.build_entity(Resource.Dsl, [:actions], :read,
+ name: :sign_in,
+ arguments: arguments,
+ preparations: preparations,
+ get?: true
+ )
+ end
+ end
+end
diff --git a/lib/ash_authentication/password_authentication/user_validations.ex b/lib/ash_authentication/password_authentication/user_validations.ex
new file mode 100644
index 0000000..bb712b2
--- /dev/null
+++ b/lib/ash_authentication/password_authentication/user_validations.ex
@@ -0,0 +1,191 @@
+defmodule AshAuthentication.PasswordAuthentication.UserValidations do
+ @moduledoc """
+ Provides validations for the "user" resource.
+
+ See the module docs for `AshAuthentication.PasswordAuthentication.Transformer` for more
+ information.
+ """
+ alias Ash.Resource.Actions
+ alias AshAuthentication.HashProvider
+
+ alias AshAuthentication.PasswordAuthentication.{
+ GenerateTokenChange,
+ HashPasswordChange,
+ Info,
+ PasswordConfirmationValidation,
+ SignInPreparation
+ }
+
+ alias Spark.{Dsl, Dsl.Transformer, Error.DslError}
+ import AshAuthentication.Validations
+ import AshAuthentication.Validations.Action
+ import AshAuthentication.Validations.Attribute
+
+ @doc """
+ Validates at the `AshAuthentication` extension is also present on the resource.
+ """
+ @spec validate_authentication_extension(Dsl.t()) :: :ok | {:error, Exception.t()}
+ def validate_authentication_extension(dsl_state) do
+ extensions = Transformer.get_persisted(dsl_state, :extensions, [])
+
+ if AshAuthentication in extensions,
+ do: :ok,
+ else:
+ {:error,
+ DslError.exception(
+ path: [:extensions],
+ message:
+ "The `AshAuthentication` extension must also be present on this resource for password authentication to work."
+ )}
+ end
+
+ @doc "Validate that the configured hash provider implements the `HashProvider` behaviour"
+ @spec validate_hash_provider(Dsl.t()) :: :ok | {:error, Exception.t()}
+ def validate_hash_provider(dsl_state) do
+ case Info.hash_provider(dsl_state) do
+ {:ok, hash_provider} ->
+ validate_module_implements_behaviour(hash_provider, HashProvider)
+
+ :error ->
+ {:error,
+ DslError.exception(
+ path: [:password_authentication, :hash_provider],
+ message: "A hash provider must be set in your password authentication resource"
+ )}
+ end
+ end
+
+ @doc "Validates information about the sign in action"
+ @spec validate_sign_in_action(Dsl.t()) :: {:ok, Dsl.t()} | {:error, Exception.t()}
+ def validate_sign_in_action(dsl_state) do
+ with {:ok, identity_field} <- Info.identity_field(dsl_state),
+ {:ok, password_field} <- Info.password_field(dsl_state),
+ {:ok, action} <- validate_action_exists(dsl_state, :sign_in),
+ :ok <- validate_identity_argument(dsl_state, action, identity_field),
+ :ok <- validate_password_argument(action, password_field),
+ :ok <- validate_action_has_preparation(action, SignInPreparation) do
+ {:ok, dsl_state}
+ end
+ end
+
+ @doc "Validates information about the register action"
+ @spec validate_register_action(Dsl.t()) :: {:ok, Dsl.t()} | {:error, Exception.t()}
+ def validate_register_action(dsl_state) do
+ with {:ok, password_field} <- Info.password_field(dsl_state),
+ {:ok, password_confirmation_field} <- Info.password_confirmation_field(dsl_state),
+ {:ok, hashed_password_field} <- Info.hashed_password_field(dsl_state),
+ confirmation_required? <- Info.confirmation_required?(dsl_state),
+ {:ok, action} <- validate_action_exists(dsl_state, :register),
+ :ok <- validate_allow_nil_input(action, hashed_password_field),
+ :ok <- validate_password_argument(action, password_field),
+ :ok <-
+ validate_password_confirmation_argument(
+ action,
+ password_confirmation_field,
+ confirmation_required?
+ ),
+ :ok <- validate_action_has_change(action, HashPasswordChange),
+ :ok <- validate_action_has_change(action, GenerateTokenChange),
+ :ok <-
+ validate_action_has_validation(
+ action,
+ PasswordConfirmationValidation,
+ confirmation_required?
+ ) do
+ {:ok, dsl_state}
+ end
+ end
+
+ @doc "Validate that the action allows nil input for the provided field"
+ @spec validate_allow_nil_input(Actions.action(), atom) :: :ok | {:error, Exception.t()}
+ def validate_allow_nil_input(action, field) do
+ allowed_nil_fields = Map.get(action, :allow_nil_input, [])
+
+ if field in allowed_nil_fields do
+ :ok
+ else
+ {:error,
+ DslError.exception(
+ path: [:actions, :allow_nil_input],
+ message:
+ "Expected the action `#{inspect(action.name)}` to allow nil input for the field `#{inspect(field)}`"
+ )}
+ end
+ end
+
+ @doc "Optionally validates that the action has a validation"
+ @spec validate_action_has_validation(Actions.action(), module, really? :: boolean) ::
+ :ok | {:error, Exception.t()}
+ def validate_action_has_validation(_, _, false), do: :ok
+
+ def validate_action_has_validation(action, validation, _),
+ do: validate_action_has_validation(action, validation)
+
+ @doc "Validate the identity argument"
+ @spec validate_identity_argument(Dsl.t(), Actions.action(), atom) ::
+ :ok | {:error, Exception.t()}
+ def validate_identity_argument(dsl_state, action, identity_field) do
+ identity_attribute = Ash.Resource.Info.attribute(dsl_state, identity_field)
+ validate_action_argument_option(action, identity_field, :type, [identity_attribute.type])
+ end
+
+ @doc "Validate the password argument"
+ @spec validate_password_argument(Actions.action(), atom) :: :ok | {:error, Exception.t()}
+ def validate_password_argument(action, password_field) do
+ with :ok <- validate_action_argument_option(action, password_field, :type, [Ash.Type.String]) do
+ validate_action_argument_option(action, password_field, :sensitive?, [true])
+ end
+ end
+
+ @doc "Optionally validates the password confirmation argument"
+ @spec validate_password_confirmation_argument(Actions.action(), atom, really? :: boolean) ::
+ :ok | {:error, Exception.t()}
+ def validate_password_confirmation_argument(_, _, false), do: :ok
+
+ def validate_password_confirmation_argument(action, confirm_field, _),
+ do: validate_password_argument(action, confirm_field)
+
+ @doc "Validate the identity field in the user resource"
+ @spec validate_identity_field(Dsl.t()) :: {:ok, Dsl.t()} | {:error, Exception.t()}
+ def validate_identity_field(dsl_state) do
+ with {:ok, resource} <- persisted_option(dsl_state, :module),
+ {:ok, identity_field} <- Info.identity_field(dsl_state),
+ {:ok, attribute} <- find_attribute(dsl_state, identity_field),
+ :ok <- validate_attribute_option(attribute, resource, :writable?, [true]),
+ :ok <- validate_attribute_option(attribute, resource, :allow_nil?, [false]),
+ :ok <- validate_attribute_unique_constraint(dsl_state, identity_field, resource) do
+ {:ok, dsl_state}
+ end
+ end
+
+ @doc "Validate the hashed password field on the user resource"
+ @spec validate_hashed_password_field(Dsl.t()) :: {:ok, Dsl.t()} | {:error, Exception.t()}
+ def validate_hashed_password_field(dsl_state) do
+ with {:ok, resource} <- persisted_option(dsl_state, :module),
+ {:ok, hashed_password_field} <- identity_option(dsl_state, :hashed_password_field),
+ {:ok, attribute} <- find_attribute(dsl_state, hashed_password_field),
+ :ok <- validate_attribute_option(attribute, resource, :writable?, [true]),
+ :ok <- validate_attribute_option(attribute, resource, :allow_nil?, [false]),
+ :ok <- validate_attribute_option(attribute, resource, :sensitive?, [true]) do
+ {:ok, dsl_state}
+ end
+ end
+
+ defp identity_option(dsl_state, option) do
+ case Transformer.get_option(dsl_state, [:password_authentication], option) do
+ nil -> {:error, {:unknown_option, option}}
+ value -> {:ok, value}
+ end
+ end
+
+ defp validate_module_implements_behaviour(module, behaviour) do
+ if Spark.implements_behaviour?(module, behaviour),
+ do: :ok,
+ else:
+ {:error,
+ "Expected `#{inspect(module)}` to implement the `#{inspect(behaviour)}` behaviour"}
+ rescue
+ _ ->
+ {:error, "Expected `#{inspect(module)}` to implement the `#{inspect(behaviour)}` behaviour"}
+ end
+end
diff --git a/lib/ash_authentication/plug.ex b/lib/ash_authentication/plug.ex
new file mode 100644
index 0000000..bcd4010
--- /dev/null
+++ b/lib/ash_authentication/plug.ex
@@ -0,0 +1,194 @@
+defmodule AshAuthentication.Plug do
+ @moduledoc ~S"""
+ Generate an authentication plug.
+
+ Use in your app by creating a new module called `AuthPlug` or similar:
+
+ ```elixir
+ defmodule MyAppWeb.AuthPlug do
+ use AshAuthentication.Plug, otp_app: :my_app
+
+ def handle_success(conn, user, _token) do
+ conn
+ |> store_in_session(user)
+ |> send_resp(200, "Welcome back #{user.name})
+ end
+
+ def handle_failure(conn) do
+ conn
+ |> send_resp(401, "Better luck next time")
+ end
+ end
+ ```
+
+ ### Using in Phoenix
+
+ In your Phoenix router you can add it:
+
+ ```elixir
+ scope "/auth" do
+ pipe_through :browser
+ forward "/", MyAppWeb.AuthPlug
+ end
+ ```
+
+ In order to load any authenticated actors for either web or API users you can add the following to your router:
+
+ ```elixir
+ import MyAppWeb.AuthPlug
+
+ pipeline :session_users do
+ pipe :load_from_session
+ end
+
+ pipeline :bearer_users do
+ pipe :load_from_bearer
+ end
+
+ scope "/", MyAppWeb do
+ pipe_through [:browser, :session_users]
+
+ live "/", PageLive, :home
+ end
+
+ scope "/api", MyAppWeb do
+ pipe_through [:api, :bearer_users]
+
+ get "/" ApiController, :index
+ end
+ ```
+ ### Using in a Plug application
+
+ ```elixir
+ use Plug.Router
+
+ forward "/auth", to: MyAppWeb.AuthPlug
+ ```
+
+ Note that you will need to include a bunch of other plugs in the pipeline to
+ do useful things like session and query param fetching.
+ """
+
+ alias Ash.{Changeset, Resource}
+ alias AshAuthentication.Plug.Helpers
+ alias Plug.Conn
+
+ @type authenticator_config :: %{
+ api: module,
+ provider: module,
+ resource: module,
+ subject: atom
+ }
+
+ @doc """
+ When authentication has been succesful, this callback will be called with the
+ conn, the authenticated resource and a token.
+
+ This allows you to choose what action to take as appropriate for your
+ application.
+
+ The default implementation calls `store_in_session/2` and returns a simple
+ "Access granted" message to the user. You almost definitely want to override
+ this behaviour.
+ """
+ @callback handle_success(Conn.t(), Resource.record(), token :: String.t()) :: Conn.t()
+
+ @doc """
+ When there is any failure during authentication this callback is called.
+
+ Note that this includes not just authentication failures, but even simple
+ 404s.
+
+ 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()
+
+ defmacro __using__(opts) do
+ otp_app =
+ opts
+ |> Keyword.fetch!(:otp_app)
+ |> Macro.expand_literal(__ENV__)
+
+ AshAuthentication.Validations.validate_unique_subject_names(otp_app)
+
+ quote generated: true do
+ @behaviour AshAuthentication.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`.
+ """
+ use AshAuthentication.Plug.Router,
+ otp_app: unquote(otp_app),
+ return_to:
+ __MODULE__
+ |> Module.split()
+ |> List.delete_at(-1)
+ |> Module.concat()
+ end
+
+ @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, actor, _token) do
+ conn
+ |> store_in_session(actor)
+ |> 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()) :: Conn.t()
+ def handle_failure(conn, _) do
+ conn
+ |> send_resp(401, "Access denied")
+ end
+
+ 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)
+
+ @doc """
+ Attempt to retrieve all actors 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))
+
+ @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
diff --git a/lib/ash_authentication/plug/dispatcher.ex b/lib/ash_authentication/plug/dispatcher.ex
new file mode 100644
index 0000000..cb8f03c
--- /dev/null
+++ b/lib/ash_authentication/plug/dispatcher.ex
@@ -0,0 +1,56 @@
+defmodule AshAuthentication.Plug.Dispatcher do
+ @moduledoc """
+ Route requests and callbacks to the correct provider plugs.
+ """
+
+ @behaviour Plug
+ alias Plug.Conn
+
+ @type config :: {:request | :callback, [AshAuthentication.Plug.authenticator_config()], module}
+
+ @doc false
+ @impl true
+ @spec init([config]) :: config
+ def init([config]), do: config
+
+ @doc """
+ Match the `subject_name` and `provider` of the incoming request to a provider and
+ call the appropriate plug with the configuration.
+ """
+ @impl true
+ @spec call(Conn.t(), config | any) :: Conn.t()
+ def call(
+ %{params: %{"subject_name" => subject_name, "provider" => provider}} = conn,
+ {phase, routes, return_to}
+ ) do
+ conn =
+ case Map.get(routes, {subject_name, provider}) do
+ config when is_map(config) ->
+ conn = Conn.put_private(conn, :authenticator, config)
+
+ case phase do
+ :request -> config.provider.request_plug(conn, [])
+ :callback -> config.provider.callback_plug(conn, [])
+ end
+
+ _ ->
+ conn
+ end
+
+ case conn do
+ %{state: :sent} ->
+ conn
+
+ %{private: %{authentication_result: {:success, actor}}} ->
+ return_to.handle_success(conn, actor, Map.get(actor.__metadata__, :token))
+
+ %{private: %{authentication_result: {:failure, reason}}} ->
+ return_to.handle_failure(conn, reason)
+
+ _ ->
+ return_to.handle_failure(conn, nil)
+ end
+ end
+
+ def call(conn, {_phase, _routes, return_to}), do: return_to.handle_failure(conn)
+end
diff --git a/lib/ash_authentication/plug/helpers.ex b/lib/ash_authentication/plug/helpers.ex
new file mode 100644
index 0000000..b4c4017
--- /dev/null
+++ b/lib/ash_authentication/plug/helpers.ex
@@ -0,0 +1,139 @@
+defmodule AshAuthentication.Plug.Helpers do
+ @moduledoc """
+ Authentication helpers for use in your router, etc.
+ """
+ alias Ash.{Changeset, Error, Resource}
+ alias AshAuthentication.{Info, Jwt, TokenRevocation}
+ alias Plug.Conn
+
+ @doc """
+ Store the actor 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)
+
+ Conn.put_session(conn, subject_name, subject)
+ end
+
+ @doc """
+ Given a list of subjects, turn as many as possible into actors.
+ """
+ @spec load_subjects([AshAuthentication.subject()], module) :: map
+ def load_subjects(subjects, otp_app) when is_list(subjects) do
+ configurations =
+ otp_app
+ |> AshAuthentication.authenticated_resources()
+ |> Stream.map(&{to_string(&1.subject_name), &1})
+
+ 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
+ current_subject_name = current_subject_name(config.subject_name)
+ Map.put(result, current_subject_name, actor)
+ else
+ _ -> result
+ end
+ end)
+ end
+
+ @doc """
+ Attempt to retrieve all actors 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
+ 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`.
+ """
+ @spec retrieve_from_session(Conn.t(), module) :: Conn.t()
+ def retrieve_from_session(conn, otp_app) do
+ otp_app
+ |> AshAuthentication.authenticated_resources()
+ |> Enum.reduce(conn, fn config, conn ->
+ 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)
+ else
+ _ ->
+ Conn.assign(conn, current_subject_name, nil)
+ end
+ end)
+ end
+
+ @doc """
+ Validate authorization header(s).
+
+ 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.
+ """
+ @spec retrieve_from_bearer(Conn.t(), module) :: Conn.t()
+ def retrieve_from_bearer(conn, otp_app) do
+ conn
+ |> Conn.get_req_header("authorization")
+ |> Stream.filter(&String.starts_with?(&1, "Bearer "))
+ |> 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),
+ current_subject_name <- current_subject_name(config.subject_name) do
+ Conn.assign(conn, current_subject_name, actor)
+ else
+ _ -> conn
+ end
+ end)
+ end
+
+ @doc """
+ Revoke all authorization header(s).
+
+ Any bearer-style authorization headers will have their tokens revoked.
+ """
+ @spec revoke_bearer_tokens(Conn.t(), module) :: Conn.t()
+ def revoke_bearer_tokens(conn, otp_app) do
+ conn
+ |> Conn.get_req_header("authorization")
+ |> Stream.filter(&String.starts_with?(&1, "Bearer "))
+ |> Stream.map(&String.replace_leading(&1, "Bearer ", ""))
+ |> Enum.reduce(conn, fn token, conn ->
+ with {:ok, config} <- Jwt.token_to_resource(token, otp_app),
+ {:ok, revocation_resource} <- Info.tokens_revocation_resource(config.resource),
+ :ok <- TokenRevocation.revoke(revocation_resource, token) do
+ conn
+ else
+ _ -> conn
+ end
+ 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 """
+ Store result in private.
+
+ This is used by authentication plug handlers to store their result for passing
+ back to the dispatcher.
+ """
+ @spec private_store(
+ Conn.t(),
+ {:success, Resource.record()} | {:failure, nil | Changeset.t() | Error.t()}
+ ) ::
+ Conn.t()
+ def private_store(conn, {:success, record})
+ when is_struct(record, conn.private.authenticator.resource),
+ do: Conn.put_private(conn, :authentication_result, {:success, record})
+
+ def private_store(conn, {:failure, reason})
+ when is_nil(reason) or is_map(reason),
+ do: Conn.put_private(conn, :authentication_result, {:failure, reason})
+end
diff --git a/lib/ash_authentication/plug/router.ex b/lib/ash_authentication/plug/router.ex
new file mode 100644
index 0000000..631fd9b
--- /dev/null
+++ b/lib/ash_authentication/plug/router.ex
@@ -0,0 +1,65 @@
+defmodule AshAuthentication.Plug.Router do
+ @moduledoc """
+ Dynamically generates the authentication router for the authentication
+ requests and callbacks.
+
+ Used internally by `AshAuthentication.Plug`.
+ """
+
+ @doc false
+ @spec __using__(keyword) :: Macro.t()
+ defmacro __using__(opts) do
+ otp_app =
+ opts
+ |> Keyword.fetch!(:otp_app)
+ |> Macro.expand_literal(__ENV__)
+
+ return_to =
+ opts
+ |> Keyword.fetch!(:return_to)
+ |> Macro.expand_literal(__ENV__)
+
+ routes =
+ otp_app
+ |> AshAuthentication.authenticated_resources()
+ |> Stream.flat_map(fn config ->
+ subject_name =
+ config.subject_name
+ |> to_string()
+
+ config
+ |> Map.get(:providers, [])
+ |> Stream.map(fn provider ->
+ config =
+ config
+ |> Map.delete(:providers)
+ |> Map.put(:provider, provider)
+
+ {{subject_name, provider.provides()}, config}
+ end)
+ end)
+ |> Map.new()
+ |> Macro.escape()
+
+ quote generated: true do
+ use Plug.Router
+ plug(:match)
+ plug(:dispatch)
+
+ match("/:subject_name/:provider",
+ to: AshAuthentication.Plug.Dispatcher,
+ init_opts: [{:request, unquote(routes), unquote(return_to)}]
+ )
+
+ match("/:subject_name/:provider/callback",
+ to: AshAuthentication.Plug.Dispatcher,
+ init_opts: [{:callback, unquote(routes), unquote(return_to)}]
+ )
+
+ match(_,
+ to: AshAuthentication.Plug.Dispatcher,
+ init_opts: [{:noop, [], unquote(return_to)}]
+ )
+ end
+ end
+end
diff --git a/lib/ash_authentication/provider.ex b/lib/ash_authentication/provider.ex
new file mode 100644
index 0000000..8328696
--- /dev/null
+++ b/lib/ash_authentication/provider.ex
@@ -0,0 +1,40 @@
+defmodule AshAuthentication.Provider do
+ @moduledoc false
+ alias Ash.Resource
+ alias Plug.Conn
+
+ @doc """
+ The name of the provider for routing purposes, eg: "github".
+ """
+ @callback provides() :: String.t()
+
+ @doc """
+ Given some credentials for a potentially existing user, verify the credentials
+ and generate a token.
+
+ In the case of OAuth style providers, this is the only action that is likely to be called.
+ """
+ @callback sign_in_action(Resource.t(), map) :: {:ok, Resource.record()} | {:error, any}
+
+ @doc """
+ Given some information about a potential user of the system attempt to create the record.
+
+ Only used by the "password authentication" provider at this time.
+ """
+ @callback register_action(Resource.t(), map) :: {:ok, Resource.record()} | {:error, any}
+
+ @doc """
+ Whether the provider has a separate registration step.
+ """
+ @callback has_register_step?(Resource.t()) :: boolean
+
+ @doc """
+ A function plug which can handle the callback phase.
+ """
+ @callback callback_plug(Conn.t(), AshAuthentication.resource_config()) :: Conn.t()
+
+ @doc """
+ A function plug which can handle the request phase.
+ """
+ @callback request_plug(Conn.t(), AshAuthentication.resource_config()) :: Conn.t()
+end
diff --git a/lib/ash_authentication/token_revocation.ex b/lib/ash_authentication/token_revocation.ex
new file mode 100644
index 0000000..5a79b5f
--- /dev/null
+++ b/lib/ash_authentication/token_revocation.ex
@@ -0,0 +1,172 @@
+defmodule AshAuthentication.TokenRevocation do
+ @revocation %Spark.Dsl.Section{
+ name: :revocation,
+ describe: "Configure revocation options for this resource",
+ schema: [
+ api: [
+ type: {:behaviour, Ash.Api},
+ doc: """
+ The Ash API to use to access this resource.
+ """,
+ required: true
+ ]
+ ]
+ }
+
+ @moduledoc """
+ An Ash extension which generates the defaults for a token revocation resource.
+
+ The token revocation resource is used to store the Json Web Token ID an expiry
+ times of any tokens which have been revoked. These will be removed once the
+ expiry date has passed, so should only ever be a fairly small number of rows.
+
+ ## Storage
+
+ Token revocations are ephemeral, but their lifetime directly correlates to the
+ lifetime of your tokens - ie if you have a long expiry time on your tokens you
+ have to keep the revation records for longer. Therefore we suggest a (semi)
+ permanent data layer, such as Postgres.
+
+ ## Usage
+
+ There is no need to define any attributes, etc. The extension will generate
+ them all for you. As there is no other use-case for this resource, it's
+ unlikely that you will need to customise it.
+
+ ```elixir
+ defmodule MyApp.Accounts.TokenRevocation do
+ use Ash.Resource,
+ data_layer: AshPostgres.DataLayer,
+ extensions: [AshAuthentication.TokenRevocation]
+
+ revocation do
+ api(MyApp.Api)
+ end
+
+ postgres do
+ table("token_revocations")
+ repo(MyApp.Repo)
+ end
+ end
+ ```
+
+ Whilst it's possible to have multiple token revocation resources, in practice
+ there is no need to.
+
+ ## Dsl
+
+ ### Index
+
+ #{Spark.Dsl.Extension.doc_index([@revocation])}
+
+ ### Docs
+
+ #{Spark.Dsl.Extension.doc([@revocation])}
+ """
+
+ use Spark.Dsl.Extension,
+ sections: [@revocation],
+ transformers: [AshAuthentication.TokenRevocation.Transformer]
+
+ alias AshAuthentication.TokenRevocation.Info
+ alias Ash.{Changeset, DataLayer, Query, Resource}
+
+ @doc """
+ Revoke a token.
+ """
+ @spec revoke(Resource.t(), token :: String.t()) :: :ok | {:error, any}
+ def revoke(resource, token) do
+ with {:ok, api} <- Info.api(resource) do
+ resource
+ |> Changeset.for_create(:revoke_token, %{token: token})
+ |> api.create(upsert?: true)
+ |> case do
+ {:ok, _} -> :ok
+ {:ok, _, _} -> :ok
+ {:error, reason} -> {:error, reason}
+ end
+ end
+ end
+
+ @doc """
+ Find out if (via it's JTI) a token has been revoked?
+ """
+ @spec revoked?(Resource.t(), jti :: String.t()) :: boolean
+ def revoked?(resource, jti) do
+ with {:ok, api} <- Info.api(resource) do
+ resource
+ |> Query.for_read(:revoked, %{jti: jti})
+ |> api.read()
+ |> case do
+ {:ok, []} -> false
+ _ -> true
+ end
+ end
+ end
+
+ @doc """
+ The opposite of `revoked?/2`
+ """
+ @spec valid?(Resource.t(), jti :: String.t()) :: boolean
+ def valid?(resource, jti), do: not revoked?(resource, jti)
+
+ @doc """
+ Expunge expired revocations.
+
+ ## Note
+
+ Sadly this function iterates over all expired revocations and delete them
+ individually because Ash (as of v2.1.0) does not yet support bulk actions and
+ we can't just drop down to Ecto because we can't assume that the user's
+ resource uses an Ecto-backed data layer.
+
+ Luckily, this function is only run periodically, so it shouldn't be a huge
+ cost. Contact the maintainers if it becomes a problem for you.
+ """
+ @spec expunge(Resource.t()) :: :ok | {:error, any}
+ def expunge(resource) do
+ DataLayer.transaction(
+ resource,
+ fn ->
+ with {:ok, api} <- Info.api(resource),
+ query <- Query.for_read(resource, :expired),
+ {:ok, expired} <- api.read(query) do
+ expired
+ |> Stream.map(&remove_revocation/1)
+ |> Enum.reduce_while(:ok, fn
+ :ok, _ -> {:cont, :ok}
+ {:error, reason}, _ -> {:halt, {:error, reason}}
+ end)
+ end
+ end,
+ 5000
+ )
+ end
+
+ @doc """
+ Removes a revocation.
+
+ ## Warning
+
+ If the revocation in question is not yet expired, then this has the effect of
+ making this token valid again.
+
+ You are unlikely to need to do this, as `AshAuthentication` will periodically
+ remove all expired revocations automatically, however it is provided here in
+ case you need it.
+ """
+ @spec remove_revocation(Resource.record()) :: :ok | {:error, any}
+ def remove_revocation(revocation) do
+ with {:ok, api} <- Info.api(revocation.__struct__) do
+ revocation
+ |> Changeset.for_destroy(:expire)
+ |> api.destroy()
+ |> case do
+ :ok -> :ok
+ {:ok, _} -> :ok
+ {:ok, _, _} -> :ok
+ {:error, reason} -> {:error, reason}
+ end
+ end
+ end
+end
diff --git a/lib/ash_authentication/token_revocation/expunger.ex b/lib/ash_authentication/token_revocation/expunger.ex
new file mode 100644
index 0000000..a2e1c1a
--- /dev/null
+++ b/lib/ash_authentication/token_revocation/expunger.ex
@@ -0,0 +1,53 @@
+defmodule AshAuthentication.TokenRevocation.Expunger do
+ @default_period_hrs 12
+
+ @moduledoc """
+ A genserver which periodically removes expired token revocations.
+
+ Scans all token revocation resources every #{@default_period_hrs} hours and removes
+ any expired token revocations.
+
+ You can change the expunger period by configuring it in your application
+ environment:
+
+ ```elixir
+ config :ash_authentication, #{inspect(__MODULE__)},
+ period_hrs: #{@default_period_hrs}
+ ```
+
+ This server is started automatically as part of the `:ash_authentication`
+ supervision tree.
+ """
+
+ use GenServer
+ alias AshAuthentication.TokenRevocation
+
+ @doc false
+ @spec start_link(any) :: GenServer.on_start()
+ def start_link(opts), do: GenServer.start_link(__MODULE__, opts)
+
+ @doc false
+ @impl true
+ @spec init(any) :: {:ok, :timer.tref()}
+ def init(_) do
+ period =
+ :ash_authentication
+ |> Application.get_env(__MODULE__, [])
+ |> Keyword.get(:period_hrs, @default_period_hrs)
+ |> then(&(&1 * 60 * 60 * 1000))
+
+ :timer.send_interval(period, :expunge)
+ end
+
+ @doc false
+ @impl true
+ def handle_info(:expunge, tref) do
+ :code.all_loaded()
+ |> Stream.map(&elem(&1, 0))
+ |> Stream.filter(&function_exported?(&1, :spark_dsl_config, 0))
+ |> Stream.filter(&(TokenRevocation in Spark.extensions(&1)))
+ |> Enum.each(&TokenRevocation.expunge/1)
+
+ {:noreply, tref}
+ end
+end
diff --git a/lib/ash_authentication/token_revocation/info.ex b/lib/ash_authentication/token_revocation/info.ex
new file mode 100644
index 0000000..99463c4
--- /dev/null
+++ b/lib/ash_authentication/token_revocation/info.ex
@@ -0,0 +1,9 @@
+defmodule AshAuthentication.TokenRevocation.Info do
+ @moduledoc """
+ Generated configuration functions based on a resource's DSL configuration
+ """
+
+ use AshAuthentication.InfoGenerator,
+ extension: AshAuthentication.TokenRevocation,
+ sections: [:revocation]
+end
diff --git a/lib/ash_authentication/token_revocation/revoke_token_change.ex b/lib/ash_authentication/token_revocation/revoke_token_change.ex
new file mode 100644
index 0000000..b4bbfb4
--- /dev/null
+++ b/lib/ash_authentication/token_revocation/revoke_token_change.ex
@@ -0,0 +1,32 @@
+defmodule AshAuthentication.TokenRevocation.RevokeTokenChange do
+ @moduledoc """
+ Decode the passed in token and build a revocation based on it's claims.
+ """
+
+ use Ash.Resource.Change
+ alias Ash.{Changeset, Error.Changes.InvalidArgument, Resource.Change}
+
+ @doc false
+ @impl true
+ @spec change(Changeset.t(), keyword, Change.context()) :: Changeset.t()
+ def change(changeset, _opts, _) do
+ changeset
+ |> Changeset.before_action(fn changeset ->
+ changeset
+ |> Changeset.get_argument(:token)
+ |> Joken.peek_claims()
+ |> case do
+ {:ok, %{"jti" => jti, "exp" => exp}} ->
+ expires_at =
+ exp
+ |> DateTime.from_unix!()
+
+ changeset
+ |> Changeset.change_attributes(jti: jti, expires_at: expires_at)
+
+ {:error, reason} ->
+ {:error, InvalidArgument.exception(field: :token, message: to_string(reason))}
+ end
+ end)
+ end
+end
diff --git a/lib/ash_authentication/token_revocation/transformer.ex b/lib/ash_authentication/token_revocation/transformer.ex
new file mode 100644
index 0000000..cea3ee0
--- /dev/null
+++ b/lib/ash_authentication/token_revocation/transformer.ex
@@ -0,0 +1,212 @@
+defmodule AshAuthentication.TokenRevocation.Transformer do
+ @moduledoc """
+ The token revocation transformer
+
+ Sets up the default schema and actions for the token revocation resource.
+ """
+
+ use Spark.Dsl.Transformer
+ require Ash.Expr
+ alias Ash.Resource
+ alias AshAuthentication.TokenRevocation
+ alias Spark.{Dsl.Transformer, Error.DslError}
+ import AshAuthentication.Utils
+ import AshAuthentication.Validations
+ import AshAuthentication.Validations.Action
+ import AshAuthentication.Validations.Attribute
+
+ @doc false
+ @impl true
+ @spec after?(any) :: boolean()
+ def after?(Ash.Resource.Transformers.ValidatePrimaryActions), do: true
+ def after?(_), do: false
+
+ @doc false
+ @impl true
+ @spec before?(any) :: boolean
+ def before?(Ash.Resource.Transformers.CachePrimaryKey), do: true
+ def before?(Resource.Transformers.DefaultAccept), do: true
+ def before?(_), do: false
+
+ @doc false
+ @impl true
+ @spec transform(map) ::
+ :ok | {:ok, map} | {:error, term} | {:warn, map, String.t() | [String.t()]} | :halt
+ def transform(dsl_state) do
+ with {:ok, _api} <- validate_api_presence(dsl_state),
+ {:ok, dsl_state} <-
+ maybe_add_field(dsl_state, :jti, :string,
+ primary_key?: true,
+ allow_nil?: false,
+ sensitive?: true,
+ writable?: true
+ ),
+ :ok <- validate_jti_field(dsl_state),
+ {:ok, dsl_state} <-
+ maybe_add_field(dsl_state, :expires_at, :utc_datetime,
+ allow_nil?: false,
+ writable?: true
+ ),
+ :ok <- validate_expires_at_field(dsl_state),
+ {:ok, dsl_state} <-
+ maybe_build_action(dsl_state, :revoke_token, &build_create_revoke_token_action/1),
+ :ok <- validate_revoke_token_action(dsl_state),
+ {:ok, dsl_state} <- maybe_build_action(dsl_state, :read, &build_read_revoked_action/1),
+ :ok <- validate_read_revoked_action(dsl_state),
+ {:ok, dsl_state} <- maybe_build_action(dsl_state, :read, &build_read_expired_action/1),
+ :ok <- validate_read_expired_action(dsl_state),
+ {:ok, dsl_state} <-
+ maybe_build_action(dsl_state, :destroy, &build_destroy_expire_action/1),
+ :ok <- validate_destroy_expire_action(dsl_state) do
+ {:ok, dsl_state}
+ end
+ end
+
+ defp build_create_revoke_token_action(_dsl_state) do
+ arguments = [
+ Transformer.build_entity!(Resource.Dsl, [:actions, :create], :argument,
+ name: :token,
+ type: :string,
+ allow_nil?: false,
+ sensitive?: true
+ )
+ ]
+
+ changes = [
+ Transformer.build_entity!(Resource.Dsl, [:actions, :create], :change,
+ change: TokenRevocation.RevokeTokenChange
+ )
+ ]
+
+ Transformer.build_entity(Resource.Dsl, [:actions], :create,
+ name: :revoke_token,
+ primary?: true,
+ arguments: arguments,
+ changes: changes,
+ accept: []
+ )
+ end
+
+ defp validate_revoke_token_action(dsl_state) do
+ with {:ok, action} <- validate_action_exists(dsl_state, :revoke_token),
+ :ok <- validate_token_argument(action) do
+ validate_action_has_change(action, TokenRevocation.RevokeTokenChange)
+ end
+ end
+
+ defp validate_token_argument(action) do
+ with :ok <-
+ validate_action_argument_option(action, :token, :type, [Ash.Type.String, :string]),
+ :ok <- validate_action_argument_option(action, :token, :allow_nil?, [false]) do
+ validate_action_argument_option(action, :token, :sensitive?, [true])
+ end
+ end
+
+ defp build_read_revoked_action(_dsl_state) do
+ import Ash.Filter.TemplateHelpers
+
+ arguments = [
+ Transformer.build_entity!(Resource.Dsl, [:actions, :read], :argument,
+ name: :jti,
+ type: :string,
+ allow_nil?: false,
+ sensitive?: true
+ )
+ ]
+
+ Transformer.build_entity(Resource.Dsl, [:actions], :read,
+ name: :revoked,
+ get?: true,
+ filter: expr(jti == ^arg(:jti)),
+ arguments: arguments
+ )
+ end
+
+ defp validate_read_revoked_action(dsl_state) do
+ with {:ok, action} <- validate_action_exists(dsl_state, :revoked),
+ :ok <- validate_action_argument_option(action, :jti, :type, [Ash.Type.String, :string]),
+ :ok <- validate_action_argument_option(action, :jti, :allow_nil?, [false]) do
+ validate_action_argument_option(action, :jti, :sensitive?, [true])
+ end
+ end
+
+ defp build_read_expired_action(_dsl_state) do
+ import Ash.Filter.TemplateHelpers
+
+ Transformer.build_entity(Resource.Dsl, [:actions], :read,
+ name: :expired,
+ get?: true,
+ filter: expr(expires_at < now())
+ )
+ end
+
+ defp validate_read_expired_action(dsl_state) do
+ with {:ok, _} <- validate_action_exists(dsl_state, :expired) do
+ :ok
+ end
+ end
+
+ defp build_destroy_expire_action(_dsl_state),
+ do:
+ Transformer.build_entity(Resource.Dsl, [:actions], :destroy, name: :expire, primary?: true)
+
+ defp validate_destroy_expire_action(dsl_state) do
+ with {:ok, _} <- validate_action_exists(dsl_state, :expire) do
+ :ok
+ end
+ end
+
+ defp maybe_add_field(dsl_state, name, type, options) do
+ if Resource.Info.attribute(dsl_state, name) do
+ {:ok, dsl_state}
+ else
+ options =
+ options
+ |> Keyword.put(:name, name)
+ |> Keyword.put(:type, type)
+
+ attribute = Transformer.build_entity!(Resource.Dsl, [:attributes], :attribute, options)
+
+ {:ok, Transformer.add_entity(dsl_state, [:attributes], attribute)}
+ end
+ end
+
+ defp validate_jti_field(dsl_state) do
+ with {:ok, resource} <- persisted_option(dsl_state, :module),
+ {:ok, attribute} <- find_attribute(dsl_state, :jti),
+ :ok <- validate_attribute_option(attribute, resource, :type, [Ash.Type.String, :string]),
+ :ok <- validate_attribute_option(attribute, resource, :allow_nil?, [false]),
+ :ok <- validate_attribute_option(attribute, resource, :sensitive?, [true]),
+ :ok <- validate_attribute_option(attribute, resource, :writable?, [true]),
+ :ok <- validate_attribute_option(attribute, resource, :primary_key?, [true]) do
+ validate_attribute_option(attribute, resource, :private?, [false])
+ end
+ end
+
+ defp validate_expires_at_field(dsl_state) do
+ with {:ok, resource} <- persisted_option(dsl_state, :module),
+ {:ok, attribute} <- find_attribute(dsl_state, :expires_at),
+ :ok <-
+ validate_attribute_option(attribute, resource, :type, [
+ Ash.Type.UtcDatetime,
+ :utc_datetime
+ ]),
+ :ok <- validate_attribute_option(attribute, resource, :allow_nil?, [false]) do
+ validate_attribute_option(attribute, resource, :writable?, [true])
+ end
+ end
+
+ defp validate_api_presence(dsl_state) do
+ case Transformer.get_option(dsl_state, [:revocation], :api) do
+ nil ->
+ {:error,
+ DslError.exception(
+ path: [:revocation, :api],
+ message: "An API module must be present"
+ )}
+
+ api ->
+ {:ok, api}
+ end
+ end
+end
diff --git a/lib/ash_authentication/transformer.ex b/lib/ash_authentication/transformer.ex
new file mode 100644
index 0000000..4fe7290
--- /dev/null
+++ b/lib/ash_authentication/transformer.ex
@@ -0,0 +1,155 @@
+defmodule AshAuthentication.Transformer do
+ @moduledoc """
+ The Authentication transformer
+
+ Sets up non-provider-specific confiration for authenticated resources.
+ """
+
+ use Spark.Dsl.Transformer
+ alias AshAuthentication.Info
+ alias Spark.{Dsl.Transformer, Error.DslError}
+ import AshAuthentication.Utils
+ import AshAuthentication.Validations
+ import AshAuthentication.Validations.Action
+
+ @doc false
+ @impl true
+ @spec after?(any) :: boolean()
+ def after?(Ash.Resource.Transformers.ValidatePrimaryActions), do: true
+ def after?(_), do: false
+
+ @doc false
+ @impl true
+ @spec before?(any) :: boolean
+ def before?(Ash.Resource.Transformers.DefaultAccept), do: true
+ def before?(_), do: false
+
+ @doc false
+ @impl true
+ @spec transform(map) ::
+ :ok | {:ok, map} | {:error, term} | {:warn, map, String.t() | [String.t()]} | :halt
+ def transform(dsl_state) do
+ with {:ok, api} <- validate_api_presence(dsl_state),
+ :ok <- validate_at_least_one_authentication_provider(dsl_state),
+ {:ok, get_by_subject_action_name} <-
+ Info.authentication_get_by_subject_action_name(dsl_state),
+ {:ok, dsl_state} <-
+ maybe_build_action(
+ dsl_state,
+ get_by_subject_action_name,
+ &build_get_by_subject_action/1
+ ),
+ :ok <- validate_read_action(dsl_state, get_by_subject_action_name),
+ :ok <- validate_token_revocation_resource(dsl_state),
+ subject_name <- find_or_generate_subject_name(dsl_state) do
+ authentication =
+ dsl_state
+ |> Transformer.get_persisted(:authentication, %{providers: []})
+ |> Map.put(:subject_name, subject_name)
+ |> Map.put(:api, api)
+
+ dsl_state =
+ dsl_state
+ |> Transformer.persist(:authentication, authentication)
+ |> Transformer.set_option([:authentication], :subject_name, subject_name)
+
+ {:ok, dsl_state}
+ end
+ end
+
+ defp build_get_by_subject_action(dsl_state) do
+ with {:ok, get_by_subject_action_name} <-
+ Info.authentication_get_by_subject_action_name(dsl_state) do
+ Transformer.build_entity(Ash.Resource.Dsl, [:actions], :read,
+ name: get_by_subject_action_name,
+ get?: true
+ )
+ end
+ end
+
+ defp find_or_generate_subject_name(dsl_state) do
+ with nil <- Transformer.get_option(dsl_state, [:authentication], :subject_name),
+ nil <- Transformer.get_option(dsl_state, [:resource], :short_name) do
+ # We have to do this because the resource has not yet been compiled, so we can't call `default_short_name/0`.
+ dsl_state
+ |> Transformer.get_persisted(:module)
+ |> Module.split()
+ |> List.last()
+ |> Macro.underscore()
+ |> String.to_atom()
+ end
+ end
+
+ defp validate_token_revocation_resource(dsl_state) do
+ if Transformer.get_option(dsl_state, [:tokens], :enabled?) do
+ with resource when not is_nil(resource) <-
+ Transformer.get_option(dsl_state, [:tokens], :revocation_resource),
+ Ash.Resource <- resource.spark_is(),
+ true <- AshAuthentication.TokenRevocation in Spark.extensions(resource) do
+ :ok
+ else
+ nil ->
+ {:error,
+ DslError.exception(
+ path: [:tokens, :revocation_resource],
+ message: "A revocation resource must be configured when tokens are enabled"
+ )}
+
+ _ ->
+ {:error,
+ DslError.exception(
+ path: [:tokens, :revocation_resource],
+ message:
+ "The revocation resource must be an Ash resource with the `AshAuthentication.TokenRevocation` extension"
+ )}
+ end
+ else
+ :ok
+ end
+ end
+
+ defp validate_api_presence(dsl_state) do
+ case Transformer.get_option(dsl_state, [:authentication], :api) do
+ nil ->
+ {:error,
+ DslError.exception(
+ path: [:authentication, :api],
+ message: "An API module must be present"
+ )}
+
+ api ->
+ {:ok, api}
+ end
+ end
+
+ defp validate_at_least_one_authentication_provider(dsl_state) do
+ ok? =
+ dsl_state
+ |> Transformer.get_persisted(:extensions, [])
+ |> Enum.any?(&Spark.implements_behaviour?(&1, AshAuthentication.Provider))
+
+ if ok?,
+ do: :ok,
+ else:
+ {:error,
+ DslError.exception(
+ path: [:extensions],
+ message:
+ "At least one authentication provider extension must also be present. See the documentation for more information."
+ )}
+ end
+
+ defp validate_read_action(dsl_state, action_name) do
+ with {:ok, action} <- validate_action_exists(dsl_state, action_name),
+ :ok <- validate_field_in_values(action, :type, [:read]) do
+ :ok
+ else
+ _ ->
+ {:error,
+ DslError.exception(
+ path: [:actions],
+ message: "Expected resource to have either read action named `#{action_name}`"
+ )}
+ end
+ end
+end
diff --git a/lib/ash_authentication/utils.ex b/lib/ash_authentication/utils.ex
new file mode 100644
index 0000000..b06fb6b
--- /dev/null
+++ b/lib/ash_authentication/utils.ex
@@ -0,0 +1,80 @@
+defmodule AshAuthentication.Utils do
+ @moduledoc false
+ alias Ash.Resource
+ alias Spark.Dsl.Transformer
+
+ @spec to_sentence(Enum.t(), [
+ {:separator, String.t()} | {:final, String.t()} | {:whitespace, boolean}
+ ]) :: String.t()
+ def to_sentence(elements, opts \\ []) do
+ opts =
+ [separator: ",", final: "and", whitespace: true]
+ |> Keyword.merge(opts)
+ |> Map.new()
+
+ if Enum.count(elements) == 1 do
+ elements
+ |> Enum.to_list()
+ |> hd()
+ |> to_string()
+ else
+ elements
+ |> Enum.reverse()
+ |> convert_to_sentence("", opts.separator, opts.final, opts.whitespace)
+ end
+ end
+
+ defp convert_to_sentence([last], result, _, final, true), do: "#{result} #{final} #{last}"
+ defp convert_to_sentence([last], result, _, final, false), do: "#{result}#{final}#{last}"
+
+ defp convert_to_sentence([next | rest], "", sep, final, ws),
+ do: convert_to_sentence(rest, to_string(next), sep, final, ws)
+
+ defp convert_to_sentence([next | rest], result, sep, final, true),
+ do: convert_to_sentence(rest, "#{result}#{sep} #{next}", sep, final, true)
+
+ defp convert_to_sentence([next | rest], result, sep, final, false),
+ do: convert_to_sentence(rest, "#{result}#{sep}#{next}", sep, final, false)
+
+ @doc """
+ Optionally append an element to a collection.
+
+ When `test` is truthy, append `element` to the collection.
+ """
+ @spec maybe_append(Enum.t(), test :: any, element :: any) :: Enum.t()
+ def maybe_append(collection, test, _element) when test in [nil, false], do: collection
+ def maybe_append(collection, _test, element), do: Enum.concat(collection, [element])
+
+ @doc """
+ Generate the AST for an options function spec.
+
+ Not something you should ever need.
+ """
+ @spec spec_for_option(keyword) :: Macro.t()
+ def spec_for_option(options) do
+ case Keyword.get(options, :type, :term) do
+ {:behaviour, _module} ->
+ {:module, [], Elixir}
+
+ :string ->
+ {{:., [], [{:__aliases__, [alias: false], [:String]}, :t]}, [], []}
+
+ terminal ->
+ {terminal, [], Elixir}
+ end
+ end
+
+ @doc """
+ Used within transformers to optionally build actions as needed.
+ """
+ @spec maybe_build_action(map, atom, (map -> map)) :: {:ok, atom | map} | {:error, any}
+ def maybe_build_action(dsl_state, action_name, builder) when is_function(builder, 1) do
+ with nil <- Resource.Info.action(dsl_state, action_name),
+ {:ok, action} <- builder.(dsl_state) do
+ {:ok, Transformer.add_entity(dsl_state, [:actions], action)}
+ else
+ action when is_map(action) -> {:ok, dsl_state}
+ {:error, reason} -> {:error, reason}
+ end
+ end
+end
diff --git a/lib/ash_authentication/validations.ex b/lib/ash_authentication/validations.ex
new file mode 100644
index 0000000..01eae27
--- /dev/null
+++ b/lib/ash_authentication/validations.ex
@@ -0,0 +1,109 @@
+defmodule AshAuthentication.Validations do
+ @moduledoc """
+ Common validations shared by several transformers.
+ """
+
+ import AshAuthentication.Utils
+ alias Ash.Resource.Attribute
+ alias Spark.{Dsl, Dsl.Transformer, Error.DslError}
+
+ @doc """
+ Given a map validate that the provided field is one of the values provided.
+ """
+ @spec validate_field_in_values(map, any, [any]) :: :ok | {:error, String.t()}
+ def validate_field_in_values(map, field, []) when is_map(map) when is_map_key(map, field),
+ do: {:error, "Expected `#{inspect(field)}` to not be present."}
+
+ def validate_field_in_values(map, _field, []) when is_map(map), do: :ok
+
+ def validate_field_in_values(map, field, [value])
+ when is_map(map) and is_map_key(map, field) and :erlang.map_get(field, map) == value,
+ do: :ok
+
+ def validate_field_in_values(map, field, [value]) when is_map(map) and is_map_key(map, field),
+ do: {:error, "Expected `#{inspect(field)}` to contain `#{inspect(value)}`"}
+
+ def validate_field_in_values(map, field, values)
+ when is_map(map) and is_list(values) and is_map_key(map, field) do
+ if Map.get(map, field) in values do
+ :ok
+ else
+ values =
+ values
+ |> Enum.map(&"`#{inspect(&1)}`")
+ |> to_sentence(final: "or")
+
+ {:error, "Expected `#{inspect(field)}` to be one of #{values}"}
+ end
+ end
+
+ def validate_field_in_values(map, field, [value]) when is_map(map),
+ do: {:error, "Expected `#{inspect(field)}` to be present and contain `#{inspect(value)}`"}
+
+ def validate_field_in_values(map, field, values) when is_map(map) and is_list(values) do
+ values =
+ values
+ |> Enum.map(&"`#{inspect(&1)}`")
+ |> to_sentence(final: "or")
+
+ {:error, "Expected `#{inspect(field)}` to be present and contain one of #{values}"}
+ end
+
+ @doc """
+ Validates the uniqueness of all subject names per otp app.
+ """
+ @spec validate_unique_subject_names(module) :: :ok | no_return
+ def validate_unique_subject_names(otp_app) do
+ otp_app
+ |> AshAuthentication.authenticated_resources()
+ |> Enum.group_by(& &1.subject_name)
+ |> Enum.each(fn
+ {subject_name, configs} when length(configs) > 1 ->
+ resources =
+ configs
+ |> Enum.map(&"`#{inspect(&1.resource)}`")
+ |> AshAuthentication.Utils.to_sentence()
+
+ raise "Error: multiple resources use the `#{subject_name}` subject name: #{resources}"
+
+ _ ->
+ :ok
+ end)
+ end
+
+ @doc """
+ Find and return a named attribute in the DSL state.
+ """
+ @spec find_attribute(Dsl.t(), atom) ::
+ {:ok, Attribute.t()} | {:error, Exception.t()}
+ def find_attribute(dsl_state, attribute_name) do
+ dsl_state
+ |> Transformer.get_entities([:attributes])
+ |> Enum.find(&(&1.name == attribute_name))
+ |> case do
+ nil ->
+ resource = Transformer.get_persisted(dsl_state, :module)
+
+ {:error,
+ DslError.exception(
+ path: [:attributes, :attribute],
+ message:
+ "The resource `#{inspect(resource)}` does not define an attribute named `#{inspect(attribute_name)}`"
+ )}
+
+ attribute ->
+ {:ok, attribute}
+ end
+ end
+
+ @doc """
+ Find and return a persisted option in the DSL state.
+ """
+ @spec persisted_option(Dsl.t(), atom) :: {:ok, any} | {:error, {:unknown_persisted, atom}}
+ def persisted_option(dsl_state, option) do
+ case Transformer.get_persisted(dsl_state, option) do
+ nil -> {:error, {:unknown_persisted, option}}
+ value -> {:ok, value}
+ end
+ end
+end
diff --git a/lib/ash_authentication/validations/action.ex b/lib/ash_authentication/validations/action.ex
new file mode 100644
index 0000000..e905eb1
--- /dev/null
+++ b/lib/ash_authentication/validations/action.ex
@@ -0,0 +1,161 @@
+defmodule AshAuthentication.Validations.Action do
+ @moduledoc """
+ Validation helpers for Resource actions.
+ """
+ import AshAuthentication.Utils
+ alias Ash.Resource.{Actions, Info}
+ alias Spark.Error.DslError
+
+ @doc """
+ Validate that a named action actually exists.
+ """
+ @spec validate_action_exists(map, atom) ::
+ {:ok, Actions.action()} | {:error, Exception.t() | String.t()}
+ def validate_action_exists(dsl_state, action_name) do
+ case Info.action(dsl_state, action_name) do
+ action when is_map(action) ->
+ {:ok, action}
+
+ _ ->
+ {:error,
+ DslError.exception(
+ path: [:actions],
+ message: "Expected an action named `#{inspect(action_name)}` to be present"
+ )}
+ end
+ end
+
+ @doc """
+ Validate an action's argument has an option set to one of the provided values.
+ """
+ @spec validate_action_argument_option(Actions.action(), atom, atom, [any]) ::
+ :ok | {:error, Exception.t() | String.t()}
+ def validate_action_argument_option(action, argument_name, field, values) do
+ with argument when is_map(argument) <-
+ Enum.find(action.arguments, :missing_argument, &(&1.name == argument_name)),
+ {:ok, value} <- Map.fetch(argument, field),
+ true <- value in values do
+ :ok
+ else
+ :missing_argument ->
+ {:error,
+ DslError.exception(
+ path: [:actions, :argument],
+ message:
+ "The action `#{inspect(action.name)}` should have an argument named `#{inspect(argument_name)}`"
+ )}
+
+ :error ->
+ {:error,
+ DslError.exception(
+ path: [:actions, :argument],
+ message:
+ "The argument `#{inspect(argument_name)}` on action `#{inspect(action.name)}` is missing the `#{inspect(field)}` property"
+ )}
+
+ false ->
+ case values do
+ [] ->
+ {:error,
+ DslError.exception(
+ path: [:actions, :argument],
+ message:
+ "The argument `#{inspect(argument_name)}` on action `#{inspect(action.name)}` should not have `#{inspect(field)}` set"
+ )}
+
+ [expected] ->
+ {:error,
+ DslError.exception(
+ path: [:actions, :argument],
+ message:
+ "The argument `#{inspect(argument_name)}` on action `#{inspect(action.name)}` should have `#{inspect(field)}` set to `#{inspect(expected)}`"
+ )}
+
+ expected ->
+ expected =
+ expected
+ |> Enum.map(&"`#{inspect(&1)}`")
+ |> to_sentence(final: "or")
+
+ {:error,
+ DslError.exception(
+ path: [:actions, :argument],
+ message:
+ "The argument `#{inspect(argument_name)}` on action `#{inspect(action.name)}` should have `#{inspect(field)}` set to one of #{expected}"
+ )}
+ end
+ end
+ end
+
+ @doc """
+ Validate the presence of the named change module on an action.
+ """
+ @spec validate_action_has_change(Actions.action(), module) ::
+ :ok | {:error, Exception.t()}
+ def validate_action_has_change(action, change_module) do
+ has_change? =
+ action
+ |> Map.get(:changes, [])
+ |> Enum.map(&Map.get(&1, :change))
+ |> Enum.reject(&is_nil/1)
+ |> Enum.any?(&(elem(&1, 0) == change_module))
+
+ if has_change?,
+ do: :ok,
+ else:
+ {:error,
+ DslError.exception(
+ path: [:actions, :change],
+ message:
+ "The action `#{inspect(action.name)}` should have the `#{inspect(change_module)}` change present."
+ )}
+ end
+
+ @doc """
+ Validate the presence of the named validation module on an action.
+ """
+ @spec validate_action_has_validation(Actions.action(), module) ::
+ :ok | {:error, Exception.t()}
+ def validate_action_has_validation(action, validation_module) do
+ has_validation? =
+ action
+ |> Map.get(:changes, [])
+ |> Enum.map(&Map.get(&1, :validation))
+ |> Enum.reject(&is_nil/1)
+ |> Enum.any?(&(elem(&1, 0) == validation_module))
+
+ if has_validation?,
+ do: :ok,
+ else:
+ {:error,
+ DslError.exception(
+ path: [:actions, :validation],
+ message:
+ "The action `#{inspect(action.name)}` should have the `#{inspect(validation_module)}` validation present."
+ )}
+ end
+
+ @doc """
+ Validate the presence of the named preparation module on an action.
+ """
+ @spec validate_action_has_preparation(Actions.action(), module) ::
+ :ok | {:error, Exception.t()}
+ def validate_action_has_preparation(action, preparation_module) do
+ has_preparation? =
+ action
+ |> Map.get(:preparations, [])
+ |> Enum.map(&Map.get(&1, :preparation))
+ |> Enum.reject(&is_nil/1)
+ |> Enum.any?(&(elem(&1, 0) == preparation_module))
+
+ if has_preparation?,
+ do: :ok,
+ else:
+ {:error,
+ DslError.exception(
+ path: [:actions, :preparation],
+ message:
+ "The action `#{inspect(action.name)}` should have the `#{inspect(preparation_module)}` preparation present."
+ )}
+ end
+end
diff --git a/lib/ash_authentication/validations/attribute.ex b/lib/ash_authentication/validations/attribute.ex
new file mode 100644
index 0000000..bb2a1e7
--- /dev/null
+++ b/lib/ash_authentication/validations/attribute.ex
@@ -0,0 +1,79 @@
+defmodule AshAuthentication.Validations.Attribute do
+ @moduledoc """
+ Validation helpers for Resource attributes.
+ """
+ alias Ash.Resource.Info
+ alias Spark.Error.DslError
+ import AshAuthentication.Utils
+
+ @doc """
+ Validate that an option is set correctly on an attribute
+ """
+ @spec validate_attribute_option(Ash.Resource.Attribute.t(), module, atom, [any]) ::
+ :ok | {:error, Exception.t()}
+ def validate_attribute_option(attribute, resource, field, values) do
+ with {:ok, value} <- Map.fetch(attribute, field),
+ true <- value in values do
+ :ok
+ else
+ :error ->
+ {:error,
+ DslError.exception(
+ path: [:actions, :attribute],
+ message:
+ "The attribute `#{inspect(attribute.name)}` on the `#{inspect(resource)}` resource is missing the `#{inspect(field)}` property"
+ )}
+
+ false ->
+ case values do
+ [] ->
+ {:error,
+ DslError.exception(
+ path: [:actions, :attribute],
+ message:
+ "The attribute `#{inspect(attribute.name)}` on the `#{inspect(resource)}` resource is should not have `#{inspect(field)}` set"
+ )}
+
+ [expected] ->
+ {:error,
+ DslError.exception(
+ path: [:actions, :attribute],
+ message:
+ "The attribute `#{inspect(attribute.name)}` on the `#{inspect(resource)}` resource should have `#{inspect(field)}` set to `#{inspect(expected)}`"
+ )}
+
+ expected ->
+ expected = expected |> Enum.map(&"`#{inspect(&1)}`") |> to_sentence(final: "or")
+
+ {:error,
+ DslError.exception(
+ path: [:actions, :attribute],
+ message:
+ "The attribute `#{inspect(attribute.name)}` on the `#{inspect(resource)}` resource should have `#{inspect(field)}` set to one of #{expected}"
+ )}
+ end
+ end
+ end
+
+ @doc """
+ Validate than an attribute has a unique identity applied.
+ """
+ @spec validate_attribute_unique_constraint(map, atom, module) :: :ok | {:error, Exception.t()}
+ def validate_attribute_unique_constraint(dsl_state, field, resource) do
+ dsl_state
+ |> Info.identities()
+ |> Enum.find(&(&1.keys == [field]))
+ |> case do
+ nil ->
+ {:error,
+ DslError.exception(
+ path: [:identities, :identity],
+ message:
+ "The `#{inspect(field)}` attribute on the resource `#{inspect(resource)}` should be uniquely constrained"
+ )}
+
+ _ ->
+ :ok
+ end
+ end
+end
diff --git a/mix.exs b/mix.exs
index 0615dc1..d1e3f3d 100644
--- a/mix.exs
+++ b/mix.exs
@@ -14,6 +14,7 @@ defmodule AshAuthentication.MixProject do
aliases: aliases(),
deps: deps(),
package: package(),
+ elixirc_paths: elixirc_paths(Mix.env()),
dialyzer: [
plt_add_apps: [:mix, :ex_unit],
plt_core_path: "priv/plts",
@@ -38,19 +39,33 @@ defmodule AshAuthentication.MixProject do
# Run "mix help compile.app" to learn about applications.
def application do
[
- extra_applications: [:logger],
+ extra_applications: extra_applications(Mix.env()),
mod: {AshAuthentication.Application, []}
]
end
+ defp extra_applications(:dev), do: [:logger, :bcrypt_elixir]
+ defp extra_applications(:test), do: [:logger, :bcrypt_elixir]
+ defp extra_applications(_), do: [:logger]
+
# Run "mix help deps" to learn about dependencies.
defp deps do
[
+ {:ash, "~> 2.2"},
+ {:bcrypt_elixir, "~> 3.0", optional: true},
+ {:jason, "~> 1.4"},
+ {:joken, "~> 2.5"},
+ {:plug, "~> 1.13"},
+ {:ash_postgres, "~> 1.1", only: [:dev, :test]},
{:credo, "~> 1.6", only: [:dev, :test], runtime: false},
- {:git_ops, "~> 2.4", only: [:dev, :test], runtime: false},
- {:ex_doc, ">= 0.0.0", only: [:dev, :test]},
+ {:dialyxir, "~> 1.2", only: [:dev, :test], runtime: false},
{:doctor, "~> 0.18", only: [:dev, :test]},
- {:dialyxir, "~> 1.2", only: [:dev, :test], runtime: false}
+ {:elixir_sense, github: "elixir-lsp/elixir_sense", only: [:dev, :test]},
+ {:ex_doc, ">= 0.0.0", only: [:dev, :test]},
+ {:faker, "~> 0.17.0", only: [:dev, :test]},
+ {:git_ops, "~> 2.4", only: [:dev, :test], runtime: false},
+ {:plug_cowboy, "~> 2.5", only: [:dev, :test]},
+ {:mimic, "~> 1.7", only: [:dev, :test]}
]
end
@@ -58,12 +73,17 @@ defmodule AshAuthentication.MixProject do
[
ci: [
"format --check-formatted",
- "doctor --full",
+ "doctor --full --raise",
"credo --strict",
"dialyzer",
"hex.audit",
"test"
- ]
+ ],
+ test: ["ecto.create --quiet", "ecto.migrate --quiet", "test"]
]
end
+
+ defp elixirc_paths(:test), do: ["lib", "test/support"]
+ defp elixirc_paths(:dev), do: ["lib", "test/support", "dev"]
+ defp elixirc_paths(_), do: ["lib"]
end
diff --git a/mix.lock b/mix.lock
index 1e92b07..f2a42db 100644
--- a/mix.lock
+++ b/mix.lock
@@ -1,18 +1,53 @@
%{
+ "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"},
+ "ash_postgres": {:hex, :ash_postgres, "1.1.0", "d911c72f5a9c8fa524745c0a075d6e7b9a177dddf17e93644772dec73f91c10f", [: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", "11d9c8a968611d1130498a1349615fa29e822373051f218c824e2431677dc4e7"},
+ "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"},
+ "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"},
+ "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"},
+ "docsh": {:hex, :docsh, "0.7.2", "f893d5317a0e14269dd7fe79cf95fb6b9ba23513da0480ec6e77c73221cae4f2", [:rebar3], [{:providers, "1.8.1", [hex: :providers, repo: "hexpm", optional: false]}], "hexpm", "4e7db461bb07540d2bc3d366b8513f0197712d0495bb85744f367d3815076134"},
"doctor": {:hex, :doctor, "0.20.0", "2a8ff8f87eaf3fc78f20ffcfa7a3181f2bdb6a115a4abd52582e6156a89649a5", [:mix], [{:decimal, "~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}], "hexpm", "36ba43bdf7d799c41e1dc00b3429eb48bc5d4dc3f63b181ca1aa8829ec638862"},
"earmark_parser": {:hex, :earmark_parser, "1.4.28", "0bf6546eb7cd6185ae086cbc5d20cd6dbb4b428aad14c02c49f7b554484b4586", [:mix], [], "hexpm", "501cef12286a3231dc80c81352a9453decf9586977f917a96e619293132743fb"},
+ "ecto": {:hex, :ecto, "3.9.1", "67173b1687afeb68ce805ee7420b4261649d5e2deed8fe5550df23bab0bc4396", [:mix], [{:decimal, "~> 1.6 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "c80bb3d736648df790f7f92f81b36c922d9dd3203ca65be4ff01d067f54eb304"},
+ "ecto_sql": {:hex, :ecto_sql, "3.9.0", "2bb21210a2a13317e098a420a8c1cc58b0c3421ab8e3acfa96417dab7817918c", [:mix], [{:db_connection, "~> 2.5 or ~> 2.4.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:ecto, "~> 3.9.0", [hex: :ecto, repo: "hexpm", optional: false]}, {:myxql, "~> 0.6.0", [hex: :myxql, repo: "hexpm", optional: true]}, {:postgrex, "~> 0.16.0 or ~> 1.0", [hex: :postgrex, repo: "hexpm", optional: true]}, {:tds, "~> 2.1.1 or ~> 2.2", [hex: :tds, repo: "hexpm", optional: true]}, {:telemetry, "~> 0.4.0 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "a8f3f720073b8b1ac4c978be25fa7960ed7fd44997420c304a4a2e200b596453"},
+ "elixir_make": {:hex, :elixir_make, "0.6.3", "bc07d53221216838d79e03a8019d0839786703129599e9619f4ab74c8c096eac", [:mix], [], "hexpm", "f5cbd651c5678bcaabdbb7857658ee106b12509cd976c2c2fca99688e1daf716"},
+ "elixir_sense": {:git, "https://github.com/elixir-lsp/elixir_sense.git", "de434402bd0d266cbe535dbca8befd88ded66996", []},
"erlex": {:hex, :erlex, "0.2.6", "c7987d15e899c7a2f34f5420d2a2ea0d659682c06ac607572df55a43753aa12e", [:mix], [], "hexpm", "2ed2e25711feb44d52b17d2780eabf998452f6efda104877a3881c2f8c0c0c75"},
- "ex_doc": {:hex, :ex_doc, "0.29.0", "4a1cb903ce746aceef9c1f9ae8a6c12b742a5461e6959b9d3b24d813ffbea146", [:mix], [{:earmark_parser, "~> 1.4.19", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_elixir, "~> 0.14", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1", [hex: :makeup_erlang, repo: "hexpm", optional: false]}], "hexpm", "f096adb8bbca677d35d278223361c7792d496b3fc0d0224c9d4bc2f651af5db1"},
+ "ets": {:hex, :ets, "0.8.1", "8ff9bcda5682b98493f8878fc9dbd990e48d566cba8cce59f7c2a78130da29ea", [:mix], [], "hexpm", "6be41b50adb5bc5c43626f25ea2d0af1f4a242fb3fad8d53f0c67c20b78915cc"},
+ "ex_doc": {:hex, :ex_doc, "0.28.6", "2bbd7a143d3014fc26de9056793e97600ae8978af2ced82c2575f130b7c0d7d7", [:mix], [{:earmark_parser, "~> 1.4.19", [hex: :earmark_parser, repo: "hexpm", optional: false]}, {:makeup_elixir, "~> 0.14", [hex: :makeup_elixir, repo: "hexpm", optional: false]}, {:makeup_erlang, "~> 0.1", [hex: :makeup_erlang, repo: "hexpm", optional: false]}], "hexpm", "bca1441614654710ba37a0e173079273d619f9160cbcc8cd04e6bd59f1ad0e29"},
+ "faker": {:hex, :faker, "0.17.0", "671019d0652f63aefd8723b72167ecdb284baf7d47ad3a82a15e9b8a6df5d1fa", [:mix], [], "hexpm", "a7d4ad84a93fd25c5f5303510753789fc2433ff241bf3b4144d3f6f291658a6a"},
"file_system": {:hex, :file_system, "0.2.10", "fb082005a9cd1711c05b5248710f8826b02d7d1784e7c3451f9c1231d4fc162d", [:mix], [], "hexpm", "41195edbfb562a593726eda3b3e8b103a309b733ad25f3d642ba49696bf715dc"},
+ "getopt": {:hex, :getopt, "1.0.1", "c73a9fa687b217f2ff79f68a3b637711bb1936e712b521d8ce466b29cbf7808a", [:rebar3], [], "hexpm", "53e1ab83b9ceb65c9672d3e7a35b8092e9bdc9b3ee80721471a161c10c59959c"},
"git_cli": {:hex, :git_cli, "0.3.0", "a5422f9b95c99483385b976f5d43f7e8233283a47cda13533d7c16131cb14df5", [:mix], [], "hexpm", "78cb952f4c86a41f4d3511f1d3ecb28edb268e3a7df278de2faa1bd4672eaf9b"},
"git_ops": {:hex, :git_ops, "2.5.1", "94ab6e3bc69fe765a62cbdb09969016613a154dec8fc4f6ebae682f030451da9", [:mix], [{:git_cli, "~> 0.2", [hex: :git_cli, repo: "hexpm", optional: false]}, {:nimble_parsec, "~> 1.0", [hex: :nimble_parsec, repo: "hexpm", optional: false]}], "hexpm", "1219edc8810dcea40472ec5b7ed04786a9e1b0e4e49d8642b0e1cdfb8a6ad261"},
"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"},
"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"},
+ "mime": {:hex, :mime, "2.0.3", "3676436d3d1f7b81b5a2d2bd8405f412c677558c81b1c92be58c00562bb59095", [:mix], [], "hexpm", "27a30bf0db44d25eecba73755acf4068cbfe26a4372f9eb3e4ea3a45956bff6b"},
+ "mimic": {:hex, :mimic, "1.7.4", "cd2772ffbc9edefe964bc668bfd4059487fa639a5b7f1cbdf4fd22946505aa4f", [:mix], [], "hexpm", "437c61041ecf8a7fae35763ce89859e4973bb0666e6ce76d75efc789204447c3"},
+ "nimble_options": {:hex, :nimble_options, "0.4.0", "c89babbab52221a24b8d1ff9e7d838be70f0d871be823165c94dd3418eea728f", [:mix], [], "hexpm", "e6701c1af326a11eea9634a3b1c62b475339ace9456c1a23ec3bc9a847bca02d"},
"nimble_parsec": {:hex, :nimble_parsec, "1.2.3", "244836e6e3f1200c7f30cb56733fd808744eca61fd182f731eac4af635cc6d0b", [:mix], [], "hexpm", "c8d789e39b9131acf7b99291e93dae60ab48ef14a7ee9d58c6964f59efb570b0"},
+ "picosat_elixir": {:hex, :picosat_elixir, "0.2.2", "1cacfdb4fb0c3ead5e5e9b1e98ac822a777f07eab35e29c3f8fc7086de2bfb36", [:make, :mix], [{:elixir_make, "~> 0.6", [hex: :elixir_make, repo: "hexpm", optional: false]}], "hexpm", "9d0cc569552cca417abea8270a54b71153a63be4b951ff249e94642f1c0f35d1"},
+ "plug": {:hex, :plug, "1.13.6", "187beb6b67c6cec50503e940f0434ea4692b19384d47e5fdfd701e93cadb4cc2", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "02b9c6b9955bce92c829f31d6284bf53c591ca63c4fb9ff81dfd0418667a34ff"},
+ "plug_cowboy": {:hex, :plug_cowboy, "2.5.2", "62894ccd601cf9597e2c23911ff12798a8a18d237e9739f58a6b04e4988899fe", [:mix], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:cowboy_telemetry, "~> 0.3", [hex: :cowboy_telemetry, repo: "hexpm", optional: false]}, {:plug, "~> 1.7", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "ea6e87f774c8608d60c8d34022a7d073bd7680a0a013f049fc62bf35efea1044"},
+ "plug_crypto": {:hex, :plug_crypto, "1.2.3", "8f77d13aeb32bfd9e654cb68f0af517b371fb34c56c9f2b58fe3df1235c1251a", [:mix], [], "hexpm", "b5672099c6ad5c202c45f5a403f21a3411247f164e4a8fab056e5cd8a290f4a2"},
+ "postgrex": {:hex, :postgrex, "0.16.5", "fcc4035cc90e23933c5d69a9cd686e329469446ef7abba2cf70f08e2c4b69810", [:mix], [{:connection, "~> 1.1", [hex: :connection, repo: "hexpm", optional: false]}, {:db_connection, "~> 2.1", [hex: :db_connection, repo: "hexpm", optional: false]}, {:decimal, "~> 1.5 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: false]}, {:jason, "~> 1.0", [hex: :jason, repo: "hexpm", optional: true]}, {:table, "~> 0.1.0", [hex: :table, repo: "hexpm", optional: true]}], "hexpm", "edead639dc6e882618c01d8fc891214c481ab9a3788dfe38dd5e37fd1d5fb2e8"},
+ "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.1.28", "8ce732daa56ad0dc11190b28461f85e71b67c5b61ce4818841bc8fcdbf799676", [:mix], [{:nimble_options, "~> 0.4.0", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:sourceror, "~> 0.1", [hex: :sourceror, repo: "hexpm", optional: false]}], "hexpm", "11b2d52b473345e2ecb4fe70c76ca8400b2fa9417acb629a6bd92db9d3ff953b"},
+ "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"},
}
diff --git a/priv/repo/migrations/20221002235524_install_2_extensions.exs b/priv/repo/migrations/20221002235524_install_2_extensions.exs
new file mode 100644
index 0000000..571c88e
--- /dev/null
+++ b/priv/repo/migrations/20221002235524_install_2_extensions.exs
@@ -0,0 +1,21 @@
+defmodule Example.Repo.Migrations.Install2Extensions do
+ @moduledoc """
+ Installs any extensions that are mentioned in the repo's `installed_extensions/0` callback
+
+ This file was autogenerated with `mix ash_postgres.generate_migrations`
+ """
+
+ use Ecto.Migration
+
+ def up do
+ execute("CREATE EXTENSION IF NOT EXISTS \"uuid-ossp\"")
+ execute("CREATE EXTENSION IF NOT EXISTS \"citext\"")
+ end
+
+ def down do
+ # Uncomment this if you actually want to uninstall the extensions
+ # when this migration is rolled back:
+ # execute("DROP EXTENSION IF EXISTS \"uuid-ossp\"")
+ # execute("DROP EXTENSION IF EXISTS \"citext\"")
+ end
+end
\ No newline at end of file
diff --git a/priv/repo/migrations/20221002235526_migrate_resources1.exs b/priv/repo/migrations/20221002235526_migrate_resources1.exs
new file mode 100644
index 0000000..6271d98
--- /dev/null
+++ b/priv/repo/migrations/20221002235526_migrate_resources1.exs
@@ -0,0 +1,23 @@
+defmodule Example.Repo.Migrations.MigrateResources1 do
+ @moduledoc """
+ Updates resources based on their most recent snapshots.
+
+ This file was autogenerated with `mix ash_postgres.generate_migrations`
+ """
+
+ use Ecto.Migration
+
+ def up do
+ create table(:user_with_username, primary_key: false) do
+ add :id, :uuid, null: false, default: fragment("uuid_generate_v4()"), primary_key: true
+ add :username, :citext, null: false
+ add :hashed_password, :text, null: false
+ add :created_at, :utc_datetime_usec, null: false, default: fragment("now()")
+ add :updated_at, :utc_datetime_usec, null: false, default: fragment("now()")
+ end
+ end
+
+ def down do
+ drop table(:user_with_username)
+ end
+end
\ No newline at end of file
diff --git a/priv/repo/migrations/20221020042559_add_token_revocation_table.exs b/priv/repo/migrations/20221020042559_add_token_revocation_table.exs
new file mode 100644
index 0000000..e476790
--- /dev/null
+++ b/priv/repo/migrations/20221020042559_add_token_revocation_table.exs
@@ -0,0 +1,28 @@
+defmodule Example.Repo.Migrations.AddTokenRevocationTable do
+ @moduledoc """
+ Updates resources based on their most recent snapshots.
+
+ This file was autogenerated with `mix ash_postgres.generate_migrations`
+ """
+
+ use Ecto.Migration
+
+ def up do
+ create unique_index(:user_with_username, [:username],
+ name: "user_with_username_username_index"
+ )
+
+ create table(:token_revocations, primary_key: false) do
+ add :expires_at, :utc_datetime, null: false
+ add :jti, :text, null: false, primary_key: true
+ end
+ end
+
+ def down do
+ drop table(:token_revocations)
+
+ drop_if_exists unique_index(:user_with_username, [:username],
+ name: "user_with_username_username_index"
+ )
+ end
+end
\ No newline at end of file
diff --git a/priv/resource_snapshots/extensions.json b/priv/resource_snapshots/extensions.json
new file mode 100644
index 0000000..bd5215b
--- /dev/null
+++ b/priv/resource_snapshots/extensions.json
@@ -0,0 +1,6 @@
+{
+ "installed": [
+ "uuid-ossp",
+ "citext"
+ ]
+}
\ No newline at end of file
diff --git a/priv/resource_snapshots/repo/token_revocations/20221020042559.json b/priv/resource_snapshots/repo/token_revocations/20221020042559.json
new file mode 100644
index 0000000..642e431
--- /dev/null
+++ b/priv/resource_snapshots/repo/token_revocations/20221020042559.json
@@ -0,0 +1,39 @@
+{
+ "attributes": [
+ {
+ "allow_nil?": false,
+ "default": "nil",
+ "generated?": false,
+ "primary_key?": false,
+ "references": null,
+ "size": null,
+ "source": "expires_at",
+ "type": "utc_datetime"
+ },
+ {
+ "allow_nil?": false,
+ "default": "nil",
+ "generated?": false,
+ "primary_key?": true,
+ "references": null,
+ "size": null,
+ "source": "jti",
+ "type": "text"
+ }
+ ],
+ "base_filter": null,
+ "check_constraints": [],
+ "custom_indexes": [],
+ "custom_statements": [],
+ "has_create_action": true,
+ "hash": "98092CB4D3ED441847CB38BC76408D77082FA08266FC8224FDF994A08A42CECB",
+ "identities": [],
+ "multitenancy": {
+ "attribute": null,
+ "global": null,
+ "strategy": null
+ },
+ "repo": "Elixir.Example.Repo",
+ "schema": null,
+ "table": "token_revocations"
+}
\ No newline at end of file
diff --git a/priv/resource_snapshots/repo/user_with_username/20221002235526.json b/priv/resource_snapshots/repo/user_with_username/20221002235526.json
new file mode 100644
index 0000000..07a45d6
--- /dev/null
+++ b/priv/resource_snapshots/repo/user_with_username/20221002235526.json
@@ -0,0 +1,69 @@
+{
+ "attributes": [
+ {
+ "allow_nil?": false,
+ "default": "fragment(\"uuid_generate_v4()\")",
+ "generated?": false,
+ "primary_key?": true,
+ "references": null,
+ "size": null,
+ "source": "id",
+ "type": "uuid"
+ },
+ {
+ "allow_nil?": false,
+ "default": "nil",
+ "generated?": false,
+ "primary_key?": false,
+ "references": null,
+ "size": null,
+ "source": "username",
+ "type": "citext"
+ },
+ {
+ "allow_nil?": false,
+ "default": "nil",
+ "generated?": false,
+ "primary_key?": false,
+ "references": null,
+ "size": null,
+ "source": "hashed_password",
+ "type": "text"
+ },
+ {
+ "allow_nil?": false,
+ "default": "fragment(\"now()\")",
+ "generated?": false,
+ "primary_key?": false,
+ "references": null,
+ "size": null,
+ "source": "created_at",
+ "type": "utc_datetime_usec"
+ },
+ {
+ "allow_nil?": false,
+ "default": "fragment(\"now()\")",
+ "generated?": false,
+ "primary_key?": false,
+ "references": null,
+ "size": null,
+ "source": "updated_at",
+ "type": "utc_datetime_usec"
+ }
+ ],
+ "base_filter": null,
+ "check_constraints": [],
+ "custom_indexes": [],
+ "custom_statements": [],
+ "has_create_action": false,
+ "hash": "F14A731ABBE8055A62D20F13B89906CC1EA14CB22BE344DC4E542A17429D55C0",
+ "identities": [],
+ "multitenancy": {
+ "attribute": null,
+ "global": null,
+ "strategy": null
+ },
+ "repo": "Elixir.Example.Repo",
+ "schema": null,
+ "table": "user_with_username"
+}
\ No newline at end of file
diff --git a/priv/resource_snapshots/repo/user_with_username/20221020042559.json b/priv/resource_snapshots/repo/user_with_username/20221020042559.json
new file mode 100644
index 0000000..19c8214
--- /dev/null
+++ b/priv/resource_snapshots/repo/user_with_username/20221020042559.json
@@ -0,0 +1,78 @@
+{
+ "attributes": [
+ {
+ "allow_nil?": false,
+ "default": "fragment(\"uuid_generate_v4()\")",
+ "generated?": false,
+ "primary_key?": true,
+ "references": null,
+ "size": null,
+ "source": "id",
+ "type": "uuid"
+ },
+ {
+ "allow_nil?": false,
+ "default": "nil",
+ "generated?": false,
+ "primary_key?": false,
+ "references": null,
+ "size": null,
+ "source": "username",
+ "type": "citext"
+ },
+ {
+ "allow_nil?": false,
+ "default": "nil",
+ "generated?": false,
+ "primary_key?": false,
+ "references": null,
+ "size": null,
+ "source": "hashed_password",
+ "type": "text"
+ },
+ {
+ "allow_nil?": false,
+ "default": "fragment(\"now()\")",
+ "generated?": false,
+ "primary_key?": false,
+ "references": null,
+ "size": null,
+ "source": "created_at",
+ "type": "utc_datetime_usec"
+ },
+ {
+ "allow_nil?": false,
+ "default": "fragment(\"now()\")",
+ "generated?": false,
+ "primary_key?": false,
+ "references": null,
+ "size": null,
+ "source": "updated_at",
+ "type": "utc_datetime_usec"
+ }
+ ],
+ "base_filter": null,
+ "check_constraints": [],
+ "custom_indexes": [],
+ "custom_statements": [],
+ "has_create_action": true,
+ "hash": "EFE086DA7BA408EF8829E710CD8F250BE7CDFB956F0AC86EF83726A54210E933",
+ "identities": [
+ {
+ "base_filter": null,
+ "index_name": "user_with_username_username_index",
+ "keys": [
+ "username"
+ ],
+ "name": "username"
+ }
+ ],
+ "multitenancy": {
+ "attribute": null,
+ "global": null,
+ "strategy": null
+ },
+ "repo": "Elixir.Example.Repo",
+ "schema": null,
+ "table": "user_with_username"
+}
\ No newline at end of file
diff --git a/test/ash_authentication/jwt/config_test.exs b/test/ash_authentication/jwt/config_test.exs
new file mode 100644
index 0000000..a580491
--- /dev/null
+++ b/test/ash_authentication/jwt/config_test.exs
@@ -0,0 +1,73 @@
+defmodule AshAuthentication.Jwt.ConfigTest do
+ @moduledoc false
+ use ExUnit.Case, async: true
+ use Mimic
+ alias AshAuthentication.{Jwt.Config, TokenRevocation}
+
+ describe "default_claims/1" do
+ test "it is a token config" do
+ claims = Config.default_claims(Example.UserWithUsername)
+ assert is_map(claims)
+
+ assert Enum.all?(claims, fn {name, config} ->
+ assert is_binary(name)
+ assert is_struct(config, Joken.Claim)
+ end)
+ end
+ end
+
+ describe "generate_issuer/1" do
+ test "it correctly generates" do
+ assert "AshAuthentication v1.2.3" = Config.generate_issuer(Version.parse!("1.2.3"))
+ end
+ end
+
+ describe "validate_issuer/3" do
+ test "is true when the issuer starts with \"AshAuthentication\"" do
+ assert Config.validate_issuer("AshAuthentication foo", nil, nil)
+ end
+
+ test "is false otherwise" do
+ garbage = 2 |> Faker.Lorem.words() |> Enum.join(" ")
+ refute Config.validate_issuer(garbage, nil, nil)
+ end
+ end
+
+ describe "generate_audience/1" do
+ test "it correctly generates" do
+ assert "~> 1.2" = Config.generate_audience(Version.parse!("1.2.3"))
+ end
+ end
+
+ describe "validate_audience/4" do
+ test "is true when the decoding version meets the minimum requirement" do
+ assert Config.validate_audience("~> 1.2", nil, nil, Version.parse!("1.2.3"))
+ end
+
+ test "is false otherwise" do
+ refute Config.validate_audience("~> 1.2", nil, nil, Version.parse!("1.1.2"))
+ end
+ end
+
+ describe "validate_jti/3" do
+ test "is true when the token has not been revoked" do
+ TokenRevocation
+ |> stub(:revoked?, fn _, _ -> false end)
+
+ assert Config.validate_jti("fake jti", nil, %{resource: Example.UserWithUsername})
+ end
+
+ test "is false when the token has been revoked" do
+ TokenRevocation
+ |> stub(:revoked?, fn _, _ -> true end)
+
+ assert Config.validate_jti("fake jti", nil, %{resource: Example.UserWithUsername})
+ end
+ end
+
+ describe "token_signer/1" do
+ test "it returns a signer configuration" do
+ assert %Joken.Signer{} = Config.token_signer(Example.UserWithUsername)
+ end
+ end
+end
diff --git a/test/ash_authentication/jwt_test.exs b/test/ash_authentication/jwt_test.exs
new file mode 100644
index 0000000..77096d0
--- /dev/null
+++ b/test/ash_authentication/jwt_test.exs
@@ -0,0 +1,77 @@
+defmodule AshAuthentication.JwtTest do
+ @moduledoc false
+ use AshAuthentication.DataCase, async: true
+ alias AshAuthentication.Jwt
+
+ describe "default_algorithm/0" do
+ test "is a supported algorithm" do
+ assert Jwt.default_algorithm() in Jwt.supported_algorithms()
+ end
+ end
+
+ describe "supported_algorithms/0" do
+ test "is a list of algorithms" do
+ algorithms = Jwt.supported_algorithms()
+
+ assert Enum.any?(algorithms)
+
+ for algorithm <- algorithms do
+ assert is_binary(algorithm)
+ assert byte_size(algorithm) > 0
+ end
+ end
+ end
+
+ describe "default_lifetime_hrs/0" do
+ test "is a positive integer" do
+ assert Jwt.default_lifetime_hrs() > 0
+ assert is_integer(Jwt.default_lifetime_hrs())
+ end
+ end
+
+ describe "token_for_record/1" do
+ test "correctly generates and signs tokens" do
+ user = build_user()
+ assert {:ok, token, claims} = Jwt.token_for_record(user)
+
+ now = DateTime.utc_now() |> DateTime.to_unix()
+
+ assert token =~ ~r/^[\w-]+\.[\w-]+\.[\w-]+$/
+ assert {:ok, _} = Version.parse_requirement(claims["aud"])
+ assert claims["exp"] > now
+ assert_in_delta(claims["iat"], now, 1.5)
+ assert claims["iss"] =~ ~r/^AshAuthentication v\d\.\d\.\d$/
+ assert claims["jti"] =~ ~r/^[0-9a-z]+$/
+ assert_in_delta(claims["nbf"], now, 1.5)
+ assert claims["sub"] == "user_with_username?id=#{user.id}"
+ end
+ end
+
+ describe "verify/2" do
+ test "it is successful when given a valid token and the correct otp app" do
+ {:ok, token, actual_claims} = build_user() |> Jwt.token_for_record()
+
+ assert {:ok, validated_claims, config} = Jwt.verify(token, :ash_authentication)
+ assert validated_claims == actual_claims
+ assert config.resource == Example.UserWithUsername
+ end
+
+ test "it is unsuccessful when the token signature isn't correct" do
+ {:ok, token, _} = build_user() |> Jwt.token_for_record()
+
+ # mangle the token.
+ [header, payload, signature] = String.split(token, ".")
+ token = [header, payload, String.reverse(signature)] |> Enum.join(".")
+
+ assert :error = Jwt.verify(token, :ash_authentication)
+ end
+
+ test "it is unsuccessful when the token has been revoked" do
+ {:ok, token, _} = build_user() |> Jwt.token_for_record()
+
+ AshAuthentication.TokenRevocation.revoke(Example.TokenRevocation, token)
+
+ assert :error = Jwt.verify(token, :ash_authentication)
+ end
+ end
+end
diff --git a/test/ash_authentication/password_authentication/action_test.exs b/test/ash_authentication/password_authentication/action_test.exs
new file mode 100644
index 0000000..8b91a88
--- /dev/null
+++ b/test/ash_authentication/password_authentication/action_test.exs
@@ -0,0 +1,166 @@
+defmodule AshAuthentication.PasswordAuthentication.ActionTest do
+ @moduledoc false
+ use AshAuthentication.DataCase, async: true
+ alias Ash.{Changeset, Query}
+ alias AshAuthentication.PasswordAuthentication.Info
+
+ describe "register action" do
+ @describetag resource: Example.UserWithUsername
+ setup :resource_config
+
+ test "password confirmation is verified", %{config: config, resource: resource} do
+ assert {:error, error} =
+ resource
+ |> Changeset.for_create(:register, %{
+ config.identity_field => username(),
+ config.password_field => password(),
+ config.password_confirmation_field => password()
+ })
+ |> Example.create()
+
+ assert Exception.message(error) =~ "#{config.password_confirmation_field}: does not match"
+ end
+
+ test "users can be created", %{config: config, resource: resource} do
+ password = password()
+
+ attrs = %{
+ config.identity_field => username(),
+ config.password_field => password,
+ config.password_confirmation_field => password
+ }
+
+ assert {:ok, user} =
+ resource
+ |> Changeset.for_create(:register, attrs)
+ |> Example.create()
+
+ refute is_nil(user.id)
+
+ created_username = user |> Map.fetch!(config.identity_field) |> to_string()
+
+ assert created_username == Map.get(attrs, config.identity_field)
+ end
+
+ test "the password is hashed correctly", %{config: config, resource: resource} do
+ password = password()
+
+ assert user =
+ resource
+ |> Changeset.for_create(:register, %{
+ config.identity_field => username(),
+ config.password_field => password,
+ config.password_confirmation_field => password
+ })
+ |> Example.create!()
+
+ assert {:ok, hashed} = Map.fetch(user, config.hashed_password_field)
+ assert hashed != password
+
+ assert config.hash_provider.valid?(password, hashed)
+ end
+ end
+
+ describe "sign_in action" do
+ @describetag resource: Example.UserWithUsername
+ setup :resource_config
+
+ test "when the user doesn't exist, it returns an empty result", %{
+ config: config,
+ resource: resource
+ } do
+ assert {:error, _} =
+ resource
+ |> Query.for_read(:sign_in, %{
+ config.identity_field => username(),
+ config.password_field => password()
+ })
+ |> Example.read()
+ end
+
+ test "when the user exists, but the password is incorrect, it returns an empty result", %{
+ config: config,
+ resource: resource
+ } do
+ username = username()
+ password = password()
+
+ resource
+ |> Changeset.for_create(:register, %{
+ config.identity_field => username,
+ config.password_field => password,
+ config.password_confirmation_field => password
+ })
+ |> Example.create!()
+
+ assert {:error, _} =
+ resource
+ |> Query.for_read(:sign_in, %{
+ config.identity_field => username,
+ config.password_field => password()
+ })
+ |> Example.read()
+ end
+
+ test "when the user exists, and the password is correct, it returns the user", %{
+ config: config,
+ resource: resource
+ } do
+ username = username()
+ password = password()
+
+ expected =
+ resource
+ |> Changeset.for_create(:register, %{
+ config.identity_field => username,
+ config.password_field => password,
+ config.password_confirmation_field => password
+ })
+ |> Example.create!()
+
+ assert {:ok, [actual]} =
+ resource
+ |> Query.for_read(:sign_in, %{
+ config.identity_field => username,
+ config.password_field => password
+ })
+ |> Example.read()
+
+ assert actual.id == expected.id
+ end
+
+ test "when the user exists, and the password is correct it generates a token", %{
+ config: config,
+ resource: resource
+ } do
+ username = username()
+ password = password()
+
+ resource
+ |> Changeset.for_create(:register, %{
+ config.identity_field => username,
+ config.password_field => password,
+ config.password_confirmation_field => password
+ })
+ |> Example.create!()
+
+ assert {:ok, [user]} =
+ resource
+ |> Query.for_read(:sign_in, %{
+ config.identity_field => username,
+ config.password_field => password
+ })
+ |> Example.read()
+
+ assert is_binary(user.__metadata__.token)
+ end
+ end
+
+ defp resource_config(%{resource: resource}) do
+ config =
+ resource
+ |> Info.options()
+
+ {:ok, config: config}
+ end
+end
diff --git a/test/ash_authentication/password_authentication/identity_test.exs b/test/ash_authentication/password_authentication/identity_test.exs
new file mode 100644
index 0000000..2a78777
--- /dev/null
+++ b/test/ash_authentication/password_authentication/identity_test.exs
@@ -0,0 +1,49 @@
+defmodule AshAuthentication.IdentityTest do
+ @moduledoc false
+ use AshAuthentication.DataCase, async: true
+ alias Ash.Error
+ alias AshAuthentication.{PasswordAuthentication, PasswordAuthentication.Info}
+
+ describe "sign_in_action/2" do
+ @describetag resource: Example.UserWithUsername
+ setup :resource_config
+
+ test "when provided invalid credentials", %{resource: resource, config: config} do
+ assert {:error, error} =
+ PasswordAuthentication.sign_in_action(resource, %{
+ config.identity_field => username(),
+ config.password_field => password()
+ })
+
+ assert Error.error_messages(error.errors, "", false) =~ "Authentication failed"
+ end
+
+ test "when provided valid credentials", %{resource: resource, config: config} do
+ username = username()
+ password = password()
+
+ {:ok, expected} =
+ PasswordAuthentication.register_action(resource, %{
+ config.identity_field => username,
+ config.password_field => password,
+ config.password_confirmation_field => password
+ })
+
+ assert {:ok, actual} =
+ PasswordAuthentication.sign_in_action(resource, %{
+ config.identity_field => username,
+ config.password_field => password
+ })
+
+ assert actual.id == expected.id
+ end
+ end
+
+ defp resource_config(%{resource: resource}) do
+ config =
+ resource
+ |> Info.options()
+
+ {:ok, config: config}
+ end
+end
diff --git a/test/ash_authentication/token_revocation_test.exs b/test/ash_authentication/token_revocation_test.exs
new file mode 100644
index 0000000..f1896c6
--- /dev/null
+++ b/test/ash_authentication/token_revocation_test.exs
@@ -0,0 +1,24 @@
+defmodule AshAuthentication.TokenRevocationTest do
+ @moduledoc false
+ use AshAuthentication.DataCase, async: true
+ alias AshAuthentication.{Jwt, TokenRevocation}
+
+ describe "revoke/2" do
+ test "it revokes tokens" do
+ {token, %{"jti" => jti}} = build_token()
+ refute TokenRevocation.revoked?(Example.TokenRevocation, jti)
+
+ assert :ok = TokenRevocation.revoke(Example.TokenRevocation, token)
+
+ assert TokenRevocation.revoked?(Example.TokenRevocation, jti)
+ end
+ end
+
+ defp build_token do
+ {:ok, token, claims} =
+ build_user()
+ |> Jwt.token_for_record()
+
+ {token, claims}
+ end
+end
diff --git a/test/ash_authentication_test.exs b/test/ash_authentication_test.exs
index 150dedb..331f357 100644
--- a/test/ash_authentication_test.exs
+++ b/test/ash_authentication_test.exs
@@ -1,8 +1,18 @@
defmodule AshAuthenticationTest do
+ @moduledoc false
use ExUnit.Case
doctest AshAuthentication
- test "greets the world" do
- assert AshAuthentication.hello() == :world
+ describe "authenticated_resources/0" do
+ test "it correctly locates all authenticatable resources" do
+ assert [
+ %{
+ api: Example,
+ providers: [AshAuthentication.PasswordAuthentication],
+ resource: Example.UserWithUsername,
+ subject_name: :user_with_username
+ }
+ ] = AshAuthentication.authenticated_resources(:ash_authentication)
+ end
end
end
diff --git a/test/support/data_case.ex b/test/support/data_case.ex
new file mode 100644
index 0000000..952a6f9
--- /dev/null
+++ b/test/support/data_case.ex
@@ -0,0 +1,76 @@
+defmodule AshAuthentication.DataCase do
+ @moduledoc """
+ This module defines the setup for tests requiring
+ access to the application's data layer.
+
+ You may define functions here to be used as helpers in
+ your tests.
+
+ Finally, if the test case interacts with the database,
+ we enable the SQL sandbox, so changes done to the database
+ are reverted at the end of every test. If you are using
+ PostgreSQL, you can even run database tests asynchronously
+ by setting `use AshAuthentication.DataCase, async: true`, although
+ this option is not recommended for other databases.
+ """
+
+ use ExUnit.CaseTemplate
+ alias Ecto.Adapters.SQL.Sandbox
+
+ using do
+ quote do
+ alias Example.Repo
+
+ import Ecto
+ import Ecto.Changeset
+ import Ecto.Query
+ import AshAuthentication.DataCase
+ end
+ end
+
+ setup tags do
+ AshAuthentication.DataCase.setup_sandbox(tags)
+ :ok
+ end
+
+ @doc """
+ Sets up the sandbox based on the test tags.
+ """
+ def setup_sandbox(tags) do
+ pid = Sandbox.start_owner!(Example.Repo, shared: not tags[:async])
+ on_exit(fn -> Sandbox.stop_owner(pid) end)
+ end
+
+ @doc """
+ A helper that transforms changeset errors into a map of messages.
+
+ assert {:error, changeset} = Accounts.create_user(%{password: "short"})
+ assert "password is too short" in errors_on(changeset).password
+ assert %{password: ["password is too short"]} = errors_on(changeset)
+
+ """
+ def errors_on(changeset) do
+ Ecto.Changeset.traverse_errors(changeset, fn {message, opts} ->
+ Regex.replace(~r"%{(\w+)}", message, fn _, key ->
+ opts |> Keyword.get(String.to_existing_atom(key), key) |> to_string()
+ end)
+ end)
+ end
+
+ def username, do: Faker.Internet.user_name()
+ def password, do: Faker.Lorem.words(4) |> Enum.join(" ")
+
+ def build_user(attrs \\ %{}) do
+ password = password()
+
+ attrs =
+ attrs
+ |> Map.put_new(:username, username())
+ |> Map.put_new(:password, password)
+ |> Map.put_new(:password_confirmation, password)
+
+ Example.UserWithUsername
+ |> Ash.Changeset.for_create(:register, attrs)
+ |> Example.create!()
+ end
+end
diff --git a/test/support/example.ex b/test/support/example.ex
new file mode 100644
index 0000000..cca718d
--- /dev/null
+++ b/test/support/example.ex
@@ -0,0 +1,8 @@
+defmodule Example do
+ @moduledoc false
+ use Ash.Api, otp_app: :ash_authentication
+
+ resources do
+ registry Example.Registry
+ end
+end
diff --git a/test/support/example/auth_plug.ex b/test/support/example/auth_plug.ex
new file mode 100644
index 0000000..128dd13
--- /dev/null
+++ b/test/support/example/auth_plug.ex
@@ -0,0 +1,21 @@
+defmodule Example.AuthPlug do
+ @moduledoc false
+ use AshAuthentication.Plug, otp_app: :ash_authentication
+
+ @impl true
+ def handle_success(conn, actor, token) do
+ conn
+ |> store_in_session(actor)
+ |> send_resp(200, """
+ Token: #{token}
+
+ Actor: #{inspect(actor)}
+ """)
+ end
+
+ @impl true
+ def handle_failure(conn, _) do
+ conn
+ |> send_resp(401, "Sorry mate")
+ end
+end
diff --git a/test/support/example/registry.ex b/test/support/example/registry.ex
new file mode 100644
index 0000000..9532cbc
--- /dev/null
+++ b/test/support/example/registry.ex
@@ -0,0 +1,9 @@
+defmodule Example.Registry do
+ @moduledoc false
+ use Ash.Registry, extensions: [Ash.Registry.ResourceValidations]
+
+ entries do
+ entry Example.UserWithUsername
+ entry Example.TokenRevocation
+ end
+end
diff --git a/test/support/example/repo.ex b/test/support/example/repo.ex
new file mode 100644
index 0000000..afe8962
--- /dev/null
+++ b/test/support/example/repo.ex
@@ -0,0 +1,7 @@
+defmodule Example.Repo do
+ @moduledoc false
+ use AshPostgres.Repo, otp_app: :ash_authentication
+
+ @doc false
+ def installed_extensions, do: ["uuid-ossp", "citext"]
+end
diff --git a/test/support/example/token_revocation.ex b/test/support/example/token_revocation.ex
new file mode 100644
index 0000000..8fc672f
--- /dev/null
+++ b/test/support/example/token_revocation.ex
@@ -0,0 +1,24 @@
+defmodule Example.TokenRevocation do
+ @moduledoc false
+ use Ash.Resource,
+ data_layer: AshPostgres.DataLayer,
+ extensions: [AshAuthentication.TokenRevocation]
+
+ @type t :: %__MODULE__{
+ jti: String.t(),
+ expires_at: DateTime.t()
+ }
+
+ actions do
+ destroy :expire
+ end
+
+ postgres do
+ table("token_revocations")
+ repo(Example.Repo)
+ end
+
+ revocation do
+ api Example
+ end
+end
diff --git a/test/support/example/user_with_username.ex b/test/support/example/user_with_username.ex
new file mode 100644
index 0000000..00a8dbc
--- /dev/null
+++ b/test/support/example/user_with_username.ex
@@ -0,0 +1,57 @@
+defmodule Example.UserWithUsername do
+ @moduledoc false
+ use Ash.Resource,
+ data_layer: AshPostgres.DataLayer,
+ extensions: [AshAuthentication, AshAuthentication.PasswordAuthentication]
+
+ @type t :: %__MODULE__{
+ id: Ecto.UUID.t(),
+ username: String.t(),
+ hashed_password: String.t(),
+ created_at: DateTime.t(),
+ updated_at: DateTime.t()
+ }
+
+ attributes do
+ uuid_primary_key(:id)
+
+ attribute(:username, :ci_string, allow_nil?: false)
+ attribute(:hashed_password, :string, allow_nil?: false, sensitive?: true)
+
+ create_timestamp(:created_at)
+ update_timestamp(:updated_at)
+ end
+
+ actions do
+ destroy :destroy do
+ primary? true
+ end
+ end
+
+ code_interface do
+ define_for(Example)
+ end
+
+ postgres do
+ table("user_with_username")
+ repo(Example.Repo)
+ end
+
+ authentication do
+ api(Example)
+ end
+
+ password_authentication do
+ identity_field(:username)
+ hashed_password_field(:hashed_password)
+ end
+
+ identities do
+ identity(:username, [:username])
+ end
+
+ tokens do
+ enabled?(true)
+ revocation_resource(Example.TokenRevocation)
+ end
+end
diff --git a/test/test_helper.exs b/test/test_helper.exs
index 869559e..3be0501 100644
--- a/test/test_helper.exs
+++ b/test/test_helper.exs
@@ -1 +1,2 @@
-ExUnit.start()
+Mimic.copy(AshAuthentication.TokenRevocation)
+ExUnit.start(capture_log: true)