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.
This commit is contained in:
James Harton 2022-10-25 11:07:07 +13:00 committed by GitHub
parent 4a13721c9f
commit a939dde9b9
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
70 changed files with 4381 additions and 33 deletions

View file

@ -1,4 +1,5 @@
version: "3.8"
name: ash_authentication
volumes:
apt-cache: {}

View file

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

View file

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

View file

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

View file

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

24
dev/dev_server.ex Normal file
View file

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

View file

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

24
dev/dev_server/plug.ex Normal file
View file

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

17
dev/dev_server/session.ex Normal file
View file

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

View file

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

View file

@ -0,0 +1,53 @@
<!DOCTYPE html>
<html lang="en">
<head>
<title>Ash Authentication</title>
<meta charset="utf-8">
</head>
<body>
<h1>Ash Authentication</h1>
<%= if Enum.any?(@resources) do %>
<h2>Resources:</h2>
<%= for config <- @resources do %>
<h2><%= inspect(config.subject_name) %> - <%= Ash.Api.Info.short_name(config.api) %> / <%= Ash.Resource.Info.short_name(config.resource) %></h2>
<%= 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 %>
<h2>Validate token</h2>
<form method="post" action="/token_check">
<textarea name="token"></textarea>
<br />
<input type="submit" value="Check token" />
</form>
<% else %>
<p>
<strong>No resources configured</strong>
<br />
Please see <a href="https://hexdocs.pm/ash_authentication">the documentation</a> for more information.
</p>
<% end %>
<%= if Enum.any?(@current_actors) do %>
<h2>Current actors:</h2>
<a href="/clear_session">Clear session</a>
<table>
<tr>
<th>Name</th>
<th>Value</th>
</tr>
<%= for {name, actor} <- @current_actors do %>
<tr>
<td><code><pre>@<%= name %></pre></code></td>
<td><code><pre><%= inspect actor, pretty: true %></pre></code></td>
</tr>
<% end %>
</table>
<% end %>
</body>
</html>

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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"""
<form method="<%= @method %>" action="<%= @action %>">
<input type="hidden" name="<%= @subject_name %>[action]" value="sign_in" />
<fieldset>
<%= if @legend do %><legend><%= @legend %></legend><% end %>
<input type="text" name="<%= @subject_name %>[<%= @identity_field %>]" placeholder="<%= @identity_field %>" />
<br />
<input type="password" name="<%= @subject_name %>[<%= @password_field %>]" placeholder="Password" />
<br />
<input type="submit" value="Sign in" />
</fieldset>
</form>
""",
[:assigns]
)
EEx.function_from_string(
:defp,
:render_register,
~s"""
<form method="<%= @method %>" action="<%= @action %>">
<input type="hidden" name="<%= @subject_name %>[action]" value="<%= @register_action_name %>" />
<fieldset>
<%= if @legend do %><legend><%= @legend %></legend><% end %>
<input type="text" name="<%= @subject_name %>[<%= @identity_field %>]" placeholder="register" />
<br />
<input type="password" name="<%= @subject_name %>[<%= @password_field %>]" placeholder="Password" />
<br />
<%= if @confirmation_required? do %>
<input type="password" name="<%= @subject_name %>[<%= @password_confirmation_field %>]" placeholder="Password confirmation" />
<br />
<% end %>
<input type="submit" value="Register" />
</fieldset>
</form>
""",
[: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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

32
mix.exs
View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -0,0 +1,6 @@
{
"installed": [
"uuid-ossp",
"citext"
]
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

76
test/support/data_case.ex Normal file
View file

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

8
test/support/example.ex Normal file
View file

@ -0,0 +1,8 @@
defmodule Example do
@moduledoc false
use Ash.Api, otp_app: :ash_authentication
resources do
registry Example.Registry
end
end

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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