Packages
ash_authentication
5.0.0-rc.10
5.0.0-rc.12
5.0.0-rc.11
5.0.0-rc.10
5.0.0-rc.9
5.0.0-rc.8
5.0.0-rc.7
5.0.0-rc.6
5.0.0-rc.5
5.0.0-rc.4
5.0.0-rc.3
5.0.0-rc.2
5.0.0-rc.1
5.0.0-rc.0
4.14.1
4.14.0
4.13.7
4.13.6
4.13.5
4.13.4
4.13.3
4.13.2
retired
4.13.1
retired
4.13.0
4.12.0
4.11.0
4.10.0
4.9.9
4.9.8
4.9.7
4.9.6
4.9.5
4.9.4
4.9.3
4.9.2
4.9.1
4.9.0
4.8.7
4.8.6
4.8.5
4.8.3
4.8.2
4.8.1
4.8.0
4.7.6
4.7.5
4.7.4
4.7.3
4.7.2
4.7.1
4.7.0
4.6.4
4.6.3
4.6.2
4.6.1
4.6.0
4.5.6
4.5.5
4.5.4
4.5.3
4.5.2
4.5.1
4.5.0
4.4.9
4.4.8
4.4.7
4.4.6
4.4.5
4.4.4
4.4.3
4.4.2
4.4.1
4.4.0
4.3.12
4.3.11
4.3.10
4.3.9
4.3.8
4.3.7
4.3.6
4.3.5
4.3.4
4.3.3
4.3.2
4.3.1
4.3.0
4.2.7
4.2.6
4.2.5
4.2.4
4.2.3
4.2.2
4.2.1
4.2.0
4.1.0
4.0.4
4.0.3
4.0.2
4.0.1
4.0.0
4.0.0-rc.6
4.0.0-rc.5
4.0.0-rc.3
4.0.0-rc.2
4.0.0-rc.1
4.0.0-rc.0
3.12.4
3.12.3
3.12.2
3.12.1
3.12.0
3.11.16
3.11.15
3.11.14
3.11.13
3.11.12
3.11.11
3.11.10
3.11.9
3.11.8
3.11.7
3.11.6
3.11.5
3.11.4
3.11.3
3.11.2
3.11.1
3.11.0
3.10.8
3.10.7
3.10.6
3.10.5
3.10.4
3.10.3
3.10.2
3.10.1
3.10.0
3.9.6
3.9.5
3.9.4
3.9.3
3.9.2
3.9.1
3.9.0
3.8.0
3.7.9
3.7.8
3.7.6
3.7.5
3.7.4
3.7.3
3.7.2
3.7.1
3.7.0
3.6.1
3.6.0
3.5.3
3.5.2
3.5.1
3.5.0
3.3.1
3.3.0
3.2.2
3.2.1
3.2.0
3.1.0
3.0.3
Authentication extension for the Ash Framework.
Current section
Files
Jump to
Current section
Files
lib/ash_authentication.ex
# SPDX-FileCopyrightText: 2022 Alembic Pty Ltd
#
# SPDX-License-Identifier: MIT
defmodule AshAuthentication do
import AshAuthentication.Dsl
@moduledoc """
AshAuthentication provides a turn-key authentication solution for folks using
[Ash](https://www.ash-hq.org/).
## Usage
This package assumes that you have [Ash](https://ash-hq.org/) installed and
configured. See the Ash documentation for details.
Once installed you can easily add support for authentication by configuring
the `AshAuthentication` extension on your resource:
```elixir
defmodule MyApp.Accounts.User do
use Ash.Resource,
extensions: [AshAuthentication],
domain: MyApp.Accounts
attributes do
uuid_primary_key :id
attribute :email, :ci_string, allow_nil?: false
attribute :hashed_password, :string, allow_nil?: false, sensitive?: true
end
authentication do
strategies do
password :password do
identity_field :email
hashed_password_field :hashed_password
end
end
end
identities do
identity :unique_email, [:email]
end
end
```
If you plan on providing authentication via the web, then you will need to
define a plug using `AshAuthentication.Plug` which builds a `Plug.Router` that
routes incoming authentication requests to the correct provider and provides
callbacks for you to manipulate the conn after success or failure.
If you're using AshAuthentication with Phoenix, then check out
[`ash_authentication_phoenix`](https://github.com/team-alembic/ash_authentication_phoenix)
which provides route helpers, a controller abstraction and LiveView components
for easy set up.
## Authentication Strategies
Currently supported strategies:
1. `AshAuthentication.Strategy.Password`
- authenticate users against your local database using a unique identity
(such as username or email address) and a password.
2. `AshAuthentication.Strategy.OAuth2`
- authenticate using local or remote [OAuth 2.0](https://oauth.net/2/) compatible services.
- also includes:
- `AshAuthentication.Strategy.Apple`
- `AshAuthentication.Strategy.Auth0`
- `AshAuthentication.Strategy.Github`
- `AshAuthentication.Strategy.Google`
- `AshAuthentication.Strategy.Microsoft`
- `AshAuthentication.Strategy.Oidc`
- `AshAuthentication.Strategy.Okta`
- `AshAuthentication.Strategy.Slack`
3. `AshAuthentication.Strategy.MagicLink`
- authenticate by sending a single-use link to the user.
### HTTP client settings
Most of the authentication strategies based on `OAuth2` wrap the [`assent`](https://hex.pm/packages/assent) package.
If you needs to customize the behavior of the http client used by `assent`, define a custom `http_adapter` in the
application settings:
`config :ash_authentication, :http_adapter, {Assent.HTTPAdapter.Finch, supervisor: MyApp.CustomFinch}`
See [`assent's documentation`](https://hexdocs.pm/assent/README.html#http-client) for more details on the supported
http clients and their configuration.
### Magic Link configuration
When using the `MagicLink` strategy, you can configure whether invalid
magic link tokens should return an error or an empty result. The current
default for backward compatibility is to return an empty result when a
token is invalid. However, this makes it difficult to distinguish between
a successful sign-in and a failed sign-in due to an invalid token.
To return an error when an invalid token is provided (recommended), set:
`config :ash_authentication, return_error_on_invalid_magic_link_token?: true`
This is especially important if you're using the `AuditLog` add-on, as it
ensures failed sign-in attempts are logged correctly. In the next major
version, returning an error will be the default behavior.
## Add-ons
Add-ons are like strategies, except that they don't actually provide
authentication - they just provide features adjacent to authentication.
Current add-ons:
1. `AshAuthentication.AddOn.Confirmation`
- allows you to force the user to confirm changes using a confirmation
token (eg. sending a confirmation email when a new user registers).
2. `AshAuthentication.AddOn.LogOutEverywhere`
- allows you to revoke all of a user's tokens on sign out.
3. `AshAuthentication.AddOn.AuditLog`
- provides audit logging for other add-ons and strategies.
## Supervisor
Some add-ons or strategies may require processes to be started which manage
their state over the lifetime of the application (eg periodically deleting
expired token revocations). Because of this you should add
`{AshAuthentication.Supervisor, otp_app: :my_app}` to your application's
supervision tree. See [the Elixir
docs](https://hexdocs.pm/elixir/Application.html#module-the-application-callback-module)
for more information.
"""
alias Ash.{
Domain,
Error.Query.NotFound,
Query,
Resource
}
alias AshAuthentication.Info
alias Spark.Dsl.Extension
@built_in_strategies [
AshAuthentication.AddOn.AuditLog,
AshAuthentication.AddOn.Confirmation,
AshAuthentication.AddOn.LogOutEverywhere,
AshAuthentication.Strategy.ApiKey,
AshAuthentication.Strategy.Apple,
AshAuthentication.Strategy.Auth0,
AshAuthentication.Strategy.DynamicOidc,
AshAuthentication.Strategy.Github,
AshAuthentication.Strategy.Google,
AshAuthentication.Strategy.MagicLink,
AshAuthentication.Strategy.Microsoft,
AshAuthentication.Strategy.OAuth2,
AshAuthentication.Strategy.Oidc,
AshAuthentication.Strategy.Okta,
AshAuthentication.Strategy.Otp,
AshAuthentication.Strategy.Password,
AshAuthentication.Strategy.RecoveryCode,
AshAuthentication.Strategy.RememberMe,
AshAuthentication.Strategy.Slack,
AshAuthentication.Strategy.Totp,
AshAuthentication.Strategy.WebAuthn
]
use Spark.Dsl.Extension,
sections: dsl(),
add_extensions: @built_in_strategies,
transformers: [
AshAuthentication.Transformer,
AshAuthentication.Transformer.SetSelectForSenders,
AshAuthentication.Strategy.Custom.Transformer,
AshAuthentication.AddOn.AuditLog.Transformer
],
verifiers: [
AshAuthentication.Verifier,
AshAuthentication.Strategy.Custom.Verifier,
AshAuthentication.AddOn.AuditLog.Verifier
]
require Ash.Query
@type resource_config :: %{
domain: module,
providers: [module],
resource: module,
subject_name: atom
}
@type subject :: String.t()
@doc """
Find all resources which support authentication for a given OTP application.
Returns a list of resource modules.
"""
@spec authenticated_resources(atom | [atom]) :: [Resource.t()]
def authenticated_resources(otp_app) do
otp_app
|> List.wrap()
|> Enum.flat_map(fn otp_app ->
otp_app
|> Application.get_env(:ash_domains, [])
|> Stream.flat_map(&Domain.Info.resources(&1))
|> Stream.uniq()
|> Stream.filter(&(AshAuthentication in Spark.extensions(&1)))
|> Enum.to_list()
end)
end
@doc """
Return a subject string for user.
This is done by concatenating the resource's subject name with the resource's
primary key field(s) to generate a uri-like string.
Example:
iex> build_user(id: "ce7969f9-afa5-474c-bc52-ac23a103cef6") |> user_to_subject()
"user?id=ce7969f9-afa5-474c-bc52-ac23a103cef6"
"""
@spec user_to_subject(Resource.record()) :: subject
def user_to_subject(record) do
subject_name =
record.__struct__
|> 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 ~S"""
Given a subject string, attempt to retrieve a user record.
iex> %{id: user_id} = build_user()
...> {:ok, %{id: ^user_id}} = subject_to_user("user?id=#{user_id}", Example.User)
Any options passed will be passed to the underlying `Domain.read/2` callback.
"""
@spec subject_to_user(subject | URI.t(), Resource.t(), keyword) ::
{:ok, Resource.record()} | {:error, any}
def subject_to_user(subject, resource, options \\ []) do
with {:ok, action_name} <- Info.authentication_get_by_subject_action_name(resource),
action when not is_nil(action) <- Ash.Resource.Info.action(resource, action_name) do
if Enum.any?(action.arguments, &(&1.name == :subject)) do
get_by_subject_action(resource, action_name, subject, options)
else
do_subject_to_user(subject, resource, options)
end
else
_ ->
{:error, NotFound.exception([])}
end
end
defp get_by_subject_action(resource, action_name, subject, options) do
resource
|> Query.new()
|> Query.set_context(%{
private: %{
ash_authentication?: true
}
})
|> Query.for_read(
action_name,
%{subject: to_string(subject)},
Keyword.put(options, :not_found_error?, true)
)
|> Ash.read_one(not_found_error?: true)
|> case do
{:error, %Ash.Error.Invalid{errors: [%Ash.Error.Query.NotFound{} = not_found]}} ->
{:error, not_found}
other ->
other
end
end
def do_subject_to_user(subject, resource, options) when is_binary(subject),
do: subject |> URI.parse() |> subject_to_user(resource, options)
def do_subject_to_user(
%URI{path: subject_name, query: primary_key} = _subject,
resource,
options
) do
with {:ok, resource_subject_name} <- Info.authentication_subject_name(resource),
^subject_name <- to_string(resource_subject_name),
{:ok, action_name} <- Info.authentication_get_by_subject_action_name(resource) do
primary_key =
primary_key
|> URI.decode_query()
|> Enum.to_list()
options =
options
|> Keyword.put_new_lazy(:domain, fn -> Info.domain!(resource) end)
resource
|> Query.new()
|> Query.set_context(%{
private: %{
ash_authentication?: true
}
})
|> Query.for_read(action_name, %{}, options)
|> Query.filter(^primary_key)
|> Ash.read_one(not_found_error?: true)
else
_ ->
{:error, Ash.Error.to_error_class(NotFound.exception([]))}
end
end
@doc """
Add extra claims to be included in the generated token.
Works with changesets, queries, and action inputs.
## Examples
For create actions (like registration):
create :register_with_password do
# ...
change AshAuthentication.GenerateTokenChange
change fn changeset, _ctx ->
AshAuthentication.add_token_claims(changeset, %{"session_type" => "web"})
end
end
For read actions (like sign-in):
MyApp.User
|> Ash.Query.for_read(:sign_in_with_password, %{email: email, password: password})
|> AshAuthentication.add_token_claims(%{"session_type" => "api"})
|> Ash.read_one!()
These claims will be merged with any claims configured via the `extra_claims`
DSL option, with action-level claims taking precedence.
"""
@spec add_token_claims(Ash.Changeset.t() | Ash.Query.t() | Ash.ActionInput.t(), map) ::
Ash.Changeset.t() | Ash.Query.t() | Ash.ActionInput.t()
def add_token_claims(%Ash.Changeset{} = changeset, claims) when is_map(claims) do
existing = changeset.context[:extra_token_claims] || %{}
Ash.Changeset.set_context(changeset, %{extra_token_claims: Map.merge(existing, claims)})
end
def add_token_claims(%Ash.Query{} = query, claims) when is_map(claims) do
existing = query.context[:extra_token_claims] || %{}
Ash.Query.set_context(query, %{extra_token_claims: Map.merge(existing, claims)})
end
def add_token_claims(%Ash.ActionInput{} = input, claims) when is_map(claims) do
existing = input.context[:extra_token_claims] || %{}
Ash.ActionInput.set_context(input, %{extra_token_claims: Map.merge(existing, claims)})
end
@doc false
@spec __built_in_strategies__ :: [module]
def __built_in_strategies__, do: @built_in_strategies
end