improvement(Strategy.Custom): handle custom strategies as extensions. (#183)

This means that users can add their own extensions to their resources which patch the strategy (and add ons) DSLs.
This commit is contained in:
James Harton 2023-02-08 16:10:28 +13:00 committed by GitHub
parent e008c7a58e
commit 3bece5f657
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
25 changed files with 356 additions and 690 deletions

9
.vscode/settings.json vendored Normal file
View file

@ -0,0 +1,9 @@
{
"cSpell.words": [
"defimpl",
"defstruct",
"ilike",
"Marties",
"moduledocs"
]
}

View file

@ -1,7 +1,7 @@
# Defining Custom Authentication Strategies
AshAuthentication allows you to bring your own authentication strategy without
having to change the Ash Authenticaiton codebase.
having to change the Ash Authentication codebase.
> There is functionally no difference between "add ons" and "strategies" other
> than where they appear in the DSL. We invented "add ons" because it felt
@ -20,8 +20,6 @@ There are several moving parts which must all work together so hold on to your h
4. The `AshAuthentication.Strategy` protocol, which provides the glue needed
for everything to wire up and wrappers around the actions needed to run on
the resource.
5. Runtime configuration of `AshAuthentication` to help it find the extra
strategies.
We're going to define an extremely dumb strategy which lets anyone with a name
that starts with "Marty" sign in with just their name. Of course you would
@ -39,52 +37,50 @@ end
```
Sadly, this isn't enough to make the magic happen. We need to define our DSL
entity by implementing the `dsl/0` callback:
entity by adding it to the `use` statement:
```elixir
defmodule OnlyMartiesAtTheParty do
use AshAuthentication.Strategy.Custom
def dsl do
%Spark.Dsl.Entity{
name: :only_marty,
describe: "Strategy which only allows folks whose name starts with \"Marty\" to sign in.",
examples: [
"""
only_marty do
case_sensitive? true
name_field :name
end
"""
@entity %Spark.Dsl.Entity{
name: :only_marty,
describe: "Strategy which only allows folks whose name starts with \"Marty\" to sign in.",
examples: [
"""
only_marty do
case_sensitive? true
name_field :name
end
"""
],
target: __MODULE__,
args: [{:optional, :name, :marty}],
schema: [
name: [
type: :atom,
doc: """
The strategy name.
""",
required: true
],
target: __MODULE__,
args: [{:optional, :name, :marty}],
schema: [
name: [
type: :atom,
doc: """
The strategy name.
""",
required: true
],
case_sensitive?: [
type: :boolean,
doc: """
Ignore letter case when comparing?
""",
required: false,
default: false
],
name_field: [
type: :atom,
doc: """
The field to check for the users' name.
""",
required: true
]
case_sensitive?: [
type: :boolean,
doc: """
Ignore letter case when comparing?
""",
required: false,
default: false
],
name_field: [
type: :atom,
doc: """
The field to check for the users' name.
""",
required: true
]
}
end
]
}
use AshAuthentication.Strategy.Custom, entity: @entity
end
```
@ -103,6 +99,10 @@ here's a brief overview of what each field we've set does:
provides a number of additional types over the default ones though, so check
out `Spark.OptionsHelpers` for more information.
> By default the entity is added to the `authentication / strategy` DSL, however
> if you want it in the `authentication / add_ons` DSL instead you can also pass
> `style: :add_on` in the `use` statement.
Next up, we need to define our struct. The struct should have *at least* the
fields named in the entity schema. Additionally, Ash Authentication requires
that it have a `resource` field which will be set to the module of the resource
@ -112,22 +112,20 @@ it's attached to during compilation.
defmodule OnlyMartiesAtTheParty do
defstruct name: :marty, case_sensitive?: false, name_field: nil, resource: nil
use AshAuthentication.Strategy.Custom
# ...
use AshAuthentication.Strategy.Custom, entity: @entity
# other code elided ...
end
```
Now it would be theoretically possible to add this custom strategies to your app
by adding it to the runtime configuration and the user resource:
by adding it to the `extensions` section of your resource:
```elixir
# config.exs
config :ash_authentication, extra_strategies: [OnlyMartiesAtTheParty]
# user resource
defmodule MyApp.Accounts.User do
use Ash.Resource, extensions: [AshAuthentication]
use Ash.Resource, extensions: [AshAuthentication, OnlyMartiesAtTheParty]
authentication do
api MyApp.Accounts
@ -169,7 +167,8 @@ concepts:
will generate routes using `Plug.Router` (or `Phoenix.Router`) - the
`routes/1` callback is used to retrieve this information from the strategy.
Given this information, let's implment the strategy. It's quite long, so I'm going to break it up into smaller chunks.
Given this information, let's implement the strategy. It's quite long, so I'm
going to break it up into smaller chunks.
```elixir
defimpl AshAuthentication.Strategy, for: OnlyMartiesAtTheParty do
@ -191,12 +190,12 @@ and action.
```
Next we generate the routes for the strategy. Routes *should* contain the
subject name of the resource being authenticated in case the implementor is
subject name of the resource being authenticated in case the implementer is
authenticating multiple different resources - eg `User` and `Admin`.
```elixir
def routes(strategy) do
subject_name = Info.authentication_subject_name!(strategy.resource)
subject_name = AshAuthentication.Info.authentication_subject_name!(strategy.resource)
[
{"/#{subject_name}/#{strategy.name}", :sign_in}
@ -307,9 +306,9 @@ end
```
In some cases you may want to modify the strategy and the resources DSL. In
this case you can return the newly muted DSL state in an ok tuple or an error
tuple, preferably containing a `Spark.Error.DslError`. For example if we
wanted to build a sign in action for `OnlyMartiesAtTheParty` to use:
this case you can return the newly mutated DSL state in an ok tuple or an error
tuple, preferably containing a `Spark.Error.DslError`. For example if we wanted
to build a sign in action for `OnlyMartiesAtTheParty` to use:
```elixir
def transform(strategy, dsl_state) do
@ -341,7 +340,7 @@ to the resource. See the docs for `Spark.Dsl.Transformer` for more information.
We also support a variant of transformers which run in the new `@after_verify`
compile hook provided by Elixir 1.14. This is a great place to put checks
to make sure that the user's configuration make sense without adding any
to make sure that the user's configuration makes sense without adding any
compile-time dependencies between modules which may cause compiler deadlocks.
For example, verifying that the "name" attribute contains "marty" (why you would
@ -355,7 +354,7 @@ def verify(strategy, _dsl_state) do
{:error,
Spark.Error.DslError.exception(
path: [:authentication, :strategies, :only_marties],
message: "Option `name_field` must contain the \"marty\""
message: "Option `name_field` must contain \"marty\""
)}
end
end

View file

@ -96,12 +96,28 @@ defmodule AshAuthentication do
<!--- ash-hq-hide-stop --> <!--- -->
"""
alias Ash.{Api, Error.Query.NotFound, Query, Resource}
alias AshAuthentication.Info
alias Ash.{
Api,
Error.Query.NotFound,
Query,
Resource
}
alias AshAuthentication.{
AddOn.Confirmation,
Info,
Strategy.Auth0,
Strategy.Github,
Strategy.OAuth2,
Strategy.Password
}
alias Spark.Dsl.Extension
use Spark.Dsl.Extension,
sections: dsl(),
dsl_patches:
Enum.flat_map([Confirmation, Auth0, Github, OAuth2, Password], & &1.dsl_patches()),
transformers: [
AshAuthentication.Transformer,
AshAuthentication.Strategy.Custom.Transformer

View file

@ -98,7 +98,9 @@ defmodule AshAuthentication.AddOn.Confirmation do
name: :confirm
alias Ash.{Changeset, Resource}
alias AshAuthentication.{AddOn.Confirmation, Jwt}
alias AshAuthentication.{AddOn.Confirmation, Jwt, Strategy.Custom}
use Custom, style: :add_on, entity: Dsl.dsl()
@type t :: %Confirmation{
token_lifetime: hours :: pos_integer,
@ -114,7 +116,6 @@ defmodule AshAuthentication.AddOn.Confirmation do
name: :confirm
}
defdelegate dsl(), to: Dsl
defdelegate transform(strategy, dsl_state), to: Transformer
defdelegate verify(strategy, dsl_state), to: Verifier

View file

@ -3,7 +3,12 @@ defmodule AshAuthentication.AddOn.Confirmation.Dsl do
Defines the Spark DSL entity for this add on.
"""
alias AshAuthentication.AddOn.Confirmation
alias AshAuthentication.{
AddOn.Confirmation,
Sender,
SenderFunction
}
alias Spark.Dsl.Entity
@default_confirmation_lifetime_days 3
@ -85,9 +90,7 @@ defmodule AshAuthentication.AddOn.Confirmation.Dsl do
default: true
],
sender: [
type:
{:spark_function_behaviour, AshAuthentication.Sender,
{AshAuthentication.SenderFunction, 3}},
type: {:spark_function_behaviour, Sender, {SenderFunction, 3}},
doc: """
How to send the confirmation instructions to the user.
Allows you to glue sending of confirmation instructions to

View file

@ -10,14 +10,6 @@ defmodule AshAuthentication.Dsl do
alias Ash.{Api, Resource}
alias AshAuthentication.{
AddOn.Confirmation,
Strategy.Auth0,
Strategy.Github,
Strategy.OAuth2,
Strategy.Password
}
@default_token_lifetime_days 14
alias Spark.Dsl.Section
@ -175,37 +167,17 @@ defmodule AshAuthentication.Dsl do
%Section{
name: :strategies,
describe: "Configure authentication strategies on this resource",
entities: Enum.map(available_strategies(), & &1.dsl())
entities: [],
patchable?: true
},
%Section{
name: :add_ons,
describe: "Additional add-ons related to, but not providing authentication",
entities: Enum.map(available_add_ons(), & &1.dsl())
entities: [],
patchable?: true
}
]
}
]
end
@doc """
Return the available strategy modules.
This is used for DSL generation and transformation.
"""
@spec available_strategies :: [module]
def available_strategies do
[Auth0, Github, OAuth2, Password]
|> Enum.concat(Application.get_env(:ash_authentication, :extra_strategies, []))
end
@doc """
Return the available add-on modules.
This is used for DSL generation and transformation.
"""
@spec available_add_ons :: [module]
def available_add_ons do
[Confirmation]
|> Enum.concat(Application.get_env(:ash_authentication, :extra_add_ons, []))
end
end

View file

@ -3,7 +3,7 @@ defmodule AshAuthentication.Info do
Generated configuration functions based on a resource's DSL configuration.
"""
use AshAuthentication.InfoGenerator,
use Spark.InfoGenerator,
extension: AshAuthentication,
sections: [:authentication]

View file

@ -1,297 +0,0 @@
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]}]
@doc false
@spec __using__(options) :: Macro.t()
defmacro __using__(opts) do
extension = Keyword.fetch!(opts, :extension) |> Macro.expand(__CALLER__)
sections = Keyword.get(opts, :sections, [])
quote do
require AshAuthentication.InfoGenerator
require unquote(extension)
AshAuthentication.InfoGenerator.generate_config_functions(
unquote(extension),
unquote(sections)
)
AshAuthentication.InfoGenerator.generate_options_functions(
unquote(extension),
unquote(sections)
)
AshAuthentication.InfoGenerator.generate_entity_functions(
unquote(extension),
unquote(sections)
)
end
end
@doc """
Given an extension and a list of DSL sections, generate an options function
which returns a map of all configured options for a resource (including
defaults).
"""
@spec generate_options_functions(module, [atom]) :: Macro.t()
defmacro generate_options_functions(extension, sections) do
for {path, options} <- extension_sections_to_option_list(extension, sections) do
function_name = :"#{Enum.join(path, "_")}_options"
quote location: :keep do
@doc """
#{unquote(Enum.join(path, "."))} DSL options
Returns a map containing the 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: 4]
unquote(Macro.escape(options))
|> Stream.map(fn option ->
value =
dsl_or_resource
|> get_opt(option.path, option.name, Map.get(option, :default))
{option.name, value}
end)
|> Stream.reject(&is_nil(elem(&1, 1)))
|> Map.new()
end
end
end
end
@doc """
Given an extension and a list of DSL sections, generate an entities function
which returns a list of entities.
"""
@spec generate_entity_functions(module, [atom]) :: Macro.t()
defmacro generate_entity_functions(extension, sections) do
entity_paths =
extension.sections()
|> Stream.filter(&(&1.name in sections))
|> Stream.flat_map(&explode_section([], &1))
|> Stream.filter(fn {_, section} -> Enum.any?(section.entities) end)
|> Stream.map(&elem(&1, 0))
for path <- entity_paths do
function_name = path |> Enum.join("_") |> String.to_atom()
quote location: :keep do
@doc """
#{unquote(Enum.join(path, "."))} DSL entities
"""
@spec unquote(function_name)(dsl_or_resource :: module | map) :: [struct]
def unquote(function_name)(dsl_or_resource) do
import Spark.Dsl.Extension, only: [get_entities: 2]
get_entities(dsl_or_resource, unquote(path))
end
end
end
end
@doc """
Given an extension and a list of DSL sections generate individual config
functions for each option.
"""
@spec generate_config_functions(module, [atom]) :: Macro.t()
defmacro generate_config_functions(extension, sections) do
for {_, options} <- extension_sections_to_option_list(extension, sections) do
for option <- options do
generate_config_function(option)
end
end
end
defp explode_section(path, %{sections: [], name: name} = section),
do: [{path ++ [name], section}]
defp explode_section(path, %{sections: sections, name: name} = section) do
path = path ++ [name]
head = [{path, section}]
tail = Stream.flat_map(sections, &explode_section(path, &1))
Stream.concat(head, tail)
end
defp extension_sections_to_option_list(extension, sections) do
extension.sections()
|> Stream.filter(&(&1.name in sections))
|> Stream.flat_map(&explode_section([], &1))
|> Stream.reject(fn {_, section} -> Enum.empty?(section.schema) end)
|> Stream.map(fn {path, section} ->
schema =
section.schema
|> Enum.map(fn {name, opts} ->
opts
|> Map.new()
|> Map.take(~w[type doc default]a)
|> Map.update!(:type, &spec_for_type/1)
|> Map.put(:pred?, name |> to_string() |> String.ends_with?("?"))
|> Map.put(:name, name)
|> Map.put(:path, path)
|> Map.put(
:function_name,
path
|> Enum.concat([name])
|> Enum.join("_")
|> String.trim_trailing("?")
|> String.to_atom()
)
end)
{path, schema}
end)
|> Map.new()
end
defp generate_config_function(%{pred?: true} = option) do
function_name = :"#{option.function_name}?"
quote location: :keep do
@doc unquote(option.doc)
@spec unquote(function_name)(dsl_or_resource :: module | map) ::
unquote(option.type)
def unquote(function_name)(dsl_or_resource) do
import Spark.Dsl.Extension, only: [get_opt: 4]
get_opt(
dsl_or_resource,
unquote(option.path),
unquote(option.name),
unquote(option.default)
)
end
end
end
defp generate_config_function(option) do
quote location: :keep do
@doc unquote(Map.get(option, :doc, false))
@spec unquote(option.function_name)(dsl_or_resource :: module | map) ::
{:ok, unquote(option.type)} | :error
def unquote(option.function_name)(dsl_or_resource) do
import Spark.Dsl.Extension, only: [get_opt: 4]
case get_opt(
dsl_or_resource,
unquote(option.path),
unquote(option.name),
unquote(Map.get(option, :default, :error))
) do
:error -> :error
value -> {:ok, value}
end
end
@doc unquote(Map.get(option, :doc, false))
@spec unquote(:"#{option.function_name}!")(dsl_or_resource :: module | map) ::
unquote(option.type) | no_return
def unquote(:"#{option.function_name}!")(dsl_or_resource) do
import Spark.Dsl.Extension, only: [get_opt: 4]
case get_opt(
dsl_or_resource,
unquote(option.path),
unquote(option.name),
unquote(Map.get(option, :default, :error))
) do
:error ->
raise "No configuration for `#{unquote(option.name)}` present on `#{inspect(dsl_or_resource)}`."
value ->
value
end
end
end
end
defp spec_for_type({:behaviour, _module}), do: {:module, [], Elixir}
defp spec_for_type({:spark_function_behaviour, behaviour, {_, arity}}),
do:
spec_for_type(
{:or,
[
{:behaviour, behaviour},
{{:behaviour, behaviour}, {:keyword, [], Elixir}},
{:fun, arity}
]}
)
defp spec_for_type({:fun, arity}) do
args =
0..(arity - 1)
|> Enum.map(fn _ -> {:any, [], Elixir} end)
[{:->, [], [args, {:any, [], Elixir}]}]
end
defp spec_for_type({:or, [type]}), do: spec_for_type(type)
defp spec_for_type({:or, [next | remaining]}),
do: {:|, [], [spec_for_type(next), spec_for_type({:or, remaining})]}
defp spec_for_type({:in, %Range{first: first, last: last}})
when is_integer(first) and is_integer(last),
do: {:.., [], [first, last]}
defp spec_for_type({:in, %Range{first: first, last: last}}),
do:
{{:., [], [{:__aliases__, [], [:Range]}, :t]}, [],
[spec_for_type(first), spec_for_type(last)]}
defp spec_for_type({:in, [type]}), do: spec_for_type(type)
defp spec_for_type({:in, [next | remaining]}),
do: {:|, [], [spec_for_type(next), spec_for_type({:in, remaining})]}
defp spec_for_type({:list, subtype}), do: [spec_for_type(subtype)]
defp spec_for_type({:custom, _, _, _}), do: spec_for_type(:any)
defp spec_for_type({:tuple, subtypes}) do
subtypes
|> Enum.map(&spec_for_type/1)
|> List.to_tuple()
end
defp spec_for_type(:string),
do: {{:., [], [{:__aliases__, [alias: false], [:String]}, :t]}, [], []}
defp spec_for_type(terminal)
when terminal in ~w[any map atom string boolean integer non_neg_integer pos_integer float timeout pid reference mfa]a,
do: {terminal, [], Elixir}
defp spec_for_type(atom) when is_atom(atom), do: atom
defp spec_for_type(number) when is_number(number), do: number
defp spec_for_type(string) when is_binary(string), do: spec_for_type(:string)
defp spec_for_type({mod, arg}) when is_atom(mod) and is_list(arg),
do: {{:module, [], Elixir}, {:list, [], Elixir}}
defp spec_for_type(tuple) when is_tuple(tuple),
do: tuple |> Tuple.to_list() |> Enum.map(&spec_for_type/1) |> List.to_tuple()
defp spec_for_type([]), do: []
defp spec_for_type([type]), do: [spec_for_type(type)]
end

View file

@ -1,4 +1,6 @@
defmodule AshAuthentication.Strategy.Auth0 do
alias __MODULE__.Dsl
@moduledoc """
Strategy for authenticating using [Auth0](https://auth0.com).
@ -14,63 +16,16 @@ defmodule AshAuthentication.Strategy.Auth0 do
See the [Auth0 quickstart guide](/documentation/tutorials/auth0-quickstart.html)
for more information.
## DSL Documentation
#{Spark.Dsl.Extension.doc_entity(Dsl.dsl())}
"""
alias AshAuthentication.Strategy.{Custom, OAuth2}
use Custom
@doc false
# credo:disable-for-next-line Credo.Check.Warning.SpecWithStruct
@spec dsl :: Custom.entity()
def dsl do
OAuth2.dsl()
|> Map.merge(%{
name: :auth0,
args: [{:optional, :name, :auth0}],
describe: """
Provides a pre-configured authentication strategy for [Auth0](https://auth0.com/).
This strategy is built using `:oauth2` strategy, and thus provides all the same
configuration options should you need them.
For more information see the [Auth0 Quick Start Guide](/documentation/tutorials/auth0-quickstart.md)
in our documentation.
#### Strategy defaults:
#{strategy_override_docs(Assent.Strategy.Auth0)}
#### Schema:
""",
auto_set_fields: strategy_fields(Assent.Strategy.Auth0, icon: :auth0)
})
end
use Custom, entity: Dsl.dsl()
defdelegate transform(strategy, dsl_state), to: OAuth2
defdelegate verify(strategy, dsl_state), to: OAuth2
defp strategy_fields(strategy, params) do
[]
|> strategy.default_config()
|> Keyword.put(:assent_strategy, strategy)
|> Keyword.merge(params)
end
defp strategy_override_docs(strategy) do
defaults =
[]
|> strategy.default_config()
|> Enum.map_join(
".\n",
fn {key, value} ->
" * `#{inspect(key)}` is set to `#{inspect(value)}`"
end
)
"""
The following defaults are applied:
#{defaults}.
"""
end
end

View file

@ -0,0 +1,56 @@
defmodule AshAuthentication.Strategy.Auth0.Dsl do
@moduledoc false
alias AshAuthentication.Strategy.{Custom, OAuth2}
@doc false
@spec dsl :: Custom.entity()
def dsl do
OAuth2.dsl()
|> Map.merge(%{
name: :auth0,
args: [{:optional, :name, :auth0}],
describe: """
Provides a pre-configured authentication strategy for [Auth0](https://auth0.com/).
This strategy is built using `:oauth2` strategy, and thus provides all the same
configuration options should you need them.
For more information see the [Auth0 Quick Start Guide](/documentation/tutorials/auth0-quickstart.md)
in our documentation.
#### Strategy defaults:
#{strategy_override_docs(Assent.Strategy.Auth0)}
#### Schema:
""",
auto_set_fields: strategy_fields(Assent.Strategy.Auth0, icon: :auth0)
})
end
defp strategy_fields(strategy, params) do
[]
|> strategy.default_config()
|> Keyword.put(:assent_strategy, strategy)
|> Keyword.merge(params)
end
defp strategy_override_docs(strategy) do
defaults =
[]
|> strategy.default_config()
|> Enum.map_join(
".\n",
fn {key, value} ->
" * `#{inspect(key)}` is set to `#{inspect(value)}`"
end
)
"""
The following defaults are applied:
#{defaults}.
"""
end
end

View file

@ -18,12 +18,6 @@ defmodule AshAuthentication.Strategy.Custom do
@type strategy :: struct
@doc """
A callback which allows the strategy to provide it's own DSL-based
configuration.
"""
@callback dsl :: entity
@doc """
If your strategy needs to modify either the entity or the parent resource then
you can implement this callback.
@ -61,7 +55,7 @@ defmodule AshAuthentication.Strategy.Custom do
@doc false
@spec __using__(keyword) :: Macro.t()
defmacro __using__(_opts) do
defmacro __using__(opts) do
quote generated: true do
@behaviour unquote(__MODULE__)
import unquote(__MODULE__).Helpers
@ -70,6 +64,31 @@ defmodule AshAuthentication.Strategy.Custom do
def verify(_entity, _dsl_state), do: :ok
defoverridable transform: 2, verify: 2
opts = unquote(opts)
path =
opts
|> Keyword.get(:style, :strategy)
|> case do
:strategy -> ~w[authentication strategies]a
:add_on -> ~w[authentication add_ons]a
end
entity =
opts
|> Keyword.get(:entity)
|> case do
%Dsl.Entity{} = entity ->
entity
_ ->
raise CompileError,
"You must provide a `Spark.Dsl.Entity` as the `entity` argument to `use AshAuthentication.Strategy.Custom`."
end
use Spark.Dsl.Extension,
dsl_patches: [%Dsl.Patch.AddEntity{section_path: path, entity: entity}]
end
end
end

View file

@ -0,0 +1,16 @@
defmodule AshAuthentication.Strategy.Custom.BeforeCompile do
@moduledoc false
alias Spark.Dsl
defmacro __before_compile__(env) do
quote generated: true do
use Dsl.Extension,
dsl_patches: [
%Dsl.Patch.AddEntity{
section_path: @patch_path,
entity: dsl()
}
]
end
end
end

View file

@ -7,7 +7,7 @@ defmodule AshAuthentication.Strategy.Custom.Transformer do
use Spark.Dsl.Transformer
alias AshAuthentication.{Dsl, Info, Strategy}
alias AshAuthentication.{Info, Strategy}
alias Spark.{Dsl.Transformer, Error.DslError}
import AshAuthentication.Strategy.Custom.Helpers
@ -32,22 +32,33 @@ defmodule AshAuthentication.Strategy.Custom.Transformer do
| {:warn, map(), String.t() | [String.t()]}
| :halt
def transform(dsl_state) do
strategy_modules =
Dsl.available_add_ons()
|> Stream.concat(Dsl.available_strategies())
|> Enum.map(&{&1.dsl().target, &1})
strategy_to_target =
:code.all_available()
|> Stream.map(&elem(&1, 0))
|> Stream.map(&to_string/1)
|> Stream.filter(&String.starts_with?(&1, "Elixir.AshAuthentication"))
|> Stream.map(&Module.concat([&1]))
|> Stream.concat(Transformer.get_persisted(dsl_state, :extensions, []))
|> Stream.filter(&Spark.implements_behaviour?(&1, Strategy.Custom))
|> Stream.flat_map(fn strategy ->
strategy.dsl_patches()
|> Stream.map(&{&1.entity.target, strategy})
end)
|> Map.new()
with {:ok, dsl_state} <- do_strategy_transforms(dsl_state, strategy_modules) do
do_add_on_transforms(dsl_state, strategy_modules)
dsl_state =
Transformer.persist(dsl_state, :ash_authentication_strategy_to_target, strategy_to_target)
with {:ok, dsl_state} <- do_strategy_transforms(dsl_state, strategy_to_target) do
do_add_on_transforms(dsl_state, strategy_to_target)
end
end
defp do_strategy_transforms(dsl_state, strategy_modules) do
defp do_strategy_transforms(dsl_state, strategy_to_target) do
dsl_state
|> Info.authentication_strategies()
|> Enum.reduce_while({:ok, dsl_state}, fn strategy, {:ok, dsl_state} ->
strategy_module = Map.fetch!(strategy_modules, strategy.__struct__)
strategy_module = Map.fetch!(strategy_to_target, strategy.__struct__)
case do_transform(strategy_module, strategy, dsl_state, :strategy) do
{:ok, dsl_state} -> {:cont, {:ok, dsl_state}}
@ -56,11 +67,11 @@ defmodule AshAuthentication.Strategy.Custom.Transformer do
end)
end
defp do_add_on_transforms(dsl_state, strategy_modules) do
defp do_add_on_transforms(dsl_state, strategy_to_target) do
dsl_state
|> Info.authentication_add_ons()
|> Enum.reduce_while({:ok, dsl_state}, fn strategy, {:ok, dsl_state} ->
strategy_module = Map.fetch!(strategy_modules, strategy.__struct__)
strategy_module = Map.fetch!(strategy_to_target, strategy.__struct__)
case do_transform(strategy_module, strategy, dsl_state, :add_on) do
{:ok, dsl_state} -> {:cont, {:ok, dsl_state}}

View file

@ -7,7 +7,8 @@ defmodule AshAuthentication.Strategy.Custom.Verifier do
use Spark.Dsl.Verifier
alias AshAuthentication.{Dsl, Info}
alias AshAuthentication.Info
alias Spark.Dsl.Transformer
@doc false
@impl true
@ -16,17 +17,15 @@ defmodule AshAuthentication.Strategy.Custom.Verifier do
| {:error, term}
| {:warn, String.t() | list(String.t())}
def verify(dsl_state) do
strategy_modules =
Dsl.available_add_ons()
|> Stream.concat(Dsl.available_strategies())
|> Enum.map(&{&1.dsl().target, &1})
|> Map.new()
strategy_to_target =
dsl_state
|> Transformer.get_persisted(:ash_authentication_strategy_to_target, %{})
dsl_state
|> Info.authentication_strategies()
|> Stream.concat(Info.authentication_add_ons(dsl_state))
|> Enum.reduce_while(:ok, fn strategy, :ok ->
strategy_module = Map.fetch!(strategy_modules, strategy.__struct__)
strategy_module = Map.fetch!(strategy_to_target, strategy.__struct__)
strategy
|> strategy_module.verify(dsl_state)

View file

@ -1,4 +1,6 @@
defmodule AshAuthentication.Strategy.Github do
alias __MODULE__.Dsl
@moduledoc """
Strategy for authenticating using [GitHub](https://github.com)
@ -13,63 +15,16 @@ defmodule AshAuthentication.Strategy.Github do
See the [GitHub quickstart guide](/documentation/tutorials/github-quickstart.html)
for more information.
## DSL Documentation
#{Spark.Dsl.Extension.doc_entity(Dsl.dsl())}
"""
alias AshAuthentication.Strategy.{Custom, OAuth2}
use Custom
@doc false
# credo:disable-for-next-line Credo.Check.Warning.SpecWithStruct
@spec dsl :: Custom.entity()
def dsl do
OAuth2.dsl()
|> Map.merge(%{
name: :github,
args: [{:optional, :name, :github}],
describe: """
Provides a pre-configured authentication strategy for [GitHub](https://github.com/).
This strategy is built using `:oauth2` strategy, and thus provides all the same
configuration options should you need them.
For more information see the [Github Quick Start Guide](/documentation/tutorials/github-quickstart.md)
in our documentation.
#### Strategy defaults:
#{strategy_override_docs(Assent.Strategy.Github)}
#### Schema:
""",
auto_set_fields: strategy_fields(Assent.Strategy.Github, icon: :github)
})
end
use Custom, entity: Dsl.dsl()
defdelegate transform(strategy, dsl_state), to: OAuth2
defdelegate verify(strategy, dsl_state), to: OAuth2
defp strategy_fields(strategy, params) do
[]
|> strategy.default_config()
|> Keyword.put(:assent_strategy, strategy)
|> Keyword.merge(params)
end
defp strategy_override_docs(strategy) do
defaults =
[]
|> strategy.default_config()
|> Enum.map_join(
".\n",
fn {key, value} ->
" * `#{inspect(key)}` is set to `#{inspect(value)}`"
end
)
"""
The following defaults are applied:
#{defaults}.
"""
end
end

View file

@ -0,0 +1,56 @@
defmodule AshAuthentication.Strategy.Github.Dsl do
@moduledoc false
alias AshAuthentication.Strategy.{Custom, OAuth2}
@doc false
@spec dsl :: Custom.entity()
def dsl do
OAuth2.dsl()
|> Map.merge(%{
name: :github,
args: [{:optional, :name, :github}],
describe: """
Provides a pre-configured authentication strategy for [GitHub](https://github.com/).
This strategy is built using `:oauth2` strategy, and thus provides all the same
configuration options should you need them.
For more information see the [Github Quick Start Guide](/documentation/tutorials/github-quickstart.md)
in our documentation.
#### Strategy defaults:
#{strategy_override_docs(Assent.Strategy.Github)}
#### Schema:
""",
auto_set_fields: strategy_fields(Assent.Strategy.Github, icon: :github)
})
end
defp strategy_fields(strategy, params) do
[]
|> strategy.default_config()
|> Keyword.put(:assent_strategy, strategy)
|> Keyword.merge(params)
end
defp strategy_override_docs(strategy) do
defaults =
[]
|> strategy.default_config()
|> Enum.map_join(
".\n",
fn {key, value} ->
" * `#{inspect(key)}` is set to `#{inspect(value)}`"
end
)
"""
The following defaults are applied:
#{defaults}.
"""
end
end

View file

@ -241,9 +241,9 @@ defmodule AshAuthentication.Strategy.OAuth2 do
icon: nil,
assent_strategy: Assent.Strategy.OAuth2
use AshAuthentication.Strategy.Custom
alias AshAuthentication.Strategy.{Custom, OAuth2}
alias AshAuthentication.Strategy.OAuth2
use Custom, entity: Dsl.dsl()
@type secret :: nil | String.t() | {module, keyword}

View file

@ -1,6 +1,5 @@
defmodule AshAuthentication.Strategy.Password do
alias __MODULE__.{Dsl, Transformer, Verifier}
use AshAuthentication.Strategy.Custom
alias __MODULE__.Dsl
@moduledoc """
Strategy for authenticating using local resources as the source of truth.
@ -110,7 +109,17 @@ defmodule AshAuthentication.Strategy.Password do
resource: nil
alias Ash.Resource
alias AshAuthentication.{Jwt, Strategy.Password}
alias AshAuthentication.{
Jwt,
Strategy.Custom,
Strategy.Password,
Strategy.Password.Resettable,
Strategy.Password.Transformer,
Strategy.Password.Verifier
}
use Custom, entity: Dsl.dsl()
@type t :: %Password{
identity_field: atom,
@ -121,7 +130,7 @@ defmodule AshAuthentication.Strategy.Password do
password_confirmation_field: atom,
register_action_name: atom,
sign_in_action_name: atom,
resettable: [Password.Resettable.t()],
resettable: [Resettable.t()],
name: atom,
provider: atom,
resource: module
@ -138,7 +147,7 @@ defmodule AshAuthentication.Strategy.Password do
"""
@spec reset_token_for(t(), Resource.record()) :: {:ok, String.t()} | :error
def reset_token_for(
%Password{resettable: [%Password.Resettable{} = resettable]} = _strategy,
%Password{resettable: [%Resettable{} = resettable]} = _strategy,
user
) do
case Jwt.token_for_user(user, %{"act" => resettable.password_reset_action_name},

View file

@ -4,7 +4,7 @@ defmodule AshAuthentication.TokenResource.Info do
extension.
"""
use AshAuthentication.InfoGenerator,
use Spark.InfoGenerator,
extension: AshAuthentication.TokenResource,
sections: [:token]
end

View file

@ -4,7 +4,7 @@ defmodule AshAuthentication.UserIdentity.Info do
extension.
"""
use AshAuthentication.InfoGenerator,
use Spark.InfoGenerator,
extension: AshAuthentication.UserIdentity,
sections: [:user_identity]
end

View file

@ -143,7 +143,7 @@ defmodule AshAuthentication.MixProject do
defp deps do
[
{:ash, ash_version("~> 2.5 and >= 2.5.11")},
{:spark, "~> 0.3.4"},
{:spark, "~> 0.4 and >= 0.4.1"},
{:jason, "~> 1.4"},
{:joken, "~> 2.5"},
{:plug, "~> 1.13"},

View file

@ -53,7 +53,7 @@
"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"},
"ranch": {:hex, :ranch, "1.8.0", "8c7a100a139fd57f17327b6413e4167ac559fbc04ca7448e9be9057311597a1d", [:make, :rebar3], [], "hexpm", "49fbcfd3682fab1f5d109351b61257676da1a2fdbe295904176d5e521a2ddfe5"},
"sourceror": {:hex, :sourceror, "0.12.0", "b0a43f9b91d69fead8ffa307e203fadaf30001d8c2643f597518dd9508d6b32d", [:mix], [], "hexpm", "67dcc48a4d4bb8c397ad0b86cf0d4667e1b50ffdf31cda585524640d1caded85"},
"spark": {:hex, :spark, "0.3.12", "6754698b5aa27358365fff88e3c5c3abc138e165fbc394cf482e39779ee92cd6", [:mix], [{:nimble_options, "~> 0.5", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:sourceror, "~> 0.1", [hex: :sourceror, repo: "hexpm", optional: false]}], "hexpm", "27f13b604ddaf1d2b3b6ce5b3a21ed170e2f1235e167ab6fb8c2c33c8165d545"},
"spark": {:hex, :spark, "0.4.1", "72b4bdc67fb297dce209501a25db5f9e2c80ae22095d9bcf6acfdc9a445786c2", [:mix], [{:nimble_options, "~> 0.5", [hex: :nimble_options, repo: "hexpm", optional: false]}, {:sourceror, "~> 0.1", [hex: :sourceror, repo: "hexpm", optional: false]}], "hexpm", "83ef6910bd3b47aa33403753c3211ff58f34287ea3e86b62063dfe3e1a1dee99"},
"stream_data": {:hex, :stream_data, "0.5.0", "b27641e58941685c75b353577dc602c9d2c12292dd84babf506c2033cd97893e", [:mix], [], "hexpm", "012bd2eec069ada4db3411f9115ccafa38540a3c78c4c0349f151fc761b9e271"},
"telemetry": {:hex, :telemetry, "1.2.1", "68fdfe8d8f05a8428483a97d7aab2f268aaff24b49e0f599faa091f1d4e7f61c", [:rebar3], [], "hexpm", "dad9ce9d8effc621708f99eac538ef1cbe05d6a874dd741de2e689c47feafed5"},
"typable": {:hex, :typable, "0.3.0", "0431e121d124cd26f312123e313d2689b9a5322b15add65d424c07779eaa3ca1", [:mix], [], "hexpm", "880a0797752da1a4c508ac48f94711e04c86156f498065a83d160eef945858f8"},

View file

@ -1,112 +0,0 @@
defmodule Example.CustomStrategy do
@moduledoc """
An extremely dumb custom strategy that let's anyone with a name that starts
with "Marty" sign in.
"""
defstruct case_sensitive?: false, name_field: nil, resource: nil
use AshAuthentication.Strategy.Custom
def dsl do
%Spark.Dsl.Entity{
name: :only_marty,
describe: "Strategy which only allows folks whose name starts with \"Marty\" to sign in.",
examples: [
"""
only_marty do
case_sensitive? true
name_field :name
end
"""
],
target: __MODULE__,
schema: [
case_sensitive?: [
type: :boolean,
doc: """
Ignore letter case when comparing?
""",
required: false,
default: false
],
name_field: [
type: :atom,
doc: """
The field to check for the users' name.
""",
required: true
]
]
}
end
defimpl AshAuthentication.Strategy do
alias AshAuthentication.{Errors.AuthenticationFailed, Info}
require Ash.Query
import AshAuthentication.Plug.Helpers, only: [store_authentication_result: 2]
def name(_), do: :marty
def phases(_), do: [:sign_in]
def actions(_), do: [:sign_in]
def routes(strategy) do
subject_name = Info.authentication_subject_name!(strategy.resource)
[
{"/#{subject_name}/marty", :sign_in}
]
end
def method_for_phase(_, :sign_in), do: :post
def plug(strategy, :sign_in, conn) do
params = Map.take(conn.params, [to_string(strategy.name_field)])
result = AshAuthentication.Strategy.action(strategy, :sign_in, params, [])
store_authentication_result(conn, result)
end
def action(strategy, :sign_in, params, _options) do
name_field = strategy.name_field
name = Map.get(params, to_string(name_field))
api = Info.authentication_api!(strategy.resource)
strategy.resource
|> Ash.Query.filter(ref(^name_field) == ^name)
|> Ash.Query.after_action(fn
query, [user] ->
name =
user
|> Map.get(name_field)
|> to_string()
{name, prefix} =
if strategy.case_sensitive? do
{name, "Marty"}
else
{String.downcase(name), "marty"}
end
if String.starts_with?(name, prefix) do
{:ok, [user]}
else
{:error,
AuthenticationFailed.exception(query: query, caused_by: %{reason: :not_a_marty})}
end
query, [] ->
{:error, AuthenticationFailed.exception(query: query, caused_by: %{reason: :no_user})}
query, _ ->
{:error,
AuthenticationFailed.exception(query: query, caused_by: %{reason: :too_many_users})}
end)
|> api.read()
|> case do
{:ok, [user]} -> {:ok, user}
{:error, reason} -> {:error, reason}
end
end
end
end

View file

@ -5,48 +5,46 @@ defmodule Example.OnlyMartiesAtTheParty do
defstruct name: :marty, case_sensitive?: false, name_field: nil, resource: nil
use AshAuthentication.Strategy.Custom
def dsl do
%Spark.Dsl.Entity{
name: :only_marty,
describe: "Strategy which only allows folks whose name starts with \"Marty\" to sign in.",
examples: [
"""
only_marty do
case_sensitive? true
name_field :name
end
"""
@entity %Spark.Dsl.Entity{
name: :only_marty,
describe: "Strategy which only allows folks whose name starts with \"Marty\" to sign in.",
examples: [
"""
only_marty do
case_sensitive? true
name_field :name
end
"""
],
target: __MODULE__,
args: [{:optional, :name, :marty}],
schema: [
name: [
type: :atom,
doc: """
The strategy name.
""",
required: true
],
target: __MODULE__,
args: [{:optional, :name, :marty}],
schema: [
name: [
type: :atom,
doc: """
The strategy name.
""",
required: true
],
case_sensitive?: [
type: :boolean,
doc: """
Ignore letter case when comparing?
""",
required: false,
default: false
],
name_field: [
type: :atom,
doc: """
The field to check for the users' name.
""",
required: true
]
case_sensitive?: [
type: :boolean,
doc: """
Ignore letter case when comparing?
""",
required: false,
default: false
],
name_field: [
type: :atom,
doc: """
The field to check for the users' name.
""",
required: true
]
}
end
]
}
use AshAuthentication.Strategy.Custom, entity: @entity
defimpl AshAuthentication.Strategy do
alias AshAuthentication.Errors.AuthenticationFailed

View file

@ -5,7 +5,8 @@ defmodule Example.User do
extensions: [
AshAuthentication,
AshGraphql.Resource,
AshJsonApi.Resource
AshJsonApi.Resource,
Example.OnlyMartiesAtTheParty
]
require Logger