View Source Defining Custom Authentication Strategies
AshAuthentication allows you to bring your own authentication strategy without having to change the Ash Authenticaiton 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 weird calling "confirmation" an authentication strategy.
There are several moving parts which must all work together so hold on to your hat!
- A
Spark.Dsl.Entitystruct. This is used to define the strategy DSL inside thestrategies(oradd_ons) section of theauthenticationDSL. - A strategy struct, which stores information about the strategy as configured on a resource which must comply with a few rules.
- An optional transformer, which can be used to manipulate the DSL state of the entity and the resource.
- An optional verifier, which can be used to verify the DSL state of the entity and the resource after compilation.
- The
AshAuthentication.Strategyprotocol, which provides the glue needed for everything to wire up and wrappers around the actions needed to run on the resource. - Runtime configuration of
AshAuthenticationto 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 never do this in real life, but this isn't real life - it's documentation!
dsl-setup
DSL setup
Let's start by defining a module for our strategy to live in. Let's call it
OnlyMartiesAtTheParty:
defmodule OnlyMartiesAtTheParty do
use AshAuthentication.Strategy.Custom
endSadly, this isn't enough to make the magic happen. We need to define our DSL
entity by implementing the dsl/0 callback:
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
"""
],
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
]
]
}
end
endIf you haven't you should take a look at the docs for Spark.Dsl.Entity, but
here's a brief overview of what each field we've set does:
nameis the name for which the helper function will be generated in the DSL (ieonly_marty do #... end).describeandexamplesare used when generating documentation. Probably worth doing this (and usingSpark.Dsl.Extension.doc_entity/2to generate your moduledocs if you plan on sharing this strategy with others).targetis the name of the module which defines our entity struct. We've set it to__MODULE__which means that we'll have to define the struct on this module.schemais a keyword list that defines aNimbleOptionsschema. Spark provides a number of additional types over the default ones though, so check outSpark.OptionsHelpersfor more information.
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
it's attached to during compilation.
defmodule OnlyMartiesAtTheParty do
defstruct name: :marty, case_sensitive?: false, name_field: nil, resource: nil
use AshAuthentication.Strategy.Custom
# other code elided ...
endNow it would be theoretically possible to add this custom strategies to your app by adding it to the runtime configuration and the user resource:
# config.exs
config :ash_authentication, extra_strategies: [OnlyMartiesAtTheParty]
# user resource
defmodule MyApp.Accounts.User do
use Ash.Resource, extensions: [AshAuthentication]
authentication do
api MyApp.Accounts
strategies do
only_marty do
name_field :name
end
end
end
attributes do
uuid_primary_key
attribute :name, :string, allow_nil?: false
end
end
implementing-the-ashauthentication-strategy-protocol
Implementing the AshAuthentication.Strategy protocol
The Strategy protocol is used to introspect the strategy so that it can seamlessly fit in with the rest of Ash Authentication. Here are the key concepts:
- "phases" - in terms of HTTP, each strategy is likely to have many phases (eg OAuth 2.0's "request" and "callback" phases). Essentially you need one phase for each HTTP endpoint you wish to support with your strategy. In our case we just want one sign in endpoint.
- "actions" - actions are exactly as they sound - Resource actions which can
be executed by the strategy, whether generated by the strategy (as in the
password strategy) or typed in by the user (as in the OAuth 2.0 strategy).
The reason that we wrap the strategy's actions this way is that all the
built-in strategies (and we hope yours too) allow the user to customise the
name of the actions that it uses. At the very least it should probably
append the strategy name to the action. Using
Strategy.action/4allows us to refer these by a more generic name rather than via the user-specified one (eg:registervs:register_with_password). - "routes" -
AshAuthentication.Plug(orAshAuthentication.Phoenix.Router) will generate routes usingPlug.Router(orPhoenix.Router) - theroutes/1callback 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.
defimpl AshAuthentication.Strategy, for: OnlyMartiesAtTheParty doThe name/1 function is used to uniquely identify the strategy. It must be an
atom and should be the same as the path fragment used in the generated routes.
def name(strategy), do: strategy.nameSince our strategy only supports sign-in we only need a single :sign_in phase
and action.
def phases(_), do: [:sign_in]
def actions(_), do: [:sign_in]Next we generate the routes for the strategy. Routes should contain the
subject name of the resource being authenticated in case the implementor is
authenticating multiple different resources - eg User and Admin.
def routes(strategy) do
subject_name = Info.authentication_subject_name!(strategy.resource)
[
{"/#{subject_name}/#{strategy.name}", :sign_in}
]
endWhen generating routes or forms for this phase, what HTTP method should we use?
def method_for_phase(_, :sign_in), do: :postNext up, we write our plug. We take the "name field" from the input params in
the conn and pass them to our sign in action. As long as the action returns
{:ok, Ash.Resource.record} or {:error, any} then we can just pass it
straight into store_authentication_result/2 from
AshAuthentication.Plug.Helpers.
import AshAuthentication.Plug.Helpers, only: [store_authentication_result: 2]
def plug(strategy, :sign_in, conn) do
params = Map.take(conn.params, [to_string(strategy.name_field)])
result = action(strategy, :sign_in, params, [])
store_authentication_result(conn, result)
endFinally, we implement our sign in action. We use Ash.Query to find all
records whose name field matches the input, then constrain it to only records
whose name field starts with "Marty". Depending on whether the name field has a
unique identity on it we have to deal with it returning zero or more users, or
an error. When it returns a single user we return that user in an ok tuple,
otherwise we return an authentication failure.
In this example we're assuming that there is a default read action present on
the resource.
Warning
When it comes to authentication, you never want to reveal to the user what the failure was - this helps prevent enumeration attacks.
You can use
AshAuthentication.Errors.AuthenticationFailedfor this purpose as it will causeash_authentication,ash_authentication_phoenix,ash_graphqlandash_json_apito return the correct HTTP 401 error.
alias AshAuthentication.Errors.AuthenticationFailed
require Ash.Query
def action(strategy, :sign_in, params, options) when strategy.case_sensitive? do
name_field = strategy.name_field
name = Map.get(params, to_string(name_field))
api = AshAuthentication.Info.authentication_api!(strategy.resource)
strategy.resource
|> Ash.Query.filter(ref(^name_field) == ^name)
|> then(fn query ->
if strategy.case_sensitive? do
Ash.Query.filter(query, like(ref(^name_field), "Marty%"))
else
Ash.Query.filter(query, ilike(ref(^name_field), "Marty%"))
end
end)
|> api.read(options)
|> case do
{:ok, [user]} ->
{:ok, user}
{:ok, []} ->
{:error, AuthenticationFailed.exception(caused_by: %{reason: :no_user})}
{:ok, _users} ->
{:error, AuthenticationFailed.exception(caused_by: %{reason: :too_many_users})}
{:error, reason} ->
{:error, AuthenticationFailed.exception(caused_by: %{reason: reason})}
end
end
end
bonus-round-transformers-and-verifiers
Bonus round - transformers and verifiers
In some cases it may be required for your strategy to modify it's own
configuration or that of the whole resource at compile time. For that you can
define the transform/2 callback on your strategy module.
At the very least it is good practice to call
AshAuthentication.Strategy.Custom.Helpers.register_strategy_actions/3 so that
Ash Authentication can keep track of which actions are related to which
strategies and AshAuthentication.Strategy.Custom.Helpers is automatically
imported by use AshAuthentication.Strategy.Custom for this purpose.
transformers
Transformers
For simple cases where you're just transforming the strategy you can just return the modified strategy and the DSL will be updated accordingly. For example if you wanted to generate the name of an action if the user hasn't specified it:
def transform(strategy, _dsl_state) do
{:ok, Map.put_new(strategy, :sign_in_action_name, :"sign_in_with_#{strategy.name}")}
endIn 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:
def transform(strategy, dsl_state) do
strategy = Map.put_new(strategy, :sign_in_action_name, :"sign_in_with_#{strategy.name}")
sign_in_action =
Spark.Dsl.Transformer.build_entity(Ash.Resource.Dsl, [:actions], :read,
name: strategy.sign_in_action_name,
accept: [strategy.name_field],
get?: true
)
dsl_state =
dsl_state
|> Spark.Dsl.Transformer.add_entity([:actions], sign_in_action)
|> put_strategy(strategy)
|> then(fn dsl_state ->
register_strategy_actions([strategy.sign_in_action_name], dsl_state, strategy)
end)
{:ok, dsl_state}
endTransformers can also be used to validate user input or even directly add code
to the resource. See the docs for Spark.Dsl.Transformer for more information.
verifiers
Verifiers
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
compile-time dependencies between modules which may cause compiler deadlocks.
For example, verifying that the "name" attribute contains "marty" (why you would do this I don't know but I'm running out of sensible examples):
def verify(strategy, _dsl_state) do
if String.contains?(to_string(strategy.name_field), "marty") do
:ok
else
{:error,
Spark.Error.DslError.exception(
path: [:authentication, :strategies, :only_marties],
message: "Option `name_field` must contain the \"marty\""
)}
end
end
summary
Summary
You should now have all the tools you need to build custom strategies - and in fact the strategies provided by Ash Authentication are built using this system.
If there is functionality or documentation missing please raise an issue and we'll take a look at it.
Go forth and strategise!